From 237498c22fa6ed41356b1245212eb9a55b050dc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 11:39:41 -0700 Subject: [PATCH 001/144] fix(ci): Android merges stop cutting a new Play Store build every time (#26596) Co-authored-by: Claude Opus 5.5 --- .github/workflows/mobile-eas-production.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 4dd434a956e25..b2c03c04ab11c 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -160,7 +160,20 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull production --non-interactive + run: | + eas env:pull production --non-interactive + # EAS commands skip dotenv, and the Android fingerprint hashes this + # file. Without it every merge misses the build's hash and rebuilds. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (!googleServicesFile) { + throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment"); + } + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + NODE - name: Apply manual version override if: steps.version_app_token.outcome == 'success' From 886d8f681234e3d88e30e0fd803fe9ccb13b0daf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 11:41:42 -0700 Subject: [PATCH 002/144] fix(mobile): dictation confirm button no longer hugs the small composer's edge (#26597) Co-authored-by: Claude Opus 5.5 --- .../src/features/voice-input/ComposerDictationControl.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx index dedfed547c8e0..f38c4f05825a5 100644 --- a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx +++ b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx @@ -282,7 +282,8 @@ function VoiceActionButton(props: { style={{ opacity: props.disabled && !props.loading ? 0.4 : 1, // Matches ComposerActionButton so the circle stays concentric with the large composer corner. - width: variant === "primary" && large ? 62 : undefined, + // An undefined width would still override the class's 44px width. + ...(variant === "primary" && large ? { width: 62 } : null), }} > Date: Wed, 28 Oct 2026 12:09:32 -0700 Subject: [PATCH 003/144] feat(devices): lease simulators and emulators from a Simlock pool (alpha) (#25259) Co-authored-by: Claude Opus 5.5 --- .../devices/DevicePreviewRouteScreen.tsx | 1 + .../features/devices/DeviceStreamWebView.tsx | 1 + .../devices/device-stream-document.ts | 2 + .../features/devices/threadDevicePreviews.ts | 1 + apps/server/src/device/AgentDeviceTarget.ts | 9 +- apps/server/src/device/DeviceHost.ts | 16 +- .../src/device/DeviceHubProxy.stream.test.ts | 34 + apps/server/src/device/DeviceHubProxy.ts | 26 +- apps/server/src/device/DeviceService.test.ts | 123 ++- apps/server/src/device/DeviceService.ts | 129 ++- apps/server/src/device/EasDeviceHost.test.ts | 19 + apps/server/src/device/EasDeviceHost.ts | 24 +- .../src/device/SimlockDeviceHost.test.ts | 335 ++++++++ apps/server/src/device/SimlockDeviceHost.ts | 545 ++++++++++++ .../src/mcp/toolkits/device/handlers.ts | 44 +- apps/server/src/settingsSecrets.ts | 23 +- .../components/device/DeviceControlsRail.tsx | 192 +++-- .../web/src/components/device/DevicePanel.tsx | 8 +- .../web/src/components/device/DeviceSetup.tsx | 8 +- .../components/device/DeviceStreamView.tsx | 3 + .../src/components/device/DeviceWorkspace.tsx | 4 + .../components/device/deviceStreamSession.ts | 9 +- .../preview/ThreadPreviewMiniPlayer.tsx | 5 +- .../settings/DeviceHostsSettings.tsx | 593 ------------- .../settings/DeviceSourcesSettings.tsx | 808 ++++++++++++++++++ ...tsSettings.tsx => EasDeviceHostEditor.tsx} | 121 +-- .../settings/IntegrationsSettings.tsx | 111 +-- .../components/settings/LabsSettingsPanel.tsx | 41 + .../settings/SimlockDeviceHostEditor.tsx | 224 +++++ .../easDeviceHostsSettings.logic.test.ts | 17 +- .../settings/easDeviceHostsSettings.logic.ts | 13 +- .../src/components/settings/settingsSearch.ts | 27 +- .../simlockDeviceHostsSettings.logic.test.ts | 69 ++ .../simlockDeviceHostsSettings.logic.ts | 94 ++ docs/internals/devices.md | 21 + docs/user/devices.md | 49 +- .../client-runtime/src/device/stream.test.ts | 61 +- packages/client-runtime/src/device/stream.ts | 62 +- packages/contracts/src/device.test.ts | 5 +- packages/contracts/src/device.ts | 53 +- packages/contracts/src/settings.ts | 7 +- 41 files changed, 2926 insertions(+), 1011 deletions(-) create mode 100644 apps/server/src/device/SimlockDeviceHost.test.ts create mode 100644 apps/server/src/device/SimlockDeviceHost.ts delete mode 100644 apps/web/src/components/settings/DeviceHostsSettings.tsx create mode 100644 apps/web/src/components/settings/DeviceSourcesSettings.tsx rename apps/web/src/components/settings/{EasDeviceHostsSettings.tsx => EasDeviceHostEditor.tsx} (72%) create mode 100644 apps/web/src/components/settings/SimlockDeviceHostEditor.tsx create mode 100644 apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 65aaee6f0d479..99aed81cf2b82 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -411,6 +411,7 @@ function OpenDevicePreview({ access={access} platform={session.platform} deviceId={session.deviceId} + serveSimOnly={preview.serveSimOnly} colors={{ background: themeVariables["--color-sheet-solid"], foreground: themeVariables["--color-foreground"], diff --git a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx index 798e650b73e26..94db8527c5d67 100644 --- a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx +++ b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx @@ -45,6 +45,7 @@ export function DeviceStreamWebView({ access: props.access, platform: props.platform, deviceId: props.deviceId, + ...(props.serveSimOnly ? { serveSimOnly: true } : {}), colors: props.colors, }); return ( diff --git a/apps/mobile/src/features/devices/device-stream-document.ts b/apps/mobile/src/features/devices/device-stream-document.ts index 07a699567b6ea..abf045cdd172c 100644 --- a/apps/mobile/src/features/devices/device-stream-document.ts +++ b/apps/mobile/src/features/devices/device-stream-document.ts @@ -6,6 +6,8 @@ export interface DeviceStreamConfiguration { readonly access: DeviceHubAccess; readonly platform: DevicePlatform; readonly deviceId: string; + /** See `DeviceStreamTarget.serveSimOnly`. */ + readonly serveSimOnly?: boolean; readonly colors: { readonly background: string; readonly foreground: string; diff --git a/apps/mobile/src/features/devices/threadDevicePreviews.ts b/apps/mobile/src/features/devices/threadDevicePreviews.ts index aaf87ca9784e1..5ec92e113b8c2 100644 --- a/apps/mobile/src/features/devices/threadDevicePreviews.ts +++ b/apps/mobile/src/features/devices/threadDevicePreviews.ts @@ -14,6 +14,7 @@ export function threadDevicePreviews(state: DeviceServiceState | null, threadId: session, name: device?.name ?? (session.platform === "ios" ? "iOS Simulator" : "Android Emulator"), description: [device?.version, host?.label].filter(Boolean).join(" ยท "), + serveSimOnly: host?.kind === "simlock", }; }); } diff --git a/apps/server/src/device/AgentDeviceTarget.ts b/apps/server/src/device/AgentDeviceTarget.ts index de5ff7ec713d4..ac66a52f6d2aa 100644 --- a/apps/server/src/device/AgentDeviceTarget.ts +++ b/apps/server/src/device/AgentDeviceTarget.ts @@ -9,7 +9,11 @@ import type { AgentDaemonEndpoint } from "./DeviceHost.ts"; const encodeEndpoint = Schema.encodeEffect( Schema.fromJsonString( - Schema.Struct({ daemonBaseUrl: Schema.String, daemonAuthToken: Schema.String }), + Schema.Struct({ + daemonBaseUrl: Schema.String, + daemonAuthToken: Schema.String, + stateDir: Schema.optionalKey(Schema.String), + }), ), ); @@ -40,7 +44,7 @@ export const agentDeviceSession = (threadId: string, hostId: string, deviceId: s export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* ( file: string, - endpoint: AgentDaemonEndpoint, + endpoint: AgentDaemonEndpoint & { readonly stateDir?: string | undefined }, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -48,6 +52,7 @@ export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig") const content = yield* encodeEndpoint({ daemonBaseUrl: endpoint.baseUrl, daemonAuthToken: endpoint.token, + ...(endpoint.stateDir === undefined ? {} : { stateDir: endpoint.stateDir }), }); if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return; const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" }); diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 13a1e55e14882..c168ba657afe6 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -84,6 +84,17 @@ export interface DeviceOpenContext { export type AgentDaemonEndpoint = Pick; +export interface AgentDeviceBinding extends AgentDaemonEndpoint { + /** Null when the session alone picks the device and selector flags would be refused. */ + readonly agentDeviceId: string | null; + /** The one session the device is leased to, shared by every thread. */ + readonly session?: string; + /** agent-device state holding that session's connection. */ + readonly stateDir?: string; + /** agent-device accepts installs into the session only from the directory that opened it. */ + readonly installDirectory?: string; +} + /** Where the proxy sends a device hub request, for hosts that serve several upstreams. */ export interface HubUpstream { readonly url: string; @@ -124,10 +135,7 @@ export interface DeviceOperations { */ readonly agentEndpoint?: ( deviceId: string, - ) => Effect.Effect< - AgentDaemonEndpoint & { readonly agentDeviceId: string }, - DeviceHostUnavailableError - >; + ) => Effect.Effect; /** * Set by hosts that end devices on their own (EAS stops sessions at their * limits), so a session on a device the host stops listing is dropped. diff --git a/apps/server/src/device/DeviceHubProxy.stream.test.ts b/apps/server/src/device/DeviceHubProxy.stream.test.ts index 0251dabf7ace7..72340560d4f39 100644 --- a/apps/server/src/device/DeviceHubProxy.stream.test.ts +++ b/apps/server/src/device/DeviceHubProxy.stream.test.ts @@ -1,9 +1,12 @@ import { expect, it } from "@effect/vitest"; import { NodeWS } from "@effect/platform-node/NodeSocket"; +import * as NodeNet from "node:net"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { HttpRouter, HttpServer } from "effect/http"; +import * as Socket from "effect/socket/Socket"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { AuthOrchestrationOperateScope, @@ -87,3 +90,34 @@ it.effect("preserves binary input and admission frames through the real socket p expect(frames).toEqual([[0x83], [0x03, 1], [0x03, 2]]); }), ); + +it.live("closes an upstream socket that is still connecting without an unhandled error", () => + Effect.gen(function* () { + const dialed = Promise.withResolvers(); + // Accepts the connection and never answers the WebSocket handshake. + const hub = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer((socket) => dialed.resolve(socket)); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise(() => new Promise((resolve) => server.close(() => resolve()))), + ); + const address = hub.address(); + if (typeof address !== "object" || !address) throw new Error("Missing hub port"); + // Handshake headers, as Simlock's routes send, select the `ws` package over the global WebSocket. + const upstream = yield* Socket.makeWebSocket(`ws://127.0.0.1:${address.port}`, { + headers: { authorization: "Bearer test" }, + }).pipe(Effect.provide(DeviceHubProxy.upstreamWebSocketConstructor)); + const reading = yield* Effect.forkChild( + Effect.scoped(Effect.flatMap(upstream.reader, ({ pull }) => pull)), + ); + const connection = yield* Effect.promise(() => dialed.promise); + const abandoned = new Promise((resolve) => connection.once("close", () => resolve())); + yield* Fiber.interrupt(reading); + yield* Effect.promise(() => abandoned); + }), +); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 390247e2bda05..89cad0d7206e2 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -2,6 +2,7 @@ // Proxy with environment read scope for streams and operate scope for input and tuning. import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { HttpClient, @@ -75,16 +76,32 @@ const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: st return headers; }; +// Effect drops its listeners before closing a socket that is still connecting, and `ws` then +// emits an `error` nobody handles, which exits the server. +export const upstreamWebSocketConstructor = Layer.effect( + Socket.WebSocketConstructor, + Effect.gen(function* () { + const make = yield* Socket.WebSocketConstructor; + return (url, options) => { + const socket = make(url, options); + socket.addEventListener("error", () => undefined); + return socket; + }; + }), +).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor)); + const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( request: HttpServerRequest.HttpServerRequest, upstreamUrl: string, protocols: ReadonlyArray = [], + headers: Readonly> = {}, ) { const client = yield* request.upgrade; const upstream = yield* Socket.makeWebSocket(upstreamUrl, { openTimeout: "10 seconds", ...(protocols.length > 0 ? { protocols: [...protocols] } : {}), - }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor)); + ...(Object.keys(headers).length > 0 ? { headers: { ...headers } } : {}), + }).pipe(Effect.provide(upstreamWebSocketConstructor)); yield* Effect.scoped( Effect.gen(function* () { const writeToClient = yield* client.writer; @@ -194,7 +211,12 @@ const handler = Effect.gen(function* () { } const upstreamUrl = routed?.url ?? `${ready.hub.origin}${hubPath}${search}`; if (upgrade) { - return yield* proxyWebSocket(request, upstreamUrl.replace(/^http/, "ws"), routed?.protocols); + return yield* proxyWebSocket( + request, + upstreamUrl.replace(/^http/, "ws"), + routed?.protocols, + routed?.headers, + ); } return yield* proxyHttp(request, upstreamUrl, routed?.headers); }); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 1261094338901..a9ff957b91997 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -52,7 +52,7 @@ const fixture = Effect.fn("fixture")(function* ( | DeviceHost.AgentDeviceStartError, inspectError = false, installTool?: Parameters[3], - options: { readonly endsDevices?: boolean } = {}, + options: { readonly endsDevices?: boolean; readonly routesStreams?: boolean } = {}, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -137,6 +137,7 @@ const fixture = Effect.fn("fixture")(function* ( }, helpers: { serveSimAxSettings: null, serveSimCli: null }, run, + ...(options.routesStreams ? { upstream: () => Effect.succeed(null) } : {}), }; const host: DeviceHost.DeviceHost["Service"] = { ...(inspectError @@ -401,6 +402,29 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); + it.effect("opens emulators on hosts that route their own streams without ffmpeg", () => + Effect.gen(function* () { + const { service, requests, encoderResult } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { routesStreams: true }, + ); + yield* Ref.set(encoderResult, { code: 127, stdout: "", stderr: "host commands unavailable" }); + yield* service.configure({ enabled: true }); + const session = yield* service.open({ + threadId: ThreadId.make("pool-emulator"), + deviceId: "Pixel_API_35", + platform: "android", + }); + expect(session.deviceId).toBe("emulator-5554"); + expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); + }).pipe(Effect.scoped), + ); + it.effect("installs agent support only after the separate agent permission", () => Effect.gen(function* () { const { service, agentStarts, agentStops, settings } = yield* fixture(); @@ -787,6 +811,103 @@ it.effect.each(["shutdown", "close"] as const)( }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); +it.effect("leaves a session its host leases to every thread open when one thread closes", () => + Effect.gen(function* () { + const deviceId = DeviceId.make("33333333-3333-3333-3333-333333333333"); + const http = HttpClient.make((request) => + Effect.sync(() => + new URL(request.url).pathname === "/api/devices" + ? HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted: true, + }, + ], + }), + ) + : HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId })), + ), + ); + const run = () => Effect.succeed({ code: 0, stdout: "", stderr: "" }); + const ready: DeviceHost.DeviceHostAgentReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + devices: { + ...makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http, + run, + androidAvailable: Effect.succeed(true), + }), + agentEndpoint: () => + Effect.succeed({ + baseUrl: "http://agent.test", + token: "agent-token", + agentDeviceId: null, + session: "lease-session", + }), + }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run, + agentDevice: null, + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + label: "Pool", + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Pool", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: true, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.succeed(ready), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const released: string[] = []; + const service = yield* DeviceService.makeWithHosts( + new Map([[host.id, host]]), + undefined, + () => Effect.succeed("agent-device.json"), + undefined, + { + bind: ({ session }) => Effect.succeed(session), + prune: () => Effect.void, + release: (launcher) => Effect.sync(() => void released.push(launcher)), + }, + ).pipe(Effect.provide(NodeCrypto.layer)); + const threads = [ThreadId.make("pool-a"), ThreadId.make("pool-b")]; + for (const threadId of threads) { + yield* service.open({ threadId, deviceId, platform: "ios" }); + const target = { threadId, hostId: LOCAL_DEVICE_HOST_ID, deviceId }; + const agent = yield* service.agentTarget(target); + expect(agent.session).toBe("lease-session"); + yield* service.agentLauncher({ ...target, targetArgs: agent.args }); + } + yield* service.close({ threadId: threads[0]! }); + expect(released).toEqual([]); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), + ), + Effect.scoped, + ), +); + it.effect.each([ { hubReports: "off", outcome: "succeeds" }, { hubReports: "booted", outcome: "fails" }, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 6d7ede5e845fb..5f821f5bf0217 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -22,6 +22,7 @@ import { type DeviceEasAccountInput, type DeviceHostConfig, type EasDeviceHostConfig, + type SimlockDeviceHostConfig, type SshDeviceHostConfig, type DeviceHostSummary, LOCAL_DEVICE_HOST_ID, @@ -67,6 +68,7 @@ import * as ProcessRunner from "../processRunner.ts"; import * as DeviceHost from "./DeviceHost.ts"; import * as SshDeviceHost from "./SshDeviceHost.ts"; import * as EasDeviceHost from "./EasDeviceHost.ts"; +import * as SimlockDeviceHost from "./SimlockDeviceHost.ts"; import { makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts"; import * as Exit from "effect/Exit"; import * as LocalDeviceHost from "./LocalDeviceHost.ts"; @@ -150,9 +152,10 @@ export class DeviceService extends Context.Service< deviceId: DeviceId; }) => Effect.Effect< { - readonly agentDeviceId: string; + readonly agentDeviceId: string | null; readonly session: string; readonly args: ReadonlyArray; + readonly installDirectory?: string | undefined; }, DeviceError >; @@ -247,7 +250,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), configureAgent: ( hostId: DeviceHostId, - endpoint: DeviceHost.AgentDaemonEndpoint, + endpoint: DeviceHost.AgentDaemonEndpoint & { readonly stateDir?: string | undefined }, deviceId?: DeviceId, ) => Effect.Effect = (hostId) => Effect.fail( @@ -284,6 +287,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* supportsToolUpdate: installTool !== undefined, supportsToolInspection: true, supportsEasHosts: true, + supportsSimlockHosts: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -299,6 +303,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const launcherLock = yield* Semaphore.make(1); /** Launchers bound since startup, by agent-device session. */ const boundLaunchers = new Map(); + /** Launchers on a session the host leases to every thread; the host ends it, not a retiring thread. */ + const hostSessionLaunchers = new Set(); // Binding and pruning read sessions under the lock, so neither acts on a session list the other has outdated. // A retired session's agent-device session ends before its launcher goes, and a reopen of the same // device waits for the lock, so the close cannot end the reopened session. @@ -313,9 +319,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); const retired = [...boundLaunchers].filter(([session]) => !live.has(session)); for (const [session] of retired) boundLaunchers.delete(session); + const ending = retired.filter(([session]) => !hostSessionLaunchers.delete(session)); const release = launchers.release; if (release) - yield* Effect.forEach(retired, ([, launcher]) => release(launcher), { + yield* Effect.forEach(ending, ([, launcher]) => release(launcher), { concurrency: "unbounded", discard: true, }); @@ -703,7 +710,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId }); } - if (device.platform === "android" && !device.physical) { + // Hosts that route their own streams never encode emulator video with serve-emu's ffmpeg. + if (device.platform === "android" && !device.physical && !ready.upstream) { // Resolve the override on the device host the way its hub does. const encoders = yield* ready.run(ready.nodePath, [ "-e", @@ -1014,39 +1022,51 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", }); - const { configPath, agentDeviceId } = yield* lifecycleLock.withPermit( - Effect.gen(function* () { - if (hosts.get(host.id) !== host) - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - hostLabel: host.label, - reason: "Host configuration changed. Retry the operation.", - }); - if (ready.devices.agentEndpoint) { - const endpoint = yield* ready.devices.agentEndpoint(input.deviceId); + const { configPath, agentDeviceId, boundSession, installDirectory } = + yield* lifecycleLock.withPermit( + Effect.gen(function* () { + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: "Host configuration changed. Retry the operation.", + }); + if (ready.devices.agentEndpoint) { + const endpoint = yield* ready.devices.agentEndpoint(input.deviceId); + return { + configPath: yield* configureAgent(input.hostId, endpoint, input.deviceId), + agentDeviceId: endpoint.agentDeviceId, + boundSession: endpoint.session, + installDirectory: endpoint.installDirectory, + }; + } + if (!ready.agentDevice) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: "This host has no agent-device daemon.", + }); return { - configPath: yield* configureAgent(input.hostId, endpoint, input.deviceId), - agentDeviceId: endpoint.agentDeviceId, + configPath: yield* configureAgent(input.hostId, ready.agentDevice), + agentDeviceId: input.deviceId, + boundSession: undefined, + installDirectory: undefined, }; - } - if (!ready.agentDevice) - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - hostLabel: host.label, - reason: "This host has no agent-device daemon.", - }); - return { - configPath: yield* configureAgent(input.hostId, ready.agentDevice), - agentDeviceId: input.deviceId, - }; - }), - ); - const session = yield* agentDeviceSession( + }), + ); + const ownSession = yield* agentDeviceSession( input.threadId, input.hostId, input.deviceId, ).pipe(Effect.provideService(Crypto.Crypto, crypto)); - return { agentDeviceId, session, args: ["--config", configPath, "--session", session] }; + if (boundSession) hostSessionLaunchers.add(ownSession); + const session = boundSession ?? ownSession; + return { + agentDeviceId, + session, + args: ["--config", configPath, "--session", session], + installDirectory, + }; }), agentLauncher: Effect.fn("DeviceService.agentLauncher")(function* ({ targetArgs, @@ -1139,7 +1159,7 @@ export const make = Effect.gen(function* () { const configureAgent = Effect.fn("DeviceService.configureAgent")( function* ( hostId: DeviceHostId, - endpoint: DeviceHost.AgentDaemonEndpoint, + endpoint: DeviceHost.AgentDaemonEndpoint & { readonly stateDir?: string | undefined }, deviceId?: DeviceId, ) { const file = yield* configPath(hostId, deviceId); @@ -1290,6 +1310,23 @@ export const make = Effect.gen(function* () { yield* Effect.context>>(); const easContext = yield* Effect.context>>(); + const simlockContext = + yield* Effect.context>>(); + const simlockAgentCli = Effect.fn("DeviceService.simlockAgentCli")( + function* (hostId: string) { + const nodePath = yield* resolveNodeExecutable("Device automation"); + const tool = yield* ensureAgentDevice(config.baseDir).pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ hostId, step: "installing agent tools", cause }), + ), + ); + return { nodePath, entryPath: tool.entryPath }; + }, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + ); const configured = new Map(); const unchanged = (current: DeviceHostConfig, previous: DeviceHostConfig) => current.kind === "ssh" && previous.kind === "ssh" @@ -1334,6 +1371,16 @@ export const make = Effect.gen(function* () { if (configured.has(host.id)) continue; added = true; const hostScope = yield* Scope.fork(scope); + if (host.kind === "simlock") { + // Closing this scope releases the host's leases back to the pool. + const instance = yield* SimlockDeviceHost.make(host, simlockAgentCli(host.id)).pipe( + Effect.provideService(Scope.Scope, hostScope), + Effect.provide(simlockContext), + ); + hosts.set(host.id, instance); + configured.set(host.id, { config: host, scope: hostScope }); + continue; + } if (host.kind === "eas") { // Closing this scope stops the host's running sessions, so a changed // limit or token never leaves a billed session behind. @@ -1375,14 +1422,22 @@ export const make = Effect.gen(function* () { ); }); const changes = yield* settings.subscribeChanges; - // Settings keep EAS hosts apart from SSH hosts; an id both lists use stays the SSH host's. + // Each kind has its own settings list; an id two lists share stays the first one's. const configuredHosts = (value: { readonly deviceHosts: ReadonlyArray; readonly easDeviceHosts: ReadonlyArray; - }): ReadonlyArray => [ - ...value.deviceHosts, - ...value.easDeviceHosts.filter((host) => !value.deviceHosts.some((ssh) => ssh.id === host.id)), - ]; + readonly simlockDeviceHosts: ReadonlyArray; + readonly simlockDeviceHostsEnabled: boolean; + }): ReadonlyArray => { + const hosts: Array = [...value.deviceHosts]; + for (const host of [ + ...value.easDeviceHosts, + ...(value.simlockDeviceHostsEnabled ? value.simlockDeviceHosts : []), + ]) { + if (!hosts.some((existing) => existing.id === host.id)) hosts.push(host); + } + return hosts; + }; yield* reconcile(configuredHosts(yield* settings.getSettings)); yield* changes.pipe( Stream.runForEach((value) => reconcile(configuredHosts(value))), diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index 31b0ed0b1291c..5802956e699b7 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { EasDeviceHostConfig } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -455,6 +456,24 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("reports iOS unavailable on a server with no Expo credential", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-expo-home-" }); + const host = yield* makeHost( + fakeExpo(), + config({ accessToken: "" }), + yield* Scope.make(), + ).pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.Environment, {}), + ); + const [ios] = (yield* host.summary).platforms; + expect(ios).toMatchObject({ platform: "ios", available: false }); + expect(ios?.reason).toContain("No Expo credential on the server"); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("stops a session that is still starting when the host stops", () => Effect.gen(function* () { const previewGate = yield* makeGate; diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index 72c88de1afe20..da8a665d4b4ed 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -830,16 +830,22 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ), ); + // Each server signs in on its own, so one with no Expo credential answers for itself. Reading + // the credential is local; whether Expo accepts it is only known once a session starts. const platformAvailability = (platform: DevicePlatform) => - Effect.succeed( - platform === "ios" - ? { platform, available: true } - : { - platform, - available: false, - reason: "EAS Simulator Android emulators are not supported yet.", - }, - ); + platform === "ios" + ? resolveExpoCredential(config.accessToken).pipe( + Effect.provide(services), + Effect.as({ platform, available: true }), + Effect.catch((error) => + Effect.succeed({ platform, available: false, reason: error.message }), + ), + ) + : Effect.succeed({ + platform, + available: false, + reason: "EAS Simulator Android emulators are not supported yet.", + }); const summary: Effect.Effect = Effect.all([ platformAvailability("ios"), diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts new file mode 100644 index 0000000000000..f67f7bc8d76c6 --- /dev/null +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { SimlockDeviceHostConfig } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as SimlockDeviceHost from "./SimlockDeviceHost.ts"; + +const GATEWAY = "https://simlock.test:4700"; + +const config = (overrides: Partial = {}): SimlockDeviceHostConfig => ({ + kind: "simlock", + id: "pool", + label: "Device pool", + gatewayUrl: `${GATEWAY}/`, + agentToken: "agent-token", + maxLeases: 2, + maxLeaseMinutes: 60, + ...overrides, +}); + +const CATALOG = { + platforms: [ + { + platform: "ios", + modelRuntimes: { "iPhone 17 Pro": ["27.0"], "Apple TV 4K": ["27.0"], "iPhone 12": [] }, + modelClasses: { "iPhone 17 Pro": "phone", "Apple TV 4K": "tv", "iPhone 12": "phone" }, + }, + { + platform: "android", + modelRuntimes: { "Pixel 9": ["36"], "Pixel 8": ["36"] }, + modelWorkers: { "Pixel 9": ["worker-a"], "Pixel 8": [] }, + }, + ], +}; + +/** The gateway's HTTP API and T3's agent-device CLI, recording what was asked of them. */ +const fakePool = ( + options: { + readonly failOpen?: boolean; + readonly holdOpen?: { + readonly entered: Deferred.Deferred; + readonly proceed: Deferred.Deferred; + }; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const requests: Array = []; + const commands: Array<{ readonly args: ReadonlyArray; readonly cwd?: string }> = []; + let leases = 0; + let renewStatus = 200; + const http = HttpClient.make((request) => + Effect.sync(() => { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}`); + expect(request.headers.authorization).toBe("Bearer agent-token"); + const json = (body: unknown, status = 200) => + HttpClientResponse.fromWeb(request, Response.json(body, { status })); + if (url.pathname === "/v1/catalog") return json(CATALOG); + if (url.pathname.endsWith("/renew")) return json({}, renewStatus); + if (request.method === "DELETE") return json({}, 202); + return json({}, 404); + }), + ); + const runner = ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + const args = input.args.slice(1); + commands.push({ args, ...(input.cwd ? { cwd: input.cwd } : {}) }); + const command = args[0]; + const session = args[args.indexOf("--session") + 1]!; + const configFile = args[args.indexOf("--config") + 1]!; + const stateDir = ( + JSON.parse(yield* fs.readFileString(configFile)) as { stateDir: string } + ).stateDir; + if (command === "open") { + if (options.failOpen) { + return { + stdout: JSON.stringify({ + error: { message: "No device matches.", hint: "Retry." }, + }), + stderr: "", + code: ChildProcessSpawner.ExitCode(1), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + } + if (options.holdOpen) { + yield* Deferred.succeed(options.holdOpen.entered, undefined); + yield* Deferred.await(options.holdOpen.proceed); + } + const dir = path.join(stateDir, "remote-connections"); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString( + path.join(dir, `${session}.json`), + JSON.stringify({ leaseId: `lease-${++leases}` }), + ); + } + if (command === "screenshot") yield* fs.writeFile(args[1]!, new Uint8Array([1, 2, 3])); + return { + stdout: "{}", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }).pipe(Effect.orDie), + }); + const ran = (command: string) => commands.filter(({ args }) => args[0] === command); + return { + http, + runner, + requests, + ran, + endLeases: () => { + renewStatus = 404; + }, + }; + }); + +const layer = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-simlock-host-" }), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeHost = ( + pool: Effect.Success>, + hostConfig: SimlockDeviceHostConfig, + scope: Scope.Scope, +) => + SimlockDeviceHost.make( + hostConfig, + Effect.succeed({ nodePath: "node", entryPath: "/tools/agent-device.mjs" }), + ).pipe( + Effect.provideService(HttpClient.HttpClient, pool.http), + Effect.provideService(ProcessRunner.ProcessRunner, pool.runner), + Effect.provideService(Scope.Scope, scope), + ); + +describe("Simlock device host", () => { + it.effect("leases a pool device for agents and serves its screen and input to the panel", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const fs = yield* FileSystem.FileSystem; + const workspace = yield* fs.makeTempDirectoryScoped(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + + const catalog = (yield* ready.devices.list).devices; + // Only models a worker can make right now, and only phones and tablets. + expect(catalog.map((device) => [device.id, device.version])).toEqual([ + ["simlock:ios:iPhone 17 Pro", "iOS 27.0"], + ["simlock:android:Pixel 9", "Android 36"], + ]); + + const leaseId = yield* ready.devices.boot(catalog[1]!, { workspaceRoot: workspace }); + expect(leaseId).toBe("lease-1"); + const [open] = pool.ran("open"); + expect(open?.args.slice(0, 5)).toEqual([ + "open", + "--platform", + "android", + "--device", + "Pixel 9", + ]); + expect(open?.cwd).toBe(yield* fs.realPath(workspace)); + expect(pool.ran("connect")[0]?.args).toContain(`${GATEWAY}/v1/agent-device`); + + const binding = yield* ready.devices.agentEndpoint!(leaseId); + expect(binding).toMatchObject({ + baseUrl: `${GATEWAY}/v1/agent-device`, + token: "agent-token", + agentDeviceId: null, + session: open?.args[open.args.indexOf("--session") + 1], + installDirectory: yield* fs.realPath(workspace), + }); + expect((yield* ready.devices.list).devices.at(-1)).toMatchObject({ + id: "lease-1", + name: "Pixel 9", + booted: true, + }); + + expect( + yield* ready.upstream!( + "/vendor/serve-sim/helper/lease-1/stream.avcc", + new URLSearchParams(), + ), + ).toEqual({ + url: `${GATEWAY}/v1/leases/lease-1/stream`, + headers: { authorization: "Bearer agent-token" }, + protocols: [], + }); + expect( + yield* ready.upstream!( + "/vendor/serve-sim/helper/ws", + new URLSearchParams({ device: "lease-1" }), + ), + ).toEqual({ + url: `${GATEWAY}/v1/leases/lease-1/input`, + headers: { authorization: "Bearer agent-token" }, + protocols: [], + }); + expect( + yield* ready.upstream!("/vendor/serve-sim/helper/lease-1/config", new URLSearchParams()), + ).toBeNull(); + expect( + yield* ready.upstream!( + "/vendor/serve-sim/helper/ws", + new URLSearchParams({ device: "lease-2" }), + ), + ).toBeNull(); + expect([...(yield* ready.devices.screenshot({ ...catalog[1]!, id: leaseId }))]).toEqual([ + 1, 2, 3, + ]); + + yield* ready.devices.shutdown({ id: leaseId, platform: "android" }); + expect(pool.ran("close")).toHaveLength(1); + expect((yield* ready.devices.list).devices.some((device) => device.booted)).toBe(false); + yield* Scope.close(scope, Exit.void); + expect(pool.ran("close")).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("caps leases at the limit", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config({ maxLeases: 1 }), scope); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + yield* ready.devices.boot(iphone!); + + const refused = yield* ready.devices.boot(iphone!).pipe(Effect.flip); + expect(refused.message).toContain("limited to 1 device"); + expect(pool.ran("open")).toHaveLength(1); + yield* Scope.close(scope, Exit.void); + expect(pool.ran("close")).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("closes the session of a failed open so no lease is left behind", () => + Effect.gen(function* () { + const pool = yield* fakePool({ failOpen: true }); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const failure = yield* ready.devices.boot(iphone!).pipe(Effect.flip); + expect(String(failure.cause)).toContain("No device matches. Retry."); + expect(pool.ran("close")).toHaveLength(1); + // The failed open freed its slot. + expect(yield* ready.devices.boot(iphone!).pipe(Effect.flip)).toBeDefined(); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("keeps a watched lease renewed and stops listing it once Simlock ends it", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const leaseId = yield* ready.devices.boot(iphone!); + + yield* TestClock.adjust("2 minutes"); + expect(pool.requests).toContain(`POST /v1/leases/${leaseId}/renew`); + expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe( + true, + ); + + pool.endLeases(); + yield* TestClock.adjust("2 minutes"); + expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe( + false, + ); + yield* Scope.close(scope, Exit.void); + expect(pool.ran("close")).toHaveLength(0); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("returns a lease whose open finishes after the host stopped", () => + Effect.gen(function* () { + const holdOpen = { + entered: yield* Deferred.make(), + proceed: yield* Deferred.make(), + }; + const pool = yield* fakePool({ holdOpen }); + const host = yield* makeHost(pool, config(), yield* Scope.make()); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const opening = yield* Effect.forkChild(ready.devices.boot(iphone!)); + yield* Deferred.await(holdOpen.entered); + yield* host.stop; + yield* Deferred.succeed(holdOpen.proceed, undefined); + expect(Exit.isFailure(yield* Fiber.await(opening))).toBe(true); + expect(pool.ran("close")).toHaveLength(1); + expect((yield* ready.devices.list).devices.every((device) => !device.booted)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("holds a lease for its full maximum age, then returns it", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const host = yield* makeHost(pool, config({ maxLeaseMinutes: 5 }), yield* Scope.make()); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + yield* ready.devices.boot(iphone!); + + yield* TestClock.adjust("299 seconds"); + expect(pool.ran("close")).toHaveLength(0); + yield* TestClock.adjust("1 second"); + expect(pool.ran("close")).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts new file mode 100644 index 0000000000000..c2c25f0b7de21 --- /dev/null +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -0,0 +1,545 @@ +/** + * Alpha: devices leased from a Simlock gateway the user runs. Opening a device + * takes a lease through the gateway's agent-device front, so agents drive it + * with the same session; closing it returns the device to the pool, wiped. + * A lease left by a server that crashed ends one TTL after its last renewal. + */ +import { + DeviceBootError, + type DeviceHostSummary, + DeviceHostUnavailableError, + DeviceOperationError, + type DevicePlatform, + type SimlockDeviceHostConfig, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FiberMap from "effect/FiberMap"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { writeAgentDeviceConfig } from "./AgentDeviceTarget.ts"; +import * as DeviceHost from "./DeviceHost.ts"; + +const CATALOG_TTL_MS = 60_000; +const REQUEST_TIMEOUT = Duration.seconds(15); +// The gateway queues a lease request for up to five minutes when every device is busy. +const OPEN_TIMEOUT = Duration.minutes(7); +const COMMAND_TIMEOUT = Duration.minutes(2); +// Renewal keeps a watched but idle device; a lease this server stops renewing ends after LEASE_TTL. +const RENEW_INTERVAL = Duration.minutes(2); +const LEASE_TTL_MS = 10 * 60_000; +const STREAM_PATH = /^\/vendor\/serve-sim\/helper\/([^/]+)\/stream\.avcc$/; +const INPUT_PATH = "/vendor/serve-sim/helper/ws"; + +const Catalog = Schema.Struct({ + platforms: Schema.Array( + Schema.Struct({ + platform: Schema.String, + modelRuntimes: Schema.Record(Schema.String, Schema.Array(Schema.String)), + modelClasses: Schema.optional(Schema.Record(Schema.String, Schema.String)), + modelWorkers: Schema.optional(Schema.Record(Schema.String, Schema.Array(Schema.String))), + }), + ), +}); +const decodeCatalog = Schema.decodeUnknownEffect(Catalog); +const decodeSessionRecord = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ leaseId: Schema.String })), +); +const decodeCliFailure = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + error: Schema.Struct({ message: Schema.String, hint: Schema.optional(Schema.String) }), + }), + ), +); + +interface Lease { + readonly leaseId: string; + readonly platform: DevicePlatform; + readonly name: string; + readonly version: string; + readonly session: string; + readonly dir: string; + readonly configPath: string; + readonly installDirectory: string; +} + +const catalogId = (platform: DevicePlatform, model: string) => `simlock:${platform}:${model}`; +const parseCatalogId = (id: string) => { + const match = /^simlock:(ios|android):(.+)$/.exec(id); + return match ? { platform: match[1] as DevicePlatform, model: match[2]! } : null; +}; +const platformVersion = (platform: DevicePlatform, runtime: string | undefined) => + `${platform === "ios" ? "iOS" : "Android"}${runtime ? ` ${runtime}` : ""}`; + +/** T3's own agent-device CLI, installed on first use. */ +export type AgentDeviceCli = Effect.Effect< + { readonly nodePath: string; readonly entryPath: string }, + DeviceHost.DeviceHostError | NodeRuntimeUnavailableError +>; + +export const make = Effect.fn("SimlockDeviceHost.make")(function* ( + config: SimlockDeviceHostConfig, + agentDeviceCli: AgentDeviceCli, +) { + const http = yield* HttpClient.HttpClient; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const server = yield* ServerConfig.ServerConfig; + const services = yield* Effect.context(); + const environment = yield* HostProcess.Environment; + const hostId = config.id; + const gateway = config.gatewayUrl.replace(/\/$/, ""); + const agentDeviceUrl = `${gateway}/v1/agent-device`; + const hostDir = path.join(server.stateDir, "device", "simlock", hostId); + const leases = new Map(); + let starting = 0; + // Bumped by every stop, so an open that finishes after one returns its lease instead of keeping it. + let stops = 0; + const slotLock = yield* Semaphore.make(1); + const keepAlive = yield* FiberMap.make(); + let catalog: { + readonly at: number; + readonly devices: ReadonlyArray; + } | null = null; + let ready: DeviceHost.DeviceHostReady | null = null; + let cli: { readonly nodePath: string; readonly entryPath: string } | null = null; + + const unavailable = (reason: string, cause?: unknown) => + new DeviceHostUnavailableError({ + hostId, + hostLabel: config.label, + reason, + ...(cause === undefined ? {} : { cause }), + }); + + const request = ( + method: "GET" | "POST" | "DELETE", + route: string, + body?: Readonly>, + ) => + http + .execute( + (method === "GET" + ? HttpClientRequest.get(`${gateway}${route}`) + : method === "POST" + ? HttpClientRequest.post(`${gateway}${route}`).pipe( + HttpClientRequest.bodyJsonUnsafe(body ?? {}), + ) + : HttpClientRequest.delete(`${gateway}${route}`) + ).pipe(HttpClientRequest.bearerToken(config.agentToken)), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.scoped, + Effect.timeout(REQUEST_TIMEOUT), + ); + + const agentEnvironment: NodeJS.ProcessEnv = Object.fromEntries( + Object.entries({ + ...environment, + ELECTRON_RUN_AS_NODE: "1", + AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", + NO_COLOR: "1", + FORCE_COLOR: "0", + }).filter( + ([name]) => + !/^AGENT_DEVICE_(DAEMON_BASE_URL|DAEMON_AUTH_TOKEN|CONFIG|STATE_DIR|SESSION)$/.test(name), + ), + ); + + /** Runs T3's own agent-device CLI against the lease's session; its failure message is the reason. */ + const agentDevice = Effect.fn("SimlockDeviceHost.agentDevice")(function* ( + lease: Pick, + args: ReadonlyArray, + options: { readonly cwd?: string; readonly timeout?: Duration.Input } = {}, + ) { + if (!cli) return yield* unavailable(`${config.label} is not ready.`); + const result = yield* runner + .run({ + command: cli.nodePath, + args: [ + cli.entryPath, + ...args, + "--session", + lease.session, + "--config", + lease.configPath, + "--json", + ], + env: agentEnvironment, + ...(options.cwd ? { cwd: options.cwd } : {}), + timeout: options.timeout ?? COMMAND_TIMEOUT, + }) + .pipe(Effect.mapError((cause) => unavailable("agent-device could not run.", cause))); + if (result.code !== 0) { + const failure = decodeCliFailure(result.stdout); + return yield* unavailable( + Option.match(failure, { + onNone: () => `agent-device ${args[0]} failed (exit code ${result.code ?? "none"}).`, + onSome: ({ error }) => (error.hint ? `${error.message} ${error.hint}` : error.message), + }), + ); + } + return result.stdout; + }); + + const loadCatalog = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + if (catalog && now - catalog.at < CATALOG_TTL_MS) return catalog.devices; + if (!config.agentToken) { + return yield* unavailable(`Add an agent token for ${config.label} in Settings.`); + } + const decoded = yield* request("GET", "/v1/catalog").pipe( + Effect.flatMap(decodeCatalog), + Effect.mapError((cause) => + unavailable( + `Could not reach the Simlock gateway at ${gateway}, or it refused the agent token.`, + cause, + ), + ), + ); + const devices = decoded.platforms.flatMap((entry) => { + if (entry.platform !== "ios" && entry.platform !== "android") return []; + const platform: DevicePlatform = entry.platform; + return Object.entries(entry.modelRuntimes).flatMap(([model, runtimes]) => { + const kind = entry.modelClasses?.[model]; + const workers = entry.modelWorkers?.[model]; + if (runtimes.length === 0 || (workers !== undefined && workers.length === 0)) return []; + if (kind !== undefined && kind !== "phone" && kind !== "tablet") return []; + return [ + { + id: catalogId(platform, model), + platform, + name: model, + version: platformVersion(platform, runtimes.at(-1)), + booted: false, + physical: false, + } satisfies DeviceHost.HostDevice, + ]; + }); + }); + catalog = { at: now, devices }; + return devices; + }); + + const forget = (lease: Lease) => + Effect.suspend(() => { + leases.delete(lease.leaseId); + return fs.remove(lease.dir, { recursive: true, force: true }).pipe(Effect.ignore); + }); + + /** Ends the lease through its session, or over HTTP when the session is gone. */ + const release = Effect.fn("SimlockDeviceHost.release")(function* (lease: Lease) { + yield* agentDevice(lease, ["close"]).pipe( + Effect.catch(() => request("DELETE", `/v1/leases/${encodeURIComponent(lease.leaseId)}`)), + Effect.tapError((cause) => + Effect.logWarning("Could not release Simlock lease", { leaseId: lease.leaseId, cause }), + ), + Effect.ignore, + ); + yield* forget(lease); + }); + + /** Renews until the lease ends on its own or reaches its maximum age, then releases it. */ + const renewLoop = Effect.fn("SimlockDeviceHost.renewLoop")(function* (lease: Lease) { + const deadline = + (yield* Clock.currentTimeMillis) + + Duration.toMillis(Duration.minutes(config.maxLeaseMinutes)); + while (true) { + const left = deadline - (yield* Clock.currentTimeMillis); + yield* Effect.sleep(Duration.millis(Math.min(left, Duration.toMillis(RENEW_INTERVAL)))); + if ((yield* Clock.currentTimeMillis) >= deadline) break; + const renewed = yield* request( + "POST", + `/v1/leases/${encodeURIComponent(lease.leaseId)}/renew`, + { ttlMs: LEASE_TTL_MS }, + ).pipe(Effect.result); + if (renewed._tag === "Success") continue; + const error = renewed.failure; + // Simlock ended it (lease_lost, an agent's close, or its TTL): stop listing it. + if ( + error._tag === "HttpClientError" && + error.reason._tag === "StatusCodeError" && + error.reason.response.status === 404 + ) { + return yield* forget(lease); + } + yield* Effect.logWarning("Could not renew Simlock lease", { leaseId: lease.leaseId, error }); + } + yield* Effect.logInfo("Releasing Simlock lease at its maximum age", { + leaseId: lease.leaseId, + }); + yield* release(lease); + }); + + const reserveSlot = slotLock.withPermit( + Effect.suspend(() => { + if (leases.size + starting >= config.maxLeases) { + return Effect.fail( + unavailable( + `${config.label} is limited to ${config.maxLeases} ${config.maxLeases === 1 ? "device" : "devices"} at once. Close one in the Device panel first.`, + ), + ); + } + starting++; + return Effect.void; + }), + ); + + const openLease = Effect.fn("SimlockDeviceHost.openLease")(function* ( + device: DeviceHost.HostDevice, + context: DeviceHost.DeviceOpenContext | undefined, + ) { + const target = parseCatalogId(device.id); + if (!target) { + return yield* new DeviceBootError({ + hostId, + deviceId: device.id, + reason: "launch_failed", + cause: new Error("This Simlock device is no longer leased. Open it again from the list."), + }); + } + const suffix = (yield* Random.nextIntBetween(0, 0xffffff)).toString(16).padStart(6, "0"); + const session = `t3-${suffix}`; + const dir = path.join(hostDir, suffix); + const configPath = path.join(dir, "agent-device.json"); + yield* writeAgentDeviceConfig(configPath, { + baseUrl: agentDeviceUrl, + token: config.agentToken, + stateDir: dir, + }).pipe( + Effect.provide(services), + Effect.mapError((cause) => unavailable("Could not write the agent-device config.", cause)), + ); + // agent-device ties installs to the directory that opened the session. + const installDirectory = context?.workspaceRoot + ? yield* fs.realPath(context.workspaceRoot).pipe(Effect.orElseSucceed(() => dir)) + : dir; + const pending = { session, configPath }; + const stopsAtOpen = stops; + return yield* Effect.gen(function* () { + yield* agentDevice(pending, ["connect", "proxy", "--daemon-base-url", agentDeviceUrl]); + yield* agentDevice( + pending, + ["open", "--platform", target.platform, "--device", target.model], + { cwd: installDirectory, timeout: OPEN_TIMEOUT }, + ); + const record = yield* fs + .readFileString(path.join(dir, "remote-connections", `${session}.json`)) + .pipe( + Effect.flatMap(decodeSessionRecord), + Effect.mapError((cause) => unavailable("agent-device recorded no Simlock lease.", cause)), + ); + const opened: Lease = { + leaseId: record.leaseId, + platform: target.platform, + name: target.model, + version: device.version, + session, + dir, + configPath, + installDirectory, + }; + if (stops !== stopsAtOpen) + return yield* unavailable("Device support stopped while the device was opening."); + leases.set(opened.leaseId, opened); + yield* FiberMap.run(keepAlive, opened.leaseId, renewLoop(opened)); + return opened.leaseId; + }).pipe( + // A failed or interrupted open may still hold a lease: close the session either way. + Effect.onExit((exit) => + exit._tag === "Success" + ? Effect.void + : agentDevice(pending, ["close"]).pipe( + Effect.ignore, + Effect.andThen(fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore)), + ), + ), + Effect.mapError((error) => + error._tag === "DeviceHostUnavailableError" + ? new DeviceBootError({ + hostId, + deviceId: device.id, + reason: "launch_failed", + cause: new Error(error.reason), + }) + : error, + ), + ); + }); + + const leaseFor = (deviceId: string) => Option.fromNullishOr(leases.get(deviceId)); + const operationError = (operation: string) => (cause: unknown) => + new DeviceOperationError({ operation, reason: "request_failed", cause }); + + const screenshotLease = Effect.fn("SimlockDeviceHost.screenshot")( + function* (lease: Lease) { + const file = path.join( + lease.dir, + `screenshot-${(yield* Random.nextIntBetween(0, 0xffffff)).toString(16)}.png`, + ); + yield* agentDevice(lease, ["screenshot", file]); + return yield* fs + .readFile(file) + .pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore))); + }, + Effect.mapError(operationError("screenshot")), + ); + + const devices: DeviceHost.DeviceOperations = { + list: loadCatalog.pipe( + Effect.mapError(operationError("list")), + Effect.map((available) => ({ + devices: [ + ...available, + ...[...leases.values()].map((lease): DeviceHost.HostDevice => ({ + id: lease.leaseId, + platform: lease.platform, + name: lease.name, + version: lease.version, + booted: true, + physical: false, + })), + ], + detail: undefined, + })), + ), + boot: (device, context) => + leases.has(device.id) + ? Effect.succeed(device.id) + : Effect.acquireUseRelease( + reserveSlot, + () => openLease(device, context), + () => Effect.sync(() => void starting--), + ), + attach: () => Effect.void, + shutdown: (device) => + Option.match(leaseFor(device.id), { + onNone: () => Effect.void, + onSome: (lease) => + FiberMap.remove(keepAlive, lease.leaseId).pipe(Effect.andThen(release(lease))), + }), + screenshot: (device) => + Option.match(leaseFor(device.id), { + onNone: () => + Effect.fail(operationError("screenshot")(new Error("The Simlock lease has ended"))), + onSome: screenshotLease, + }), + agentEndpoint: (deviceId) => + Option.match(leaseFor(deviceId), { + onNone: () => Effect.fail(unavailable("This Simlock lease has ended.")), + onSome: (lease) => + Effect.succeed({ + baseUrl: agentDeviceUrl, + token: config.agentToken, + agentDeviceId: null, + session: lease.session, + stateDir: lease.dir, + installDirectory: lease.installDirectory, + }), + }), + endsDevices: true, + }; + + /** + * The client speaks serve-sim to every Simlock lease: its AVCC stream and its input + * socket map to the lease's own stream and input routes, which take the agent token. + */ + const upstream: NonNullable = (hubPath, search) => + Effect.sync(() => { + const stream = STREAM_PATH.exec(hubPath); + let deviceId: string | null; + try { + deviceId = stream + ? decodeURIComponent(stream[1]!) + : hubPath === INPUT_PATH + ? search.get("device") + : null; + } catch { + return null; + } + const lease = deviceId === null ? undefined : leases.get(deviceId); + if (!lease) return null; + return { + url: `${gateway}/v1/leases/${encodeURIComponent(lease.leaseId)}/${stream ? "stream" : "input"}`, + headers: { authorization: `Bearer ${config.agentToken}` }, + protocols: [], + }; + }); + + const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = Effect.fn( + "SimlockDeviceHost.ensureReady", + )(function* (onPhase) { + if (ready) return ready; + // A missing token or unreachable gateway does not stop readiness: listing + // and opening devices say what is wrong instead. + yield* onPhase("installing"); + cli = yield* agentDeviceCli; + const { nodePath } = cli; + ready = { + nodePath, + hub: { origin: gateway }, + devices, + upstream, + // Simlock relays only simctl, adb, and devicectl, and only to a lease's owner. + run: () => + Effect.succeed({ + stdout: "", + stderr: "Simlock devices cannot run host commands.", + code: 127, + }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }; + return ready; + }); + + const stopAll = Effect.gen(function* () { + stops++; + yield* FiberMap.clear(keepAlive); + yield* Effect.forEach([...leases.values()], release, { discard: true, concurrency: 4 }); + }).pipe(Effect.ignore); + yield* Effect.addFinalizer(() => stopAll); + + const platformAvailability = (platform: DevicePlatform) => + Effect.succeed({ platform, available: true }); + + const summary: Effect.Effect = Effect.succeed({ + id: hostId, + label: config.label, + kind: "simlock" as const, + platforms: [ + { platform: "ios" as const, available: true }, + { platform: "android" as const, available: true }, + ], + hubInstalled: true, + agentDeviceInstalled: true, + }); + + return DeviceHost.DeviceHost.of({ + id: hostId, + label: config.label, + summary, + platformAvailability, + ensureReady, + ensureAgentReady: Effect.fn("SimlockDeviceHost.ensureAgentReady")(function* (onPhase) { + return { ...(yield* ensureReady(onPhase)), agentDevice: null }; + }), + current: Effect.sync(() => ready), + stopAgent: Effect.void, + stop: stopAll, + }); +}); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 4036f083ca910..6812754af199b 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -30,16 +30,28 @@ export function agentDeviceQuickStart( device: DeviceSummary, targetArgs = agentDeviceTargetArgs(device), command = "agent-device", - cloud = false, + host?: + | { readonly kind: "eas" } + | { readonly kind: "simlock"; readonly installDirectory?: string }, ): string { const executable = shellWord(command); const target = targetArgs.map(shellWord).join(" "); const run = (args: string) => [executable, args, target].filter(Boolean).join(" "); - const platformNotes = cloud - ? "This is an EAS Simulator session in the cloud. It bills the Expo account per minute until it stops, so call device_close with shutdown: true when you are done. It starts with no apps installed: install-from-source installs a build from a URL (iOS needs a .tar.gz containing a .app), and install uploads one from this machine." - : device.platform === "ios" - ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast." - : "The Android snapshot helper installs itself on first use."; + const platformNotes = + host?.kind === "simlock" + ? [ + "This device is leased from a Simlock pool (alpha in T3 Code). The lease returns it to the pool, wiped, when it ends, so call device_close with shutdown: true when you are done. It starts with no apps installed.", + host.installDirectory + ? `agent-device accepts install for this lease only when run from ${host.installDirectory}.` + : "", + ] + .filter(Boolean) + .join(" ") + : host?.kind === "eas" + ? "This is an EAS Simulator session in the cloud. It bills the Expo account per minute until it stops, so call device_close with shutdown: true when you are done. It starts with no apps installed: install-from-source installs a build from a URL (iOS needs a .tar.gz containing a .app), and install uploads one from this machine." + : device.platform === "ios" + ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast." + : "The Android snapshot helper installs itself on first use."; return [ `The user is watching ${device.name} (${device.version}) in the Device panel.`, target === "" @@ -222,9 +234,11 @@ const handlers = { deviceId: session.deviceId, }; const agent = yield* devices.agentTarget(opened); - const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); + const hostKind = after.hosts.find((host) => host.id === device.hostId)?.kind; const targetArgs = [ - ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), + ...(agent.agentDeviceId === null + ? [] + : agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId })), ...agent.args, ]; const launcher = yield* devices.agentLauncher({ ...opened, targetArgs }); @@ -238,7 +252,19 @@ const handlers = { return { device, agentDevice: { command, targetArgs, session: agent.session }, - quickStart: agentDeviceQuickStart(device, [], command, cloud), + quickStart: agentDeviceQuickStart( + device, + [], + command, + hostKind === "simlock" + ? { + kind: "simlock", + ...(agent.installDirectory ? { installDirectory: agent.installDirectory } : {}), + } + : hostKind === "eas" + ? { kind: "eas" } + : undefined, + ), }; }).pipe(Effect.mapError(toolError)), ), diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index b80ccabc03603..dff6b96f52a1b 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -4,6 +4,7 @@ import { CodexSettings, EasDeviceHostConfig, type ServerSettings, + SimlockDeviceHostConfig, UsageLimitSourceConfig, secretSettingsFields, } from "@t3tools/contracts"; @@ -33,6 +34,7 @@ const providerSecretFields = new Map([ ]); const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); +const simlockDeviceHostSecretFields = secretSettingsFields(SimlockDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); export interface SettingsSecret { @@ -155,5 +157,24 @@ export function mapSettingsSecrets( ) as EasDeviceHostConfig, ); - return { ...settings, providerInstances, sourceControlHosts, usageLimitSources, easDeviceHosts }; + // Its own prefix: an EAS and a Simlock host may share an id. + const simlockDeviceHosts = settings.simlockDeviceHosts.map( + (host) => + mapFields( + host, + simlockDeviceHostSecretFields, + (field, serverHost) => + `simlock-device-host-${encode(host.id)}-${encode(field)}` + + (serverHost === null ? "" : `-${encode(serverHost)}`), + ) as SimlockDeviceHostConfig, + ); + + return { + ...settings, + providerInstances, + sourceControlHosts, + usageLimitSources, + easDeviceHosts, + simlockDeviceHosts, + }; } diff --git a/apps/web/src/components/device/DeviceControlsRail.tsx b/apps/web/src/components/device/DeviceControlsRail.tsx index d7ee2cd642356..3ed2b3a4fa544 100644 --- a/apps/web/src/components/device/DeviceControlsRail.tsx +++ b/apps/web/src/components/device/DeviceControlsRail.tsx @@ -46,6 +46,8 @@ export function DeviceControlsRail(props: { onFloat: () => void; onClose: () => void; onPowerOff: () => void; + /** The host takes input but runs no device commands (Simlock): no rotation, settings, tools, or screenshots. */ + inputOnly?: boolean; }) { const { view, handle, controls } = props; const popupSide = "left"; @@ -85,115 +87,123 @@ export function DeviceControlsRail(props: { > + {props.inputOnly ? null : ( + + + } + > + + + + void controls.act({ type: "setOrientation", value: "portrait" })} + > + Portrait + + + void controls.act({ type: "setOrientation", value: "landscape_left" }) + } + > + Landscape + + + + )} + + ) : props.inputOnly ? null : ( + handle?.rotate()} + > + + + )} + {props.inputOnly ? null : ( + <> + + void controls.act({ type: "setAppearance", value: nextAppearance })} + > + + } > - + - - void controls.act({ type: "setOrientation", value: "portrait" })} + + { + if ( + value === "small" || + value === "default" || + value === "large" || + value === "extra-large" + ) + void controls.act({ type: "setTextSize", value }); + }} > - Portrait - - - void controls.act({ type: "setOrientation", value: "landscape_left" }) - } - > - Landscape - + {( + [ + ["small", "Small"], + ["default", "Default"], + ["large", "Large"], + ["extra-large", "Extra large"], + ] as const + ).map(([value, label]) => ( + + + {label} + + + + ))} + + + + - ) : ( + )} + {props.inputOnly ? null : ( handle?.rotate()} + label={props.screenshotPending ? "Capturing screenshot" : "Save screenshot"} + disabled={!view.streaming || props.screenshotPending} + onClick={props.onScreenshot} > - + )} - - void controls.act({ type: "setAppearance", value: nextAppearance })} - > - - - - - } - > - - - - { - if ( - value === "small" || - value === "default" || - value === "large" || - value === "extra-large" - ) - void controls.act({ type: "setTextSize", value }); - }} - > - {( - [ - ["small", "Small"], - ["default", "Default"], - ["large", "Large"], - ["extra-large", "Extra large"], - ] as const - ).map(([value, label]) => ( - - - {label} - - - - ))} - - - - - - - - - - Power off device + {props.inputOnly ? "Return device to the pool" : "Power off device"} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 8eb40a82325f7..c8f55665113ac 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -86,6 +86,9 @@ export function DevicePanel(props: { (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId, ) : undefined; + const activeHost = activeDevice + ? state.hosts.find((host) => host.id === activeDevice.hostId) + : undefined; const grouped = groupDevices(state); @@ -234,9 +237,8 @@ export function DevicePanel(props: { key={`${environmentId}\u0000${deviceKey(activeDevice)}`} environmentId={environmentId} device={activeDevice} - hostLabel={ - state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host" - } + hostLabel={activeHost?.label ?? "Device host"} + simlock={activeHost?.kind === "simlock"} hostDiagnostics={ diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)?.detail } diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index d3866129795e8..9d7b30c0456a7 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -19,9 +19,9 @@ import { cn } from "~/lib/utils"; const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android"); -export const deviceHubDescription = +const deviceHubDescription = "Enable this environment to open simulators and emulators, whether they run here or on a remote device host."; -export const agentDeviceDescription = +const agentDeviceDescription = "Allow new agent sessions in this environment to start and control local and remote devices, with required tools set up automatically."; export const isAndroidDiscoveryUncertain = (state: DeviceServiceState) => @@ -57,7 +57,7 @@ function deviceHostDiagnostics( }); } -export function DeviceHostDiagnostics(props: { +function DeviceHostDiagnostics(props: { readonly hosts: ReadonlyArray>; readonly hostStatuses: DeviceServiceState["hostStatuses"]; readonly className?: string; @@ -360,7 +360,7 @@ export function AgentDeviceSetupStatus(props: { return null; } -export function PlatformStatus(props: { +function PlatformStatus(props: { readonly platform: string; readonly status: { readonly ready: boolean; readonly message: string }; readonly compact?: boolean; diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index 22ae466acb0ed..4b0b5c84ca5b9 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -93,6 +93,8 @@ export function DeviceStreamView(props: { readonly axOverlay?: boolean; /** Lets a parent read this view's own stream state, such as its screen size. */ readonly viewId?: string; + /** The host serves only serve-sim's stream and input socket (Simlock). */ + readonly serveSimOnly?: boolean; }) { const model = deviceModel(props.platform, props.deviceName ?? ""); const isDuo = model?.id === "iphone-duo"; @@ -118,6 +120,7 @@ export function DeviceStreamView(props: { platform: props.platform, deviceId: props.deviceId, access, + serveSimOnly: props.serveSimOnly === true, viewId, }) : NO_SESSION_ATOM, diff --git a/apps/web/src/components/device/DeviceWorkspace.tsx b/apps/web/src/components/device/DeviceWorkspace.tsx index b8d329bc0dfbf..b57adae023269 100644 --- a/apps/web/src/components/device/DeviceWorkspace.tsx +++ b/apps/web/src/components/device/DeviceWorkspace.tsx @@ -16,6 +16,8 @@ export function DeviceWorkspace(props: { environmentId: EnvironmentId; device: DeviceSummary; hostLabel: string; + /** Leased from a Simlock pool: serve-sim input, no host commands. */ + simlock?: boolean; hostDiagnostics: string | undefined; visible: boolean; onFloat: () => void; @@ -80,6 +82,7 @@ export function DeviceWorkspace(props: { deviceId={props.device.id} hostId={props.device.hostId} visible={props.visible} + serveSimOnly={props.simlock === true} axOverlay={axOverlay} allowPhoneView renderControls={(view) => ( @@ -95,6 +98,7 @@ export function DeviceWorkspace(props: { onFloat={props.onFloat} onClose={props.onClose} onPowerOff={props.onPowerOff} + inputOnly={props.simlock === true} /> )} /> diff --git a/apps/web/src/components/device/deviceStreamSession.ts b/apps/web/src/components/device/deviceStreamSession.ts index 813a4f3fd8e89..27fc79c97b03c 100644 --- a/apps/web/src/components/device/deviceStreamSession.ts +++ b/apps/web/src/components/device/deviceStreamSession.ts @@ -43,6 +43,8 @@ export interface DeviceStreamKey { readonly platform: DevicePlatform; readonly deviceId: string; readonly access: DeviceHubAccess; + /** See `DeviceStreamTarget.serveSimOnly`. */ + readonly serveSimOnly: boolean; /** Each mounted view owns its own client: one canvas cannot be in two places. */ readonly viewId: string; } @@ -89,7 +91,12 @@ export const deviceStreamSessionAtom = Atom.family((key: DeviceStreamKey) => get.setSelf(session); }; const client = createDeviceStreamClient( - { platform: key.platform, deviceId: key.deviceId, access: key.access }, + { + platform: key.platform, + deviceId: key.deviceId, + access: key.access, + serveSimOnly: key.serveSimOnly, + }, createCanvasFrameSink(canvas, () => frameListener?.()), { onDuoControl: (duoControl) => { diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 26ed49fba0fa5..50b5ae47c449b 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -366,8 +366,8 @@ function DeviceMiniPlayer({ const device = deviceState.devices.find( (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, ); - const hostLabel = - deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host"; + const host = deviceState.hosts.find((entry) => entry.id === source.hostId); + const hostLabel = host?.label ?? "Device host"; const cornerRadius = (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player); @@ -404,6 +404,7 @@ function DeviceMiniPlayer({ deviceDescription={`${hostLabel} ยท ${device?.version ?? source.platform}`} visible viewId={viewId} + serveSimOnly={host?.kind === "simlock"} /> )} diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx deleted file mode 100644 index fb5a1b2517e78..0000000000000 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import { DeviceToolVersions } from "../device/DeviceToolVersions"; -import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; -import { useAtomValue } from "@effect/atom-react"; -import * as Option from "effect/Option"; -import { AsyncResult } from "effect/reactivity"; -import { AuthSettingsWriteScope } from "@t3tools/contracts"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; -import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; -import { AppleIcon, AndroidIcon } from "../Icons"; -import { Spinner } from "../ui/spinner"; -import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; -import { randomUUID } from "../../lib/utils"; -import { useState } from "react"; -import { deviceEnvironment } from "../../state/device"; -import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { Button, InlineButton } from "../ui/button"; -import { - CheckIcon, - ChevronDownIcon, - CircleIcon, - MoreVertical, - PlusIcon, - TriangleAlertIcon, -} from "lucide-react"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; -import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; -import { SettingsRow } from "./settingsLayout"; - -import { useSettingsScope } from "./SettingsScopeContext"; -import { toastManager } from "../ui/toast"; -import { - groupDeviceHosts, - updateDeviceHosts, - type DeviceHostEnvironment, - type DeviceHostGroup, -} from "./deviceHostsSettings.logic"; -import { FoldedSettingsSection } from "./FoldedSettingsSection"; -import { DeviceHostEditor } from "./DeviceHostEditor"; -import { useHostConnectionChecks } from "./useHostConnectionChecks"; -import { deviceHostConnectionKey } from "./deviceHostConnectionChecks"; -import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; -import { DeviceHostDiagnostics } from "../device/DeviceSetup"; - -export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, environments, connectedEnvironments } = useSettingsScope(); - const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); - const projectScope = scope.kind === "project" || scope.kind === "checkout"; - const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); - const [editing, setEditing] = useState(null); - const [originalHost, setOriginalHost] = useState(null); - const [busy, setBusy] = useState(false); - const targets = environments.map((environment) => ({ - environmentId: environment.environmentId, - label: settingsScopeEnvironmentLabel(environment, environments), - connected: environment.connection.phase === "connected", - })); - const { checks, testConnection } = useHostConnectionChecks(targets); - const hostEnvironments = connectedEnvironments.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - labels: settingsScopeEnvironmentLabels(environment, connectedEnvironments), - hosts: environment.serverConfig?.settings.deviceHosts ?? [], - })); - const groupedHosts = groupDeviceHosts(hostEnvironments); - const save = async (host: SshDeviceHostConfig, remove = false, original = host) => { - if (!props.environmentId || projectScope) return; - setBusy(true); - const results = await Promise.allSettled( - environments.map(async (environment) => { - if (environment.connection.phase !== "connected" || !environment.serverConfig) { - throw new Error("Environment disconnected"); - } - if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) { - throw new Error("This connection cannot change device settings."); - } - return update({ - environmentId: environment.environmentId, - input: { - patch: { - deviceHosts: updateDeviceHosts( - environment.serverConfig.settings.deviceHosts, - host, - remove, - original, - ), - }, - }, - }); - }), - ); - setBusy(false); - const failed = environments.filter((_, index) => { - const result = results[index]; - return result?.status !== "fulfilled" || result.value._tag === "Failure"; - }); - if (failed.length === 0) { - setEditing(null); - } else { - toastManager.add({ - type: "error", - title: "Device hosts not saved on all environments", - description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`, - }); - } - }; - const listActions = { - busy: !canConfigure || projectScope || busy, - checks, - testConnection: async (host: SshDeviceHostConfig) => { - const results = await testConnection(host); - if (!results) return; - const failed = targets.filter((target) => results[target.environmentId]?.status === "failed"); - toastManager.add({ - type: failed.length ? "error" : "success", - title: failed.length - ? `${host.label}: ${failed.length} of ${targets.length} environments failed` - : `${host.label}: connection checks passed`, - description: failed.length - ? `Could not connect from ${failed.map((target) => target.label).join(", ")}.` - : "Connected or already available locally on each selected environment.", - }); - return results; - }, - onEdit: (host: SshDeviceHostConfig) => { - setOriginalHost(host); - setEditing(host); - }, - onRemove: (host: SshDeviceHostConfig) => void save(host, true), - }; - return ( - { - setOriginalHost(null); - setEditing({ kind: "ssh", id: randomUUID(), label: "", target: "" }); - }} - > - Add host - - } - > -
- {!props.environmentId ? ( -

- Connect a selected environment to manage device hosts. -

- ) : ( - <> - {hostEnvironments.length > 1 && groupedHosts === null ? ( -
- {hostEnvironments.map((environment) => ( - - - {environment.labels.primary} - - {environment.labels.secondary ? ( - - {environment.labels.secondary} - - ) : null} - - } - summary={ - summarizeLabels(environment.hosts.map((host) => host.label)) || - "No device hosts" - } - > -
- -
-
- ))} -
- ) : ( - 1} - {...listActions} - /> - )} - {editing ? ( - void save(host, false, originalHost ?? host)} - onClose={() => setEditing(null)} - /> - ) : null} - - )} -
-
- ); -} - -const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => - deviceEnvironment.state({ environmentId, input: {} }), -); - -function DeviceHostList({ - environments, - groups, - compact = false, - busy, - onEdit, - onRemove, - checks, - testConnection, -}: { - environments: ReadonlyArray; - groups?: ReadonlyArray | undefined; - compact?: boolean; - busy: boolean; - onEdit: (host: SshDeviceHostConfig) => void; - onRemove: (host: SshDeviceHostConfig) => void; - checks: ReturnType["checks"]; - testConnection: ReturnType["testConnection"]; -}) { - const { environments: scopeEnvironments } = useSettingsScope(); - const environmentLabels = new Map( - scopeEnvironments.map((environment) => [ - environment.environmentId, - settingsScopeEnvironmentLabels(environment, scopeEnvironments), - ]), - ); - const environmentIds = environments.map(({ environmentId }) => environmentId); - const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(environmentIds))); - const states = new Map( - environmentIds.map( - (environmentId, index) => - [environmentId, Option.getOrUndefined(AsyncResult.value(results[index]!))] as const, - ), - ); - const retry = useAtomCommand(deviceEnvironment.list); - const [retrying, setRetrying] = useState(null); - const retryHost = (environmentId: EnvironmentId, hostId: string, key = hostId) => { - setRetrying(key); - void retry({ environmentId, input: { retryHostId: hostId } }).finally(() => setRetrying(null)); - }; - const rows = - groups ?? - environments.flatMap((environment) => - environment.hosts.map((host) => ({ - host, - members: [{ environmentId: environment.environmentId, label: environment.label, host }], - })), - ); - return ( - <> - {rows.length === 0 ? ( -

No device hosts.

- ) : null} - {rows.map(({ host, members }) => { - const hostChecks = checks[deviceHostConnectionKey(host)]; - const checkedMembers = scopeEnvironments.flatMap((environment) => - hostChecks?.[environment.environmentId] && - !members.some((member) => member.environmentId === environment.environmentId) - ? [{ environmentId: environment.environmentId, label: environment.label, host }] - : [], - ); - const results = [...members, ...checkedMembers].map((member) => { - const state = states.get(member.environmentId); - const status = state?.hostStatuses[member.host.id]; - const check = hostChecks?.[member.environmentId]; - const summary = state?.hosts.find((value) => value.id === member.host.id); - const label = environmentLabels.get(member.environmentId)?.primary ?? member.label; - const address = environmentLabels.get(member.environmentId)?.secondary; - const error = - check?.status === "failed" - ? check.error - : check?.status === "local" - ? undefined - : status?.status === "failed" - ? status.detail - : undefined; - return { - ...member, - label, - address, - name: [label, address].filter(Boolean).join(" ยท "), - state, - status, - check, - summary, - error, - versions: ( - - ), - }; - }); - const first = results[0]; - if (!first) return null; - const { environmentId, status, check, summary, error } = first; - const platforms = - (check?.status === "connected" ? check.platforms : undefined) ?? summary?.platforms ?? []; - const progress = - check?.status === "pending" - ? "Checking connectionโ€ฆ" - : status?.status === "installing" - ? "Installing device supportโ€ฆ" - : status?.status === "starting" - ? "Connectingโ€ฆ" - : null; - const failed = results.filter( - (result) => - result.check?.status === "failed" || - (result.check?.status !== "local" && result.status?.status === "failed"), - ); - const pending = results.some( - (result) => - result.check?.status === "pending" || - result.status?.status === "installing" || - result.status?.status === "starting", - ); - const ready = results.every( - (result) => - result.check?.status === "connected" || - result.check?.status === "local" || - result.status?.status === "ready", - ); - const retryable = results.filter( - (result) => - result.status?.status === "failed" && - result.state?.supportsHostRetry && - result.state.hostStatus !== "disabled", - ); - const canRetry = !compact && retryable.includes(first); - const indicators = compact - ? [ - { - key: "connection", - label: failed.length - ? "Connection failed" - : pending - ? "Checking connections" - : ready - ? "Connected" - : "Connection not checked", - warning: failed.length > 0, - icon: failed.length ? ( - - ) : pending ? ( - - ) : ready ? ( - - ) : ( - - ), - detail: failed.length ? ( -
- {failed.map((result) => ( -

- {result.name}: {result.error || "Connection failed"} -

- ))} -
- ) : pending ? ( - "Checking connectionsโ€ฆ" - ) : ready ? ( - "Connected or available locally on each listed machine" - ) : ( - "Test the connection to check access from each machine" - ), - }, - ] - : platforms - .filter((platform) => platform.available) - .map((platform) => ({ - key: platform.platform, - label: platform.platform === "ios" ? "iOS available" : "Android available", - warning: false, - icon: - platform.platform === "ios" ? ( - // The Apple mark is bottom-heavy; lift it so it does not dip under the label. - - ) : ( - - ), - })); - return ( -
-
-
-

{host.label}

- {indicators.map((indicator) => ( - - - } - > - {indicator.icon} - - - {"detail" in indicator ? indicator.detail : indicator.label} - - - ))} -
-

{host.target}

- {results - .filter( - (result) => - result.status?.status === "ready" && result.status.androidDiscoveryIncomplete, - ) - .map((result) => ( -

- {compact ? `${result.label}: ` : ""} - {result.status?.detail ?? - "The Android device list may be incomplete. Refresh devices to try again."} -

- ))} - {compact ? ( - -
- } - > - - {summarizeLabels(results.map((result) => result.name))} - - - -
- -
- {results.map((result) => ( -
-

{result.label}

- {result.address ? ( -

- {result.address} -

- ) : null} - {result.error ? ( -

- {result.error} -

- ) : null} - {result.state ? ( - - ) : null} - {result.versions} - {result.check?.status === "local" ? ( -

- Already available locally -

- ) : ( - - )} -
- ))} -
-
-
- ) : ( - first.versions - )} - {!compact && check?.status === "local" ? ( -

Already available locally

- ) : null} - {!compact && error ? ( -
-
- Connection failed -

{error}

-
-
- ) : null} - {!compact - ? results.map((result) => - result.state ? ( - - ) : null, - ) - : null} -
- {!compact && progress ? ( - - - {progress} - - ) : null} - - - } - > - - - - onEdit(host)}>Edit - onRemove(host)}> - Remove - - {compact - ? retryable.map((result) => ( - - retryHost(result.environmentId, result.host.id, result.environmentId) - } - > - {retrying === result.environmentId ? "Retrying" : "Retry"} from{" "} - {result.label} - {result.address ? ` (${result.address})` : null} - - )) - : null} - - - -
- ); - })} - - ); -} - -function summarizeLabels(labels: ReadonlyArray) { - return `${labels.slice(0, 2).join(", ")}${labels.length > 2 ? ` +${labels.length - 2}` : ""}`; -} diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx new file mode 100644 index 0000000000000..69e23c3cdf964 --- /dev/null +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -0,0 +1,808 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Fragment, useState, type ReactNode } from "react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { + AuthSettingsWriteScope, + type DevicePlatform, + type DevicePlatformAvailability, + type DeviceServiceState, + type EasDeviceHostConfig, + type EnvironmentId, + type ServerSettingsPatch, + type SimlockDeviceHostConfig, + type SshDeviceHostConfig, +} from "@t3tools/contracts"; +import { ChevronDownIcon, MoreVertical, PlusIcon } from "lucide-react"; + +import { cn, randomUUID } from "../../lib/utils"; +import { deviceEnvironment } from "../../state/device"; +import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; +import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { AndroidIcon, AppleIcon } from "../Icons"; +import { Button, InlineButton } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; +import { toastManager } from "../ui/toast"; +import { DeviceHostEditor } from "./DeviceHostEditor"; +import { updateDeviceHosts, groupDeviceHosts } from "./deviceHostsSettings.logic"; +import { EasDeviceHostEditor } from "./EasDeviceHostEditor"; +import { + easSessionLimits, + newEasDeviceHost, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsSection } from "./settingsLayout"; +import { settingsScopeEnvironmentLabel } from "./settingsScopeAxis"; +import { SimlockDeviceHostEditor } from "./SimlockDeviceHostEditor"; +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockLeaseLimits, + updateSimlockDeviceHosts, + type SimlockTokenEntry, +} from "./simlockDeviceHostsSettings.logic"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; + +const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => + deviceEnvironment.state({ environmentId, input: {} }), +); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +/** Something on one environment that is in progress or needs a fix; a working source has none. */ +interface SourceStatus { + readonly tone: "pending" | "warning" | "error"; + readonly label: string; + readonly detail?: string | undefined; + readonly action?: { readonly label: string; readonly run: () => void } | undefined; +} + +/** A source on one environment whose devices are on. */ +interface SourceEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly added: boolean; + readonly status?: SourceStatus | undefined; + /** Unknown until the environment's hub has reached the source. */ + readonly platforms?: ReadonlyArray | undefined; +} + +interface SourceRow { + readonly key: string; + readonly title: string; + readonly kind?: string; + readonly description: ReactNode; + readonly environments: ReadonlyArray; + /** + * One machine or account every environment reaches the same way (an SSH host, EAS, a Simlock + * pool), so its platforms are the same for all of them. Local simulators differ per machine. + */ + readonly shared: boolean; + readonly actions?: ReactNode; +} + +type Editing = + | { + readonly kind: "ssh"; + readonly host: SshDeviceHostConfig; + readonly original: SshDeviceHostConfig | null; + /** Limits the save to these environments, for a host that differs between them. */ + readonly only?: ReadonlySet; + } + | { + readonly kind: "eas"; + readonly host: EasDeviceHostConfig; + readonly isNew: boolean; + /** Where the editor checks the Expo account: an environment that holds the host. */ + readonly environmentId: EnvironmentId; + } + | { readonly kind: "simlock"; readonly host: SimlockDeviceHostConfig; readonly isNew: boolean }; + +/** What a configured host is doing on one environment, when it is not simply working. */ +function hostStatus( + state: DeviceServiceState | undefined, + hostId: string, +): SourceStatus | undefined { + if (!state) return { tone: "pending", label: "Loading" }; + const status = state.hostStatuses[hostId]; + switch (status?.status) { + case "ready": + return status.androidDiscoveryIncomplete + ? { tone: "warning", label: "Android list may be incomplete", detail: status.detail } + : undefined; + case "installing": + case "starting": + return { tone: "pending", label: "Starting", detail: status.detail }; + case "failed": + return { tone: "error", label: "Failed", detail: status.detail }; + default: + return undefined; + } +} + +const hostPlatforms = (state: DeviceServiceState | undefined, hostId: string) => + state?.hosts.find((host) => host.id === hostId)?.platforms; + +/** + * Every device source the selected environments can open, one row each. A source configured on + * several environments is one row; how it stands on each lives in the row's breakdown. + */ +export function DeviceSourcesSettings(props: { + readonly supportsEas: boolean; + readonly supportsSimlock: boolean; +}) { + const { scope, environments, connectedEnvironments, environment: primary } = useSettingsScope(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const label = (environment: (typeof environments)[number]) => + settingsScopeEnvironmentLabel(environment, environments); + const targets = environments.map((environment) => ({ + environmentId: environment.environmentId, + label: label(environment), + connected: environment.connection.phase === "connected", + })); + const { testConnection } = useHostConnectionChecks(targets); + const ids = connectedEnvironments.map((environment) => environment.environmentId); + const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(ids))); + const members = connectedEnvironments.map((environment, index) => { + const result = results[index]; + return { + environmentId: environment.environmentId, + label: label(environment), + settings: environment.serverConfig?.settings, + state: result ? Option.getOrUndefined(AsyncResult.value(result)) : undefined, + }; + }); + // Environments with devices off are left out: the switch above already says so. + const active = members.filter((member) => member.settings?.enableDeviceSupport); + const simlockEnabled = members.some((member) => member.settings?.simlockDeviceHostsEnabled); + // Like every scoped setting, a change reaches the connected environments and skips the rest. + const disabled = projectScope || busy || members.length === 0; + + /** Writes one settings patch per environment; reports the environments it could not change. */ + const write = async ( + patches: ReadonlyArray<{ environmentId: EnvironmentId; patch: ServerSettingsPatch }>, + failedTitle: string, + ) => { + if (projectScope) return false; + const name = (environmentId: EnvironmentId) => + members.find((member) => member.environmentId === environmentId)?.label ?? environmentId; + const unwritable = patches.filter( + (patch) => !readEnvironmentScope(patch.environmentId, AuthSettingsWriteScope), + ); + if (unwritable.length > 0) { + toastManager.add({ + type: "error", + title: failedTitle, + description: `This connection cannot change settings on ${unwritable.map((patch) => name(patch.environmentId)).join(", ")}.`, + }); + return false; + } + setBusy(true); + const outcomes = await Promise.all( + patches.map((patch) => + update({ environmentId: patch.environmentId, input: { patch: patch.patch } }), + ), + ); + setBusy(false); + const failed = patches.flatMap((patch, index) => { + const outcome = outcomes[index]; + return outcome && outcome._tag === "Failure" + ? [`${name(patch.environmentId)}: ${failureMessage(outcome.cause)}`] + : []; + }); + if (failed.length === 0) return true; + toastManager.add({ type: "error", title: failedTitle, description: failed.join("\n") }); + return false; + }; + + const saveSsh = async ( + host: SshDeviceHostConfig, + remove: boolean, + original = host, + only?: ReadonlySet, + ) => { + try { + const saved = await write( + members + .filter((member) => !only || only.has(member.environmentId)) + .map((member) => ({ + environmentId: member.environmentId, + patch: { + deviceHosts: updateDeviceHosts( + member.settings?.deviceHosts ?? [], + host, + remove, + original, + ), + }, + })), + remove ? "Device host not removed everywhere" : "Device host not saved everywhere", + ); + if (saved) setEditing(null); + } catch (error) { + toastManager.add({ + type: "error", + title: "Device host not saved", + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + const saveEas = async ( + host: EasDeviceHostConfig, + remove: boolean, + previous?: EasDeviceHostConfig, + ) => { + const keepToken = previous !== undefined && host.accessToken === previous.accessToken; + const saved = await write( + members.map((member) => ({ + environmentId: member.environmentId, + patch: { + easDeviceHosts: updateEasDeviceHosts( + member.settings?.easDeviceHosts ?? [], + host, + remove, + keepToken, + ), + }, + })), + remove ? "EAS host not removed everywhere" : "EAS host not saved everywhere", + ); + if (saved) setEditing(null); + }; + + const simlockEnvironments = members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.simlockDeviceHosts ?? [], + })); + const saveSimlock = async ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => { + const plan = planSimlockDeviceHostSave(simlockEnvironments, host, tokens); + if ("problem" in plan) return; + const saved = await write( + plan.patches.map((patch) => ({ + environmentId: patch.environmentId, + patch: { simlockDeviceHosts: patch.hosts }, + })), + "Simlock host not saved everywhere", + ); + if (saved) setEditing(null); + }; + const removeSimlock = (host: SimlockDeviceHostConfig) => + void write( + simlockEnvironments + .filter((environment) => environment.hosts.some((candidate) => candidate.id === host.id)) + .map((environment) => ({ + environmentId: environment.environmentId, + patch: { simlockDeviceHosts: updateSimlockDeviceHosts(environment.hosts, host, true) }, + })), + "Simlock host not removed everywhere", + ); + + const [testing, setTesting] = useState>(new Set()); + const testSsh = async (host: SshDeviceHostConfig) => { + setTesting((current) => new Set(current).add(host.id)); + const checked = await testConnection(host).finally(() => + setTesting((current) => { + const next = new Set(current); + next.delete(host.id); + return next; + }), + ); + if (!checked) return; + const failed = targets.filter((target) => checked[target.environmentId]?.status === "failed"); + toastManager.add({ + type: failed.length ? "error" : "success", + title: failed.length + ? `${host.label}: ${failed.length} of ${targets.length} environments failed` + : `${host.label}: connection checks passed`, + description: failed.length + ? failed + .map((target) => { + const result = checked[target.environmentId]; + return `${target.label}: ${result?.status === "failed" ? result.error : "failed"}`; + }) + .join("\n") + : "Connected or already available locally on each selected environment.", + }); + }; + + const retry = (environmentId: EnvironmentId, hostId: string) => + void list({ environmentId, input: { retryHostId: hostId } }); + + const rowActions = (onEdit: () => void, onRemove: () => void, extra?: ReactNode) => ( + <> + + + + } + > + + + + {extra} + + Remove + + + + + ); + + const rows: SourceRow[] = []; + + rows.push({ + key: "local", + shared: false, + title: "Local simulators", + description: + members.length > 1 + ? "Simulators and emulators installed on each environment's own machine." + : "Simulators and emulators installed on this environment's machine.", + environments: active.map((member) => { + const local = member.state?.hosts.find((host) => host.kind === "local"); + const status = local ? hostStatus(member.state, local.id) : hostStatus(member.state, ""); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: local?.platforms, + status: + status?.tone === "error" + ? { + ...status, + action: { + label: "Refresh", + run: () => void list({ environmentId: member.environmentId, input: {} }), + }, + } + : status, + }; + }), + }); + + const grouped = groupDeviceHosts( + members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.deviceHosts ?? [], + })), + ); + const sshGroups = + grouped ?? + // Hosts that cannot be matched across environments stay one row per environment. + members.flatMap((member) => + (member.settings?.deviceHosts ?? []).map((host) => ({ + host, + members: [{ environmentId: member.environmentId, label: member.label, host }], + })), + ); + for (const group of sshGroups) { + const host = group.host; + const holders = new Set(group.members.map((member) => member.environmentId)); + rows.push({ + key: `ssh:${host.id}:${group.members.map((member) => member.environmentId).join(",")}`, + shared: true, + title: host.label, + kind: "SSH", + description: host.target, + environments: active.map((member) => { + const held = group.members.find( + (candidate) => candidate.environmentId === member.environmentId, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + const status = testing.has(host.id) + ? ({ tone: "pending", label: "Testing connection" } as const) + : hostStatus(member.state, held.host.id); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, held.host.id), + status: + status?.tone === "error" && member.state?.supportsHostRetry + ? { + ...status, + label: "Can't connect", + action: { label: "Retry", run: () => retry(member.environmentId, held.host.id) }, + } + : status, + }; + }), + actions: rowActions( + () => + setEditing({ kind: "ssh", host, original: host, ...(grouped ? {} : { only: holders }) }), + () => void saveSsh(host, true, host, holders), + void testSsh(host)}>Test connection, + ), + }); + } + + if (props.supportsEas) { + const easHosts = new Map(); + for (const member of members) + for (const host of member.settings?.easDeviceHosts ?? []) + if (!easHosts.has(host.id)) easHosts.set(host.id, host); + for (const host of easHosts.values()) { + const holders = members.filter((member) => + (member.settings?.easDeviceHosts ?? []).some((candidate) => candidate.id === host.id), + ); + const [firstHolder] = holders; + if (!firstHolder) continue; + const checkOn = ( + holders.find((member) => member.environmentId === primary?.environmentId) ?? firstHolder + ).environmentId; + rows.push({ + key: `eas:${host.id}`, + shared: true, + title: host.label, + kind: "EAS", + description: easSessionLimits(host), + environments: active.map((member) => { + const added = (member.settings?.easDeviceHosts ?? []).some( + (candidate) => candidate.id === host.id, + ); + return { + environmentId: member.environmentId, + label: member.label, + added, + ...(added + ? { + status: hostStatus(member.state, host.id), + platforms: hostPlatforms(member.state, host.id), + } + : {}), + }; + }), + actions: rowActions( + () => setEditing({ kind: "eas", host, isNew: false, environmentId: checkOn }), + () => void saveEas(host, true), + ), + }); + } + } + + if (props.supportsSimlock && simlockEnabled) { + const simlockHosts = new Map(); + for (const environment of simlockEnvironments) + for (const host of environment.hosts) + if (!simlockHosts.has(host.id)) simlockHosts.set(host.id, host); + for (const host of simlockHosts.values()) { + const edit = () => setEditing({ kind: "simlock", host, isNew: false }); + rows.push({ + key: `simlock:${host.id}`, + shared: true, + title: host.label, + kind: "Simlock", + description: `${host.gatewayUrl.replace(/^https?:\/\//, "")} ยท ${simlockLeaseLimits(host)}`, + environments: active.map((member) => { + const held = (member.settings?.simlockDeviceHosts ?? []).find( + (candidate) => candidate.id === host.id, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, host.id), + status: + held.agentToken === "" + ? ({ + tone: "warning", + label: "No agent token", + ...(disabled ? {} : { action: { label: "Add token", run: edit } }), + } as const) + : hostStatus(member.state, host.id), + }; + }), + actions: rowActions(edit, () => removeSimlock(host)), + }); + } + } + + const addMenu = ( + + } + > + Add source + + + + + setEditing({ + kind: "ssh", + host: { kind: "ssh", id: randomUUID(), label: "", target: "" }, + original: null, + }) + } + > + Machine over SSH + + {props.supportsEas ? ( + + primary && + setEditing({ + kind: "eas", + host: newEasDeviceHost(randomUUID()), + isNew: true, + environmentId: primary.environmentId, + }) + } + > + EAS Simulator + + ) : null} + {props.supportsSimlock && simlockEnabled ? ( + + setEditing({ kind: "simlock", host: newSimlockDeviceHost(randomUUID()), isNew: true }) + } + > + Simlock pool (alpha) + + ) : null} + + + ); + + return ( + + {rows.map((row) => ( + + ))} + {editing?.kind === "ssh" ? ( + void saveSsh(host, false, editing.original ?? host, editing.only)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "eas" ? ( + void saveEas(host, false, editing.isNew ? undefined : editing.host)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "simlock" ? ( + void saveSimlock(host, tokens)} + onClose={() => setEditing(null)} + /> + ) : null} + + ); +} + +const PLATFORMS = [ + { platform: "ios", label: "iOS", Icon: AppleIcon }, + { platform: "android", label: "Android", Icon: AndroidIcon }, +] as const satisfies ReadonlyArray<{ platform: DevicePlatform; label: string; Icon: unknown }>; + +/** The platforms a source offers: one icon each, solid where available, muted where not. */ +function SourcePlatforms(props: { row: SourceRow }) { + const added = props.row.environments.filter((environment) => environment.added); + const known = added.filter((environment) => environment.platforms !== undefined); + return PLATFORMS.filter(({ platform }) => + known.some((environment) => + environment.platforms?.some((candidate) => candidate.platform === platform), + ), + ).map((entry) => ); +} + +function PlatformIcon(props: { + platform: DevicePlatform; + label: string; + Icon: (typeof PLATFORMS)[number]["Icon"]; + row: SourceRow; + known: ReadonlyArray; +}) { + const availability = (environment: SourceEnvironment) => + environment.platforms?.find((candidate) => candidate.platform === props.platform); + const availableOn = props.known.filter((environment) => availability(environment)?.available); + const several = props.row.environments.length > 1; + const available = availableOn.length > 0; + const partial = available && availableOn.length < props.known.length; + // A shared source usually answers the same everywhere, so its answer is given once. Each + // environment still signs in to it on its own, which is where they can differ. + const sharedAnswer = props.known.map(availability).find(Boolean); + const uniform = props.known.every( + (environment) => + availability(environment)?.available === sharedAnswer?.available && + availability(environment)?.reason === sharedAnswer?.reason, + ); + const unavailableOn = props.known.filter((environment) => !availability(environment)?.available); + const connected = props.row.environments.filter((environment) => environment.added); + const notAdded = props.row.environments.filter((environment) => !environment.added); + return ( + + + } + > + + {partial ? ( + + ) : null} + + +

+ {props.label}{" "} + {available ? (partial ? "available on some" : "available") : "not available"} +

+ {props.row.shared ? ( + <> + {uniform && !available && sharedAnswer?.reason ? ( +

{sharedAnswer.reason}

+ ) : null} + {several ? ( +
+ {( + [ + ...(uniform + ? ([["Connected", connected]] as const) + : ([ + ["Available", availableOn], + ["Not available", unavailableOn], + ] as const)), + ["Not added", notAdded], + ] as const + ).map(([heading, environments]) => + environments.length > 0 ? ( +
+
{heading}
+ {environments.map((environment) => { + const reason = + heading === "Not available" + ? availability(environment)?.reason + : undefined; + return ( +
+ {environment.label} + {reason ? ( + {reason} + ) : null} +
+ ); + })} +
+ ) : null, + )} +
+ ) : null} + + ) : several ? ( +
+ {props.known.map((environment) => { + const entry = availability(environment); + return ( + +
{environment.label}
+
+ {entry?.available ? "Available" : (entry?.reason ?? "Not available")} +
+
+ ); + })} +
+ ) : !available ? ( +

+ {availability(props.known[0]!)?.reason ?? "Not set up on this machine."} +

+ ) : null} +
+
+ ); +} + +function DeviceSourceRow(props: { row: SourceRow }) { + const { row } = props; + const several = row.environments.length > 1; + const issues = row.environments.filter( + (environment): environment is SourceEnvironment & { status: SourceStatus } => + environment.status !== undefined, + ); + const problems = issues.filter((environment) => environment.status.tone !== "pending"); + const pending = issues.find((environment) => environment.status.tone === "pending"); + return ( +
+
+
+

{row.title}

+ {row.kind ? ( + + {row.kind} + + ) : null} +
+

{row.description}

+ {problems.map((environment) => ( +

+ + {several ? `${environment.label}: ` : ""} + {!several && environment.status.detail + ? environment.status.detail + : environment.status.label} + + {environment.status.action ? ( + + {environment.status.action.label} + + ) : null} +

+ ))} +
+ {pending ? ( + + + {pending.status.label} + + ) : ( +
+ +
+ )} + {row.actions ?
{row.actions}
: null} +
+ ); +} diff --git a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx b/apps/web/src/components/settings/EasDeviceHostEditor.tsx similarity index 72% rename from apps/web/src/components/settings/EasDeviceHostsSettings.tsx rename to apps/web/src/components/settings/EasDeviceHostEditor.tsx index 43c80cdb740c0..220f5ffa3269b 100644 --- a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/EasDeviceHostEditor.tsx @@ -3,17 +3,13 @@ import { useId, useRef, useState } from "react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { - AuthSettingsWriteScope, type DeviceEasAccount, type EasDeviceHostConfig, type EnvironmentId, } from "@t3tools/contracts"; -import { CloudIcon, PlusIcon, XIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; -import { randomUUID } from "../../lib/utils"; import { deviceEasAccountCheck, deviceEnvironment } from "../../state/device"; -import { serverEnvironment } from "../../state/server"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { @@ -28,16 +24,8 @@ import { import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Spinner } from "../ui/spinner"; -import { toastManager } from "../ui/toast"; -import { - easSessionLimits, - newEasDeviceHost, - parseEasDeviceHostDraft, - updateEasDeviceHosts, -} from "./easDeviceHostsSettings.logic"; +import { parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; import { SecretInput } from "./SecretInput"; -import { useSettingsScope } from "./SettingsScopeContext"; -import { SettingsRow } from "./settingsLayout"; const FROM_THREAD_PROJECT = "thread-project"; @@ -52,116 +40,13 @@ const failureMessage = (cause: Cause.Cause) => { return error instanceof Error ? error.message : String(error); }; -/** EAS hosts belong to one environment: its server holds the Expo credential and runs the sessions. */ -export function EasDeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, connectedEnvironments } = useSettingsScope(); - const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); - const projectScope = scope.kind === "project" || scope.kind === "checkout"; - const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); - const environment = connectedEnvironments.find( - (candidate) => candidate.environmentId === props.environmentId, - ); - const hosts = environment?.serverConfig?.settings.easDeviceHosts ?? []; - const [editing, setEditing] = useState<{ host: EasDeviceHostConfig; isNew: boolean } | null>( - null, - ); - const [busy, setBusy] = useState(false); - const disabled = !canConfigure || projectScope || busy || !props.environmentId; - - const save = async (host: EasDeviceHostConfig, remove: boolean) => { - if (!props.environmentId || !readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) - return; - setBusy(true); - const result = await update({ - environmentId: props.environmentId, - input: { patch: { easDeviceHosts: updateEasDeviceHosts(hosts, host, remove) } }, - }); - setBusy(false); - if (result._tag === "Success") setEditing(null); - else - toastManager.add({ - type: "error", - title: remove ? "EAS host not removed" : "EAS host not saved", - description: failureMessage(result.cause), - }); - }; - - return ( - setEditing({ host: newEasDeviceHost(randomUUID()), isNew: true })} - > - Add EAS host - - } - > -
- {hosts.length === 0 ? ( -

No EAS hosts.

- ) : ( -
    - {hosts.map((host) => ( -
  • -
    -

    - {host.label} -

    -

    {easSessionLimits(host)}

    -
    -
    - - -
    -
  • - ))} -
- )} - {editing && props.environmentId ? ( - void save(host, false)} - onClose={() => setEditing(null)} - /> - ) : null} -
-
- ); -} - type AccountCheck = | { status: "idle" } | { status: "checking" } | { status: "signed-in"; account: DeviceEasAccount } | { status: "failed"; error: string }; -function EasDeviceHostEditor(props: { +export function EasDeviceHostEditor(props: { environmentId: EnvironmentId; host: EasDeviceHostConfig; isNew: boolean; diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 58e5d6877c827..5239f57bfdc37 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -2,8 +2,7 @@ import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; -import { DeviceHostsSettings } from "./DeviceHostsSettings"; -import { EasDeviceHostsSettings } from "./EasDeviceHostsSettings"; +import { DeviceSourcesSettings } from "./DeviceSourcesSettings"; import { AuthSettingsWriteScope, BrowserImportFailureReason, @@ -42,7 +41,6 @@ import { MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; -import { AnimatedHeight } from "~/components/AnimatedHeight"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; @@ -58,11 +56,6 @@ import { AgentDeviceSetupStatus, DeviceHubSetupStatus, deviceHubReadiness, - PlatformStatus, - platformSetupStatus, - deviceHubDescription, - agentDeviceDescription, - DeviceHostDiagnostics, } from "~/components/device/DeviceSetup"; import { isElectron } from "../../env"; @@ -797,15 +790,23 @@ function DeviceIntegrationSettings() { const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; const environmentId = connected ? selected.environmentId : null; + const { state } = useDeviceState(environmentId); + return ( - - + + + + - + ); } @@ -831,20 +832,7 @@ function DeviceIntegrationControls({ const [pending, setPending] = useState< "hub" | "check" | "agent" | "update-hub" | "update-agent" | null >(null); - const { ready: hubReady, busy } = deviceHubReadiness(state); - const localPlatformsUnavailable = state.hosts.some( - (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available), - ); - const [platformsRevealed, setPlatformsRevealed] = useState(false); - if (platformsRevealed && !enabled) setPlatformsRevealed(false); - if ( - enabled && - !platformsRevealed && - (hubReady || localPlatformsUnavailable) && - pending !== "hub" - ) { - setPlatformsRevealed(true); - } + const { busy } = deviceHubReadiness(state); const update = async ( kind: NonNullable, @@ -941,14 +929,18 @@ function DeviceIntegrationControls({ {...searchableSetting("device-hub")} serverScoped settingKeys={["enableDeviceSupport"]} - description={deviceHubDescription} - control={ + description={ <> + Open simulators and emulators in the Device panel. Hub:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "hub" ? : null} } /> - - {platformsRevealed ? ( - 1 - ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.` - : undefined - } - status={ -
-
- - -
- {/* Remote hosts report their own warnings in the device host list below. */} - host.kind === "local")} - hostStatuses={state.hostStatuses} - /> -
- } - control={ - - } - /> - ) : null} -
+ New agent sessions can open and drive these devices. agent-device:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "agent" ? : null} ) : null} - - {state.supportsEasHosts ? : null} ); } diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 15c943728f144..ce3f68eac49d0 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -21,6 +21,7 @@ export function LabsSettingsPanel() { {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -148,3 +149,43 @@ function LinkedEnvironmentsRow() { /> ); } + +function SimlockHostsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + Let the Device panel lease simulators and emulators from your own{" "} + + Simlock + {" "} + pool, added in{" "} + + Devices + + . An alpha that may change or break. T3 Code never installs Simlock. + + } + control={ + + updateSettings({ simlockDeviceHostsEnabled: Boolean(checked) }) + } + aria-label="Simlock device hosts" + /> + } + /> + ); +} diff --git a/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx new file mode 100644 index 0000000000000..7564d86e67a25 --- /dev/null +++ b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx @@ -0,0 +1,224 @@ +import { useId, useState, type ReactNode } from "react"; +import type { EnvironmentId, SimlockDeviceHostConfig } from "@t3tools/contracts"; +import { XIcon } from "lucide-react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Spinner } from "../ui/spinner"; +import { SecretInput } from "./SecretInput"; +import { + planSimlockDeviceHostSave, + type SimlockHostEnvironment, + type SimlockTokenEntry, +} from "./simlockDeviceHostsSettings.logic"; + +/** Shared fields once, then one agent token per environment: Simlock counts leases per token. */ +export function SimlockDeviceHostEditor(props: { + host: SimlockDeviceHostConfig; + isNew: boolean; + environments: ReadonlyArray; + busy: boolean; + onSave: ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => void; + onClose: () => void; +}) { + const [draft, setDraft] = useState(props.host); + const savedToken = (environment: SimlockHostEnvironment) => + environment.hosts.some((host) => host.id === props.host.id && host.agentToken !== ""); + const [tokens, setTokens] = useState>( + () => + new Map( + props.environments.map((environment) => [ + environment.environmentId, + savedToken(environment) ? { kind: "saved" } : { kind: "cleared" }, + ]), + ), + ); + const next: SimlockDeviceHostConfig = { + ...draft, + label: draft.label.trim(), + gatewayUrl: draft.gatewayUrl.trim(), + }; + const plan = planSimlockDeviceHostSave(props.environments, next, tokens); + const problem = "problem" in plan ? plan.problem : null; + const several = props.environments.length > 1; + + const limit = ( + field: "maxLeases" | "maxLeaseMinutes", + label: string, + min: number, + max: number, + ) => ( + + ); + + return ( + { + if (!open && !props.busy) props.onClose(); + }} + > + +
{ + event.preventDefault(); + if (problem === null && !props.busy) props.onSave(next, tokens); + }} + > + + {props.isNew ? "Add Simlock host" : "Edit Simlock host"} + + {several + ? "Each selected environment's server leases devices from your Simlock gateway with its own agent token, and releases them when they close." + : "The environment's server leases devices from your Simlock gateway and releases them when they close."} + + + + + +
+ {props.environments.map((environment) => ( + + setTokens((current) => new Map(current).set(environment.environmentId, token)) + } + /> + ))} +

+ Leases, drives, and streams devices. Create one{several ? " per environment" : ""}{" "} + with simlock --gateway token create --role agent. +

+
+
+ {limit("maxLeases", "Devices at once", 1, 20)} + {limit("maxLeaseMinutes", "Lease minutes", 5, 240)} +
+

+ A lease ends after the lease minutes even while in use. + {props.isNew ? null : " Saving restarts this host, which releases its devices."} +

+ {problem === null || !draft.gatewayUrl ? null : ( +

+ {problem} +

+ )} +
+ + + + +
+
+
+ ); +} + +function SimlockTokenField(props: { + label: ReactNode; + token: SimlockTokenEntry; + hasSaved: boolean; + busy: boolean; + onChange: (token: SimlockTokenEntry) => void; +}) { + const id = useId(); + return ( +
+ +
+ { + const value = event.target.value; + // Emptying the field is not clearing the saved token; the Clear + // button is, so a saved token comes back here. + props.onChange( + value + ? { kind: "new", value } + : props.hasSaved + ? { kind: "saved" } + : { kind: "cleared" }, + ); + }} + /> + {props.token.kind !== "cleared" ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts index 545d1ac212ec7..c3105b115e21f 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as Option from "effect/Option"; -import { newEasDeviceHost, parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; +import { + newEasDeviceHost, + parseEasDeviceHostDraft, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; describe("EAS device host settings", () => { it("rejects limits EAS does not allow", () => { @@ -18,4 +22,15 @@ describe("EAS device host settings", () => { true, ); }); + + it("keeps each environment's own token when a save leaves the token unchanged", () => { + const edited = { ...newEasDeviceHost("eas"), label: "Renamed", accessToken: "" }; + const elsewhere = { ...newEasDeviceHost("eas"), accessToken: "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" }; + expect(updateEasDeviceHosts([elsewhere], edited, false, true)).toEqual([ + { ...edited, accessToken: "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" }, + ]); + expect(updateEasDeviceHosts([], { ...edited, accessToken: "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" }, false, true)).toEqual([ + { ...edited, accessToken: "" }, + ]); + }); }); diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts index 418a15d0064e1..d91fe45bb9205 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts @@ -17,15 +17,22 @@ export const newEasDeviceHost = (id: string): EasDeviceHostConfigType => ({ }); /** Adds, replaces, or removes one EAS host. */ +/** + * With `keepToken`, the environment keeps the token it holds: a saved token arrives redacted, and + * one environment's token says nothing about another's. One new to the host uses its own Expo login. + */ export function updateEasDeviceHosts( hosts: ReadonlyArray, host: EasDeviceHostConfigType, remove: boolean, + keepToken = false, ): ReadonlyArray { if (remove) return hosts.filter((candidate) => candidate.id !== host.id); - return hosts.some((candidate) => candidate.id === host.id) - ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) - : [...hosts, host]; + const existing = hosts.find((candidate) => candidate.id === host.id); + const next = keepToken ? { ...host, accessToken: existing?.accessToken ?? "" } : host; + return existing + ? hosts.map((candidate) => (candidate.id === host.id ? next : candidate)) + : [...hosts, next]; } export function easSessionLimits(host: EasDeviceHostConfigType) { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 325d0c1ba75e2..36f63bf64ba8a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -451,6 +451,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment", searchTerms: ["beta link another machine vps peer agents work there delegate"], }, + { + id: "simlock-hosts", + title: "Simlock device hosts (alpha)", + to: "/settings/labs", + scope: "environment", + searchTerms: ["beta alpha simlock callstack simulator emulator ios android pool lease gateway"], + }, { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", @@ -806,31 +813,33 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["allow disable enable open drive preview tools sessions project override"], }, { - id: "device-hosts", - title: "Device hosts", + id: "device-sources", + title: "Device sources", to: "/settings/devices", - searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"], + searchTerms: [ + "device hosts ssh remote mac mini identity key connection eas expo cloud simlock pool gateway local simulators", + ], }, { id: "agent-device-access", - title: "Agent device access", + title: "Agents can use devices", to: "/settings/devices", targetId: "devices", searchTerms: ["allow simulator emulator ios android drive tools sessions"], }, { id: "device-hub", - title: "Device hub", + title: "Use devices", to: "/settings/devices", targetId: "devices", - searchTerms: ["simulator emulator ios android install start"], + searchTerms: ["device hub simulator emulator ios android install start version"], }, { id: "device-platform-support", - title: "Simulator support", + title: "Local simulators", to: "/settings/devices", - targetId: "devices", - searchTerms: ["xcode android studio sdk avd runtime"], + targetId: "device-sources", + searchTerms: ["simulator support xcode android studio sdk avd runtime"], }, { id: "browser-profiles", diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts new file mode 100644 index 0000000000000..752ab616bdb58 --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockDeviceHostProblem, + updateSimlockDeviceHosts, +} from "./simlockDeviceHostsSettings.logic"; + +const host = { + ...newSimlockDeviceHost("simlock"), + gatewayUrl: "https://gateway.example.ts.net:4700", + agentToken: "agent", +}; + +describe("Simlock device host settings", () => { + it("names what keeps a draft from saving", () => { + expect(simlockDeviceHostProblem(host)).toBeNull(); + expect(simlockDeviceHostProblem({ ...host, label: " " })).toBe("Give the host a label."); + expect(simlockDeviceHostProblem({ ...host, gatewayUrl: "gateway:4700" })).toMatch(/origin/); + expect( + simlockDeviceHostProblem({ ...host, gatewayUrl: "https://gateway.example/api" }), + ).toMatch(/origin/); + expect(simlockDeviceHostProblem({ ...host, maxLeases: 21 })).toMatch(/ranges/); + expect(simlockDeviceHostProblem({ ...host, maxLeaseMinutes: 4 })).toMatch(/ranges/); + }); + + it("adds, replaces, and removes one host", () => { + const other = { ...host, id: "other", label: "Other" }; + const added = updateSimlockDeviceHosts([other], host, false); + expect(added.map((candidate) => candidate.id)).toEqual(["other", "simlock"]); + const renamed = updateSimlockDeviceHosts(added, { ...host, label: "Renamed" }, false); + expect(renamed.map((candidate) => candidate.label)).toEqual(["Other", "Renamed"]); + expect(updateSimlockDeviceHosts(renamed, host, true)).toEqual([other]); + }); + + it("saves the shared fields everywhere and each environment's own token", () => { + const mbp = "mbp" as EnvironmentId; + const cups = "cups" as EnvironmentId; + const environments = [ + { environmentId: mbp, label: "mbp", hosts: [{ ...host, agentToken: "" }] }, + { environmentId: cups, label: "cups", hosts: [] }, + ]; + const draft = { ...host, label: "Pool", agentToken: "" }; + + expect( + planSimlockDeviceHostSave(environments, draft, new Map([[mbp, { kind: "saved" }]])), + ).toEqual({ + problem: "Add an agent token for cups.", + }); + expect( + planSimlockDeviceHostSave( + environments, + draft, + new Map([ + [mbp, { kind: "saved" }], + [cups, { kind: "new", value: " cups-token " }], + ]), + ), + ).toEqual({ + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "cups-token" }] }, + ], + }); + }); +}); diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts new file mode 100644 index 0000000000000..3a2f65b1d9065 --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -0,0 +1,94 @@ +import { + SimlockDeviceHostConfig, + type EnvironmentId, + type SimlockDeviceHostConfig as SimlockDeviceHostConfigType, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const parseSimlockDeviceHostDraft = Schema.decodeUnknownOption(SimlockDeviceHostConfig); + +export const newSimlockDeviceHost = (id: string): SimlockDeviceHostConfigType => ({ + kind: "simlock", + id, + label: "Simlock", + gatewayUrl: "", + agentToken: "", + maxLeases: 2, + maxLeaseMinutes: 60, +}); + +/** Adds, replaces, or removes one Simlock host. */ +export function updateSimlockDeviceHosts( + hosts: ReadonlyArray, + host: SimlockDeviceHostConfigType, + remove: boolean, +): ReadonlyArray { + if (remove) return hosts.filter((candidate) => candidate.id !== host.id); + return hosts.some((candidate) => candidate.id === host.id) + ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) + : [...hosts, host]; +} + +/** Why the shared fields keep a draft from saving, or null; tokens are checked per environment. */ +export function simlockDeviceHostProblem(host: SimlockDeviceHostConfigType): string | null { + const withToken = { ...host, agentToken: "token" }; + if (Option.isSome(parseSimlockDeviceHostDraft(withToken))) return null; + if (!host.label.trim()) return "Give the host a label."; + // With in-range limits, a draft that still fails has a bad gateway URL. + return Option.isNone( + parseSimlockDeviceHostDraft({ ...withToken, maxLeases: 1, maxLeaseMinutes: 60 }), + ) + ? "Enter the gateway's origin, such as https://gateway.example.ts.net:4700." + : "Keep the limits within the ranges shown."; +} + +export function simlockLeaseLimits(host: SimlockDeviceHostConfigType) { + const devices = host.maxLeases === 1 ? "1 device" : `${host.maxLeases} devices`; + return `Up to ${devices} at once, each released after ${host.maxLeaseMinutes} min`; +} + +export interface SimlockHostEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly hosts: ReadonlyArray; +} + +/** A token field: the environment's saved token, a replacement, or none. */ +export type SimlockTokenEntry = + | { kind: "saved" } + | { kind: "new"; value: string } + | { kind: "cleared" }; + +/** + * The host list each environment saves: the shared fields everywhere, and each environment's own + * token. Saved tokens arrive redacted, and sending the redacted value back keeps them. + */ +export function planSimlockDeviceHostSave( + environments: ReadonlyArray, + draft: SimlockDeviceHostConfigType, + tokens: ReadonlyMap, +): + | { readonly problem: string } + | { + readonly patches: ReadonlyArray<{ + environmentId: EnvironmentId; + hosts: ReadonlyArray; + }>; + } { + const shared = simlockDeviceHostProblem(draft); + if (shared) return { problem: shared }; + const patches = []; + for (const environment of environments) { + const saved = environment.hosts.find((host) => host.id === draft.id)?.agentToken ?? ""; + const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" }; + const agentToken = + entry.kind === "saved" ? saved : entry.kind === "new" ? entry.value.trim() : ""; + if (!agentToken) return { problem: `Add an agent token for ${environment.label}.` }; + patches.push({ + environmentId: environment.environmentId, + hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), + }); + } + return { patches }; +} diff --git a/docs/internals/devices.md b/docs/internals/devices.md index c50cb504a84e7..5273f7942e9e0 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -44,6 +44,27 @@ clients never hold it. Agent config files are written per device device before targeting it. Device settings run `simctl` on the simulator's machine, which EAS does not expose, so they are unavailable there. +## Simlock hosts lease through the agent-device front + +A [Simlock host](../../apps/server/src/device/SimlockDeviceHost.ts) (alpha) opens a +device by running T3's own agent-device CLI against the gateway's agent-device +front (`connect proxy`, then `open`), not with Simlock's lease HTTP API: +agent-device cannot adopt a lease it did not allocate. The lease is bound to +that one agent-device session and to the directory `open` ran in, so +`agentEndpoint` hands every thread the same session, a `stateDir` holding its +connection record, and no `--udid`/`--serial` (the gateway refuses selectors). +Installs work only from that directory; `device_open` names it. + +Simlock serves a lease's screen and input to its holder in serve-sim's formats +on both platforms: AVCC envelopes at `/v1/leases//stream` and serve-sim's +input messages at `/v1/leases//input`, both with the agent token. Clients +therefore treat a Simlock Android lease like iOS (`serveSimOnly` in the stream +client), and `upstream` maps serve-sim's `stream.avcc` and `helper/ws` paths to +those routes. Simlock has no MJPEG, screenshot, or host command routes, so the +panel offers only input there. +Leases are renewed while tracked and released at the host's age limit; one a +crashed server left behind ends on its own one TTL later, so there is no ledger. + ## The hub is never exposed serve-sim has a shell-exec route whose token is readable from its own diff --git a/docs/user/devices.md b/docs/user/devices.md index 42c0db4b27afa..ce5a7d8d06d05 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -22,7 +22,7 @@ Choose **Float device over chat** in the toolbar to keep watching and tapping th device in a small window while the right panel shows something else; drag the window by its handle, resize it from any edge, and use **Open in right panel** to bring it back. -Turn off the device hub in **Settings โ†’ Devices** to stop the +Turn off **Use devices** in **Settings โ†’ Devices** to stop the helper processes; simulators and emulators keep running until you power them off. @@ -80,12 +80,12 @@ viewer stops streaming and leaves the device available to the agent. Device activity also appears in the thread timeline. Agents drive the device through the `agent-device` command line. T3 -Code installs and starts it only after **Agent device access** is enabled. iOS +Code installs and starts it only after **Agents can use devices** is on. iOS taps build a small test runner on first use, which takes a couple of minutes once per server. Restart an existing agent session after granting access so it receives the device CLI environment. -To keep agents away from simulators, turn off **Agent device access** in +To keep agents away from simulators, turn off **Agents can use devices** in **Settings โ†’ Devices**. This hides the device tools from agents started from then on; your own Device panel is unaffected. @@ -99,12 +99,12 @@ still-image stream and Android cannot show video. ## SSH device hosts In Settings โ†’ Devices, choose the environments that should use -the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with +the host and choose **Add source โ†’ Machine over SSH**. Enter an SSH alias or `user@host`, with an optional identity file and port. These resolve on the environment server, so use the SSH configuration and keys available there. Password prompts are not supported. -**Test connection** checks SSH, Node, npm, and platform tools without installing +**Test connection**, in the host's menu, checks SSH, Node, npm, and platform tools without installing anything, with a result for each selected environment. Targets that resolve to the environmentโ€™s own machine are skipped, since its devices are already local. The first device listing installs pinned device tools on the host. @@ -123,8 +123,45 @@ installation, and connectivity to development servers such as Metro separately. A simulator on another machine cannot reach Metro through your environment's localhost without forwarding or another reachable address. +## Simlock hosts (alpha) + +[Simlock](https://incubator.callstack.com/simlock/) pools iOS simulators and +Android emulators across machines. If you run a Simlock gateway, T3 can lease +devices from it. Simlock is new and unreleased, so this integration is an +alpha: it may change or stop working with a newer Simlock. T3 does not install +or manage Simlock; set up the gateway and its workers yourself. It needs a +gateway and workers built from Simlock's `main` at +[6316e10](https://github.com/callstackincubator/simlock/commit/6316e10) or +later, the first with a lease holder's screen and input. + +Turn on **Settings โ†’ Labs โ†’ Simlock device hosts (alpha)** and **Use devices** +in Settings โ†’ Devices, then choose **Add source โ†’ Simlock pool** with an **agent token** +(`simlock --gateway token create --role agent`). Give each T3 server its own +token: Simlock counts every lease made with one token against the same +requester, and allows one at a time unless the gateway's +`lease.maxPerRequester` is raised to the host's **Devices at once**. The token +leases devices and reaches only its own leases, but commands on a leased device +run on its worker, so treat it like shell access to your workers. T3 sends it +with every request, so use an HTTPS gateway URL, or plain HTTP only over an +encrypted network such as Tailscale. The gateway +and workers need `capabilities.stream.enabled` and `capabilities.input.enabled` +for the live screen and for input. + +The picker lists the models your workers can make. Opening one takes a lease; +closing it with **Return device to the pool**, or removing the host, returns it +wiped. The panel keeps a watched device leased up to the host's lease limit. +You can tap, swipe, type, and press Home, and Back and Recents on Android. +Rotation, appearance, text size, device tools, and screenshots are not offered +for Simlock devices; agents can still do them through `device_open`. On iOS 27 +simulators, typing on your keyboard does nothing; tap the on-screen keyboard +instead. Workers must run the same agent-device +version as T3 (see Device tool updates), and agent-device accepts app installs +for a lease only from the folder it was opened from, which `device_open` +names. A device cannot reach development servers on your machine through +localhost; use an address its worker can reach. + ## Device tool updates -The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings โ†’ Devices โ†’ Check versions reads installed versions without installing tools or starting devices. +The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. In Settings โ†’ Devices, select a version and choose **Check versions** to read installed versions without installing tools or starting devices. To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. diff --git a/packages/client-runtime/src/device/stream.test.ts b/packages/client-runtime/src/device/stream.test.ts index 7da96cb3d75b7..737daf5158c04 100644 --- a/packages/client-runtime/src/device/stream.test.ts +++ b/packages/client-runtime/src/device/stream.test.ts @@ -199,7 +199,11 @@ class MjpegImage extends EventTarget { } } -function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true) { +function recoveryFixture( + platform: "ios" | "android" = "ios", + preferMjpeg = true, + serveSimOnly = false, +) { vi.useFakeTimers(); const sockets: Socket[] = []; class Socket { @@ -211,7 +215,9 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true onclose: ((event: { code: number; reason: string }) => void) | null = null; send = vi.fn(); close = vi.fn(); - constructor() { + readonly url: string; + constructor(url: string) { + this.url = url; sockets.push(this); } } @@ -273,6 +279,7 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true { platform, preferMjpeg, + serveSimOnly, deviceId: "test-device", access: { httpBase: "https://test/api/device-hub", @@ -353,6 +360,56 @@ describe("shared device stream readiness and recovery", () => { } }); + it("streams and drives a serve-sim-only Android device over serve-sim's AVCC and input socket", async () => { + const { client, events, fetch, sockets, videoBodies, decoders } = recoveryFixture( + "android", + false, + true, + ); + client.start(); + await vi.advanceTimersByTimeAsync(0); + // No MJPEG priming: Simlock serves only the AVCC stream and the input socket. + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://test/api/device-hub/vendor/serve-sim/helper/test-device/stream.avcc", + ]); + expect(sockets.map((socket) => socket.url)).toEqual([ + "wss://test/api/device-hub/vendor/serve-sim/helper/ws?device=test-device", + ]); + // Simlock adds meta (5), keep-alive (6), and lease-end (7) frames to serve-sim's envelopes. + videoBodies[0]!.enqueue( + new Uint8Array([ + ...envelope(5, [0x7b, 0x7d]), + ...envelope(1, [1, 0x64, 0, 0x1f]), + ...envelope(6, []), + ...envelope(2, [2]), + ...envelope(7, [0x7b, 0x7d]), + ]), + ); + await vi.advanceTimersByTimeAsync(0); + expect(decoders[0]!.decode).toHaveBeenCalledOnce(); + + const input = sockets[0]!; + input.onmessage?.({ data: Uint8Array.of(0x83).buffer }); + input.onmessage?.({ + data: new Uint8Array([ + 0x82, + ...new TextEncoder().encode('{"width":1080,"height":2400,"orientation":"portrait"}'), + ]).buffer, + }); + expect(events.onInputConnected).toHaveBeenLastCalledWith(true, undefined); + client.sendTouch("begin", 0.25, 0.5); + client.pressButton("back"); + const sent = input.send.mock.calls.map(([payload]) => { + const bytes = payload as Uint8Array; + return [bytes[0], JSON.parse(new TextDecoder().decode(bytes.subarray(1)))]; + }); + expect(sent).toEqual([ + [0x03, { type: "begin", x: 0.25, y: 0.5 }], + [0x04, { button: "back" }], + ]); + client.stop(); + }); + it("does not repaint a delayed JPEG seed over newer decoded video", async () => { const { client, videoBodies, drawImage, decodedFrame } = recoveryFixture("ios", false); let resolveBitmap!: (bitmap: ImageBitmap) => void; diff --git a/packages/client-runtime/src/device/stream.ts b/packages/client-runtime/src/device/stream.ts index dc1720725f4f1..c4b8078597245 100644 --- a/packages/client-runtime/src/device/stream.ts +++ b/packages/client-runtime/src/device/stream.ts @@ -120,6 +120,8 @@ export interface DeviceStreamTarget { readonly preferMjpeg?: boolean; readonly panelId?: 1 | 3; readonly videoOnly?: boolean; + /** The host speaks only serve-sim's AVCC stream and input socket, on both platforms (Simlock). */ + readonly serveSimOnly?: boolean; } export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher"; @@ -388,12 +390,16 @@ export function createDeviceStreamClient( ): DeviceStreamClient { const { access, platform, deviceId } = target; const sink = "present" in output ? output : createCanvasFrameSink(output); - const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; + // Android normally streams over serve-emu's socket; a serve-sim-only host serves it like iOS. + const serveSimOnly = target.serveSimOnly === true; + const avcc = platform === "ios" || serveSimOnly; + const vendor = avcc ? "/vendor/serve-sim" : "/vendor/serve-emu"; const device = encodeURIComponent(deviceId); const httpUrl = (path: string) => withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access); const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access); - const useWebCodecs = isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg); + const useWebCodecs = + isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg && !serveSimOnly); let stopped = true; let socket: WebSocket | null = null; @@ -551,7 +557,7 @@ export function createDeviceStreamClient( const paint = (source: CanvasImageSource, width: number, height: number) => { if (stopped) return; - if (platform === "android" && (screen?.width !== width || screen.height !== height)) { + if (!avcc && (screen?.width !== width || screen.height !== height)) { screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" }; events.onScreen(screen); } @@ -572,7 +578,11 @@ export function createDeviceStreamClient( }; const recoverDecoder = () => { - if (platform === "ios") fallBackToMjpeg(); + if (serveSimOnly) { + // A new AVCC request starts a fresh capture with a keyframe; there is no MJPEG. + closeDecoder(); + controller?.abort(); + } else if (platform === "ios") fallBackToMjpeg(); else { closeDecoder(); connecting("Video decoder restarted."); @@ -585,10 +595,7 @@ export function createDeviceStreamClient( const decoder = new VideoDecoder({ output: (frame) => { try { - if ( - videoDecoder === decoder && - (platform !== "ios" || feedGeneration === videoGeneration) - ) { + if (videoDecoder === decoder && (!avcc || feedGeneration === videoGeneration)) { decodedVideoGeneration = feedGeneration; paint(frame, frame.displayWidth, frame.displayHeight); } @@ -597,11 +604,7 @@ export function createDeviceStreamClient( } }, error: () => { - if ( - stopped || - videoDecoder !== decoder || - (platform === "ios" && feedGeneration !== videoGeneration) - ) + if (stopped || videoDecoder !== decoder || (avcc && feedGeneration !== videoGeneration)) return; recoverDecoder(); }, @@ -638,7 +641,7 @@ export function createDeviceStreamClient( awaitingKeyframe = false; } if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) { - if (platform !== "ios") { + if (!avcc) { recoverDecoder(); return; } @@ -769,7 +772,12 @@ export function createDeviceStreamClient( if (!configured) { await reader.cancel().catch(() => {}); if (!isCurrent()) return; - if (target.videoOnly) + if (serveSimOnly) + setStatus( + "error", + `This browser cannot decode this device's ${avcCodecString(chunk.payload)} stream.`, + ); + else if (target.videoOnly) setStatus( "error", `This browser cannot decode the Duo panel's ${avcCodecString(chunk.payload)} stream.`, @@ -1070,7 +1078,7 @@ export function createDeviceStreamClient( const connectIosInput = async () => { if (stopped) return; const session = generation; - await primeIosHelper(session); + if (!serveSimOnly) await primeIosHelper(session); if (stopped || generation !== session) return; const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`)); ws.binaryType = "arraybuffer"; @@ -1231,7 +1239,11 @@ export function createDeviceStreamClient( iosInputAdmitted = false; iosInputUnavailable = false; connecting(); - if (platform === "ios") { + if (serveSimOnly) { + if (!target.videoOnly) void connectIosInput(); + if (useWebCodecs) void readIosVideo(); + else fail("This browser cannot decode this device's stream (WebCodecs unavailable)."); + } else if (platform === "ios") { if (!target.videoOnly) void connectIosInput(); if (useWebCodecs) void readIosVideo(); else fallBackToMjpeg(); @@ -1275,7 +1287,7 @@ export function createDeviceStreamClient( const send = (payload: Uint8Array | string) => { if ( !stopped && - (platform !== "ios" || (iosInputAdmitted && !iosInputUnavailable)) && + (!avcc || (iosInputAdmitted && !iosInputUnavailable)) && socket?.readyState === WebSocket.OPEN ) socket.send(payload); @@ -1326,7 +1338,7 @@ export function createDeviceStreamClient( startDuoVideo(panels); }, sendTouch: (phase, x, y) => { - if (platform === "ios") { + if (avcc) { send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) })); return; } @@ -1334,7 +1346,7 @@ export function createDeviceStreamClient( send(JSON.stringify({ type: "touch", action, x, y })); }, sendKey: (event, phase) => { - if (platform === "ios") { + if (avcc) { const usage = hidUsageForCode(event.code); if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage })); return; @@ -1348,7 +1360,7 @@ export function createDeviceStreamClient( } }, sendText: (text) => { - if (platform !== "android") return; + if (avcc) return; let chunk = ""; let chunkBytes = 0; let sentChunks = 0; @@ -1366,15 +1378,17 @@ export function createDeviceStreamClient( if (chunk.length > 0) send(JSON.stringify({ type: "text", text: chunk })); }, pressButton: (button) => { - if (platform === "ios") { + if (avcc) { const name = button === "home" ? "home" - : button === "appSwitcher" + : button === "appSwitcher" || button === "recents" ? "app_switcher" : button === "power" ? "lock" - : null; + : button === "back" && platform === "android" + ? "back" + : null; if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name })); return; } diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts index 6de9f15125d45..e49e54a09fdf5 100644 --- a/packages/contracts/src/device.test.ts +++ b/packages/contracts/src/device.test.ts @@ -4,6 +4,7 @@ import { DeviceBootError, DeviceServiceState, EasDeviceHostConfig, + SimlockDeviceHostConfig, EasDeviceHostConfigs, SshDeviceHostConfigs, } from "./device.ts"; @@ -49,6 +50,7 @@ describe("configured device hosts", () => { it("marks the EAS access token as a secret setting", () => { expect(secretSettingsFields(EasDeviceHostConfig)).toEqual(["accessToken"]); + expect(secretSettingsFields(SimlockDeviceHostConfig)).toEqual(["agentToken"]); }); }); @@ -63,6 +65,7 @@ describe("device state for clients from before EAS hosts", () => { hosts: [ { ...summary, id: "local", kind: "local" as const }, { ...summary, id: "eas", kind: "eas" as const }, + { ...summary, id: "pool", kind: "simlock" as const }, ], hostStatus: "ready" as const, hostStatuses: {}, @@ -74,7 +77,7 @@ describe("device state for clients from before EAS hosts", () => { revision: 1, }; - it("sends EAS hosts where an older client does not look, and reads them back", () => { + it("sends EAS and Simlock hosts where an older client does not look, and reads them back", () => { const wire = encodeState(state); expect(decodeOlderState(wire).hosts.map((host) => host.id)).toEqual(["local"]); expect(decodeState(wire).hosts).toEqual(state.hosts); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index dbb90f6d41e58..2335d6ba8d654 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -68,7 +68,32 @@ export const EasDeviceHostConfig = Schema.Struct({ ); export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type; -export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig; +/** + * Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock). + * T3 never installs Simlock; the gateway and its workers are the user's own. + */ +export const SimlockDeviceHostConfig = Schema.Struct({ + kind: Schema.Literal("simlock"), + id: ConfiguredDeviceHostId, + label: TrimmedNonEmptyString, + /** The gateway's origin, such as https://gateway.example.ts.net:4700. */ + gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)), + /** Leases, drives, watches, and takes input on its own leases. Kept in the secret store. */ + agentToken: TrimmedString.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("")), + Schema.annotateKey({ settingsSecret: true }), + ), + maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(2)), + ), + /** A lease the Device panel keeps alive ends this long after it started. */ + maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(60)), + ), +}); +export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type; + +export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig; const uniqueHostIds = Schema.makeFilter( (hosts: ReadonlyArray<{ readonly id: string }>) => @@ -85,6 +110,9 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniq */ export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds); +/** Kept apart from the other host lists for the same reason as EAS hosts. */ +export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds); + export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), @@ -161,7 +189,15 @@ const EasDeviceHostSummary = Schema.Struct({ ...deviceHostSummaryFields, kind: Schema.Literal("eas"), }); -export const DeviceHostSummary = Schema.Union([MachineDeviceHostSummary, EasDeviceHostSummary]); +const SimlockDeviceHostSummary = Schema.Struct({ + ...deviceHostSummaryFields, + kind: Schema.Literal("simlock"), +}); +export const DeviceHostSummary = Schema.Union([ + MachineDeviceHostSummary, + EasDeviceHostSummary, + SimlockDeviceHostSummary, +]); export type DeviceHostSummary = typeof DeviceHostSummary.Type; export const DeviceHostStatus = Schema.Literals([ @@ -198,6 +234,8 @@ const deviceServiceStateFields = { supportsToolInspection: Schema.optional(Schema.Boolean), /** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */ supportsEasHosts: Schema.optional(Schema.Boolean), + /** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */ + supportsSimlockHosts: Schema.optional(Schema.Boolean), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), hostStatuses: DeviceHostStatuses, @@ -221,6 +259,7 @@ const DeviceServiceStateWire = Schema.Struct({ ...deviceServiceStateFields, hosts: Schema.Array(MachineDeviceHostSummary), easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)), + simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)), }); const DeviceServiceStateDecoded = Schema.Struct({ ...deviceServiceStateFields, @@ -234,18 +273,22 @@ export const DeviceServiceState = DeviceServiceStateWire.pipe( typeof DeviceServiceStateDecoded.Type, typeof DeviceServiceStateWire.Type >({ - decode: ({ easHosts, ...state }) => ({ + decode: ({ easHosts, simlockHosts, ...state }) => ({ ...state, - hosts: [...state.hosts, ...(easHosts ?? [])], + hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])], }), encode: (state) => ({ ...state, hosts: state.hosts.filter( - (host): host is typeof MachineDeviceHostSummary.Type => host.kind !== "eas", + (host): host is typeof MachineDeviceHostSummary.Type => + host.kind === "local" || host.kind === "ssh", ), easHosts: state.hosts.filter( (host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas", ), + simlockHosts: state.hosts.filter( + (host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock", + ), }), }), ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c6a59445d66fa..dda56d86bae60 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { EasDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; +import { EasDeviceHostConfigs, SimlockDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -1195,6 +1195,9 @@ export const ServerSettings = Schema.Struct({ deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), easDeviceHosts: EasDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + simlockDeviceHosts: SimlockDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Alpha: Simlock device hosts. Off, the server ignores `simlockDeviceHosts`. */ + simlockDeviceHostsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1551,6 +1554,8 @@ export const ServerSettingsPatch = Schema.Struct({ deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), easDeviceHosts: Schema.optionalKey(EasDeviceHostConfigs), + simlockDeviceHosts: Schema.optionalKey(SimlockDeviceHostConfigs), + simlockDeviceHostsEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), compactIdleThreads: Schema.optionalKey(LegacyCompactIdleThreads), idleCompaction: Schema.optionalKey(IdleCompactionMode), From ba18706bcec1968ceea22bad11d8d24a4587fb0f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Oct 2026 12:20:40 -0700 Subject: [PATCH 004/144] fix(mobile): use a native split button for Android merges (#26373) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/mobile/assets/icons/chevron-down.xml | 4 + .../src/components/ui/MenuPopup.android.tsx | 11 +- .../src/components/ui/SplitButton.android.tsx | 145 ++++++ apps/mobile/src/components/ui/SplitButton.tsx | 65 +++ .../src/components/ui/SplitButton.types.ts | 14 + .../PullRequestMergeControls.tsx | 143 ++---- patches/@expo__ui@58.0.20.patch | 472 +++++++++++++++++- pnpm-lock.yaml | 8 +- 8 files changed, 737 insertions(+), 125 deletions(-) create mode 100644 apps/mobile/assets/icons/chevron-down.xml create mode 100644 apps/mobile/src/components/ui/SplitButton.android.tsx create mode 100644 apps/mobile/src/components/ui/SplitButton.tsx create mode 100644 apps/mobile/src/components/ui/SplitButton.types.ts diff --git a/apps/mobile/assets/icons/chevron-down.xml b/apps/mobile/assets/icons/chevron-down.xml new file mode 100644 index 0000000000000..285eda9c1b463 --- /dev/null +++ b/apps/mobile/assets/icons/chevron-down.xml @@ -0,0 +1,4 @@ + + + diff --git a/apps/mobile/src/components/ui/MenuPopup.android.tsx b/apps/mobile/src/components/ui/MenuPopup.android.tsx index b5af18cd2bcbb..dd6ef2b046566 100644 --- a/apps/mobile/src/components/ui/MenuPopup.android.tsx +++ b/apps/mobile/src/components/ui/MenuPopup.android.tsx @@ -55,7 +55,9 @@ function MenuIcon(props: { ); } -export function MenuPopup(props: MenuPopupProps) { +export function MenuPopupItems( + props: Pick, +) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const { scale, menuItemHeight } = useAndroidControlSizing(); const body = resolveScaledTextRole("body", appearance.baseFontSize); @@ -64,7 +66,7 @@ export function MenuPopup(props: MenuPopupProps) { const muted = colors["--color-foreground-muted"]; // A fixed native item height clips wrapped labels; a minimum lets each row grow. const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })]; - const items = ( + return ( <> {props.parent ? ( @@ -146,6 +148,11 @@ export function MenuPopup(props: MenuPopupProps) { ))} ); +} + +export function MenuPopup(props: MenuPopupProps) { + const { themeVariables: colors } = useAppearancePreferences(); + const items = ; if (props.inline) { return ( ; + const enabled = !props.disabled && !props.loading; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + const buttonColors = { + containerColor: colors["--color-primary"], + contentColor: colors["--color-primary-foreground"], + disabledContainerColor: colors["--color-subtle-strong"], + disabledContentColor: colors["--color-foreground-muted"], + }; + // A usable menu beside a disabled action still takes the disabled palette, so the pair reads as one control. + const trailingColors = enabled + ? buttonColors + : { + ...buttonColors, + containerColor: buttonColors.disabledContainerColor, + contentColor: buttonColors.disabledContentColor, + }; + const label = ( + + {props.loading ? ( + <> + + + + ) : props.icon ? ( + <> + {/* The glyph host consumes touches, so it runs the action itself. */} + + + + + + + + ) : null} + {props.label} + + ); + return ( + + + + {label} + + + setExpanded(false)} + color={colors["--color-card-alt"]} + > + + + + + { + setExpanded(false); + if (action.id !== undefined) props.onPressAction(action.id); + }} + onBack={() => {}} + /> + + + + + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.tsx b/apps/mobile/src/components/ui/SplitButton.tsx new file mode 100644 index 0000000000000..511c3a5b7c29f --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.tsx @@ -0,0 +1,65 @@ +import { Pressable, View } from "react-native"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { Menu } from "./Menu"; +import type { SplitButtonProps } from "./SplitButton.types"; + +export function SplitButton(props: SplitButtonProps) { + const active = !props.disabled; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + return ( + + + {props.loading ? ( + + ) : props.icon ? ( + + ) : null} + + {props.label} + + + {props.actions.length > 0 ? ( + + props.onPressAction(nativeEvent.event)} + > + + + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.types.ts b/apps/mobile/src/components/ui/SplitButton.types.ts new file mode 100644 index 0000000000000..73d0b36f2ce20 --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.types.ts @@ -0,0 +1,14 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { AppSymbolName } from "./AppSymbol"; + +export interface SplitButtonProps { + readonly label: string; + readonly accessibilityLabel?: string; + readonly icon?: AppSymbolName; + readonly disabled?: boolean; + readonly loading?: boolean; + readonly onPress: () => void; + readonly menuTitle: string; + readonly actions: MenuAction[]; + readonly onPressAction: (id: string) => void; +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index b784bdb4eb427..8ba68d2a2bfa9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -1,4 +1,3 @@ -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -6,12 +5,10 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { Menu } from "../../components/ui/Menu"; import { Button } from "../../components/ui/Button"; -import { IconButton } from "../../components/ui/IconButton"; +import { SplitButton } from "../../components/ui/SplitButton"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -26,8 +23,8 @@ import { import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; -import type { PullRequestStackState } from "./usePullRequestStack"; import { PullRequestGlyph } from "./pullRequestGlyphs"; +import type { PullRequestStackState } from "./usePullRequestStack"; export function PullRequestMergeControls(props: { readonly pr: PullRequestDetail; @@ -169,107 +166,41 @@ export function PullRequestMergeControls(props: { }; return ( - - {Platform.OS === "android" ? ( - - ) : null} + {status ?

{status}

: null} ); } diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts index 752ab616bdb58..2efe3c78c24eb 100644 --- a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -36,7 +36,7 @@ describe("Simlock device host settings", () => { expect(updateSimlockDeviceHosts(renamed, host, true)).toEqual([other]); }); - it("saves the shared fields everywhere and each environment's own token", () => { + it("saves the shared fields everywhere and each environment's own token, if it has one", () => { const mbp = "mbp" as EnvironmentId; const cups = "cups" as EnvironmentId; const environments = [ @@ -48,7 +48,10 @@ describe("Simlock device host settings", () => { expect( planSimlockDeviceHostSave(environments, draft, new Map([[mbp, { kind: "saved" }]])), ).toEqual({ - problem: "Add an agent token for cups.", + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "" }] }, + ], }); expect( planSimlockDeviceHostSave( diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts index 3a2f65b1d9065..a4ef1e1bb90b1 100644 --- a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -62,7 +62,7 @@ export type SimlockTokenEntry = /** * The host list each environment saves: the shared fields everywhere, and each environment's own - * token. Saved tokens arrive redacted, and sending the redacted value back keeps them. + * token, which may stay blank until later. Saved tokens arrive redacted; sending one back keeps it. */ export function planSimlockDeviceHostSave( environments: ReadonlyArray, @@ -84,7 +84,6 @@ export function planSimlockDeviceHostSave( const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" }; const agentToken = entry.kind === "saved" ? saved : entry.kind === "new" ? entry.value.trim() : ""; - if (!agentToken) return { problem: `Add an agent token for ${environment.label}.` }; patches.push({ environmentId: environment.environmentId, hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index fcd4bb4b8f2ec..da19cc7d5a2b9 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -171,3 +171,14 @@ export const deviceEasAccountCheck = createEnvironmentRpcQueryAtomFamily(connect staleTimeMs: 0, idleTtlMs: 0, }); + +/** Who a Simlock token is, checked as the host editor's URL and token fields change. */ +export const deviceSimlockIdentityCheck = createEnvironmentRpcQueryAtomFamily( + connectionAtomRuntime, + { + label: "environment-data:device:simlock-identity-check", + tag: WS_METHODS.deviceSimlockIdentity, + staleTimeMs: 0, + idleTtlMs: 0, + }, +); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 2335d6ba8d654..24b72dbe26d7a 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -127,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({ }); export type DeviceEasAccount = typeof DeviceEasAccount.Type; +export const DeviceSimlockIdentityInput = Schema.Struct({ + /** Without one, the gateway this machine's agent-device points at. */ + gatewayUrl: Schema.optional(TrimmedString), + /** A saved Simlock host whose stored token is checked when no new token is given. */ + hostId: Schema.optional(DeviceHostId), + agentToken: Schema.optional(TrimmedString), +}); +export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type; + +export const DeviceSimlockIdentity = Schema.Struct({ + gatewayUrl: Schema.String, + /** The label the gateway gave the token. */ + signedInAs: Schema.String, + /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */ + tokenSource: Schema.Literals(["entered", "settings", "env"]), +}); +export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type; + +export class DeviceSimlockIdentityError extends Schema.TaggedError()( + "DeviceSimlockIdentityError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + /** Checking an Expo credential failed; no device host is involved yet. */ export class DeviceEasAccountError extends Schema.TaggedError()( "DeviceEasAccountError", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e4823f30272bd..658f6276e247d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -293,6 +293,9 @@ import { DeviceEasAccount, DeviceEasAccountError, DeviceEasAccountInput, + DeviceSimlockIdentity, + DeviceSimlockIdentityError, + DeviceSimlockIdentityInput, DeviceHostSummary, DeviceOpenInput, DeviceServiceState, @@ -473,6 +476,7 @@ export const WS_METHODS = { deviceList: "device.list", deviceTestHost: "device.testHost", deviceEasAccount: "device.easAccount", + deviceSimlockIdentity: "device.simlockIdentity", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1613,6 +1617,12 @@ const WsDeviceEasAccountRpc = Rpc.make(WS_METHODS.deviceEasAccount, { error: Schema.Union([DeviceEasAccountError, EnvironmentAuthorizationError]), }); +const WsDeviceSimlockIdentityRpc = Rpc.make(WS_METHODS.deviceSimlockIdentity, { + payload: DeviceSimlockIdentityInput, + success: DeviceSimlockIdentity, + error: Schema.Union([DeviceSimlockIdentityError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -2088,6 +2098,7 @@ export const WsRpcGroup = RpcGroup.make( WsDeviceListRpc, WsDeviceTestHostRpc, WsDeviceEasAccountRpc, + WsDeviceSimlockIdentityRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, From a5448f4c5030b4f70744db5ecfec4cc06828b621 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 18:05:40 -0700 Subject: [PATCH 019/144] fix(web): pull request diff follows the file tree, with a hide-tests filter (#26730) Co-authored-by: Claude Opus 5.5 --- .../pull-requests/PullRequestRouteScreen.tsx | 4 +- .../pull-requests/pullRequestFileFilters.ts | 10 +--- apps/web/src/components/DiffPanel.tsx | 48 +++++++++++++++++-- .../diffs/diffFileTree.logic.test.ts | 26 +++++++++- .../components/diffs/diffFileTree.logic.ts | 30 ++++++++++++ .../src/components/diffs/useHideTestFiles.ts | 9 ++++ .../pullRequest/PullRequestCodeTab.tsx | 48 +++++++++++++++++-- packages/shared/src/changedFileKind.ts | 5 ++ 8 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/diffs/useHideTestFiles.ts diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 80391ef4060eb..446794800eadb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -41,7 +41,7 @@ import { pullRequestDiffInitialPosition, } from "./pullRequestDiffDebug"; import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles"; -import { isPullRequestTestFile } from "./pullRequestFileFilters"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; @@ -1035,7 +1035,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { const jumpedUnder = useRef(filter.hideTestFiles); const hasTestFiles = result.parsed.kind === "files" && - result.parsed.files.some((file) => isPullRequestTestFile(file.path)); + result.parsed.files.some((file) => isTestChangedFile(file.path)); const jumpStarted = () => jumpedUnder.current !== null && (!hasTestFiles || jumpedUnder.current === filter.hideTestFiles); const scrollToFile = (fileId: string) => { diff --git a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts index 2d8e3a94fe4be..2e0424ddbbaed 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts @@ -1,12 +1,6 @@ -import { classifyChangedFile } from "@t3tools/shared/changedFileKind"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import type { ReviewParsedDiff } from "../review/reviewModel"; -/** Test support such as fixtures and snapshots is hidden along with the tests. */ -export function isPullRequestTestFile(path: string): boolean { - const kind = classifyChangedFile(path); - return kind === "tests" || kind === "mocks"; -} - export function filterPullRequestTestFiles( parsed: ReviewParsedDiff, hideTestFiles: boolean, @@ -14,7 +8,7 @@ export function filterPullRequestTestFiles( ): ReviewParsedDiff { if (!hideTestFiles || parsed.kind !== "files") return parsed; const files = parsed.files.filter( - (file) => file.path === includePath || !isPullRequestTestFile(file.path), + (file) => file.path === includePath || !isTestChangedFile(file.path), ); if (files.length === parsed.files.length) return parsed; return { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c257d0ea52d34..ddce50db3cfe3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,6 +12,7 @@ import { CheckIcon, ChevronDownIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, PilcrowIcon, Rows3Icon, @@ -37,6 +38,8 @@ import { useDiffPanelStore, } from "../diffPanelStore"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; +import { useHideTestFiles } from "./diffs/useHideTestFiles"; import { useRenderedAppearance } from "../hooks/useTheme"; import { useStableCallback } from "../hooks/useStableCallback"; import { @@ -218,6 +221,7 @@ export default function DiffPanel({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [baseRefQuery, setBaseRefQuery] = useState(""); const [codeViewRevision, setCodeViewRevision] = useState(0); @@ -539,6 +543,16 @@ export default function DiffPanel({ const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending; const renderableFileEntries = renderableFiles.map(getCachedFileEntry); const diffFileKeys = renderableFileEntries.map((file) => file.fileKey); + const testFileCount = renderableFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // Hiding is display-only: lazy loading indexes into the full list. A requested file stays visible. + const isFileShown = (file: FileDiffMetadata) => { + const path = resolveFileDiffPath(file); + return !hideTestFiles || path === selectedFilePath || !isTestChangedFile(path); + }; + const shownFileEntries = renderableFileEntries.filter(({ fileDiff }) => isFileShown(fileDiff)); + const shownFileKeys = shownFileEntries.map((file) => file.fileKey); const defaultCollapsedDiffFileKeys = settings.diffFilesCollapsed ? new Set(diffFileKeys) : EMPTY_COLLAPSED_DIFF_FILE_KEYS; @@ -561,7 +575,7 @@ export default function DiffPanel({ count={renderableFiles.length - settledFileCount} /> ) : null; - const codeViewFiles = renderableFileEntries + const codeViewFiles = shownFileEntries .filter( ({ fileDiff }) => !lazySource || !readyFilePaths || readyFilePaths.has(resolveFileDiffPath(fileDiff)), @@ -574,10 +588,12 @@ export default function DiffPanel({ collapsed: collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, })); - const pendingFiles = renderableFiles.filter((file) => file.cacheKey?.endsWith(":pending")); - const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); + const pendingFiles = renderableFiles.filter( + (file) => file.cacheKey?.endsWith(":pending") && isFileShown(file), + ); + const allDiffFilesCollapsed = areAllDiffFilesCollapsed(shownFileKeys, collapsedDiffFileKeys); const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); - const fileTreeEntries = diffFileTreeEntries(renderableFiles); + const fileTreeEntries = diffFileTreeEntries(renderableFiles.filter(isFileShown)); const codeViewReveal = useCodeViewFileReveal(); const revealDiffFile = (filePath: string) => { const index = renderableFileEntries.findIndex( @@ -661,7 +677,7 @@ export default function DiffPanel({ const toggleDiffFileCollapse = () => { setCodeViewRevision((current) => current + 1); - updateCollapsedDiffFiles((current) => toggleAllDiffFiles(diffFileKeys, current)); + updateCollapsedDiffFiles((current) => toggleAllDiffFiles(shownFileKeys, current)); }; const selectTurn = (runId: RunId) => { @@ -991,6 +1007,28 @@ export default function DiffPanel({ {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + {testFileCount > 0 && ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles + ? `Show ${testFileCount} hidden test ${testFileCount === 1 ? "file" : "files"}` + : "Hide test files"} + + + )} {diffFileKeys.length > 0 && ( { }); }); +describe("orderDiffFilesLikeTree", () => { + it("pulls a test listed last into its directory, keeping the first-seen order", () => { + expect( + orderDiffFilesLikeTree([ + file("change", "server/device/Host.ts"), + file("change", "web/settings.ts"), + file("change", "server/ws.ts"), + file("change", "server/device/Host.test.ts"), + file("change", "web/settings.test.ts"), + ]).map((entry) => entry.name), + ).toEqual([ + "server/device/Host.ts", + "server/device/Host.test.ts", + "server/ws.ts", + "web/settings.ts", + "web/settings.test.ts", + ]); + }); +}); + describe("buildDiffFileTreeUpdates", () => { it("adds a new file's directories before the file", () => { expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index abb3a00c7689b..41d2a5e676e70 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -98,6 +98,36 @@ export function diffFileTreePositions(paths: ReadonlyArray): ReadonlyMap return positions; } +// A flat list in tree order: each directory stays contiguous, siblings ranked as +// compareDiffFileTreeEntries ranks them, so the list never disagrees with the tree. +export function orderDiffFilesLikeTree( + files: ReadonlyArray, +): ReadonlyArray { + const paths = files.map(resolveFileDiffPath); + const positions = diffFileTreePositions(paths); + const keys = paths.map((path) => { + const key: number[] = []; + let directory = ""; + for (const segment of path.split("/").slice(0, -1)) { + directory += `${segment}/`; + key.push(positions.get(directory)!); + } + key.push(positions.get(path)!); + return key; + }); + return files + .map((file, index) => ({ file, key: keys[index]! })) + .toSorted((left, right) => { + const length = Math.min(left.key.length, right.key.length); + for (let index = 0; index < length; index++) { + const difference = left.key[index]! - right.key[index]!; + if (difference !== 0) return difference; + } + return left.key.length - right.key.length; + }) + .map(({ file }) => file); +} + export function compareDiffFileTreeEntries( getPositions: () => ReadonlyMap, ): FileTreeSortComparator { diff --git a/apps/web/src/components/diffs/useHideTestFiles.ts b/apps/web/src/components/diffs/useHideTestFiles.ts new file mode 100644 index 0000000000000..a1f806ecb467d --- /dev/null +++ b/apps/web/src/components/diffs/useHideTestFiles.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema"; + +import { useLocalStorage } from "~/hooks/useLocalStorage"; + +const HIDE_TEST_FILES_STORAGE_KEY = "t3code.diffHideTestFiles"; + +export function useHideTestFiles() { + return useLocalStorage(HIDE_TEST_FILES_STORAGE_KEY, false, Schema.Boolean); +} diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d1b165c62147b..94f4ccda82558 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -19,6 +19,7 @@ import { ChevronDownIcon, ChevronRightIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, InfoIcon, MessageSquareOffIcon, @@ -51,6 +52,7 @@ import { canEditPullRequestComment, canResolvePullRequestThread, } from "@t3tools/client-runtime/pull-request-editing"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -80,7 +82,8 @@ import { useDiffWorkerPool } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; -import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; +import { useHideTestFiles } from "../diffs/useHideTestFiles"; +import { diffFileTreeEntries, orderDiffFilesLikeTree } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -307,6 +310,7 @@ function PullRequestCodeTab({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -496,9 +500,22 @@ function PullRequestCodeTab({ ignoreWhitespace, ), ); - const files = parsedSlices.flatMap((parsed) => - parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + const loadedFiles = orderDiffFilesLikeTree( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + ), ); + const testFileCount = loadedFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // A requested file stays visible so a jump to a hidden test still lands. + const files = hideTestFiles + ? loadedFiles.filter((file) => { + const path = resolveFileDiffPath(file); + return path === revealRequest?.path || !isTestChangedFile(path); + }) + : loadedFiles; + const hiddenTestFileCount = loadedFiles.length - files.length; const filePaths = files.map((file) => resolveFileDiffPath(file)); const omittedFileStats = new Map( loadedSlices.flatMap((slice) => @@ -1071,6 +1088,11 @@ function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {hiddenTestFileCount > 0 ? ( + + ) : null} {filesViewed.enabled && files.length > 0 ? ( @@ -1283,6 +1305,26 @@ function PullRequestCodeTab({ ) : null} + {testFileCount > 0 ? ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles ? "Show test files" : "Hide test files"} + + + ) : null} {fileKeys.length > 0 ? ( Date: Wed, 28 Oct 2026 18:09:45 -0700 Subject: [PATCH 020/144] fix(mobile): delivered messages no longer reappear at the bottom of long threads (#26739) Co-authored-by: Claude Opus 5.5 --- .../threads/pending-thread-feed.test.ts | 9 ++- .../features/threads/pending-thread-feed.ts | 18 +++-- .../src/state/acknowledged-thread-messages.ts | 32 +++++---- apps/mobile/src/state/thread-outbox-model.ts | 5 ++ .../src/state/thread-task-permissions.test.ts | 1 + .../threadOutboxDelivery.permissions.test.ts | 8 ++- .../src/state/threadOutboxDelivery.test.ts | 72 ++++++++++++------- apps/mobile/src/state/threadOutboxDelivery.ts | 23 +++++- .../src/state/use-thread-composer-state.ts | 7 ++ .../ThreadLaunchService.test.ts | 3 + .../orchestration-v2/ThreadLaunchService.ts | 4 ++ .../operations/commands.performance.test.ts | 2 +- .../client-runtime/src/state/threadDetail.ts | 7 ++ .../client-runtime/src/state/threadState.ts | 19 +++++ .../src/state/threads-sync.test.ts | 5 +- packages/client-runtime/src/state/threads.ts | 19 +++-- packages/contracts/src/orchestrationV2.ts | 2 + 17 files changed, 176 insertions(+), 60 deletions(-) diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts index 314dc1aa477b5..3d9463c79bd06 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -114,14 +114,17 @@ describe("pending timeline messages", () => { expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]); }); - it("stops showing an acknowledged message once the projection holds it, even when the feed hides it", () => { + it("stops showing an acknowledged message once the thread catches up, even when the feed hides it", () => { // A rolled-back or cancelled queued run keeps its message in projection.messages // while its user_message item leaves the visible feed. const echoed = pending("rolled-back"); - const waiting = pending("waiting"); - const shown = selectPendingAcknowledgedMessages([echoed, waiting], { + // A bounded projection pages an applied message out; it must not come back as pending. + const pagedOut = { ...pending("paged-out"), sequence: 40 }; + const waiting = { ...pending("waiting"), sequence: 50 }; + const shown = selectPendingAcknowledgedMessages([echoed, pagedOut, waiting], { threadKey: "env:thread", queuedMessages: [], + sequence: 45, projectedMessages: [{ id: echoed.messageId }], }); expect(shown).toEqual([waiting]); diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts index 9eaf42dcee81b..d38cc026fdd62 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -5,7 +5,11 @@ import { } from "@t3tools/contracts"; import type { ThreadFeedEntry } from "../../lib/threadActivity"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; +import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import type { + AcknowledgedThreadMessage, + QueuedThreadMessage, +} from "../../state/thread-outbox-model"; /** * A guest's message waiting for the owner's Send or Dismiss. It is a feed row, not the list @@ -24,21 +28,25 @@ export type PendingThreadFeedEntry = ThreadFeedEntry & { readonly acknowledged?: boolean; }; -// An acknowledged row stops being pending once the projection holds its message, +// An acknowledged row stops being pending once the thread catches up to it, // even when the feed hides that message (a rolled-back or cancelled run). export function selectPendingAcknowledgedMessages( - acknowledged: ReadonlyArray, + acknowledged: ReadonlyArray, thread: { readonly threadKey: string | null; readonly queuedMessages: ReadonlyArray; + readonly sequence: number; readonly projectedMessages: ReadonlyArray<{ readonly id: string }> | undefined; }, -): ReadonlyArray { +): ReadonlyArray { return acknowledged.filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === thread.threadKey && !thread.queuedMessages.some((queued) => queued.messageId === message.messageId) && - !thread.projectedMessages?.some((projected) => projected.id === message.messageId), + !threadHasCaughtUpTo(message, { + sequence: thread.sequence, + messages: thread.projectedMessages, + }), ); } diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts index ab9f26dd7d412..d7935d1debcab 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -1,31 +1,39 @@ import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "./atom-registry"; -import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import type { AcknowledgedThreadMessage, QueuedThreadMessage } from "./thread-outbox-model"; import { environmentThreadDetails } from "./threads"; -// Keep acknowledged rows until their subscribed projection arrives; command receipts can precede messages. -// Render hides rows the projection has echoed, so dropping them here only frees memory. -export const acknowledgedThreadMessagesAtom = Atom.make>( +// Keep acknowledged rows until the thread catches up; command receipts can precede messages. +export const acknowledgedThreadMessagesAtom = Atom.make>( [], ).pipe(Atom.keepAlive); // Peeks at a thread only when something already has it in the registry: reading // it through the registry would open a snapshot load and a live stream of its own. -function isEchoedInMountedThread(message: QueuedThreadMessage): boolean { - const threadAtom = environmentThreadDetails.threadAtom({ +function isEchoedInMountedThread(message: AcknowledgedThreadMessage): boolean { + const stateAtom = environmentThreadDetails.stateAtom({ environmentId: message.environmentId, threadId: message.threadId, }); - const node = appAtomRegistry.getNodes().get(threadAtom); + const node = appAtomRegistry.getNodes().get(stateAtom); if (node === undefined) return false; - const loaded: Option.Option> = node.valueOption(); - const thread = Option.getOrNull(loaded); - return thread?.projection.messages.some((entry) => entry.id === message.messageId) ?? false; + const loaded: Option.Option> = node.valueOption(); + return Option.match(loaded, { + onNone: () => false, + onSome: (thread) => + threadHasCaughtUpTo(message, { + sequence: thread.sequence, + messages: Option.getOrUndefined(thread.data)?.messages, + }), + }); } function writeAcknowledgedThreadMessages( - update: (current: ReadonlyArray) => ReadonlyArray, + update: ( + current: ReadonlyArray, + ) => ReadonlyArray, ) { const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); const next = update(current).filter((entry) => !isEchoedInMountedThread(entry)); @@ -35,7 +43,7 @@ function writeAcknowledgedThreadMessages( appAtomRegistry.set(acknowledgedThreadMessagesAtom, next); } -export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { +export function retainAcknowledgedThreadMessage(message: AcknowledgedThreadMessage) { writeAcknowledgedThreadMessages((current) => current.some((entry) => entry.messageId === message.messageId) ? current diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 16f2de8f099b4..6c5da0db20991 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -287,3 +287,8 @@ export function resolveThreadOutboxFailureAction(input: { } return "restore"; } + +export type AcknowledgedThreadMessage = QueuedThreadMessage & { + /** The server sequence that committed the message; older servers do not report it. */ + readonly sequence?: number; +}; diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index acfdd803d4259..0b529e9947805 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -113,6 +113,7 @@ vi.mock("./threads", async () => { environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null), checkpointSummariesAtom: () => Atom.make([]), + sequenceAtom: () => Atom.make(0), pendingRequestsAtom: () => Atom.make(state.pendingRequests), }, threadEnvironment: { diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index edc80c5007024..0ea64764e5da2 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -100,7 +100,7 @@ vi.mock("./threads", async () => { familyAtom: () => Atom.make(AsyncResult.success({})), refresh: vi.fn(), }, - environmentThreadDetails: { threadAtom: () => Atom.make(null) }, + environmentThreadDetails: { stateAtom: () => Atom.make(null) }, }; }); vi.mock("./git", () => ({ gitEnvironment: { preparePullRequestThread: state.checkout } })); @@ -224,6 +224,7 @@ beforeEach(() => { ]) { command.mockReset().mockResolvedValue(AsyncResult.success(undefined)); } + state.start.mockResolvedValue(AsyncResult.success({})); state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({ status: "ready", attachments: [], @@ -427,7 +428,8 @@ describe("queued task operation access", () => { ); await state.manager.enqueue(queued); const started = Promise.withResolvers(); - const delivery = Promise.withResolvers>(); + const delivery = + Promise.withResolvers>(); state.start.mockImplementationOnce(() => { started.resolve(); return delivery.promise; @@ -437,7 +439,7 @@ describe("queued task operation access", () => { setOperateGrant("secondary", false); delivery.resolve( accepted - ? AsyncResult.success(undefined) + ? AsyncResult.success({}) : AsyncResult.failure(Cause.fail(new Error("This connection cannot control this task."))), ); await drained; diff --git a/apps/mobile/src/state/threadOutboxDelivery.test.ts b/apps/mobile/src/state/threadOutboxDelivery.test.ts index 901ffaa9e8291..d77ffe1942b6c 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.test.ts @@ -13,9 +13,12 @@ import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; const harness = vi.hoisted(() => ({ canOperate: true, - threadProjection: null as unknown as import("effect/reactivity").Atom.Writable<{ - readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; - } | null>, + threadState: null as unknown as import("effect/reactivity").Atom.Writable<{ + readonly data: import("effect/Option").Option<{ + readonly messages: ReadonlyArray<{ readonly id: string }>; + }>; + readonly sequence: number; + }>, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -92,12 +95,14 @@ vi.mock("./git", () => ({ gitEnvironment: {} })); vi.mock("./threads", async () => { const { Atom } = await import("effect/reactivity"); - harness.threadProjection = Atom.make<{ - readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; - } | null>(null).pipe(Atom.keepAlive); + const Option = await import("effect/Option"); + harness.threadState = Atom.make({ + data: Option.none<{ readonly messages: ReadonlyArray<{ readonly id: string }> }>(), + sequence: 0, + }).pipe(Atom.keepAlive); return { threadEnvironment: {}, - environmentThreadDetails: { threadAtom: () => harness.threadProjection }, + environmentThreadDetails: { stateAtom: () => harness.threadState }, }; }); @@ -138,6 +143,7 @@ vi.mock("./thread-outbox", async () => { }; }); +import * as Option from "effect/Option"; import { appAtomRegistry } from "./atom-registry"; import { pendingThreadCreationOutcomesAtom } from "./pending-thread-creation"; import { clearThreadComposerError, threadComposerErrorsAtom } from "./thread-composer-error"; @@ -204,7 +210,7 @@ function remainingMessages(): ReadonlyArray { beforeEach(() => { appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); - appAtomRegistry.set(harness.threadProjection, null); + appAtomRegistry.set(harness.threadState, { data: Option.none(), sequence: 0 }); harness.canOperate = true; harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -330,26 +336,32 @@ describe("thread outbox attachment preparation", () => { }); describe("thread outbox drain delivery cleanup", () => { - it("drops echoed acknowledged rows at the next delivery without subscribing to the thread", async () => { + it("drops caught-up acknowledged rows at the next delivery without subscribing to the thread", async () => { const first = queuedMessage({ messageId: "awaiting-echo", text: "Delivered" }); const second = queuedMessage({ messageId: "next-delivery", text: "Also delivered" }); await harness.manager.enqueue(first); await harness.manager.enqueue(second); await expect( - completeQueuedMessageDelivery(first, harness.manager.revisionOf(first.messageId)), + completeQueuedMessageDelivery(first, harness.manager.revisionOf(first.messageId), 7), ).resolves.toBe("removed"); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); - - appAtomRegistry.set(harness.threadProjection, { - projection: { messages: [{ id: "unrelated" }, { id: first.messageId }] }, + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...first, sequence: 7 }, + ]); + + // The thread applied the message, but its bounded window does not hold it. + appAtomRegistry.set(harness.threadState, { + data: Option.some({ messages: [{ id: "unrelated" }] }), + sequence: 7, }); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toHaveLength(1); await expect( - completeQueuedMessageDelivery(second, harness.manager.revisionOf(second.messageId)), + completeQueuedMessageDelivery(second, harness.manager.revisionOf(second.messageId), 9), ).resolves.toBe("removed"); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([second]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...second, sequence: 9 }, + ]); }); it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { @@ -359,7 +371,11 @@ describe("thread outbox drain delivery cleanup", () => { harness.draftFile.setWriteError(new Error("Draft storage unavailable")); await expect( - completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)), + completeQueuedMessageDelivery( + message, + harness.manager.revisionOf(message.messageId), + undefined, + ), ).resolves.toBe("removed"); expect(remainingMessages()).toEqual([]); @@ -390,9 +406,9 @@ describe("thread outbox drain delivery cleanup", () => { ).toEqual([message]); if (cleared) await harness.manager.clearEnvironment(message.environmentId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe( - cleared ? "edited" : "removed", - ); + await expect( + completeQueuedMessageDelivery(message, deliveryRevision, undefined), + ).resolves.toBe(cleared ? "edited" : "removed"); appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, @@ -412,7 +428,9 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.update(edited); await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); await harness.manager.clearEnvironment(message.environmentId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "edited", + ); await composerDrafts.restoreCloudComposerDrafts("account-a"); expect(remainingMessages()).toEqual([edited]); }); @@ -447,7 +465,9 @@ describe("thread outbox drain delivery cleanup", () => { const edited = { ...message, text: "edited while the turn delivered" }; await harness.manager.update(edited); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "edited", + ); expect(remainingMessages()).toEqual([edited]); expect(harness.removePersistedFile).not.toHaveBeenCalled(); @@ -458,7 +478,9 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.enqueue(message); const deliveryRevision = harness.manager.revisionOf(message.messageId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "removed", + ); expect(remainingMessages()).toEqual([]); expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); @@ -479,7 +501,7 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.enqueue(message); const deliveryRevision = harness.manager.revisionOf(message.messageId); - const cleanup = completeQueuedMessageDelivery(message, deliveryRevision); + const cleanup = completeQueuedMessageDelivery(message, deliveryRevision, undefined); await removeStarted.promise; appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); removeBarrier.resolve(); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 5ba633de634a7..4a20db3b9e965 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -250,6 +250,7 @@ function isQueuedMessagePayloadCurrent( export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, + sequence: number | undefined, ): Promise<"removed" | "edited" | "failed"> { clearThreadComposerError( scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), @@ -266,7 +267,9 @@ export async function completeQueuedMessageDelivery( if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return "edited"; } - retainAcknowledgedThreadMessage(queuedMessage); + retainAcknowledgedThreadMessage( + sequence === undefined ? queuedMessage : { ...queuedMessage, sequence }, + ); const removed = await removeThreadOutboxMessage( queuedMessage, deliveryRevision, @@ -616,6 +619,12 @@ const settledCreationOutcomeKeysAtom = Atom.make((get): ReadonlyArray => ); }).pipe(Atom.withLabel("mobile:thread-outbox:settled-creation-outcomes")); +function deliveredSequence( + result: AtomCommandResult<{ readonly sequence?: number }, unknown>, +): number | undefined { + return AsyncResult.isSuccess(result) ? result.value.sequence : undefined; +} + const runDeliveryCommand = ( command: AtomCommand, input: W, @@ -896,7 +905,11 @@ export function createThreadOutboxDelivery(): { } acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); const delivered = - (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; + (await completeQueuedMessageDelivery( + persistedMessage, + deliveryRevision, + deliveredSequence(deliveryResult), + )) === "removed"; if (delivered) { acknowledgedExistingThreadMessageIds.delete(persistedMessage.messageId); } @@ -1052,7 +1065,11 @@ export function createThreadOutboxDelivery(): { return restoreQueuedMessage(persistedMessage, failure.message); } recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); - const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); + const outcome = await completeQueuedMessageDelivery( + persistedMessage, + deliveryRevision, + deliveredSequence(deliveryResult), + ); if (outcome === "edited") { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return true; diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 45f06e198f47a..4c14fb56254cf 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -110,6 +110,7 @@ const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); +const EMPTY_THREAD_SEQUENCE_ATOM = Atom.make(0); export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -227,6 +228,11 @@ export function useThreadComposerState() { const selectedThreadMessages = selectedThreadProjection?.projection.messages; const selectedThreadAttempts = selectedThreadProjection?.projection.attempts; const selectedThreadNodes = selectedThreadProjection?.projection.nodes; + const selectedThreadSequence = useAtomValue( + selectedThreadDetailRef === null + ? EMPTY_THREAD_SEQUENCE_ATOM + : environmentThreadDetails.sequenceAtom(selectedThreadDetailRef), + ); const checkpointSummaries = useAtomValue( selectedThreadDetailRef === null ? EMPTY_CHECKPOINT_SUMMARIES_ATOM @@ -254,6 +260,7 @@ export function useThreadComposerState() { const pendingAcknowledgments = selectPendingAcknowledgedMessages(acknowledgedMessages, { threadKey: selectedThreadKey, queuedMessages: selectedThreadQueuedMessages, + sequence: selectedThreadSequence, projectedMessages: selectedThreadMessages, }); if (pendingAcknowledgments.length === 0) return feed; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 88fc87d3bc092..ea1669f4b7ddc 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1380,6 +1380,9 @@ it.effect("replays a server-allocated launch", () => assert.equal(first.threadId, retry.threadId); assert.isFalse(first.resumed); assert.isTrue(retry.resumed); + // Clients drop their pending copy once the thread reaches the message's commit. + assert.isNumber(first.sequence); + assert.equal(retry.sequence, first.sequence); assert.equal(harness.runSetup.mock.calls.length, 1); assert.equal(retry.projection.messages.length, 1); assert.equal(retry.projection.runs.length, 1); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 289df535c12ea..7a45a54a312f4 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -123,6 +123,7 @@ export interface ThreadLaunchResult { readonly projection: OrchestrationV2ThreadProjection; /** The launch's last write (the initial message, else the thread claim) was accepted before this call. */ readonly resumed: boolean; + readonly sequence?: number; } export class ThreadLaunchError extends Schema.TaggedError()( @@ -941,6 +942,7 @@ const make = Effect.gen(function* () { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } let runId: RunId | null = null; + let messageSequence: number | undefined; let messageWasAlreadyAccepted = false; if (input.initialMessage !== undefined) { const messageCommandId = CommandId.make(`${input.commandId}:initial-message`); @@ -987,6 +989,7 @@ const make = Effect.gen(function* () { ); // A concurrent retry can read no receipt and still replay the dispatch. messageWasAlreadyAccepted ||= dispatched.replayed === true; + messageSequence = dispatched.sequence; const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); @@ -1053,6 +1056,7 @@ const make = Effect.gen(function* () { input.initialMessage === undefined ? Option.isSome(launchReceipt) || claimed.replayed === true : messageWasAlreadyAccepted, + ...(messageSequence === undefined ? {} : { sequence: messageSequence }), }; }); }, diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index 2378407c2df73..d4be3f1a9f456 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -223,7 +223,7 @@ describe("routine command transport budget", () => { if (command.type !== "message.dispatch") { throw new Error(`Expected message.dispatch, received ${command.type}`); } - if (!("sequence" in receipt)) { + if ("projection" in receipt) { throw new Error("Expected a dispatch receipt"); } const { deliveryIntent: _deliveryIntent, ...formerCommand } = command; diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0c93800378797..e1f30998caefb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -409,6 +409,12 @@ export function createEnvironmentThreadDetailAtoms( Atom.withLabel(`environment-thread-status:${key}`), ), ); + const sequenceAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).sequence).pipe( + Atom.setIdleTTL(0), + Atom.withLabel(`environment-thread-sequence:${key}`), + ), + ); const errorAtomFamily = Atom.family((key: string) => Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( Atom.setIdleTTL(0), @@ -439,6 +445,7 @@ export function createEnvironmentThreadDetailAtoms( quietVisibleTurnItemsAtom: (ref: ScopedThreadRef) => quietVisibleTurnItemsAtomFamily(threadKey(ref)), statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)), + sequenceAtom: (ref: ScopedThreadRef) => sequenceAtomFamily(threadKey(ref)), errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)), historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)), }; diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 67b227089ab15..3b53b68b2f4fa 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -14,6 +14,8 @@ export interface EnvironmentThreadState { * disconnect path. */ readonly history: ThreadHistoryMeta; + /** The last server sequence `data` reflects, so readers can tell a write has landed without searching paged-out history. */ + readonly sequence: number; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { @@ -21,4 +23,21 @@ export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { status: "empty", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: 0, }; + +/** + * A thread holds a write once it has applied the write's commit sequence. A bounded + * projection can page the message out later, so finding it is only a fallback for + * servers that do not report the sequence. + */ +export function threadHasCaughtUpTo( + write: { readonly messageId: string; readonly sequence?: number | undefined }, + thread: { + readonly sequence: number; + readonly messages: ReadonlyArray<{ readonly id: string }> | undefined; + }, +): boolean { + if (write.sequence !== undefined && thread.sequence >= write.sequence) return true; + return thread.messages?.some((entry) => entry.id === write.messageId) ?? false; +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c8c212db41ca1..eaf1751a1c2d2 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -678,7 +678,7 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ cached: BASE_PROJECTION }); yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); - yield* awaitThreadState( + const live = yield* awaitThreadState( harness.observed, (value) => value.status === "live" && @@ -686,6 +686,8 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "Live title", ); + // Readers compare this to a command's commit sequence; it moves with the data it describes. + expect(live.sequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.loaderCalls)).toBe(0); }), @@ -1288,6 +1290,7 @@ describe("EnvironmentThreads", () => { status: "cached", error: Option.none(), history: { ...EMPTY_THREAD_HISTORY_META, historyCursor: null, expanded: true }, + sequence: CACHED_SNAPSHOT_SEQUENCE, }, sequence: CACHED_SNAPSHOT_SEQUENCE, persisted: true, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 3cb4192f0ab6c..c7b22d9dcdff0 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -224,6 +224,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) : Option.none(); const cachedThread = Option.map(cached, (snapshot) => snapshot.projection); + // Seed the resume cursor from the cached snapshot so a warm cache can catch up + // via `afterSequence` instead of re-downloading the full thread body. + const initialSequence = + retained?.sequence ?? + Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { @@ -234,15 +239,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onNone: () => EMPTY_THREAD_HISTORY_META, onSome: historyMetaFromCachedSnapshot, }), + sequence: initialSequence, }; const state = yield* SubscriptionRef.make(initialState); const canLoadHistory = Option.isSome(httpClient) && Option.isSome(historyController); const acceptsBoundedSocketSnapshots = yield* Ref.make(canLoadHistory); - // Seed the resume cursor from the cached snapshot so a warm cache can catch up - // via `afterSequence` instead of re-downloading the full thread body. - const initialSequence = - retained?.sequence ?? - Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, @@ -400,6 +401,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ) { const waiting = yield* Ref.get(awaitingCompletion); + const sequence = yield* SubscriptionRef.get(lastSequence); // Atomic with concurrent history meta updates: never get-then-set the whole // state when only the projection changes. Bounded installs pass history so // projection + cursor persist together in one enqueue. @@ -420,6 +422,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : ("live" as const), error: previous.error, history, + sequence, }; }); // Active projections can update many times per second and retain large tool @@ -451,6 +454,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "deleted", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: yield* SubscriptionRef.get(lastSequence), }); yield* remember; if (resumeCache !== undefined && resumeCache.owner !== owner) return; @@ -571,7 +575,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (applied._tag !== "noop") result = applied; if (applied._tag === "delete") break; } - return [result, current]; + // A batch that changed nothing keeps its state object, so readers do not re-render. + return [result, result._tag === "noop" ? current : { ...current, sequence }]; }, ); @@ -796,7 +801,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); if (result._tag !== "loaded") return result; - if (complete) yield* remember; + if (complete) yield* applyLock.withPermits(1)(remember); nextCursor = keepPaging ? page.nextCursor : null; } if (nextCursor === null) return { _tag: "loaded" } as const; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index a1dd871205bb9..498f81a0e848a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3573,6 +3573,8 @@ export const OrchestrationV2ThreadLaunchResult = Schema.Struct({ threadId: ThreadId, projection: OrchestrationV2ThreadProjection, resumed: Schema.Boolean, + /** The initial message's commit sequence. Older servers omit it. */ + sequence: Schema.optionalKey(NonNegativeInt), }); export type OrchestrationV2ThreadLaunchResult = typeof OrchestrationV2ThreadLaunchResult.Type; From d8f4980f6c0ba0b1ea3c52b69f3f3794f6973d3f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 18:17:44 -0700 Subject: [PATCH 021/144] ci(mobile): fork PRs now get the Native Change label (#26755) Co-authored-by: Claude Opus 5.5 --- .../workflows/mobile-fingerprint-check.yml | 106 ++-------- .../workflows/mobile-fingerprint-label.yml | 186 ++++++++++++++++++ 2 files changed, 201 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/mobile-fingerprint-label.yml diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index b8b872f019f6e..9fabb3a5f2ad2 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -2,7 +2,8 @@ name: Mobile Fingerprint Check # Detects whether a PR changes the native fingerprint โ€” i.e. whether merging # it would leave main un-OTA-able until a new store build ships. Native-change -# PRs get the "๐Ÿ“ฑ Native Change" label so they can be held and merged as a +# PRs get the "๐Ÿ“ฑ Native Change" label (applied by mobile-fingerprint-label.yml, +# which runs from main so fork PRs get it too) so they can be held and merged as a # batch right before the next store submission, keeping main OTA-able for # everything else in between. (Once one native PR merges, every later merge # inherits the drifted fingerprint and loses OTA reach too โ€” that is why the @@ -27,8 +28,6 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read - issues: write - pull-requests: write env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 @@ -127,94 +126,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" - - name: Sync native change label - # Fork PRs get a read-only token under pull_request; the check stays - # advisory there (summary only). This workflow must not move to - # pull_request_target โ€” it installs and runs PR code. - if: github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v8 + # mobile-fingerprint-label.yml reads this from main with a write token + # and syncs the label; this job's token is read-only for fork PRs. + - name: Record changed platforms env: CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} - with: - script: | - const managedLabel = { - name: "๐Ÿ“ฑ Native Change", - color: "d93f0b", - description: - "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", - }; - const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; - const issueNumber = context.payload.pull_request.number; - - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - }); - - if ( - existing.color !== managedLabel.color || - (existing.description ?? "") !== managedLabel.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); - - if (nativeChanged && !hasLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [managedLabel.name], - }); - } else if (!nativeChanged && hasLabel) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: managedLabel.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } + run: | + mkdir -p "$RUNNER_TEMP/fingerprint-result" + printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms" - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); + - name: Upload changed platforms + uses: actions/upload-artifact@v7 + with: + name: fingerprint-result + path: ${{ runner.temp }}/fingerprint-result + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml new file mode 100644 index 0000000000000..191c953279972 --- /dev/null +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -0,0 +1,186 @@ +name: Mobile Fingerprint Label + +# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code +# (fork PRs included) with a read-only token and records which platforms' +# fingerprints changed. This one runs from main with a write token and syncs +# the "๐Ÿ“ฑ Native Change" label. It never checks out or runs PR code. +# +# A PR can edit the check workflow it runs under, so the recorded result is +# untrusted data: it is validated, and at worst a PR mislabels itself. + +on: + workflow_run: + workflows: [Mobile Fingerprint Check] + types: [completed] + +permissions: + contents: read + +jobs: + label: + name: Sync native change label + # A cancelled run was superseded by a newer push, whose run labels instead. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + issues: write + pull-requests: write + steps: + - id: pr + name: Resolve the pull request behind the check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const results = artifacts.filter( + (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, + ); + if (results.length !== 1) { + core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); + return; + } + + // workflow_run.pull_requests is empty for fork PRs, so resolve the + // PR from the checked commit. Requiring the PR's current head to be + // that commit keeps a slow run for an older push from overwriting a + // newer result. + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: run.head_sha, per_page: 100 }, + ); + const matching = associated.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); + return; + } + + core.setOutput("artifact_id", String(results[0].id)); + core.setOutput("pr_number", String(matching[0].number)); + + # Read the archive as bytes; never extract PR-produced files into a + # workspace this job runs code from. + - id: result + name: Read changed platforms + if: steps.pr.outputs.pr_number != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" + changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" + if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then + echo "Unexpected changed-platforms value '$changed'." >&2 + exit 1 + fi + echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + if: steps.pr.outputs.pr_number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "๐Ÿ“ฑ Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = Number(process.env.PR_NUMBER); + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); From 5eebf77b373b719ca4456985340707afa267310e Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 01:27:58 +0000 Subject: [PATCH 022/144] fix(claude): command approvals show the command, not Claude's description of it (#20145) --- .../Adapters/ClaudeAdapterV2.test.ts | 20 +++++++++++++------ .../Adapters/ClaudeAdapterV2.ts | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ba4b7444b7b08..47457be660998 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -893,8 +893,11 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { assert.isFunction(canUseTool); const requestEvent = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.runHead, + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, Effect.forkScoped, ); const command = { command: "node -e 'console.log(42)'" }; @@ -903,6 +906,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { signal: new AbortController().signal, toolUseID: "tool-bash-accept-edits", requestId: "request-bash-accept-edits", + description: "Check the Node runtime", }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -915,12 +919,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); assert.equal(first.type, "request", "the command ran without asking"); if (first.type !== "request") return; - const event = first.event; - if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; - assert.equal(event.value.runtimeRequest.kind, "command"); + const event = first.event.find((event) => event.type === "runtime_request.updated"); + assert.equal(event?.runtimeRequest.kind, "command"); + const approval = first.event.findLast((event) => event.type === "turn_item.updated"); + assert.equal( + approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, + command.command, + ); yield* runtime.respondToRuntimeRequest({ - requestId: event.value.runtimeRequest.id, + requestId: event!.runtimeRequest.id, decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 030fb5785e6d9..dd4fc8f30af92 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9551,7 +9551,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); + // The user approves the command itself; Claude's title and description only summarize it. + const command = inputRecordValue(nativeToolInput, "command"); const prompt = + (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? callbackOptions.title ?? callbackOptions.description ?? callbackOptions.decisionReason ?? From 7b87466731bfe3662fd86689e8754c494567bca8 Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 29 Oct 2026 02:56:40 +0100 Subject: [PATCH 023/144] docs: add nixpkgs installation instructions (#26750) --- README.md | 10 ++++++++++ docs/user/install.md | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 36f558a79dc60..6f6dab985e154 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ yay -S t3code-nightly-bin The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). +#### Nix (nixpkgs) + +```bash +nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code +``` + +The package is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code) +and includes the desktop app (`t3code-desktop`) and the `t3` command. +See [Nix installation](./docs/user/install.md#nix-nixpkgs) for NixOS configuration and updates. + ## Some notes We are very very early in this project. Expect bugs. diff --git a/docs/user/install.md b/docs/user/install.md index 3edf46d80ee39..1e1693d0b32ee 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -65,18 +65,41 @@ update it with `git pull` and a rebuild. Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or use a package manager: -| Platform | Install | -| ------------------ | ---------------------------------- | -| Windows | `winget install T3Tools.T3Code` | -| macOS | `brew install --cask t3-code` | -| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | -| Arch Linux | `yay -S t3code-bin` | -| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Platform | Install | +| ------------------ | ------------------------------------------------------------ | +| Windows | `winget install T3Tools.T3Code` | +| macOS | `brew install --cask t3-code` | +| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | +| Arch Linux | `yay -S t3code-bin` | +| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Nix | `nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code` | The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +### Nix (nixpkgs) + +T3 Code is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code). +The command above installs from `nixos-unstable` and requires Nix 2.30 or later with +`nix-command` and `flakes` enabled. Launch the desktop app with `t3code-desktop`; +the package also includes the `t3` command. + +On NixOS, you can instead add the package to your system configuration, using +a nixpkgs revision that includes `t3code`: + +```nix +environment.systemPackages = [ pkgs.t3code ]; +``` + +Apply the change with `sudo nixos-rebuild switch`. For Home Manager, add +`pkgs.t3code` to `home.packages` and run `home-manager switch`. + +Update a profile installation with `nix profile upgrade t3code`. For NixOS or +Home Manager, update your configuration's nixpkgs input or channel and rebuild. +Nix manages both the desktop app and the bundled +`t3` command; use Nix to update them instead of the in-app updater or `t3 update`. + ### The `t3` command The desktop app includes the `t3` command-line tool. To run it from any From 861a51a6c4a036a6f14fea2a6f093808de1d96ce Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 02:07:28 +0000 Subject: [PATCH 024/144] feat(server,web,mobile): fall back when the default provider is out of usage (#26164) Co-authored-by: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 17 ++- .../src/mcp/toolkits/project/handlers.test.ts | 58 ++++++++++ .../src/mcp/toolkits/project/handlers.ts | 16 ++- apps/server/src/mcp/toolkits/project/tools.ts | 2 + apps/server/src/serverSettings.ts | 1 + .../CodexTextGeneration.test.ts | 103 +++++++++++++++++ .../src/textGeneration/TextGeneration.ts | 105 +++++++++++++++--- apps/web/src/components/ChatView.tsx | 98 ++++++++++++++-- .../components/chat/ComposerUsageLimits.tsx | 35 ++++++ .../src/components/chat/DraftHeroHeadline.tsx | 35 +++++- .../settings/ProjectDefaultsSettings.tsx | 63 +++++++++++ apps/web/src/hooks/useHandleNewThread.test.ts | 1 + apps/web/src/hooks/useHandleNewThread.ts | 23 +++- apps/web/src/providerInstances.ts | 16 +++ packages/contracts/src/settings.ts | 4 + packages/shared/src/serverSettings.test.ts | 18 +++ packages/shared/src/serverSettings.ts | 19 ++-- packages/shared/src/usageLimits.test.ts | 103 +++++++++++++++++ packages/shared/src/usageLimits.ts | 83 ++++++++++++++ 19 files changed, 763 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index b2159706abeee..14a9720cb4e96 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,4 +1,6 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { useNowMinuteMs } from "../../lib/clock"; import * as Dialog from "../../components/ui/Dialog"; import React, { useEffect, useRef, useState } from "react"; @@ -483,6 +485,7 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); + const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, @@ -501,12 +504,24 @@ export function NewTaskFlowProvider( selectedProject?.id ?? null, ); - const selectedModel = resolveNewTaskModelSelection({ + const selectedDefaultModel = resolveNewTaskModelSelection({ draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, }); + const selectedModel = resolveNewThreadModelSelection({ + defaultSelection: selectedDefaultModel, + explicitSelection: + draftModelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), + fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null, + providers: selectedEnvironmentServerConfig?.providers ?? [], + ...(selectedEnvironmentServerConfig + ? { settings: selectedEnvironmentServerConfig.settings } + : {}), + projectId: selectedProject?.id ?? null, + now: modelSelectionNow, + }).selection; const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 0d7ca3e1c9d98..03b97c32af6db 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,8 +1,11 @@ +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProjectId, ProviderInstanceId, + ProviderDriverKind, + type ServerProvider, type Project as ProjectRecord, type ModelSelection, type RuntimeMode, @@ -37,6 +40,8 @@ import { ProjectToolkit } from "./tools.ts"; const clientLaunchHarness = (input: { readonly runtimeModeCeiling: RuntimeMode; readonly defaultRuntimeMode?: RuntimeMode; + readonly fallbackModel?: ModelSelection; + readonly providers?: readonly ServerProvider[]; /** Where Settings saves a project's model; the legacy project field stays null. */ readonly overrideModel?: ModelSelection; readonly launched: Array; @@ -54,7 +59,11 @@ const clientLaunchHarness = (input: { model: input.overrideModel === undefined ? modelSelection : null, }; const layerDependencies = Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(input.providers ?? []), + }), ServerSettings.layerTest({ + fallbackModelSelection: input.fallbackModel ?? null, ...(input.defaultRuntimeMode === undefined ? {} : { defaultRuntimeMode: input.defaultRuntimeMode }), @@ -473,3 +482,52 @@ it.effect("a retried worktree launch replays after its base ref is gone", () => ]); }), ); + +it.effect("a client launches with the fallback for an exhausted default", () => + Effect.gen(function* () { + const launched: ThreadLaunch.ThreadLaunchInput[] = []; + const fallbackModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(driver === "claudeAgent" + ? { + usageLimits: { + checkedAt: "2026-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + })); + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched, + fallbackModel, + providers, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", { + projectId, + title: "Fallback", + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(result.at(-1)?.result).toMatchObject({ + modelSelection: fallbackModel, + }); + expect(launched[0]?.modelSelection).toEqual(fallbackModel); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 79f18acbc2da5..014b109448f34 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -18,6 +18,9 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import * as DateTime from "effect/DateTime"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -180,7 +183,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { input.modelSelection ?? launchedModel ?? caller?.modelSelection ?? - projectDefaults?.defaultModelSelection ?? + (projectDefaults + ? resolveNewThreadModelSelection({ + defaultSelection: projectDefaults.defaultModelSelection, + fallbackSelection: projectDefaults.fallbackModelSelection, + providers: yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + ), + settings: projectDefaults, + projectId, + now: DateTime.toEpochMillis(yield* DateTime.now), + }).selection + : null) ?? undefined; if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 38931596868b4..3ea5a8ea9e48f 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -23,6 +23,7 @@ import { import * as FileSystem from "effect/FileSystem"; import * as DocumentText from "../../../provider/DocumentText.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as Crypto from "effect/Crypto"; @@ -166,6 +167,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, ], }) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3131c84d880e7..a1e0d34c7dd53 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -435,6 +435,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "sourceControlWriterModelSelection", "reviewGuideModelSelection", "textGenerationModelSelection", + "fallbackModelSelection", "pullRequestMergeMethod", ]); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index a83e09a4110df..04ae83952b3c4 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,3 +1,13 @@ +import { + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ProviderDriverKind, + type ModelSelection, + type ServerProvider, +} from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -433,3 +443,96 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); }); + +it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhausted) => + Effect.gen(function* () { + const primary = ProviderInstanceId.make("codex"); + const fallback = ProviderInstanceId.make("claude_work"); + const selection = { + instanceId: primary, + model: "chat-model", + options: [{ id: "effort", value: "high" }], + }; + const calls: ModelSelection[] = []; + const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { + const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); + const snapshot: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(exhausted && instanceId === primary + ? { + usageLimits: { + checkedAt: "1970-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + }; + return { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: TextGeneration.TextGeneration.of({ + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + generateThreadTitle: (input) => + Effect.sync(() => { + calls.push(input.modelSelection); + return { title: "Generated title" }; + }), + }), + }; + }); + const service = yield* TextGeneration.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed(instances), + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + }), + ServerSettings.layerTest({ + providerInstances: { + [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + }, + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => Effect.die("unused"), + }), + ), + ), + ); + expect( + yield* service.generateThreadTitle({ + cwd: "/tmp", + message: "hello", + linkedContext: "", + modelSelection: selection, + }), + ).toEqual({ title: "Generated title" }); + expect(calls).toEqual([ + exhausted + ? { + instanceId: fallback, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + } + : selection, + ]); + }), +); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 96f266c6f8959..fc8d0fb68da31 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { + isProviderAvailable, + isProviderTextGenerationCapable, + resolveProjectProviderInstanceEnabled, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; +import { + defaultTextGenerationModel, + fallbackTextGenerationModelSelection, +} from "@t3tools/shared/serverSettings"; +import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -52,36 +64,94 @@ const resolveInstance = ( ), ); +// Review guides investigate the checkout read-only; the other drivers reject that request. +const INVESTIGATING_DRIVERS = new Set(["codex", "claudeAgent"]); + /** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const generate = Effect.fn("TextGeneration.generate")(function* ( + operation: TextGenerationOp, + selection: ModelSelection, + invoke: ( + service: ProviderTextGeneration, + modelSelection: ModelSelection, + ) => Effect.Effect, + ) { + const snapshots = yield* registry.listInstances.pipe( + Effect.flatMap((instances) => + Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot), + ), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const limited = exhaustedUsageWindow( + snapshots.find((provider) => provider.instanceId === selection.instanceId), + now, + selection.model, + ); + if (limited) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Could not read fallback model settings.", + cause, + }), + ), + ); + selection = + fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver) => { + const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); + return ( + instanceId !== selection.instanceId && + provider?.enabled === true && + resolveProjectProviderInstanceEnabled(settings, null, instanceId) && + provider.installed && + provider.auth.status !== "unauthenticated" && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && + !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) + ); + }, + { includeConfiguredInstances: true }, + ) ?? selection; + } + return yield* resolveInstance(registry, operation, selection.instanceId).pipe( + Effect.flatMap((service) => invoke(service, selection)), + ); + }); return TextGeneration.of({ generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { - return resolveInstance( - registry, - "generateCommitMessage", - input.modelSelection.instanceId, - ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input))); + return generate("generateCommitMessage", input.modelSelection, (service, modelSelection) => + service.generateCommitMessage({ ...input, modelSelection }), + ); }), generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { - return resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + return generate("generatePrContent", input.modelSelection, (service, modelSelection) => + service.generatePrContent({ ...input, modelSelection }), ); }), generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { - return resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + return generate("generateBranchName", input.modelSelection, (service, modelSelection) => + service.generateBranchName({ ...input, modelSelection }), ); }), generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { - return resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), + return generate("generateReviewGuide", input.modelSelection, (service, modelSelection) => + service.generateReviewGuide({ ...input, modelSelection }), ); }), generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { - return resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => + return generate( + "generateThreadTitle", + input.modelSelection, + (textGeneration, modelSelection) => Effect.gen(function* () { const linkedContext = input.linkedContext ?? @@ -91,9 +161,12 @@ export const make = Effect.gen(function* () { sourceControl, ), )); - return yield* textGeneration.generateThreadTitle({ ...input, linkedContext }); + return yield* textGeneration.generateThreadTitle({ + ...input, + linkedContext, + modelSelection, + }); }), - ), ); }), }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 253cc1ea2e84b..d6d3e09536070 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; @@ -189,6 +190,7 @@ import { newMessageId } from "~/lib/utils"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, + resolveProjectDefaultProviderModelSelection, shouldShowInstanceBadge, } from "../providerInstances"; import { @@ -305,6 +307,7 @@ import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -400,6 +403,8 @@ type ChatViewProps = draftId: DraftId; }; +const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -586,6 +591,9 @@ export default function ChatView(props: ChatViewProps) { ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null) : null; }); + const composerModelSelectionExplicit = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true, + ); const composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; @@ -751,15 +759,37 @@ export default function ChatView(props: ChatViewProps) { }); } } + const nowMinute = useNowMinute(); + const draftDefaultModelSelection = draftThread + ? resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection + : null; + const draftModelResolution = draftThread + ? resolveNewThreadModelSelection({ + defaultSelection: + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + draftThread.projectId, + ), + fallbackSelection: settings.fallbackModelSelection, + providers: providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + projectId: draftThread.projectId, + now: Date.parse(`${nowMinute}:00Z`), + }) + : null; + const draftFallback = draftModelResolution?.fallback ? draftModelResolution : null; const localDraftThread = draftThread ? buildLocalDraftThread( threadId, draftThread, - resolveProjectSettings( - settings, - fallbackDraftProject?.id ?? null, - fallbackDraftProject ?? undefined, - ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + (draftFallback ? draftFallback.selection : draftDefaultModelSelection) ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined; const isServerThread = serverThread !== null; @@ -1052,7 +1082,10 @@ export default function ChatView(props: ChatViewProps) { const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; const { projectCloneBannerItem, projectCloneSendBlockReason } = useProjectCloneBanner(activeProjectRef); - const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; + const activeProjectDefaultModelSelection = + !isServerThread && draftFallback + ? draftFallback.selection + : activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); }; @@ -1624,6 +1657,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed, ]); + const [dismissedFallbackWindow, setDismissedFallbackWindow] = useLocalStorage( + `t3code:fallback-notice-dismissed:${environmentId}`, + null, + DismissedFallbackWindowSchema, + ); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -2308,7 +2346,6 @@ export default function ChatView(props: ChatViewProps) { closeRightPanelSurface, }); const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const nowMinute = useNowMinute(); const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, @@ -2478,12 +2515,58 @@ export default function ChatView(props: ChatViewProps) { }) : null; const composerBannerItems = (() => { + const fallbackSelection = draftFallback?.selection; + const defaultSelection = + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providerStatuses, + settings, + draftThread?.projectId ?? null, + ); + const fallback = + !isServerThread && + multipleModelSelections === null && + !composerModelSelectionExplicit && + draftFallback?.fallback && + (!composerSelectedModelSelection || + (composerSelectedModelSelection.instanceId === fallbackSelection?.instanceId && + composerSelectedModelSelection.model === fallbackSelection?.model)) + ? draftFallback.fallback + : null; + const fallbackWindow = fallback + ? `${fallback.provider.instanceId}:${fallback.resetsAt ?? ""}` + : null; + const fallbackItems: ComposerBannerStackItem[] = + fallback && + fallbackSelection && + defaultSelection && + fallbackWindow !== dismissedFallbackWindow + ? [ + modelFallbackBannerItem({ + fallbackModelName: + providerStatuses + .find((provider) => provider.instanceId === fallbackSelection.instanceId) + ?.models.find((model) => model.slug === fallbackSelection.model)?.name ?? + fallbackSelection.model, + defaultProviderName: fallback.provider.displayName ?? fallback.provider.driver, + defaultModelName: + fallback.provider.models.find((model) => model.slug === defaultSelection.model) + ?.name ?? defaultSelection.model, + resetsAt: fallback.resetsAt, + timestampFormat, + onUseDefault: () => + onProviderModelSelectStable(defaultSelection.instanceId, defaultSelection.model), + onDismiss: () => setDismissedFallbackWindow(fallbackWindow), + }), + ] + : []; const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, @@ -2492,6 +2575,7 @@ export default function ChatView(props: ChatViewProps) { } return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 294816c140a29..12c11d213cdf7 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -1,8 +1,10 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; import { limitsNotice } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; @@ -67,6 +69,39 @@ export function usageLimitsBannerItem( }; } +export function modelFallbackBannerItem({ + fallbackModelName, + defaultProviderName, + defaultModelName, + resetsAt, + timestampFormat, + onUseDefault, + onDismiss, +}: { + readonly fallbackModelName: string; + readonly defaultProviderName: string; + readonly defaultModelName: string; + readonly resetsAt: string | null; + readonly timestampFormat: TimestampFormat; + readonly onUseDefault: () => void; + readonly onDismiss: () => void; +}): ComposerBannerStackItem { + return { + id: "fallback-model", + variant: "info", + icon: , + title: `Using ${fallbackModelName}`, + description: `${defaultProviderName} is out of usage${resetsAt ? ` ยท resets ${formatUpcomingTimestamp(resetsAt, timestampFormat)}` : ""}`, + actions: ( + + ), + dismissLabel: "Dismiss fallback model notice", + onDismiss, + }; +} + function UsageLimitsBannerBody({ report, environmentId, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4a8a894bedbb6..d2e259692204b 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -25,7 +25,11 @@ import { projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useEnvironments, + usePrimaryEnvironmentId, + useEnvironmentProviders, +} from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; @@ -45,6 +49,8 @@ import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { TriageDraftHero } from "./TriageDraftHero"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveProjectDefaultProviderModelSelection } from "../../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; const ADD_PROJECT_VALUE = "add-project"; @@ -67,6 +73,7 @@ export function DraftHeroHeadline({ const projects = useProjects(); const threads = useThreadShells(); const { environments } = useEnvironments(); + const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); @@ -193,13 +200,33 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const environmentSettings = environments.find( + const environmentConfig = environments.find( (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings + )?.serverConfig; + const environmentSettings = environmentConfig?.settings; + const defaultSelection = environmentSettings ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection : project.defaultModelSelection; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + defaultSelection ?? + (useComposerDraftStore.getState().stickyActiveProvider !== null || !environmentSettings + ? null + : resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(project.environmentId) ?? [], + environmentSettings, + project.id, + )), + fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, + providers: providersByEnvironment.get(project.environmentId) ?? [], + ...(environmentSettings ? { settings: environmentSettings } : {}), + projectId: project.id, + now: Date.now(), + }); + const defaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : defaultSelection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 0bfa19498f6d9..43c4419962a02 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -77,6 +77,14 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); + const fallbackSelection = settings.fallbackModelSelection ?? selection; + const fallbackModelOptions = getCustomModelOptionsByInstance( + settings, + providers, + fallbackSelection?.instanceId, + fallbackSelection?.model, + ); const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); @@ -216,6 +224,59 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting /> ); + const fallbackRow = !isProjectScope ? ( + updateSettings({ fallbackModelSelection: null })} + /> + ) : null + } + control={ + fallbackSelection ? ( + { + const reason = modelDisabledReason(instanceId, model); + if (reason) { + toastManager.add({ + type: "error", + title: "Fallback model not saved", + description: reason, + }); + return; + } + updateSettings({ fallbackModelSelection: createModelSelection(instanceId, model) }); + }} + /> + ) : ( + No providers available + ) + } + /> + ) : null; + return ( {modelRow} + {fallbackRow} {workspaceRow} ) : category === "general" ? ( <> {modelRow} + {fallbackRow} ({ hasExplicitComposerModelSelection: () => false, resolveNewThreadModelSelectionOverride: () => null, })); +vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => testState.projectFileRead, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 235dc557bab63..e95a2d0d799bd 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -44,6 +44,8 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { getClientSettings } from "./useSettings"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; +import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -144,7 +146,26 @@ export function createNewThreadHandler(router: AnyRouter) { project?.id ?? null, project, ); - const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; + const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + configuredDefaultModelSelection ?? + (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null + ? null + : resolveProjectDefaultProviderModelSelection( + environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + targetServerSettings, + projectRef.projectId, + )), + fallbackSelection: targetServerSettings.fallbackModelSelection ?? null, + providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + settings: targetServerSettings, + projectId: projectRef.projectId, + now: Date.now(), + }); + const projectDefaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : configuredDefaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index a3364df9a15d6..f9e711c40e7fc 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -240,3 +240,19 @@ export function resolveDefaultProviderModelSelection( const model = getDefaultProviderInstanceModel(providers, instanceId); return model ? { instanceId, model } : null; } + +export function resolveProjectDefaultProviderModelSelection( + providers: ReadonlyArray, + settings: ServerSettings, + projectId: ProjectId | null, +): ModelSelection | null { + const entries = providerInstanceEntriesFor(providers, settings, projectId); + // Matches resolveComposerProviderSelection: the first driver group wins before any other. + const entry = + resolveSelectableProviderInstanceEntry( + entries.filter((candidate) => candidate.driverKind === entries[0]?.driverKind), + undefined, + ) ?? resolveSelectableProviderInstanceEntry(entries, undefined); + const model = entry ? getDefaultProviderInstanceModel(providers, entry.instanceId) : undefined; + return entry && model ? { instanceId: entry.instanceId, model } : null; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dda56d86bae60..438d8fa6ee746 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1179,6 +1179,9 @@ export const ServerSettings = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + fallbackModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), defaultRuntimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), @@ -1537,6 +1540,7 @@ export const ServerSettingsPatch = Schema.Struct({ ), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), + fallbackModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), /** Replaces each project's override set; null removes it. Omitted providerInstances retains its stored value. */ projectSettingsOverrides: Schema.optionalKey( Schema.Record( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 97846939d31cc..f9a43b6123a1e 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -24,6 +24,24 @@ import { const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { + it("replaces and clears the fallback without carrying model options", () => { + const fallbackModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "new" }; + const settings = applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { + ...fallbackModelSelection, + model: "old", + options: [{ id: "effort", value: "high" }], + }, + }, + { fallbackModelSelection }, + ); + expect(settings.fallbackModelSelection).toEqual(fallbackModelSelection); + expect( + applyServerSettingsPatch(settings, { fallbackModelSelection: null }).fallbackModelSelection, + ).toBeNull(); + }); it("ignores a configured non-default instance at machine scope, matching no fallback", () => { const configured = ProviderInstanceId.make("codex_work"); const providerInstances = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 0c151eb5025af..e7c856070658a 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -92,13 +92,15 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { - instanceId, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + return { instanceId, model: defaultTextGenerationModel(driver) }; +} + +export function defaultTextGenerationModel(driver: ProviderDriverKind): string { + return ( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL + ); } export function resolveSourceControlWriterModelSelection( @@ -538,6 +540,9 @@ export function applyServerSettingsPatch( ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } : {}), + ...(patch.fallbackModelSelection !== undefined + ? { fallbackModelSelection: patch.fallbackModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 503521c087d72..5da7bc2cc02db 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderDriverKind, ProviderInstanceId, @@ -17,9 +18,11 @@ import { collectLimitNotices, collectLimitPools, elapsedShare, + exhaustedUsageWindow, paceOf, paceRemainingPercent, usesChatGptSharing, + resolveNewThreadModelSelection, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -50,6 +53,106 @@ function provider(overrides: Partial): ServerProvider { }; } +describe("new thread model fallback", () => { + const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const fallback = { instanceId: ProviderInstanceId.make("claude"), model: "claude-fable-5-1" }; + const limited = provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...window, usedPercent: 100 }], + }, + }); + const available = provider({ + instanceId: fallback.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + }); + + const defaults = { + defaultSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now, + }; + it.each([ + ["exhausted default", {}, fallback], + ["explicit selection", { explicitSelection: selection }, selection], + ["missing default", { defaultSelection: null }, null], + ["no fallback", { fallbackSelection: null }, selection], + ["expired window", { now: Date.parse("2026-09-04T00:00:00Z") }, selection], + [ + "environment disabled", + { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [fallback.instanceId]: { driver: available.driver, enabled: false, config: {} }, + }, + }, + }, + selection, + ], + ] as const)("resolves %s", (_name, overrides, expected) => { + expect(resolveNewThreadModelSelection({ ...defaults, ...overrides }).selection).toEqual( + expected, + ); + }); + it.each([ + { enabled: false }, + { installed: false }, + { auth: { status: "unauthenticated" as const } }, + { availability: "unavailable" as const }, + { usageLimits: limited.usageLimits }, + ])("rejects an unusable fallback: %j", (overrides) => { + expect( + resolveNewThreadModelSelection({ + ...defaults, + providers: [limited, { ...available, ...overrides }], + }).selection, + ).toEqual(selection); + }); + it.each([undefined, "invalid"])("treats an unknown reset as exhausted: %s", (resetsAt) => { + expect( + exhaustedUsageWindow( + provider({ + usageLimits: { + checkedAt: limited.checkedAt, + windows: [{ ...window, usedPercent: 100, resetsAt }], + }, + }), + now, + ), + ).toBeDefined(); + }); + it.each([ + ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false], + ["cursor", ["autoPercentUsed"], "composer-2.5", false], + ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true], + ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false], + ["antigravity", ["3p-weekly"], "claude-opus-5-5", true], + ["antigravity", ["gemini-weekly"], "antigravity-default", true], + ["opencode", ["go_weekly"], "opencode/big-pickle", false], + ["opencode", ["go_weekly"], "opencode-go/big-pickle", true], + ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback) => { + const defaultSelection = { ...selection, model }; + expect( + resolveNewThreadModelSelection({ + ...defaults, + defaultSelection, + providers: [ + provider({ + driver: ProviderDriverKind.make(driver), + usageLimits: { + checkedAt: limited.checkedAt, + windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), + }, + }), + available, + ], + }).selection, + ).toEqual(usesFallback ? fallback : defaultSelection); + }); +}); + describe("pace", () => { it("places the clock three fifths through a five-hour window with two hours left", () => { expect(elapsedShare(window, now)).toBeCloseTo(0.6); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 01e2ad59a332d..3dca207a2efb7 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1,5 +1,9 @@ import { type EnvironmentId, + type ModelSelection, + type ServerSettings, + type ProjectId, + resolveProjectProviderInstanceEnabled, type UsageLimitsReport, type ProviderInstanceId, type ProviderConsumeResetCreditInput, @@ -17,6 +21,85 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export function exhaustedUsageWindow( + provider: ServerProvider | undefined, + now: number, + model?: string, +) { + const exhausted = (provider?.usageLimits?.windows ?? []).filter( + (window) => + (window.usedPercent ?? 0) >= 100 && + (!window.resetsAt || + !Number.isFinite(Date.parse(window.resetsAt)) || + Date.parse(window.resetsAt) > now), + ); + if (model === undefined) return exhausted[0]; + switch (provider?.driver) { + case "cursor": { + const api = exhausted.find((window) => window.id === "apiPercentUsed"); + const auto = exhausted.find((window) => window.id === "autoPercentUsed"); + return /^(auto|default|grok|composer)/.test(model) ? auto && api : api; + } + case "antigravity": { + const family = model.match(/^(gemini|claude|gpt)-/)?.[1]; + return exhausted.find((window) => { + const pool = window.id.match(/^(gemini|claude|gpt|3p)-/)?.[1]; + return ( + pool === undefined || + family === undefined || + pool === family || + (pool === "3p" && (family === "claude" || family === "gpt")) + ); + }); + } + case "opencode": + return exhausted.find( + (window) => !window.id.startsWith("go_") || model.startsWith("opencode-go/"), + ); + default: + return exhausted[0]; + } +} + +export function resolveNewThreadModelSelection(input: { + readonly defaultSelection: ModelSelection | null; + readonly explicitSelection?: ModelSelection | null; + readonly fallbackSelection: ModelSelection | null; + readonly providers: readonly ServerProvider[]; + readonly now: number; + readonly settings?: ServerSettings; + readonly projectId?: ProjectId | null; +}) { + const selection = input.explicitSelection ?? input.defaultSelection; + const unchanged = { selection, fallback: null }; + if (input.explicitSelection || !selection || !input.fallbackSelection) return unchanged; + const provider = input.providers.find((entry) => entry.instanceId === selection.instanceId); + const window = exhaustedUsageWindow(provider, input.now, selection.model); + const fallback = input.providers.find( + (entry) => entry.instanceId === input.fallbackSelection?.instanceId, + ); + if ( + !provider || + !window || + !fallback?.enabled || + !fallback.installed || + fallback.auth.status === "unauthenticated" || + !isProviderAvailable(fallback) || + exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) || + (input.settings && + !resolveProjectProviderInstanceEnabled( + input.settings, + input.projectId ?? null, + fallback.instanceId, + )) + ) + return unchanged; + return { + selection: input.fallbackSelection, + fallback: { provider, resetsAt: window.resetsAt ?? null }, + }; +} + export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; /** A ChatGPT Business admin caps plan use in other apps; only the admin can raise it. */ From 783581d461dc72cdd8f78fc8abb4ad536dfc4547 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 19:15:50 -0700 Subject: [PATCH 025/144] fix(ci): fork PRs now resolve in workflow_run PR lookups (#26779) Co-authored-by: Claude Opus 5.5 --- .../desktop-macos-preview-publish.yml | 19 ++++++++++------- .../workflows/mobile-fingerprint-label.yml | 21 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index bd6f59bc0f0bc..6e8805b7f790b 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -77,16 +77,19 @@ jobs: return; } - // workflow_run.pull_requests is empty for fork PRs, so resolve the - // PR from the built commit instead and require exactly one open PR - // from the same head repository and branch. The build baked its + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch + // and require exactly one at the built commit. The build baked its // PR number into the version, so two candidates would mean the // asset name could belong to either. - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: run.head_sha, per_page: 100 }, - ); - const matching = associated.filter( + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( (candidate) => candidate.state === "open" && candidate.head.sha === run.head_sha && diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml index 191c953279972..45e4c9f2ad42d 100644 --- a/.github/workflows/mobile-fingerprint-label.yml +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -53,15 +53,18 @@ jobs: return; } - // workflow_run.pull_requests is empty for fork PRs, so resolve the - // PR from the checked commit. Requiring the PR's current head to be - // that commit keeps a slow run for an older push from overwriting a - // newer result. - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: run.head_sha, per_page: 100 }, - ); - const matching = associated.filter( + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch. + // Requiring the PR's current head to be the checked commit keeps a + // slow run for an older push from overwriting a newer result. + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( (candidate) => candidate.state === "open" && candidate.head.sha === run.head_sha && From 8298664e9e46ad8af2c8966cfb4c08a2a31898a4 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 02:37:31 +0000 Subject: [PATCH 026/144] fix(server,cursor,opencode): refresh provider usage when a turn hits its usage limit (#26234) Co-authored-by: Claude Opus 5.5 --- .../ProviderEventIngestor.test.ts | 109 ++++++++++++++++- .../orchestration-v2/ProviderEventIngestor.ts | 21 ++++ .../ProviderSessionManager.test.ts | 5 + .../testkit/ProviderReplayHarness.ts | 5 + .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/server/managedProvider.test.ts | 111 ++++++++++++++++++ .../src/server/managedProvider.ts | 35 ++++++ packages/provider-core/src/server/snapshot.ts | 7 +- .../src/server/CursorAgentSdk.ts | 15 +++ .../src/server/adapter.test.ts | 13 +- .../provider-cursor/src/server/adapter.ts | 8 +- .../src/server/adapter.test.ts | 45 +++++++ .../provider-opencode/src/server/adapter.ts | 6 +- 13 files changed, 373 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 3fecc1c7d2d61..7f1006f1c5ec5 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -46,6 +46,9 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -68,8 +71,16 @@ const layerGit = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); -const makeLayerTest = (ingestorEventSink: Layer.Layer) => +const layerEmptyInstances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), +}); +const makeLayerTest = ( + ingestorEventSink: Layer.Layer, + instances = layerEmptyInstances, +) => Layer.mergeAll( + instances, layerTestDatabase, layerTestStores, layerTestEventSink, @@ -79,6 +90,7 @@ const makeLayerTest = (ingestorEventSink: Layer.Layer + Effect.scoped( + Effect.gen(function* () { + const refreshed = yield* Deferred.make(); + const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( + Effect.provide( + Layer.mock(ProviderAdapter.ProviderAdapterV2)({ + instanceId: ProviderInstanceId.make("cursor-work"), + driver: ProviderDriverKind.make("cursor"), + }), + ), + ); + const instance: ProviderInstance = { + instanceId: ProviderInstanceId.make("cursor-work"), + driverKind: ProviderDriverKind.make("cursor"), + continuationIdentity: { + driverKind: ProviderDriverKind.make("cursor"), + continuationKey: "work", + }, + enabled: true, + displayName: undefined, + orchestrationAdapter: adapter, + textGeneration: { + generateBranchName: () => Effect.die("unused"), + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateThreadTitle: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + }, + snapshot: { + refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), + getSnapshot: Effect.die("unused"), + refresh: Effect.die("unused"), + resolveMaintenance: () => Effect.die("unused"), + applyUsageLimits: () => Effect.die("unused"), + streamChanges: Stream.empty, + }, + }; + const lookups: ProviderInstanceId[] = []; + const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.sync(() => { + lookups.push(id); + return id === instance.instanceId ? instance : undefined; + }), + }); + yield* Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [threadEvent] }); + const input = { + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }), + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }; + for (const failureClass of ["validation_error", "usage_limit"] as const) { + const stored = yield* ingestor.ingestNormalized({ + ...input, + event: { + type: "turn.terminal", + driver: instance.driverKind, + providerThreadId: idAllocator.derive.providerThread({ + driver: instance.driverKind, + nativeThreadId: "limited", + }), + providerTurnId: idAllocator.derive.providerTurn({ + driver: instance.driverKind, + nativeTurnId: failureClass, + }), + runOrdinal: 1, + failureItemOrdinal: 1, + status: "failed", + failure: makeProviderFailure({ + class: failureClass, + message: "Provider rejected turn.", + }), + threadDisposition: "reusable", + }, + }); + assert.equal(stored.length, 1); + if (failureClass === "validation_error") assert.deepEqual(lookups, []); + } + assert.deepEqual(lookups, [instance.instanceId]); + yield* Deferred.await(refreshed); + }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }), + ), +); + it.effect("records accepted billed turn usage once without billing the context window", () => { const recorded: Array>> = []; const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 6beb4d0e55471..06f444776cb3c 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -34,6 +34,7 @@ import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -326,6 +327,7 @@ export const layer: Layer.Layer< | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 | ThreadCommandExecutor.ThreadCommandExecutor + | ProviderInstanceRegistry.ProviderInstanceRegistry > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { @@ -336,6 +338,8 @@ export const layer: Layer.Layer< const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; + const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); const makeDomainEvent = @@ -794,6 +798,23 @@ export const layer: Layer.Layer< }, (effect, input) => effect.pipe( + Effect.tap((storedEvents) => + Effect.gen(function* () { + if ( + storedEvents.length === 0 || + input.event.type !== "turn.terminal" || + input.event.status !== "failed" || + input.event.failure.class !== "usage_limit" + ) + return; + const instance = yield* instances.getInstance(input.providerInstanceId); + if (!instance?.snapshot.refreshUsage) return; + yield* instance.snapshot.refreshUsage.pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope, { startImmediately: true }), + ); + }), + ), Effect.flatMap((storedEvents) => Effect.gen(function* () { if (input.event.type !== "app_thread.created") return storedEvents; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 9ae9ddce81a99..cac8e4fbbb8e0 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -47,6 +47,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -533,6 +534,10 @@ function layerTest(input: { const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerConfiguredEventSink, IdAllocator.layer, layerTestStores, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 4da359f74c920..afd11a9ab7ecb 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -40,6 +40,7 @@ import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "../ProviderSessionManager.ts"; import * as CommandOutputRecorder from "./CommandOutputRecorder.ts"; @@ -320,6 +321,10 @@ export function layerWithRegistry( const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerStores, layerEventSinkProvided, IdAllocator.layer, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 86832d53d3119..e1fbcd30080fd 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -299,6 +299,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 668502f93e432..7794107824c9b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -6,6 +6,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -138,6 +139,116 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const cache = yield* Cache.makeWith( + () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { + checkedAt: refreshedSnapshot.checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: count === 1 ? 20 : 100, + }, + ], + }, + } satisfies ServerProvider; + }), + { capacity: 1, timeToLive: () => "5 minutes" }, + ); + const warmed = yield* Cache.get(cache, "claude"); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(warmed), + checkProvider: Cache.get(cache, "claude"), + invalidateUsageCache: Cache.invalidateAll(cache), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + assert.strictEqual(yield* Ref.get(calls), 1); + const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const queued = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 2); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(refresh); + yield* Fiber.join(queued); + assert.strictEqual(yield* Ref.get(calls), 3); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("keeps the waiting usage refresh's claim when the running one is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const usageProbeStarted = yield* Deferred.make(); + const statusProbeStarted = yield* Deferred.make(); + const releaseStatusProbe = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(usageProbeStarted, undefined); + return yield* Effect.never; + } + if (count === 3) { + yield* Deferred.succeed(statusProbeStarted, undefined); + yield* Deferred.await(releaseStatusProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] }, + } satisfies ServerProvider; + }), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + const running = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(usageProbeStarted); + const status = yield* provider.refresh.pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(running); + yield* Deferred.await(statusProbeStarted); + const extra = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseStatusProbe, undefined); + yield* Fiber.join(status); + yield* Fiber.join(waiting); + yield* Fiber.join(extra); + assert.strictEqual(yield* Ref.get(calls), 4); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index f40baee525c8d..41ff65b0f294b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -44,6 +44,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly invalidateUsageCache?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -60,6 +61,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( > { const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); + const usageRefreshQueued = yield* Ref.make(null); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -216,6 +218,38 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { + const { snapshot } = yield* Ref.get(snapshotStateRef); + if ( + !snapshot.enabled || + !snapshot.usageLimits || + snapshot.usageLimits.unavailable?.reason === "unsupported" + ) + return; + // An in-flight refresh may have read cached usage, so one refresh waits behind it. + const claim = Symbol(); + const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); + yield* Effect.uninterruptibleMask((restore) => + Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim]).pipe( + Effect.flatMap((held) => + held + ? Effect.void + : restore( + refreshSemaphore.withPermits(1)( + release.pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => + applySnapshotBase(settings, { forceRefresh: true }), + ), + ), + ), + ).pipe(Effect.onInterrupt(() => release)), + ), + ), + ); + }); + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only @@ -304,6 +338,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( .resolveMaintenance({ fresh: true }) .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, + refreshUsage: refreshUsage(), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index c9c877d29fe20..88f2c1f113848 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -1,4 +1,8 @@ -import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderUsageLimitsUpdate, + ServerProvider, + ServerSettingsError, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; @@ -10,6 +14,7 @@ export interface ManagedServerProvider { }) => Effect.Effect; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ readonly applyUsageLimits: ( diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 7f0decb49af3f..5986d09506d09 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -43,6 +43,21 @@ export function cursorSdkFailureMessage(error: CursorAgentSdkRunnerError): strin : `Cursor Agent SDK ${error.method} failed: ${reason}`; } +// The SDK maps backend details to codes like PRO_USER_USAGE_LIMIT or RATE_LIMITED, without a status. +const CURSOR_USAGE_LIMIT_CODE = + /^(?:resource_exhausted|rate_limited(?:_changeable)?|\w*_usage_limit|\w*_rate_limit(?:_exceeded)?)$/i; + +export function isCursorUsageLimitError(cause: unknown): boolean { + const error = isCursorAgentSdkRunnerError(cause) ? cause.cause : cause; + if (typeof error !== "object" || error === null) return false; + const code = Reflect.get(error, "code"); + return ( + Reflect.get(error, "name") === "RateLimitError" || + Reflect.get(error, "status") === 429 || + (typeof code === "string" && CURSOR_USAGE_LIMIT_CODE.test(code)) + ); +} + const GENERIC_SDK_ERROR_CODES = new Set(["", "error", "unknown"]); function sdkErrorReason(cause: unknown): string | undefined { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index d91d3bc339933..062089fffa0c3 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -214,6 +214,15 @@ describe("CursorAdapterV2", () => { ); it.effect.each([ + { + reason: "the SDK's detailed plan usage limit code", + wait: Effect.succeed({ + id: "native-cursor-run", + status: "error" as const, + error: { message: "You've hit your usage limit", code: "PRO_USER_USAGE_LIMIT" }, + }), + failure: { class: "usage_limit", message: "You've hit your usage limit" }, + }, { reason: "the error Cursor's run ended with", wait: Effect.succeed({ @@ -232,7 +241,7 @@ describe("CursorAdapterV2", () => { }, }, { - reason: "the SDK error that broke the run", + reason: "the SDK rate limit that broke the run", wait: Effect.fail( new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "run.wait", @@ -243,7 +252,7 @@ describe("CursorAdapterV2", () => { }), ), failure: { - class: "transport_error", + class: "usage_limit", message: "Cursor Agent SDK run.wait failed: Too many requests (resource_exhausted, HTTP 429)", }, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 93652e492e031..72aca919b25f4 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2506,7 +2506,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( cause: result.error, // makeProviderFailure never reads a cause's own text. message: result.error?.message, - class: "provider_error", + class: CursorAgentSdk.isCursorUsageLimitError(result.error) + ? "usage_limit" + : "provider_error", }) : makeProviderFailure({ cause: transportFailure, @@ -2532,7 +2534,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message: CursorAgentSdk.isCursorAgentSdkRunnerError(cause) ? CursorAgentSdk.cursorSdkFailureMessage(cause) : undefined, - class: "transport_error", + class: CursorAgentSdk.isCursorUsageLimitError(cause) + ? "usage_limit" + : "transport_error", }), }), }); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 759545fff0d19..41da6bd0ff435 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -240,6 +240,51 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect("classifies APIError 429 as a usage limit", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("usage-limit", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "APIError", + data: { + message: "Provider rejected request", + statusCode: 429, + isRetryable: true, + }, + }, + }, + }), + ); + const terminal = (yield* Fiber.join(received)).find( + (event) => event.type === "turn.terminal", + ); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.class, "usage_limit"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("applies a changed runtime policy to the session on resume and turn start", () => Effect.gen(function* () { const updates: Array<{ sessionID: string; permission: unknown }> = []; diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index ef85a7e70b29b..2a851188d076e 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2643,6 +2643,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function (state): state is OpenCodeThreadState => state !== undefined, ); const message = openCodeErrorMessage(event); + const error = event.properties.error; + const usageLimit = error?.name === "APIError" && error.data.statusCode === 429; if ( !isMessageAbortedError(event) && (event.properties.sessionID === undefined || @@ -2659,8 +2661,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function { failure: makeProviderFailure({ message, - code: event.properties.error?.name ?? null, - class: "provider_error", + code: error?.name ?? null, + class: usageLimit ? "usage_limit" : "provider_error", }), threadDisposition: event.properties.sessionID === undefined ? "broken" : "reusable", From fe219c4de18f5ee37c28cbd3914a5569b1579156 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:29:07 +0530 Subject: [PATCH 027/144] fix(web): keep tool groups collapsed after a usage-limit pause (#24937) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 74 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 23 +++++- 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3ee079809f14e..8f4b15cc11f91 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3841,9 +3841,15 @@ it("keeps the working header in place across worktree setup handoff", () => { }); describe("failed turn transcript", () => { - it.each(["provider_error", "usage_limit"] as const)( - "keeps historical %s failures and preceding work visible without disclosures", - (failureClass) => { + it.each([ + { position: "historical", failureClass: "provider_error" }, + { position: "historical", failureClass: "usage_limit" }, + { position: "latest", failureClass: "usage_limit" }, + ] as const)( + "keeps $position $failureClass failures visible with preceding work grouped appropriately", + ({ position, failureClass }) => { + const isLatest = position === "latest"; + const commands = isLatest ? ["pwd", "ls", "false"] : ["pwd"]; const runId = RunId.make("failed-run"); const threadId = ThreadId.make("failed-thread"); const at = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); @@ -3880,23 +3886,24 @@ describe("failed turn transcript", () => { attachments: [], }, }, - { - position: 1, + ...commands.map((input, index): OrchestrationV2ProjectedTurnItem => ({ + position: index + 1, visibility: "local", sourceThreadId: threadId, - sourceItemId: TurnItemId.make("command"), + sourceItemId: TurnItemId.make(index === 0 ? "command" : `command-${index}`), item: { ...base, - id: TurnItemId.make("command"), + id: TurnItemId.make(index === 0 ? "command" : `command-${index}`), + ordinal: index + 1, type: "command_execution", status: "completed", - input: "pwd", + input, output: "", exitCode: 0, }, - }, + })), { - position: 2, + position: commands.length + 1, visibility: "local", sourceThreadId: threadId, sourceItemId: TurnItemId.make("failure"), @@ -3918,20 +3925,24 @@ describe("failed turn transcript", () => { timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items, optimisticMessages: [], - }).map((entry) => ({ - ...entry, - attempt: { - id: RunAttemptId.make("superseded-attempt"), - runId, - attemptOrdinal: 1, - rootNodeId: NodeId.make("superseded-root"), - status: "superseded" as const, - completedAt: null, - }, - })), + }).map((entry) => + isLatest + ? entry + : { + ...entry, + attempt: { + id: RunAttemptId.make("superseded-attempt"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("superseded-root"), + status: "superseded" as const, + completedAt: null, + }, + }, + ), latestRun: { - runId: RunId.make("newer-run"), - status: "completed", + runId: isLatest ? runId : RunId.make("newer-run"), + status: isLatest ? "failed" : "completed", startedAt: DateTime.formatIso(at), completedAt: DateTime.formatIso(at), }, @@ -3939,16 +3950,19 @@ describe("failed turn transcript", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - expect( - rows.some( - (row) => - row.kind === "turn-fold" || row.kind === "attempt-fold" || row.kind === "work-toggle", - ), - ).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold" || row.kind === "attempt-fold")).toBe( + false, + ); const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : [])); - expect(work.map((entry) => entry.id)).toEqual(["command", "failure"]); + expect(rows.find((row) => row.kind === "work-toggle")).toEqual( + isLatest ? expect.objectContaining({ hiddenCount: 3, expanded: false }) : undefined, + ); + expect(work.map((entry) => entry.id)).toEqual( + isLatest ? ["failure"] : ["command", "failure"], + ); expect(work.at(-1)?.detail).toBe("The provider stopped this turn.\nRetry later."); expect(work.at(-1)?.createdAt).toBe("2026-09-20T12:00:00.000Z"); + if (isLatest) return; const entriesWithoutTools = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items.filter((row) => row.item.type !== "command_execution"), optimisticMessages: [], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f4b409f901b53..c26bcf51a718a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -973,6 +973,24 @@ function failedTimelineRunIds( return failed; } +/** Usage-limit pauses keep their tool groups; any other failure itemizes the run's work. */ +function itemizedFailedRunIds( + entries: ReadonlyArray, + failedRunIds: ReadonlySet, +) { + const usageLimited = new Set(); + const otherFailures = new Set(); + for (const entry of entries) { + const item = timelineEntryFailedItem(entry); + if (item?.runId) { + (item.failure.class === "usage_limit" ? usageLimited : otherFailures).add(item.runId); + } + } + return new Set( + [...failedRunIds].filter((runId) => !usageLimited.has(runId) || otherFailures.has(runId)), + ); +} + function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; @@ -1410,6 +1428,7 @@ export function deriveMessagesTimelineRows(input: { const nextRows: MessagesTimelineRow[] = []; const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); + const itemizedRunIds = itemizedFailedRunIds(timelineEntries, failedRunIds); const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( @@ -1772,7 +1791,7 @@ export function deriveMessagesTimelineRows(input: { if (timelineEntry.kind === "work") { if ( isPendingApproval(timelineEntry.entry) || - (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) || + (timelineEntry.entry.runId != null && itemizedRunIds.has(timelineEntry.entry.runId)) || (timelineEntry.entry.itemType === "error" && timelineEntry.entry.toolLifecycleStatus === "failed") || timelineEntry.entry.tone === "error" || @@ -1798,6 +1817,8 @@ export function deriveMessagesTimelineRows(input: { !nextEntry || nextEntry.kind !== "work" || nextEntry.entry.sourceActivityKind === "context-compaction" || + (nextEntry.entry.itemType === "error" && + nextEntry.entry.toolLifecycleStatus === "failed") || nextEntry.entry.tone === "error" || nextEntry.entry.sourceActivityKind === "runtime.error" || nextEntry.entry.itemType === "system_notice" || From 7632c3dc7039953cc50282e1bc339e1bf11f2ffc Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:14:44 +0530 Subject: [PATCH 028/144] fix(mobile): photo-library PNGs no longer fail after resizing (#26522) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/lib/composerFiles.test.ts | 60 ++++++++++++++++++++--- apps/mobile/src/lib/composerImageFit.ts | 59 ++++++++++++++++------ 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 546c28f408f78..bdcd35c36ff75 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -124,28 +124,35 @@ describe("composer file attachments", () => { width: 4032, height: 3024, }; + type Size = { width: number; height: number }; const native = { - size: { width: 1, height: 1 }, - resizes: [] as Array<{ width?: number | null; height?: number | null }>, + size: { width: 1, height: 1 } as Size, + resizes: [] as Size[], saved: rendered as { uri: string; base64?: string }, + encode: null as ((size: Size, format: string) => { uri: string; base64?: string }) | null, }; beforeEach(() => { native.size = { width: 1, height: 1 }; native.resizes = []; native.saved = rendered; + native.encode = null; mocks.manipulate.mockReset(); mocks.release.mockReset(); - mocks.manipulate.mockImplementation(() => { + mocks.manipulate.mockImplementation((source: string | Size) => { + let size = typeof source === "string" ? native.size : source; const context = { - resize(size: { width?: number | null; height?: number | null }) { - native.resizes.push(size); + resize(next: Size) { + native.resizes.push(next); + size = next; return context; }, renderAsync: async () => ({ - ...native.size, + width: size.width, + height: size.height, release: mocks.release, - saveAsync: async () => native.saved, + saveAsync: async (options: { format: string }) => + native.encode?.(size, options.format) ?? native.saved, }), }; return context; @@ -210,6 +217,45 @@ describe("composer file attachments", () => { ]); }); + it("shrinks a fitted PNG further when re-encoding it exceeds the image limit", async () => { + const header = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header); + header.write("IHDR", 12); + header.writeUInt32BE(1668, 16); + header.writeUInt32BE(2388, 20); + native.size = { width: 1668, height: 2388 }; + // Native PNG encoding of the 2000px resample is larger than the 7 MB original. + native.encode = (size, format) => ({ + uri: `file:///cache/fitted-${size.width}x${size.height}.${format}`, + base64: Buffer.alloc(size.width * size.height * 4).toString("base64"), + }); + mocks.size.mockReturnValue(7 * 1024 * 1024); + mocks.readBase64.mockResolvedValue(header.toString("base64")); + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "IMG_9280.png", mimeType: "image/png" }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.error).toBeNull(); + const [image] = result.images; + const last = native.resizes.at(-1)!; + expect(native.resizes[0]).toEqual({ width: 1397, height: 2000 }); + expect(Math.max(last.width, last.height)).toBeLessThan(2000); + expect(image).toEqual( + expect.objectContaining({ + name: "IMG_9280.png", + mimeType: "image/png", + sizeBytes: last.width * last.height * 4, + previewUri: `file:///cache/fitted-${last.width}x${last.height}.png`, + }), + ); + expect(image!.sizeBytes).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES); + expect(mocks.delete).toHaveBeenCalledWith("file:///cache/fitted-1397x2000.png"); + expect(mocks.delete).not.toHaveBeenCalledWith(image!.previewUri); + }); + it("measures the picker file instead of trusting the reported size", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, diff --git a/apps/mobile/src/lib/composerImageFit.ts b/apps/mobile/src/lib/composerImageFit.ts index fddeac277bcfe..18cd93b3fe723 100644 --- a/apps/mobile/src/lib/composerImageFit.ts +++ b/apps/mobile/src/lib/composerImageFit.ts @@ -1,3 +1,4 @@ +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import { downscalableImageMimeType, fitImageAttachmentSize, @@ -6,8 +7,24 @@ import { IMAGE_DIMENSIONS_HEADER_BYTES, readImageDimensions, } from "@t3tools/shared/imageDimensions"; +import { estimateBase64ByteSize } from "./base64"; const PHOTO_JPEG_QUALITY = 0.85; +const MAX_BYTE_LIMIT_SHRINKS = 3; + +function shrinkToByteLimit( + size: { readonly width: number; readonly height: number }, + bytes: number, +): { width: number; height: number } | null { + if (bytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) return null; + // Encoded size tracks pixel count, so scale each edge by the square root with some headroom. + const scale = Math.sqrt(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / bytes) * 0.9; + const next = { + width: Math.max(1, Math.floor(size.width * scale)), + height: Math.max(1, Math.floor(size.height * scale)), + }; + return next.width < size.width || next.height < size.height ? next : null; +} export async function renderPhoto( uri: string, @@ -16,21 +33,35 @@ export async function renderPhoto( const { ImageManipulator, SaveFormat } = await import("expo-image-manipulator"); let image = await ImageManipulator.manipulate(uri).renderAsync(); try { - const fittedSize = fitImageAttachmentSize(image); - if (fittedSize !== null) { - const resized = await ImageManipulator.manipulate(image).resize(fittedSize).renderAsync(); - image.release(); - image = resized; - } - const saved = await image.saveAsync({ - format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG, - compress: PHOTO_JPEG_QUALITY, - base64: true, - }); - if (!saved.base64) { - throw new Error("The rendered photo has no bytes."); + let size = fitImageAttachmentSize(image); + for (let shrinks = 0; ; shrinks += 1) { + if (size !== null) { + const resized = await ImageManipulator.manipulate(image).resize(size).renderAsync(); + image.release(); + image = resized; + } + const saved = await image.saveAsync({ + format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG, + compress: PHOTO_JPEG_QUALITY, + base64: true, + }); + if (!saved.base64) { + throw new Error("The rendered photo has no bytes."); + } + // PNG ignores compress, so a resampled PNG can encode larger than its original. + size = + shrinks < MAX_BYTE_LIMIT_SHRINKS + ? shrinkToByteLimit(image, estimateBase64ByteSize(saved.base64)) + : null; + if (size === null) return { base64: saved.base64, uri: saved.uri }; + try { + const { File } = await import("expo-file-system"); + const discarded = new File(saved.uri); + if (discarded.exists) discarded.delete(); + } catch (error) { + console.warn("[composer-images] could not remove discarded render", error); + } } - return { base64: saved.base64, uri: saved.uri }; } finally { image.release(); } From ab1e221618724177d67ff3d2cf07e5b211a0bfc2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 29 Oct 2026 10:24:38 +0530 Subject: [PATCH 029/144] fix(server): guest links no longer return file diffs (#26538) --- .../src/guest/GuestShareService.test.ts | 51 +++++++++++++++---- apps/server/src/guest/GuestShareService.ts | 8 ++- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 31acd7bdb60bf..1ef25744c8c55 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -411,7 +411,6 @@ it.effect("serves no render for an item outside the shared thread", () => }).pipe(Effect.provide(layerTest)), ); -/** A message from the owner, which opens a run for the command below. */ const ownerRun = Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; yield* orchestrator.dispatch({ @@ -430,12 +429,17 @@ const ownerRun = Effect.gen(function* () { return runs[0]!.id; }); -const writeCommand = ( +const writeItem = ( id: string, - fields: Pick< - Extract, - "status" | "output" - >, + fields: + | Pick< + Extract, + "type" | "status" | "input" | "output" + > + | Pick< + Extract, + "type" | "status" | "fileName" | "diffStr" | "oldStr" | "newStr" + >, ) => Effect.gen(function* () { const runId = yield* ownerRun; @@ -464,8 +468,6 @@ const writeCommand = ( startedAt: now, completedAt: fields.status === "running" ? null : now, updatedAt: now, - type: "command_execution", - input: "ls /work/src", ...fields, }, }, @@ -474,17 +476,40 @@ const writeCommand = ( return TurnItemId.make(id); }); -it.effect("gives the guest a command's detail and output, as the owner's row loads them", () => +it.effect("gives the guest a command's detail and output, but no file diffs", () => Effect.gen(function* () { const { guestShares, credential } = yield* shareThread; const { token: session } = yield* guestShares.redeem({ credential, name: "Sam" }); - const command = yield* writeCommand("item:command", { + const command = yield* writeItem("item:command", { + type: "command_execution", status: "completed", + input: "ls /work/src", output: "/work/src/auth.ts\n", }); const detail = (yield* guestShares.item(session, command)).item; assert.deepInclude(detail, { input: "ls /work/src", output: "/work/src/auth.ts\n" }); + const edit = yield* writeItem("item:edit", { + type: "file_change", + status: "completed", + fileName: "src/auth.ts", + diffStr: "-const token = 1;\n+const token = 2;\n", + }); + const write = yield* writeItem("item:write", { + type: "file_change", + status: "completed", + fileName: "src/config.ts", + oldStr: "", + newStr: "export const key = 1;\n", + }); + for (const [id, fileName] of [ + [edit, "src/auth.ts"], + [write, "src/config.ts"], + ] as const) { + const change = (yield* guestShares.item(session, id)).item; + assert.deepInclude(change, { type: "file_change", fileName }); + assert.doesNotHaveAnyKeys(change, ["diffStr", "oldStr", "newStr"]); + } const frames = Array.from( yield* Stream.runCollect(yield* guestShares.commandOutput(session, command)), ); @@ -508,7 +533,11 @@ it.effect("ends a running command's output once the owner stops sharing", () => Effect.gen(function* () { const { orchestrator, guestShares, credential } = yield* shareThread; const { token: session } = yield* guestShares.redeem({ credential, name: "Sam" }); - const command = yield* writeCommand("item:running", { status: "running" }); + const command = yield* writeItem("item:running", { + type: "command_execution", + status: "running", + input: "ls /work/src", + }); const collecting = yield* Stream.runCollect( yield* guestShares.commandOutput(session, command), ).pipe(Effect.forkChild); diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 0346068bc546f..347bdc11ece5b 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -57,6 +57,7 @@ import { buildBoundedThreadStreamSnapshot } from "../orchestration-v2/ThreadStre import { projectDomainEventForWire, projectTurnItemForDetail, + projectTurnItemForWire, } from "../orchestration-v2/WireProjection.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -179,7 +180,7 @@ export class GuestShareService extends Context.Service< session: string, itemId: string, ) => Effect.Effect<{ readonly path: string; readonly title: string }, GuestShareAccessError>; - /** One item of the shared thread, as the owner's expanded row loads it. */ + /** One item of the shared thread, as the owner's expanded row loads it, without file diffs. */ readonly item: ( session: string, itemId: string, @@ -658,7 +659,10 @@ const make = Effect.gen(function* () { const item = Effect.fn("GuestShareService.item")(function* (session: string, itemId: string) { const { item } = yield* threadItem(session, itemId); - return { item: projectTurnItemForDetail(item) }; + return { + item: + item.type === "file_change" ? projectTurnItemForWire(item) : projectTurnItemForDetail(item), + }; }); const commandOutput = Effect.fn("GuestShareService.commandOutput")(function* ( From eb565e13abebe1a71736b4651e4131f52b1950ba Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 29 Oct 2026 10:24:48 +0530 Subject: [PATCH 030/144] fix(claude): Open diff shows Claude edits as a diff instead of raw JSON (#26529) --- apps/server/package.json | 1 + .../Adapters/ClaudeAdapterV2.test.ts | 146 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 43 +++++- pnpm-lock.yaml | 3 + 4 files changed, 192 insertions(+), 1 deletion(-) diff --git a/apps/server/package.json b/apps/server/package.json index 5abc769412d1a..2735d71c6533b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -58,6 +58,7 @@ "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "character-entities": "2.0.2", + "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", "node-pty": "^1.2.0-beta.15", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 47457be660998..ea5ee249a787f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8740,6 +8740,152 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { + case: "an Edit", + toolName: "Edit", + toolInput: { + file_path: "/workspace/src/math.ts", + old_string: " return a - b;", + new_string: " return a + b;", + }, + toolUseResult: { + filePath: "/workspace/src/math.ts", + oldString: " return a - b;", + newString: " return a + b;", + originalFile: "export function add(a: number, b: number) {\n return a - b;\n}\n", + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [ + " export function add(a: number, b: number) {", + "- return a - b;", + "+ return a + b;", + " }", + ], + }, + ], + userModified: false, + replaceAll: false, + }, + diffStr: + "--- /workspace/src/math.ts\n+++ /workspace/src/math.ts\n@@ -1,3 +1,3 @@\n export function add(a: number, b: number) {\n- return a - b;\n+ return a + b;\n }\n", + }, + { + case: "a Write that creates a file", + toolName: "Write", + toolInput: { file_path: "/workspace/notes.md", content: "# Notes\n\nShip it.\n" }, + toolUseResult: { + type: "create", + filePath: "/workspace/notes.md", + content: "# Notes\n\nShip it.\n", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: "--- /dev/null\n+++ /workspace/notes.md\n@@ -0,0 +1,3 @@\n+# Notes\n+\n+Ship it.\n", + }, + { + case: "a Write that creates an empty file", + toolName: "Write", + toolInput: { file_path: "/workspace/.keep", content: "" }, + toolUseResult: { + type: "create", + filePath: "/workspace/.keep", + content: "", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: undefined, + }, + ])("stores the diff panel patch for $case", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const toolUseId = `toolu_01${testCase.toolName}Patch`; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-${testCase.toolName}-patch`), + text: "Change it.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_${testCase.toolName}_patch`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: testCase.toolName, + input: testCase.toolInput, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000793", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `The file ${testCase.toolInput.file_path} has been updated successfully.`, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000794", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: testCase.toolUseResult, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000795", result: "Done." }), + ); + yield* harness.awaitTerminal(1); + + const fileChange = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === toolUseId, + ); + assert.deepEqual( + fileChange?.type === "turn_item.updated" && fileChange.turnItem.type === "file_change" + ? { diffStr: fileChange.turnItem.diffStr } + : undefined, + { diffStr: testCase.diffStr }, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index dd4fc8f30af92..b1273cee72061 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -106,6 +106,7 @@ import { resolveIdleCompaction, } from "@t3tools/contracts"; +import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -2965,6 +2966,44 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); } +const decodeClaudeFileEditResult = Schema.decodeUnknownOption( + Schema.Struct({ + type: Schema.optional(Schema.String), + filePath: Schema.String, + content: Schema.optional(Schema.String), + structuredPatch: Schema.mutable( + Schema.Array( + Schema.Struct({ + oldStart: Schema.Number, + oldLines: Schema.Number, + newStart: Schema.Number, + newLines: Schema.Number, + lines: Schema.mutable(Schema.Array(Schema.String)), + }), + ), + ), + }), +); + +function claudeFileChangeText(output: ClaudeNativeToolOutput): string { + const result = + output.type === "structured_tool_use_result" + ? Option.getOrUndefined(decodeClaudeFileEditResult(output.value)) + : undefined; + if (result === undefined) return claudeNativeToolOutputText(output); + const patch = + result.structuredPatch.length === 0 && result.type === "create" && result.content !== undefined + ? structuredPatch("/dev/null", result.filePath, "", result.content) + : { + oldFileName: result.filePath, + newFileName: result.filePath, + oldHeader: undefined, + newHeader: undefined, + hunks: result.structuredPatch, + }; + return patch.hunks.length === 0 ? "" : formatPatch(patch, FILE_HEADERS_ONLY); +} + function claudeCommandOutputText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); if (typeof value === "object" && value !== null) { @@ -5498,7 +5537,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const outputText = itemType === "command_execution" ? claudeCommandOutputText(input.output) - : claudeNativeToolOutputText(input.output); + : itemType === "file_change" + ? claudeFileChangeText(input.output) + : claudeNativeToolOutputText(input.output); const turnItem: OrchestrationV2TurnItem = itemType === "command_execution" ? { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2eabadf0a4f9..7f1de3cd97bba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -897,6 +897,9 @@ importers: character-entities: specifier: 2.0.2 version: 2.0.2 + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.5 version: 4.0.5 From dac8b11ddc25bd5da62a367fdb6c3de777ae2986 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 22:01:08 -0700 Subject: [PATCH 031/144] chore: vouch nexxeln (#26812) Co-authored-by: Claude Opus 5.5 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 353a1f41a2616..1c4ae817060a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -59,6 +59,7 @@ github:myacoub91 github:naMqe-h github:nateEc github:naveed949 +github:nexxeln github:nmggithub github:Noojuno github:notkainoa From f855f016b3fe103f0b6cb05f26de08cb735b1f7b Mon Sep 17 00:00:00 2001 From: "t3-code-bot[bot]" <269035359+t3-code-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:45:11 +0000 Subject: [PATCH 032/144] chore(release): prepare v0.1.4 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 728ab36f7fcb2..0d9ab5ae879d3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 2735d71c6533b..bd81fd993c43e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 729d44f8ea6cd..c35aadeef5d3f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c87a9fed41033..cff21c52a6e32 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.3", + "version": "0.1.4", "private": true, "files": [ "dist" From 310c12855238cc191021dfbaaa51aef203374769 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 01:14:00 -0700 Subject: [PATCH 033/144] feat(web): Labs toggle keeps 5h, weekly, and context limits in view (#26859) Co-authored-by: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 13 + .../components/chat/ThreadDetailsPanel.tsx | 5 + .../components/chat/ThreadDetailsUsage.tsx | 228 ++++++++++++++++++ .../components/settings/LabsSettingsPanel.tsx | 21 ++ .../src/components/settings/settingsSearch.ts | 6 + docs/user/usage.md | 6 + packages/contracts/src/settings.ts | 2 + 8 files changed, 282 insertions(+) create mode 100644 apps/web/src/components/chat/ThreadDetailsUsage.tsx diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea351b6ceb2f4..c80498c14c1fb 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -64,6 +64,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, boringSidebarMultilineRows: false, + threadDetailsUsageEnabled: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d6d3e09536070..b9104836b62ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1228,6 +1228,9 @@ export default function ChatView(props: ChatViewProps) { const canChangeEnvironment = hasMultipleEnvironments && !envLocked && draftId !== null && !draftThread?.promotedTo; + const threadDetailsUsageEnabled = useClientSettings( + (settings) => settings.threadDetailsUsageEnabled, + ); const loadBalancingSettings = useClientSettings((settings) => ({ loadBalancingEnabled: settings.loadBalancingEnabled, loadBalancingWeights: settings.loadBalancingWeights, @@ -3770,6 +3773,16 @@ export default function ChatView(props: ChatViewProps) { onAddProjectScript={saveProjectScriptStable} onUpdateProjectScript={updateProjectScriptStable} onDeleteProjectScript={deleteProjectScriptStable} + usage={ + threadDetailsUsageEnabled + ? { + providerInstanceId: activeProviderInstanceId, + providers: providerStatuses, + sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, + contextWindow: activeContextWindow, + } + : undefined + } /> Promise; onDeleteProjectScript: (scriptId: string) => Promise; + /** Set only while the Labs usage readout is on. */ + usage?: ThreadDetailsUsageInput | undefined; } export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { @@ -195,6 +198,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadId={props.threadId} /> ) : null} + + {props.usage ? : null} )} diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx new file mode 100644 index 0000000000000..6a9adadfc7200 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -0,0 +1,228 @@ +import type { + ProviderInstanceId, + ServerProvider, + ServerProviderUsageWindow, + UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + formatDuration, + type LimitPace, + limitsNotice, + paceOf, + remainingPercent, +} from "@t3tools/shared/usageLimits"; +import type { ReactNode } from "react"; + +import { usePrimarySettings } from "../../hooks/useSettings"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { type ContextWindowSnapshot, formatContextWindowTokens } from "../../lib/contextWindow"; +import { cn } from "../../lib/utils"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThreadDetailsSection } from "./ThreadDetailsSection"; + +const PACE_LABEL: Record = { + ahead: "Ahead of pace", + on: "On pace", + under: "Under pace", +}; + +export interface ThreadDetailsUsageInput { + readonly providerInstanceId: ProviderInstanceId | null; + readonly providers: readonly ServerProvider[]; + readonly sources: UsageLimitSourceSnapshots; + readonly contextWindow: ContextWindowSnapshot | null; +} + +/** + * Labs readout of the selected provider's limit windows and the thread's context window. + * Every row reads as room left, so a longer bar always means more headroom. + */ +export function ThreadDetailsUsage({ + providerInstanceId, + providers, + sources, + contextWindow, +}: ThreadDetailsUsageInput) { + const nowMinute = useNowMinute(); + const now = Date.parse(`${nowMinute}:00Z`); + const report = + providerInstanceId !== null + ? collectProviderUsageLimits(providerInstanceId, providers, sources, now) + : null; + // Only this instance's data: the report keeps one instance per login, and any other entry may be + // a different login or workspace, so fall back to the instance's own snapshot. + const limits = + report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? + providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; + const notice = limits ? limitsNotice(limits) : null; + if (!limits && !contextWindow) return null; + + return ( + +
+ {notice ? ( + {notice}} + /> + ) : null} + {limits && !notice + ? limits.windows.map((window) => ) + : null} + {contextWindow ? : null} +
+
+ ); +} + +function LimitRow({ + window, + now, +}: { + readonly window: ServerProviderUsageWindow; + readonly now: number; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const left = remainingPercent(window); + const pace = paceOf(window, now); + const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN; + return ( + + + {window.label}: {left}% left + + {window.resetsAt && resetsAt > now ? ( + + Resets in {formatDuration(resetsAt - now)} ยท{" "} + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + + ) : null} + {pace ? {PACE_LABEL[pace]} : null} + + } + /> + ); +} + +function ContextRow({ + usage, + spaced, +}: { + readonly usage: ContextWindowSnapshot; + readonly spaced: boolean; +}) { + const maxTokens = usage.maxTokens ?? null; + const left = + maxTokens !== null && usage.usedPercentage !== null + ? Math.round(100 - Math.max(0, Math.min(100, usage.usedPercentage))) + : null; + const used = formatContextWindowTokens(usage.usedTokens); + return ( + + {left !== null ? Context: {left}% left : null} + + {maxTokens !== null + ? `${used} of ${formatContextWindowTokens(maxTokens)} tokens used` + : `${used} tokens used`} + + + } + value={left === null ? used : undefined} + /> + ); +} + +/** One label, hairline, and value row; the whole row opens the details tooltip. */ +function UsageRow({ + label, + left, + tooltip, + value, + muted = false, + className, +}: { + readonly label: string; + readonly left: number | null; + readonly tooltip: ReactNode; + /** Replaces the percentage when there is none. */ + readonly value?: string | undefined; + readonly muted?: boolean; + readonly className?: string | undefined; +}) { + const tone = left === null ? null : left <= 10 ? "error" : left <= 20 ? "warning" : null; + return ( + + + } + > + {label} + {left !== null ? ( + + {left > 0 ? ( + + ) : null} + + ) : null} + + {value ?? `${left}%`} + + + {/* Rows sit 8px inside the card; clear its edge instead of overlapping it. */} + +
{tooltip}
+
+
+ ); +} diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index ce3f68eac49d0..1f51e20c0f839 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -18,6 +18,7 @@ export function LabsSettingsPanel() { + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -69,6 +70,26 @@ function BoringSidebarMultilineRow() { ); } +function ThreadDetailsUsageRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ threadDetailsUsageEnabled: Boolean(checked) }) + } + aria-label="Usage in thread details" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 36f63bf64ba8a..0d87e9bd32c83 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -437,6 +437,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], }, + { + id: "thread-details-usage", + title: "Usage in thread details", + to: "/settings/labs", + searchTerms: ["beta limits 5 hour weekly context window tokens quota rate limit"], + }, { id: "guest-links", title: "Guest links", diff --git a/docs/user/usage.md b/docs/user/usage.md index e41e60ca73d76..8064380c5e717 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -116,6 +116,12 @@ current model's limits without leaving the conversation. The result opens above closes when you dismiss it or send your next message. It uses the same snapshot as **Usage โ†’ Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage โ†’ Limits**. +To keep them in view, turn on **Settings โ†’ Labs โ†’ Usage in thread details** on web and desktop. +The bottom of the thread details card then shows how much is left in each limit window of the +current provider account and in the thread's context window. Some windows, such as a weekly Opus +limit, count only one model. Hover a row to see when it resets, your pace, or +the token count. + OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 438d8fa6ee746..d789770f1616a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -477,6 +477,7 @@ export const ClientSettingsSchema = Schema.Struct({ boringSidebarMultilineRows: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + threadDetailsUsageEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Opt-in for the removed legacy sidebar. Still read so those users land in // the boring sidebar; the boring sidebar toggle clears it. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1720,6 +1721,7 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), + threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), From 6d82a22e9df93f1dd14afa89d820b7dd1b624eef Mon Sep 17 00:00:00 2001 From: tatsuaki watanabe Date: Thu, 29 Oct 2026 17:15:03 +0900 Subject: [PATCH 034/144] fix(desktop): bump Electron to 44.10.0 for the Node.js Brotli fix (CVE-2026-111497) (#26348) Co-authored-by: Claude Opus 5.5 --- apps/desktop/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- pnpm-workspace.yaml | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0d9ab5ae879d3..7f768c1b44d75 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,7 +22,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.9.3", + "electron": "44.10.0", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f1de3cd97bba..087858aeabb82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,7 +416,7 @@ importers: dependencies: '@clerk/electron': specifier: 0.1.3 - version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -451,8 +451,8 @@ importers: specifier: 4.0.5 version: 4.0.5 electron: - specifier: 44.9.3 - version: 44.9.3(supports-color@8.1.1) + specifier: 44.10.0 + version: 44.10.0(supports-color@8.1.1) electron-store: specifier: ^8.2.0 version: 8.2.0 @@ -1011,7 +1011,7 @@ importers: version: 6.40.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/electron': specifier: 0.1.3 - version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/react': specifier: 6.18.0 version: 6.18.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -8433,8 +8433,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@44.9.3: - resolution: {integrity: sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==} + electron@44.10.0: + resolution: {integrity: sha512-ss4pCfrQKV4TWkuVYtWB4HmeysPZgB96cSYNKYZv1UVCRP4vQMGNW5oopFbvx6Bbfce1XtP5pm5RDuKehp0svQ==} engines: {node: '>= 22.12.0'} hasBin: true @@ -13213,12 +13213,12 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@clerk/electron@0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@clerk/clerk-js': 6.40.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/react': 6.18.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/shared': 4.41.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - electron: 44.9.3(supports-color@8.1.1) + electron: 44.10.0(supports-color@8.1.1) react: 19.3.0 tslib: 2.8.1 optionalDependencies: @@ -18059,7 +18059,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@44.9.3(supports-color@8.1.1): + electron@44.10.0(supports-color@8.1.1): dependencies: '@electron-internal/extract-zip': 1.0.5 '@electron/get': 5.1.0(supports-color@8.1.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a07ad2b008d79..acd8c742f836a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -170,7 +170,7 @@ minimumReleaseAgeExclude: - expo-sharing@58.0.19 - expo-sqlite@58.0.13 - expo-symbols@58.0.4 - - electron@44.9.3 + - electron@44.10.0 - "@anthropic-ai/claude-agent-sdk@0.3.276" - "@shadcn/lint@0.1.5" - cn@0.3.2 From da1eab01f712a6c1595ff0f51e7f4fea9b7ebacb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 01:51:16 -0700 Subject: [PATCH 035/144] chore: make agent mistakes fail early (#26167) Co-authored-by: Claude Opus 5.5 --- .github/workflows/agent-context-budget.yml | 2 +- AGENTS.md | 2 +- .../features/threads/PendingUserInputCard.tsx | 4 +- .../features/threads/ThreadDetailScreen.tsx | 2 +- apps/mobile/src/lib/threadActivity.test.ts | 104 ---------- apps/mobile/src/lib/threadActivity.ts | 162 ---------------- .../src/state/use-selected-thread-requests.ts | 2 +- .../src/provider/AgentContextBudget.test.ts | 5 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../chat/ComposerPendingUserInputPanel.tsx | 6 +- .../chat/usePendingThreadRequests.ts | 10 +- apps/web/src/pendingUserInput.test.ts | 156 ++------------- apps/web/src/pendingUserInput.ts | 130 ++----------- docs/operations/development.md | 4 +- packages/client-runtime/package.json | 4 + .../src/state/pendingUserInput.test.ts | 180 ++++++++++++++++++ .../src/state/pendingUserInput.ts | 176 +++++++++++++++++ scripts/check-changed.ts | 103 +++++++--- vite.config.ts | 6 +- 19 files changed, 490 insertions(+), 570 deletions(-) create mode 100644 packages/client-runtime/src/state/pendingUserInput.test.ts create mode 100644 packages/client-runtime/src/state/pendingUserInput.ts diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml index 9396d8e3e7a63..41efb7b69a5a2 100644 --- a/.github/workflows/agent-context-budget.yml +++ b/.github/workflows/agent-context-budget.yml @@ -51,5 +51,5 @@ jobs: echo "Accepted by the agent-context-increase label." exit 0 fi - echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text, or ask a maintainer to add the agent-context-increase label." + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text and rerun the evals that cover it (evals/README.md), or ask a maintainer to add the agent-context-increase label." exit 1 diff --git a/AGENTS.md b/AGENTS.md index cd27babf3f81f..493f7fc0be715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying -- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, tests beside changes, and the cross-file checks those miss. CI still runs the full test suites. - While you iterate, run `vp test run `. For big files, add `-t ""`. - Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index d097416b2ef20..9ecbbb70f1d8e 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -26,9 +26,9 @@ import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { isPendingUserInputOptionSelected, - type PendingUserInput, type PendingUserInputDraftAnswer, -} from "../../lib/threadActivity"; +} from "@t3tools/client-runtime/state/pending-user-input"; +import type { PendingUserInput } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly environmentId: EnvironmentId; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a23f06abef24b..27655f503bd65 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -157,10 +157,10 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { threadStreamHaptics } from "./threadStreamHaptics"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { PendingApproval, PendingUserInput, - PendingUserInputDraftAnswer, ThreadFeedEntry, ThreadFeedLatestRun, } from "../../lib/threadActivity"; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5060f359ebcd3..acdee2249c0d0 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -31,10 +31,6 @@ import { type ThreadFeedCache, type ThreadFeedEntry, type ThreadFeedLatestRun, - togglePendingUserInputOptionSelection, - setPendingUserInputCustomAnswer, - isPendingUserInputOptionSelected, - buildPendingUserInputAnswers, } from "./threadActivity"; const threadId = ThreadId.make("thread-1"); @@ -1368,17 +1364,6 @@ describe("retained v2 feed presentation", () => { }); }); -const singleSelectQuestion = { - id: "runtime", - header: "Runtime", - question: "Which runtime should be used?", - options: [ - { label: "Go", description: "One binary" }, - { label: "Node.js", description: "Reuse TypeScript" }, - ], - multiSelect: false, -} as const; - describe("subagent continued by a later turn", () => { const run2 = RunId.make("run-2"); const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`; @@ -1454,95 +1439,6 @@ describe("subagent continued by a later turn", () => { }); }); -describe("pending user input answers", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect( - buildPendingUserInputAnswers([question], { - runtime: { customAnswer: question.initialAnswer }, - }), - ).toEqual({ runtime: question.initialAnswer }); - expect( - buildPendingUserInputAnswers([question], { - runtime: { customAnswer: " Edited message\n\n" }, - }), - ).toEqual({ runtime: " Edited message\n\n" }); - expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({ - runtime: "", - }); - expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({ - runtime: " \n", - }); - expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual( - { customAnswer: "" }, - ); - }); -}); - -describe("provider question values", () => { - const question = { - ...singleSelectQuestion, - allowCustomAnswer: false, - options: [ - { label: "Same label", value: " exact first ", description: "First" }, - { label: "Same label", value: "second", description: "Second" }, - ], - } as const; - - it("submits raw option values and distinguishes duplicate labels", () => { - const first = togglePendingUserInputOptionSelection(question, undefined, " exact first "); - expect(isPendingUserInputOptionSelected(question, first, " exact first ")).toBe(true); - expect(isPendingUserInputOptionSelected(question, first, "second")).toBe(false); - expect(buildPendingUserInputAnswers([question], { runtime: first })).toEqual({ - runtime: " exact first ", - }); - expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first); - }); - - it("rejects arbitrary text when the provider only accepts offered options", () => { - expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({}); - expect( - buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([question], { - runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, - }), - ).toEqual({ runtime: "second" }); - }); -}); - -it("accepts ready attachment-only answers while preserving selected options", () => { - const question = { - id: "q", - header: "Spec", - question: "Provide a specification", - options: [{ label: "Yes", description: "Approve" }], - multiSelect: false, - }; - expect(buildPendingUserInputAnswers([question], { q: { attachmentCount: 1 } })).toEqual({ - q: "", - }); - expect( - buildPendingUserInputAnswers([question], { - q: { attachmentCount: 1, selectedOptionValues: ["Yes"] }, - }), - ).toEqual({ q: "Yes" }); - expect( - buildPendingUserInputAnswers([question], { - q: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...question, allowCustomAnswer: false }], { - q: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fc085a7c50a0d..e17748d77aa6c 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,7 +1,6 @@ import type { ThreadPendingApproval, ThreadPendingUserInput, - ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; @@ -81,13 +80,6 @@ import { export type PendingApproval = ThreadPendingApproval; export type PendingUserInput = ThreadPendingUserInput; -export interface PendingUserInputDraftAnswer { - readonly selectedOptionValues?: ReadonlyArray; - readonly customAnswer?: string; - readonly attachmentCount?: number; - readonly attachmentsBlocked?: boolean; -} - export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; @@ -406,73 +398,6 @@ function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function resolvePendingUserInputOptionValue( - question: ThreadUserInputQuestion, - value: string, -): string | null { - if (question.options.some((option) => option.value === value)) { - return value; - } - - const label = value.trim(); - return label.length > 0 && - question.options.some((option) => option.value === undefined && option.label.trim() === label) - ? label - : null; -} - -function normalizeSelectedOptionValues( - question: ThreadUserInputQuestion, - value: ReadonlyArray | undefined, -): ReadonlyArray { - if (!Array.isArray(value)) { - return []; - } - - return Array.from( - new Set( - value - .map((entry) => resolvePendingUserInputOptionValue(question, entry)) - .filter((entry): entry is string => entry !== null), - ), - ); -} - -function resolvePendingUserInputAnswer( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | ReadonlyArray | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - function capitalizePhrase(value: string): string { const trimmed = value.trim(); return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; @@ -1974,93 +1899,6 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo return activity.detail ?? activity.summary; } -export function setPendingUserInputCustomAnswer( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, -): PendingUserInputDraftAnswer { - if (question.allowCustomAnswer === false) { - return draft ?? {}; - } - - const selectedOptionValues = - question.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - -export function isPendingUserInputOptionSelected( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): boolean { - if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { - return false; - } - - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - return ( - resolvedOptionValue !== null && - normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( - resolvedOptionValue, - ) - ); -} - -export function togglePendingUserInputOptionSelection( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - if (resolvedOptionValue === null) { - return draft ?? {}; - } - - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues( - question, - draft?.selectedOptionValues, - ); - const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) - ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) - : [...selectedOptionValues, resolvedOptionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [resolvedOptionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record> | null { - const answers: Record> = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - interface ThreadFeedBuild { readonly rows: ReadonlyArray; readonly attempts: ReadonlyArray | undefined; diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index f869719601750..d4bbc6bfe5bc0 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -42,7 +42,7 @@ import { setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, -} from "../lib/threadActivity"; +} from "@t3tools/client-runtime/state/pending-user-input"; import { appAtomRegistry } from "./atom-registry"; import { useSelectedThreadPendingRequests } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index ad19948c06886..9a26b53bff4e6 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -123,7 +123,10 @@ it.effect("agent context matches the committed budget", () => ), }; - expect(measured).toEqual(budget); + expect( + measured, + "Set agentContextBudget.json to the measured values. Before you cut text to fit, run the evals that cover it (evals/README.md).", + ).toEqual(budget); }).pipe( Effect.provide( McpHttpServer.layerTools.pipe( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 57838e598161e..d7878cc29a165 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -241,7 +241,7 @@ import { type ThreadShell, } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; -import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { LatestProposedPlanState, PendingApproval, diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 697a0e0bfd6f2..e3e14c4a84005 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -3,10 +3,8 @@ import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; -import { - derivePendingUserInputProgress, - type PendingUserInputDraftAnswer, -} from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import { derivePendingUserInputProgress } from "../../pendingUserInput"; import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 35e4cc5f08551..91c5bd0d6c912 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -27,13 +27,15 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; import { buildPendingUserInputAnswers, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, + type PendingUserInputDraftAnswer, +} from "@t3tools/client-runtime/state/pending-user-input"; +import { carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, - togglePendingUserInputOptionSelection, - type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; import { questionAttachmentDraftId, @@ -98,9 +100,9 @@ function makePendingCustomAnswerSetter(input: { [activePendingRequestKey]: { ...existing[activePendingRequestKey], [questionId]: setPendingUserInputCustomAnswer( + question, existing[activePendingRequestKey]?.[questionId], value, - question, ), }, })); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 69800be4b7574..1178c91043052 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -5,14 +5,10 @@ import { threadContextReference } from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { - buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - resolvePendingUserInputAnswer, - setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, - togglePendingUserInputOptionSelection, } from "./pendingUserInput"; describe("projectPendingUserInputAnswer", () => { @@ -68,128 +64,6 @@ const singleSelectQuestion = { multiSelect: false, } as const; -const nativeChoiceQuestion = { - id: "result", - header: "Result", - question: "Which result should be used?", - options: [ - { value: " first\t", label: "Result", description: "First result" }, - { value: "second", label: "Result", description: "Second result" }, - ], - allowCustomAnswer: false, - multiSelect: false, -} as const; - -describe("resolvePendingUserInputAnswer", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( - question.initialAnswer, - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( - " Edited message\n\n", - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); - expect( - setPendingUserInputCustomAnswer( - { selectedOptionValues: ["Orchestration-first"] }, - "", - question, - ), - ).toEqual({ customAnswer: "" }); - const drafts = { scope: { customAnswer: "" } }; - expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ - customAnswer: "", - resolvedAnswer: "", - canAdvance: true, - isComplete: true, - }); - }); - - it("prefers a custom answer over selected options", () => { - expect( - resolvePendingUserInputAnswer(singleSelectQuestion, { - selectedOptionValues: ["Orchestration-first"], - customAnswer: "Keep the existing envelope for one release", - }), - ).toBe("Keep the existing envelope for one release"); - }); - - it("does not replace a required choice with a custom answer", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["second"], - customAnswer: "Use another result", - }), - ).toBe("second"); - }); - - it("does not submit labels or unknown values when an option has a value", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["Result", "unknown"], - }), - ).toBeNull(); - }); -}); - -describe("togglePendingUserInputOptionSelection", () => { - it("selects and removes options with the same label independently", () => { - const question = { ...nativeChoiceQuestion, multiSelect: true }; - const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); - const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); - - expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ - result: [" first\t", "second"], - }); - - const secondSelected = togglePendingUserInputOptionSelection( - question, - bothSelected, - " first\t", - ); - expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ - result: ["second"], - }); - }); -}); - -describe("buildPendingUserInputAnswers", () => { - it("returns a canonical answer map for complete prompts", () => { - expect( - buildPendingUserInputAnswers( - [ - singleSelectQuestion, - { - id: "compat", - header: "Compat", - question: "How strict should compatibility be?", - options: [ - { - label: "Keep current envelope", - description: "Preserve current wire format", - }, - ], - multiSelect: false, - }, - ], - { - scope: { - selectedOptionValues: ["Orchestration-first"], - }, - compat: { - customAnswer: "Keep the current envelope for one release window", - }, - }, - ), - ).toEqual({ - scope: "Orchestration-first", - compat: "Keep the current envelope for one release window", - }); - }); -}); - describe("pending user input question progress", () => { const questions = [ singleSelectQuestion, @@ -207,6 +81,17 @@ describe("pending user input question progress", () => { }, ] as const; + it("counts a deliberately cleared answer as answered", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + const drafts = { scope: { customAnswer: "" } }; + expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ + customAnswer: "", + resolvedAnswer: "", + canAdvance: true, + isComplete: true, + }); + }); + it("derives the active question and advancement state", () => { expect( derivePendingUserInputProgress( @@ -232,25 +117,6 @@ describe("pending user input question progress", () => { }); }); -it("accepts attachment-only answers after every upload finishes", () => { - const questions = [ - { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false }, - ]; - expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({ - spec: "", - }); - expect( - buildPendingUserInputAnswers(questions, { - spec: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], { - spec: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - describe("carryDisplacedCustomAnswerIntoPrompt", () => { // regression: #12577 clicking a question option discarded typed text it("keeps the thread draft when nothing was typed into the answer", () => { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index f8f9d6d52b5ba..17998eaf8010c 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,6 +1,13 @@ import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { + buildPendingUserInputAnswers, + normalizeSelectedOptionValues, + resolvePendingUserInputAnswer, + type PendingUserInputAnswer, + type PendingUserInputDraftAnswer, +} from "@t3tools/client-runtime/state/pending-user-input"; import { collectInlineContextIds } from "./lib/composerContextReferences"; @@ -25,20 +32,13 @@ export function threadContextsReferencedByAnswer( return threadContexts.filter((record) => referencedIds.has(record.contextId)); } -export interface PendingUserInputDraftAnswer { - selectedOptionValues?: string[]; - customAnswer?: string; - attachmentCount?: number; - attachmentsBlocked?: boolean; -} - export interface PendingUserInputProgress { questionIndex: number; activeQuestion: UserInputQuestion | null; activeDraft: PendingUserInputDraftAnswer | undefined; - selectedOptionValues: string[]; + selectedOptionValues: ReadonlyArray; customAnswer: string; - resolvedAnswer: string | string[] | null; + resolvedAnswer: PendingUserInputAnswer | null; usingCustomAnswer: boolean; answeredQuestionCount: number; isLastQuestion: boolean; @@ -46,72 +46,6 @@ export interface PendingUserInputProgress { canAdvance: boolean; } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeSelectedOptionValues(value: string[] | undefined): string[] { - if (!Array.isArray(value)) { - return []; - } - - // Provider option IDs must stay unchanged, including whitespace. - return Array.from(new Set(value.filter((entry) => typeof entry === "string"))); -} - -export function resolvePendingUserInputAnswer( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | string[] | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( - (value) => question.options.some((option) => (option.value ?? option.label) === value), - ); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - -export function setPendingUserInputCustomAnswer( - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, - question?: Pick, -): PendingUserInputDraftAnswer { - const selectedOptionValues = - question?.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(draft?.selectedOptionValues); - - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - const DISPLACED_ANSWER_SEPARATOR = "\n\n"; export function carryDisplacedCustomAnswerIntoPrompt( @@ -128,48 +62,6 @@ export function carryDisplacedCustomAnswerIntoPrompt( return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; } -export function togglePendingUserInputOptionSelection( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); - const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) - ? selectedOptionValues.filter((value) => value !== optionValue) - : [...selectedOptionValues, optionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [optionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record | null { - const answers: Record = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray, draftAnswers: Record, @@ -203,7 +95,9 @@ export function derivePendingUserInputProgress( questionIndex: normalizedQuestionIndex, activeQuestion, activeDraft, - selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), + selectedOptionValues: activeQuestion + ? normalizeSelectedOptionValues(activeQuestion, activeDraft?.selectedOptionValues) + : [], customAnswer, resolvedAnswer, usingCustomAnswer: diff --git a/docs/operations/development.md b/docs/operations/development.md index 211041c22a24e..3307dbc22b969 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -163,7 +163,9 @@ for the security model. `vp run check:changed` runs CI's fast checks for your branch's changes: format and lint of the changed files, `knip:check` when code or a manifest changes, the generated GraphQL checks, typecheck -of the affected packages and their dependents, and the test files beside the changed files. It maps +of the affected packages and their dependents, and the test files beside the changed files. It also +runs the checks that read other files: the agent context budget when the server is affected, and +React Compiler coverage for only the changed web and mobile files. It maps files to packages with [ci-changes.cjs](../../.github/scripts/ci-changes.cjs), as CI does. It compares against the merge base with `origin/main`, or with a ref you pass. CI still runs the full test suites and the jobs the summary lists, so a local pass does not promise a green CI run. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 66e79c7492ae3..5808641a5606a 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -331,6 +331,10 @@ "types": "./src/state/threadExecution.ts", "default": "./src/state/threadExecution.ts" }, + "./state/pending-user-input": { + "types": "./src/state/pendingUserInput.ts", + "default": "./src/state/pendingUserInput.ts" + }, "./state/thread-requests": { "types": "./src/state/threadRequests.ts", "default": "./src/state/threadRequests.ts" diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts new file mode 100644 index 0000000000000..7f5a2b0b84c3d --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPendingUserInputAnswers, + isPendingUserInputOptionSelected, + resolvePendingUserInputAnswer, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, +} from "./pendingUserInput.ts"; + +const singleSelectQuestion = { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { label: "Orchestration-first", description: "Focus on orchestration first" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const nativeChoiceQuestion = { + id: "result", + header: "Result", + question: "Which result should be used?", + options: [ + { value: " first\t", label: "Result", description: "First result" }, + { value: "second", label: "Result", description: "Second result" }, + ], + allowCustomAnswer: false, + multiSelect: false, +} as const; + +describe("resolvePendingUserInputAnswer", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( + question.initialAnswer, + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( + " Edited message\n\n", + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); + expect( + setPendingUserInputCustomAnswer( + question, + { selectedOptionValues: ["Orchestration-first"] }, + "", + ), + ).toEqual({ customAnswer: "" }); + }); + + it("prefers a custom answer over selected options", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + selectedOptionValues: ["Orchestration-first"], + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("does not replace a required choice with a custom answer", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["second"], + customAnswer: "Use another result", + }), + ).toBe("second"); + }); + + it("does not submit labels or unknown values when an option has a value", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["Result", "unknown"], + }), + ).toBeNull(); + }); + + it("matches a label only for an option without a value", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { selectedOptionValues: [" Node.js "] }), + ).toBe("Node.js"); + }); + + it("accepts ready attachment-only answers while preserving selected options", () => { + expect(resolvePendingUserInputAnswer(singleSelectQuestion, { attachmentCount: 1 })).toBe(""); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + selectedOptionValues: ["Node.js"], + }), + ).toBe("Node.js"); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + attachmentsBlocked: true, + }), + ).toBeNull(); + expect( + resolvePendingUserInputAnswer( + { ...singleSelectQuestion, allowCustomAnswer: false }, + { attachmentCount: 1 }, + ), + ).toBeNull(); + }); +}); + +describe("togglePendingUserInputOptionSelection", () => { + it("selects and removes options with the same label independently", () => { + const question = { ...nativeChoiceQuestion, multiSelect: true }; + const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); + const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); + + expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ + result: [" first\t", "second"], + }); + + const secondSelected = togglePendingUserInputOptionSelection( + question, + bothSelected, + " first\t", + ); + expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ + result: ["second"], + }); + }); + + it("submits raw option values and leaves the draft alone for an unknown option", () => { + const first = togglePendingUserInputOptionSelection( + nativeChoiceQuestion, + undefined, + " first\t", + ); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, " first\t")).toBe(true); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, "second")).toBe(false); + expect(buildPendingUserInputAnswers([nativeChoiceQuestion], { result: first })).toEqual({ + result: " first\t", + }); + expect(togglePendingUserInputOptionSelection(nativeChoiceQuestion, first, "Result")).toBe( + first, + ); + }); +}); + +describe("setPendingUserInputCustomAnswer", () => { + it("rejects arbitrary text when the provider only accepts offered options", () => { + expect(setPendingUserInputCustomAnswer(nativeChoiceQuestion, undefined, "Other")).toEqual({}); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { result: { customAnswer: "Other" } }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { + result: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, + }), + ).toEqual({ result: "second" }); + }); +}); + +const compatQuestion = { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [{ label: "Keep current envelope", description: "Preserve current wire format" }], + multiSelect: false, +} as const; + +describe("buildPendingUserInputAnswers", () => { + it("returns a canonical answer map for complete prompts", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, compatQuestion], { + scope: { selectedOptionValues: ["Orchestration-first"] }, + compat: { customAnswer: "Keep the current envelope for one release window" }, + }), + ).toEqual({ + scope: "Orchestration-first", + compat: "Keep the current envelope for one release window", + }); + }); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts new file mode 100644 index 0000000000000..1c71cdb29b4df --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -0,0 +1,176 @@ +/** The question fields answer handling reads. Web and mobile question types both fit it. */ +export interface PendingUserInputQuestion { + readonly id: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>; + readonly multiSelect?: boolean | undefined; + readonly allowCustomAnswer?: boolean | undefined; + readonly initialAnswer?: string | undefined; +} + +export interface PendingUserInputDraftAnswer { + readonly selectedOptionValues?: ReadonlyArray; + readonly customAnswer?: string; + readonly attachmentCount?: number; + readonly attachmentsBlocked?: boolean; +} + +export type PendingUserInputAnswer = string | ReadonlyArray; + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// Provider option values stay exact, including whitespace. Only options without a value match by label. +function resolvePendingUserInputOptionValue( + question: PendingUserInputQuestion, + value: string, +): string | null { + if (question.options.some((option) => option.value === value)) { + return value; + } + + const label = value.trim(); + return label.length > 0 && + question.options.some((option) => option.value === undefined && option.label.trim() === label) + ? label + : null; +} + +/** The draft's selections that name an offered option, deduplicated, in selection order. */ +export function normalizeSelectedOptionValues( + question: PendingUserInputQuestion, + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((entry) => resolvePendingUserInputOptionValue(question, entry)) + .filter((entry): entry is string => entry !== null), + ), + ); +} + +/** The answer to submit for one question, or null while it is unanswered. */ +export function resolvePendingUserInputAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, +): PendingUserInputAnswer | null { + if (draft?.attachmentsBlocked) return null; + const customAnswer = + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { + return customAnswer; + } + + const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + if (question.multiSelect) { + return selectedOptionValues.length > 0 + ? selectedOptionValues + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); + } + return ( + selectedOptionValues[0] ?? + customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) + ); +} + +export function setPendingUserInputCustomAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + if (question.allowCustomAnswer === false) { + return draft ?? {}; + } + + const selectedOptionValues = + question.initialAnswer !== undefined || customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + return { + customAnswer, + ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): boolean { + if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + return ( + resolvedOptionValue !== null && + normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( + resolvedOptionValue, + ) + ); +} + +/** Toggles an offered option. An unknown option returns the same draft. */ +export function togglePendingUserInputOptionSelection( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): PendingUserInputDraftAnswer { + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + if (resolvedOptionValue === null) { + return draft ?? {}; + } + + if (question.multiSelect) { + const selectedOptionValues = normalizeSelectedOptionValues( + question, + draft?.selectedOptionValues, + ); + const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) + ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) + : [...selectedOptionValues, resolvedOptionValue]; + + return { + customAnswer: "", + ...(nextSelectedOptionValues.length > 0 + ? { selectedOptionValues: nextSelectedOptionValues } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionValues: [resolvedOptionValue], + }; +} + +/** Every question's answer keyed by question id, or null while any is unanswered. */ +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); + if (answer === null) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index 25630728e3d66..b854cf0a4e539 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -1,7 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off globalConsole:off // Runs CI's fast checks for this branch's changes: format and lint of the // changed files, knip, generated GraphQL operations, typecheck of the affected -// packages and their dependents, and the test files beside the changed files. +// packages and their dependents, the test files beside the changed files, and +// the cross-file checks those miss (agent context budget, React Compiler +// coverage of the changed client files). // A dependency change also gets the unused-patch install check below. CI still // runs the full test suites and the jobs listed in the summary. // @@ -138,6 +140,52 @@ for (const file of code) { if (entry.startsWith(prefix) && TEST.test(entry)) tests.add(NodePath.join(dir, entry)); } } +// It reads files other than the ones beside it, so no changed file selects it. +if (affected.includes("t3")) tests.add("apps/server/src/provider/AgentContextBudget.test.ts"); + +// React Compiler coverage compiles every client source file, so run only the +// changed files' cases, in one extra step: a -t filter in the main test step +// would drop every other test. An app is skipped when the main step already +// runs its coverage file unfiltered. File filters mirror each coverage test. +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const compilerApps = [ + { + test: "apps/web/src/reactCompilerCoverage.test.ts", + root: "apps/web/src/", + // Test names keep the repo-relative path. + name: (file: string) => file, + compiled: (file: string) => + /\.tsx?$/.test(file) && !/\.(test|browser)\.tsx?$/.test(file) && !file.endsWith(".gen.ts"), + // Web has only per-file cases. + always: [], + }, + { + test: "apps/mobile/src/reactCompilerCoverage.test.ts", + root: "apps/mobile/src/", + name: (file: string) => file.slice("apps/mobile/".length), + compiled: (file: string) => /\.tsx?$/.test(file) && !/\.(test\.tsx?|d\.ts)$/.test(file), + // The Metro, cache, and stale-allowlist cases check more than one file, deletions included. + always: ["^(?!.*compiles )"], + }, +].flatMap(({ test, root: appRoot, name, compiled, always }) => { + const changed = files.filter((file) => file.startsWith(appRoot) && compiled(file)); + if (changed.length === 0 || tests.has(test)) return []; + const names = changed + .filter((file) => present.includes(file)) + .map((file) => `compiles ${escapeRegExp(name(file))}$`); + const pattern = [...names, ...always]; + return pattern.length > 0 ? [{ test, names, pattern }] : []; +}); +const compilerCoverage = compilerApps.length > 0 && { + name: `react compiler (${compilerApps.reduce((count, app) => count + app.names.length, 0)} files)`, + args: [ + "test", + "run", + ...compilerApps.map((app) => app.test), + "-t", + compilerApps.flatMap((app) => app.pattern).join("|"), + ], +}; // knip:check cannot be scoped to a package. Any code change can leave a file, // export, or dependency unused (removing the last import of a helper does), so @@ -255,33 +303,42 @@ const steps = [ name: `test (${tests.size} files)`, args: ["test", "run", "--passWithNoTests", ...tests], }, + compilerCoverage, ].filter((step) => step !== false); // Output is piped, so keep the colors a terminal would get. const env = process.stdout.isTTY ? { ...process.env, FORCE_COLOR: "1" } : process.env; +const runStep = (step: (typeof steps)[number]) => + new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { + const started = performance.now(); + const finish = (ok: boolean, output?: Buffer) => { + const seconds = ((performance.now() - started) / 1000).toFixed(1); + console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); + if (output) process.stdout.write(output); + resolve({ name: step.name, ok, seconds }); + }; + // In-process steps print their own output. The patch check is first and + // takes about a second, so the commands start right after it. + if ("run" in step) return finish(step.run()); + const output: Array = []; + const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + // A failed spawn emits "error", then "close" with a nonzero code. + child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); + child.on("close", (status) => finish(status === 0, Buffer.concat(output))); + }); + +// Vitest runs started together race to rebuild node_modules/.vitest-cache after +// an install (ENOTEMPTY), so they run one after another. +let previousVitest: Promise = Promise.resolve(); const results = await Promise.all( - steps.map( - (step) => - new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { - const started = performance.now(); - const finish = (ok: boolean, output?: Buffer) => { - const seconds = ((performance.now() - started) / 1000).toFixed(1); - console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); - if (output) process.stdout.write(output); - resolve({ name: step.name, ok, seconds }); - }; - // In-process steps print their own output. The patch check is first and - // takes about a second, so the commands start right after it. - if ("run" in step) return finish(step.run()); - const output: Array = []; - const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); - child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); - child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); - // A failed spawn emits "error", then "close" with a nonzero code. - child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); - child.on("close", (status) => finish(status === 0, Buffer.concat(output))); - }), - ), + steps.map((step) => { + if (!("args" in step) || step.args[0] !== "test") return runStep(step); + const result = previousVitest.then(() => runStep(step)); + previousVitest = result; + return result; + }), ); const summary = ["check:changed summary"]; diff --git a/vite.config.ts b/vite.config.ts index 909becac31779..468837c255686 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -149,7 +149,11 @@ export default defineConfig({ : {}), }, staged: { - "*": "vp fmt --no-error-on-unmatched-pattern", + // In order, so lint reads the formatted file. CI lint failures cost a full round trip. + "*": ["vp fmt --no-error-on-unmatched-pattern", "vp lint --no-error-on-unmatched-pattern"], + // knip checks the whole repo, so it takes no file list. Any code or manifest change can leave + // a file, export, or dependency unused. + "{*.{js,jsx,ts,tsx,mjs,cjs,mts,cts},package.json,knip.jsonc}": () => "vp run knip:check", }, fmt: { ignorePatterns: [ From ccaa3ad2956b90484052198af7f0e40996e403ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:00:06 -0700 Subject: [PATCH 036/144] feat(marketing): hero shows four agents running at once (#26875) Co-authored-by: Claude Opus 5.5 --- apps/marketing/src/assets/app-desktop.webp | Bin 268254 -> 97242 bytes apps/marketing/src/pages/index.astro | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/assets/app-desktop.webp b/apps/marketing/src/assets/app-desktop.webp index 11b51331eef3493fd0abf8e2dc2169c226f1ed0f..48c0c3e9abc5001aea4d04f348d0073938b91eaf 100644 GIT binary patch literal 97242 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 + // No min-w-0: the undo button sets this item's minimum, so a shared row squeezes its neighbour. + +
{ + if (!summary && event.target === event.currentTarget) setShownSummary(null); + }} + // Reversed wrap: a summary that does not fit beside the undo button drops to a + // second line that overflow hides, instead of truncating mid-word. Browse mode + // reads the button first; the live announcement carries the full sentence. + className={cn( + "flex h-6 min-w-0 flex-1 flex-row-reverse flex-wrap gap-x-1 overflow-hidden text-2xs leading-6 whitespace-nowrap text-sidebar-muted-foreground transition-opacity duration-200 ease-out starting:opacity-0", + !summary && "opacity-0", + )} + > + {threadUndoLabel(keybindings)} + {text}, +
+
); } diff --git a/apps/web/src/hooks/showThreadUndoNotice.ts b/apps/web/src/hooks/showThreadUndoNotice.ts index 5b6b68826fdea..977dcd68d1ab7 100644 --- a/apps/web/src/hooks/showThreadUndoNotice.ts +++ b/apps/web/src/hooks/showThreadUndoNotice.ts @@ -28,7 +28,7 @@ type UndoNotice = { export const useThreadUndoNotice = create<{ notice: UndoNotice | null }>(() => ({ notice: null })); -export function threadUndoNoticeSummary(notice: Pick): string { +function threadUndoNoticeSummary(notice: Pick): string { const noun = notice.action === "Discarded" ? "draft" : "thread"; return `${notice.action} ${notice.count} ${noun}${notice.count === 1 ? "" : "s"}`; } From 58e60bf5e808fc226d9b769d66d1ea9fec014d23 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:27:03 -0700 Subject: [PATCH 038/144] feat: turn a No project thread into a project (#26767) Co-authored-by: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 21 +- .../features/threads/turnThreadIntoProject.ts | 47 +++ .../src/environment/ServerEnvironment.ts | 1 + ...OrchestratorMcpToolkit.integration.test.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 71 +++++ .../src/orchestration-v2/runtimeLayer.test.ts | 276 ++++++++++++++++++ .../src/orchestration-v2/runtimeLayer.ts | 12 + .../src/project/ScratchThreadPromotion.ts | 161 ++++++++++ .../ScheduledTaskService.test.ts | 74 +++++ .../scheduledTasks/ScheduledTaskService.ts | 76 ++++- apps/server/src/ws.test.ts | 3 + apps/server/src/ws.ts | 10 + apps/web/src/components/Sidebar.tsx | 28 +- .../components/threadActionMenu.logic.test.ts | 13 +- .../src/components/threadActionMenu.logic.ts | 13 + apps/web/src/contextMenuFallback.ts | 10 + .../web/src/hooks/useThreadActionMenu.test.ts | 1 + apps/web/src/hooks/useThreadActionMenu.ts | 17 +- apps/web/src/hooks/useThreadActions.ts | 23 ++ docs/user/thread-sidebar.md | 6 + .../src/state/projectCommands.ts | 6 + .../contracts/src/clientRpcPermissions.ts | 1 + packages/contracts/src/environment.ts | 1 + packages/contracts/src/orchestrationV2.ts | 7 + packages/contracts/src/project.ts | 11 + packages/contracts/src/rpc.ts | 10 + 27 files changed, 879 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/features/threads/turnThreadIntoProject.ts create mode 100644 apps/server/src/project/ScratchThreadPromotion.ts diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 39c5ac82362a4..5b0c1fd2f5284 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -62,6 +62,8 @@ import { useThreadPr } from "../../state/use-thread-pr"; import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; +import { canTurnThreadIntoProject, turnThreadIntoProject } from "./turnThreadIntoProject"; +import { threadCanArchive } from "../home/threadArchive"; import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, resolveThreadListV2SnoozeGateExpiryMs, @@ -837,6 +839,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : [], [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], ); + const canTurnIntoProject = canTurnThreadIntoProject(thread); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, @@ -844,8 +847,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ...(canTurnIntoProject + ? [ + { + id: "turn-into-project", + title: "Turn into project", + image: "folder.badge.plus", + attributes: { disabled: !threadCanArchive(thread.runtime) }, + } satisfies MenuAction, + ] + : []), + ], + [ + canTurnIntoProject, + props.titleRegenerationSupported, + thread.runtime, + thread.titleRegeneration, ], - [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( () => [ @@ -923,6 +941,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); + if (nativeEvent.event === "turn-into-project") void turnThreadIntoProject(thread); if (nativeEvent.event === "copy-thread") { const target = threadChipCopyTarget( { environmentId: thread.environmentId, threadId: thread.id }, diff --git a/apps/mobile/src/features/threads/turnThreadIntoProject.ts b/apps/mobile/src/features/threads/turnThreadIntoProject.ts new file mode 100644 index 0000000000000..44e6e03fe0d9b --- /dev/null +++ b/apps/mobile/src/features/threads/turnThreadIntoProject.ts @@ -0,0 +1,47 @@ +import { + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import * as Haptics from "expo-haptics"; + +import * as Dialog from "../../components/ui/Dialog"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentProjects, projectEnvironment } from "../../state/projects"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; + +/** A No project thread that already has its own folder, on a server that can promote it. */ +export function canTurnThreadIntoProject(thread: EnvironmentThreadShell): boolean { + if (thread.worktreePath === null) return false; + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId); + if (config?.environment.capabilities.threadProjectFromScratch !== true) return false; + const project = appAtomRegistry.get( + environmentProjects.projectAtom({ + environmentId: thread.environmentId, + projectId: thread.projectId, + }), + ); + return project?.isScratch === true; +} + +export async function turnThreadIntoProject(thread: EnvironmentThreadShell): Promise { + const title = "Could not turn thread into a project"; + if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) { + Dialog.alert(title, "This connection cannot change threads."); + return; + } + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const result = await runAtomCommand( + appAtomRegistry, + projectEnvironment.createFromThread, + { environmentId: thread.environmentId, input: { threadId: thread.id } }, + { reportFailure: false }, + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert(title, error instanceof Error ? error.message : "Try again."); + } +} diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2b9b1f95b0719..90ae4519bed92 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -246,6 +246,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadAutoSettleOptOut: true, + threadProjectFromScratch: true, threadTitleRegeneration: true, commandOutputStreaming: true, threadVisitedTracking: true, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index dba3bf7c08ee4..ec8c9434bec43 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -182,6 +182,7 @@ const layerUnusedScheduledTaskStub = Layer.succeed( setEnabled: () => Effect.die("ScheduledTaskService.setEnabled is unused in this test"), delete: () => Effect.die("ScheduledTaskService.delete is unused in this test"), deleteForThread: () => Effect.die("unused in this test"), + moveThreadTasks: () => Effect.die("unused in this test"), runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), rotateWebhookToken: () => Effect.die("unused in this test"), listWebhookDeliveries: () => Effect.die("unused in this test"), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index fcbbde88f4b1d..259fa92a66023 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -150,6 +150,7 @@ const RPC_AGGREGATES = { [WS_METHODS.projectsWriteFile]: "workspace", [WS_METHODS.projectsEnsureScratch]: "orchestration", [WS_METHODS.projectsCreateNew]: "orchestration", + [WS_METHODS.projectsCreateFromThread]: "orchestration", [WS_METHODS.shellOpenInEditor]: "workspace", [WS_METHODS.filesystemBrowse]: "workspace", [WS_METHODS.filesystemGetMetadata]: "workspace", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 91acb881637c1..d0edd5dacf8f6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -455,6 +455,23 @@ function wakeWorkStartedAt( const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); +/** Unfinished runs (including queued) or open requests block moving a thread to another project. */ +export function threadHasUnfinishedWork(records: { + readonly runs: ReadonlyArray<{ readonly status: string }>; + readonly runtimeRequests: ReadonlyArray<{ readonly status: string }>; +}): boolean { + return ( + records.runs.some( + (run) => + run.status !== "completed" && + run.status !== "failed" && + run.status !== "cancelled" && + run.status !== "interrupted" && + run.status !== "rolled_back", + ) || records.runtimeRequests.some((request) => request.status === "pending") + ); +} + function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { switch (command.type) { case "thread.create": @@ -486,6 +503,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pull-request-watch.sync": case "thread.pull-request.sync": case "thread.title.regeneration.complete": + case "thread.project.adopt-folder": case "thread.runtime-mode.set": case "thread.interaction-mode.set": case "thread.model-selection.set": @@ -3983,6 +4001,56 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + const dispatchThreadProjectAdoptFolder = Effect.fn( + "orchestrationV2.dispatch.threadProjectAdoptFolder", + )(function* ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.project.adopt-folder" } + >, + events: Ref.Ref>, + ) { + const reject = (cause: string) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} ${cause}`, + }); + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "runtimeRequests"], { turnItemTypes: [] }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const thread = projection.thread; + if (thread.deletedAt !== null) return yield* reject("is deleted."); + if (threadHasUnfinishedWork(projection)) { + return yield* reject("still has work in progress. Finish or stop it first."); + } + const current = yield* projects.get(thread.projectId).pipe(mapDispatchError(command)); + const target = yield* projects.get(command.projectId).pipe(mapDispatchError(command)); + if (Option.isNone(target) || target.value.deletedAt !== null) { + return yield* reject(`cannot move to missing project ${command.projectId}.`); + } + // The thread always lands in the project root, so the move must not change its cwd. + const cwd = thread.worktreePath ?? Option.getOrUndefined(current)?.workspaceRoot; + if (cwd !== target.value.workspaceRoot) { + return yield* reject(`does not work in ${target.value.workspaceRoot}.`); + } + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { ...thread, projectId: command.projectId, worktreePath: null, updatedAt: now }, + }); + }); + const refuseAboveDispatchModeLimit = Effect.fn("orchestrationV2.dispatch.refuseAboveModeLimit")( function* ( command: OrchestrationV2ServerCommand, @@ -12487,6 +12555,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; + case "thread.project.adopt-folder": + yield* dispatchThreadProjectAdoptFolder(command, events); + break; case "message.dispatch": { // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent. const thread = yield* projectionStore diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c77837094b201..65c0c040e0daf 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -37,6 +37,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -76,6 +77,7 @@ import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "../project/ScratchThreadPromotion.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; @@ -5805,6 +5807,198 @@ it.layer(layerTest)("snooze queued runs", (it) => { ); }); +const SCRATCH_ROOT = "/tmp/runtime-layer-promotion-scratch"; +const SCRATCH_PROJECT_ID = ProjectId.make("runtime-layer-promotion-scratch"); +const layerScratchPromotionTest = ScratchThreadPromotion.layer.pipe( + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedSome(SCRATCH_ROOT), + isScratchProject: (projectId) => Effect.succeed(projectId === SCRATCH_PROJECT_ID), + namedProjectsRoot: "/projects", + }), + ), + Layer.provide(NodeServices.layer), + Layer.provideMerge(layerProjectDeletionTest), +); + +it.layer(layerScratchPromotionTest)("turning a No project thread into a project", (it) => { + const setup = Effect.fn(function* (name: string) { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const folder = `${SCRATCH_ROOT}/2026-01-01-${name}`; + if (Option.isNone(yield* projects.getById(SCRATCH_PROJECT_ID))) { + yield* projects.create({ + commandId: CommandId.make("promotion-scratch-create"), + projectId: SCRATCH_PROJECT_ID, + title: "No project", + workspaceRoot: SCRATCH_ROOT, + }); + } + const createThread = (suffix: string, worktreePath: string) => + orchestrator + .dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`promotion-${name}-${suffix}-create`), + threadId: ThreadId.make(`promotion-${name}-${suffix}`), + projectId: SCRATCH_PROJECT_ID, + title: `${name} ${suffix}`, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath, + }) + .pipe(Effect.as(ThreadId.make(`promotion-${name}-${suffix}`))); + return { folder, createThread }; + }); + const shellOf = (threadId: ThreadId) => + Orchestrator.OrchestratorV2.pipe( + Effect.flatMap((orchestrator) => orchestrator.getThreadShell(threadId)), + ); + + it.effect("moves every thread in the folder and its bound tasks into one project", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const tasks = yield* ScheduledTaskService.ScheduledTaskService; + const { folder, createThread } = yield* setup("moves"); + const threadId = yield* createThread("main", folder); + const siblingId = yield* createThread("sibling", folder); + const otherId = yield* createThread("other", `${SCRATCH_ROOT}/2026-01-01-elsewhere`); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("promotion-moves-sibling-archive"), + threadId: siblingId, + }); + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("promotion-moves-bound"), + title: "Bound", + prompt: "Check in", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: SCRATCH_PROJECT_ID, + threadId: siblingId, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + }); + + const { projectId } = yield* promotion.promote({ threadId }); + + const project = yield* projects.getById(projectId); + assert.equal(Option.getOrUndefined(project)?.workspaceRoot, folder); + assert.equal(Option.getOrUndefined(project)?.title, "moves main"); + for (const id of [threadId, siblingId]) { + const shell = yield* shellOf(id); + assert.equal(shell?.projectId, projectId); + assert.isNull(shell?.worktreePath); + } + assert.equal((yield* shellOf(otherId))?.projectId, SCRATCH_PROJECT_ID); + const saved = (yield* tasks.list()).tasks.find((candidate) => candidate.id === task.id); + assert.equal(saved?.projectId, projectId); + assert.deepEqual(yield* promotion.promote({ threadId }), { projectId }); + }), + ); + + it.effect("changes nothing while a thread in the folder is busy", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { folder, createThread } = yield* setup("busy"); + const threadId = yield* createThread("idle", folder); + const busyId = yield* createThread("delegate", folder); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("promotion-busy-message"), + threadId: busyId, + messageId: MessageId.make("promotion-busy-message"), + text: "Keep this run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const error = yield* promotion.promote({ threadId }).pipe(Effect.flip); + + assert.equal(error.message, '"busy delegate" is still working. Finish or stop it first.'); + assert.isTrue(Option.isNone(yield* projects.getByWorkspaceRoot(folder))); + assert.equal((yield* shellOf(threadId))?.projectId, SCRATCH_PROJECT_ID); + }), + ); + + it.effect("finishes a partial move from either thread", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { folder, createThread } = yield* setup("partial"); + const movedId = yield* createThread("moved", folder); + const leftId = yield* createThread("left", folder); + const earlier = yield* projects.create({ + commandId: CommandId.make("promotion-partial-project"), + projectId: ProjectId.make("promotion-partial-project"), + title: "Earlier", + workspaceRoot: folder, + }); + yield* orchestrator.dispatch({ + type: "thread.project.adopt-folder", + commandId: CommandId.make("promotion-partial-adopt"), + threadId: movedId, + projectId: earlier.id, + }); + + assert.deepEqual(yield* promotion.promote({ threadId: movedId }), { projectId: earlier.id }); + assert.equal((yield* shellOf(leftId))?.projectId, earlier.id); + }), + ); + + it.effect("refuses a thread outside No project", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make("promotion-regular-project"); + const threadId = ThreadId.make("promotion-regular-thread"); + yield* projects.create({ + commandId: CommandId.make("promotion-regular-project"), + projectId, + title: "Regular", + workspaceRoot: "/tmp/runtime-layer-promotion-regular", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("promotion-regular-thread-create"), + threadId, + projectId, + title: "Regular", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + + const error = yield* promotion.promote({ threadId }).pipe(Effect.flip); + + assert.equal( + error.message, + "Only a No project thread with its own folder can become a project.", + ); + }), + ); +}); + it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { it.effect("carries snooze state through the V2 shell projection", () => Effect.gen(function* () { @@ -5900,6 +6094,88 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { assert.isNull(awakened.thread.snoozedAt); }), ); + + it.effect("moves a thread into a project rooted at its own folder", () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const scratchProjectId = ProjectId.make("runtime-layer-adopt-scratch"); + const folderProjectId = ProjectId.make("runtime-layer-adopt-folder"); + const otherProjectId = ProjectId.make("runtime-layer-adopt-other"); + const folder = "/tmp/runtime-layer-adopt-scratch/2026-01-01-notes-a1b2c3d4"; + const createProject = (projectId: ProjectId, workspaceRoot: string) => + projects.create({ + commandId: CommandId.make(`${projectId}-create`), + projectId, + title: projectId, + workspaceRoot, + }); + const createThread = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-create`), + threadId, + projectId: scratchProjectId, + title: "Scratch work", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: folder, + }); + const adopt = (threadId: ThreadId, projectId: ProjectId, suffix: string) => + orchestrator.dispatch({ + type: "thread.project.adopt-folder", + commandId: CommandId.make(`${threadId}-adopt-${suffix}`), + threadId, + projectId, + }); + yield* createProject(scratchProjectId, "/tmp/runtime-layer-adopt-scratch"); + yield* createProject(folderProjectId, folder); + yield* createProject(otherProjectId, "/tmp/runtime-layer-adopt-other"); + const idleThreadId = ThreadId.make("runtime-layer-adopt-idle"); + const busyThreadId = ThreadId.make("runtime-layer-adopt-busy"); + yield* createThread(idleThreadId); + yield* createThread(busyThreadId); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-adopt-busy-message"), + threadId: busyThreadId, + messageId: MessageId.make("runtime-layer-adopt-busy-message"), + text: "Keep this run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const otherFolder = yield* adopt(idleThreadId, otherProjectId, "other").pipe(Effect.flip); + assert.equal( + otherFolder.cause, + `Thread ${idleThreadId} does not work in /tmp/runtime-layer-adopt-other.`, + ); + const missing = yield* adopt(idleThreadId, ProjectId.make("missing"), "missing").pipe( + Effect.flip, + ); + assert.equal(missing.cause, `Thread ${idleThreadId} cannot move to missing project missing.`); + const busy = yield* adopt(busyThreadId, folderProjectId, "busy").pipe(Effect.flip); + assert.equal( + busy.cause, + `Thread ${busyThreadId} still has work in progress. Finish or stop it first.`, + ); + + yield* adopt(idleThreadId, folderProjectId, "folder"); + const shell = yield* orchestrator.getShellSnapshot(); + const moved = shell.threads.find((thread) => thread.id === idleThreadId); + assert.equal(moved?.projectId, folderProjectId); + assert.isNull(moved?.worktreePath); + const stayed = shell.threads.find((thread) => thread.id === busyThreadId); + assert.equal(stayed?.projectId, scratchProjectId); + }), + ); }); it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 6bd33ebd26187..1b54627595372 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -16,6 +16,7 @@ import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "../project/ScratchThreadPromotion.ts"; import * as CheckpointCaptureService from "./CheckpointCaptureService.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; @@ -289,6 +290,16 @@ const layerScheduledTaskProvided = ScheduledTaskService.layer.pipe( ), ), ); +const layerScratchThreadPromotionProvided = ScratchThreadPromotion.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProjectService, + layerManagedProjectFoldersProvided, + layerThreadManagementProvided, + layerScheduledTaskProvided, + ), + ), +); const layerProviderContinuationWorkerProvided = ProviderContinuationService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -362,6 +373,7 @@ export const layerProduction = Layer.mergeAll( layerManagedProjectFoldersProvided, layerThreadLaunchProvided, layerScheduledTaskProvided, + layerScratchThreadPromotionProvided, layerSecretRequestsProvided, GuestShareService.layer.pipe( Layer.provide(Layer.merge(layerOrchestratorProvided, layerCommandOutputHubProvided)), diff --git a/apps/server/src/project/ScratchThreadPromotion.ts b/apps/server/src/project/ScratchThreadPromotion.ts new file mode 100644 index 0000000000000..671a480a95832 --- /dev/null +++ b/apps/server/src/project/ScratchThreadPromotion.ts @@ -0,0 +1,161 @@ +import { + CommandId, + type OrchestrationV2ThreadShell, + ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { threadHasUnfinishedWork } from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ManagedProjectFolders from "./ManagedProjectFolders.ts"; +import * as ProjectService from "./ProjectService.ts"; + +export class ScratchThreadPromotionError extends Schema.TaggedError()( + "ScratchThreadPromotionError", + { + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason; + } +} + +export class ScratchThreadPromotion extends Context.Service< + ScratchThreadPromotion, + { + /** + * Turns a No project thread's folder into a project, in place, and moves every + * thread working in that folder into it. Running it again finishes a partial move. + */ + readonly promote: (input: { + readonly threadId: ThreadId; + }) => Effect.Effect<{ readonly projectId: ProjectId }, ScratchThreadPromotionError>; + } +>()("t3/project/ScratchThreadPromotion") {} + +const make = Effect.gen(function* () { + const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const projects = yield* ProjectService.ProjectService; + const threads = yield* ThreadManagementService.ThreadManagementService; + const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; + const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + + const fail = (reason: string) => (cause: unknown) => + new ScratchThreadPromotionError({ reason, cause }); + + const promote: ScratchThreadPromotion["Service"]["promote"] = Effect.fn( + "ScratchThreadPromotion.promote", + )(function* (input) { + const thread = yield* threads.getThreadShell(input.threadId).pipe( + Effect.mapError(fail("Could not read the thread.")), + Effect.filterOrFail( + (shell) => shell !== null, + () => new ScratchThreadPromotionError({ reason: "Thread not found." }), + ), + ); + const project = yield* projects + .getById(thread.projectId) + .pipe(Effect.mapError(fail("Could not read the thread's project."))); + const folder = thread.worktreePath ?? Option.getOrUndefined(project)?.workspaceRoot; + const scratchRoot = Option.getOrUndefined(yield* managedFolders.scratchRoot); + const isScratch = yield* managedFolders.isScratchProject(thread.projectId); + // A thread already rooted at its folder is a partial earlier promotion; finish it. + const alreadyPromoted = + Option.isSome(project) && project.value.workspaceRoot === folder && !isScratch; + if ( + folder === undefined || + scratchRoot === undefined || + path.dirname(folder) !== scratchRoot || + !(isScratch || alreadyPromoted) + ) { + return yield* new ScratchThreadPromotionError({ + reason: "Only a No project thread with its own folder can become a project.", + }); + } + + const snapshot = yield* Effect.flatten( + threads.readShellSnapshot({ worktreePath: folder }), + ).pipe(Effect.mapError(fail("Could not read the threads in this folder."))); + const moveSet = new Map(); + if (isScratch) moveSet.set(thread.id, thread); + for (const candidate of [...snapshot.threads, ...snapshot.archivedThreads]) { + if (yield* managedFolders.isScratchProject(candidate.projectId)) { + moveSet.set(candidate.id, candidate); + } + } + + for (const candidate of moveSet.values()) { + const records = yield* threads + .getThreadRecords(candidate.id, ["runs", "runtimeRequests"], { turnItemTypes: [] }) + .pipe(Effect.mapError(fail(`Could not read "${candidate.title}".`))); + if (threadHasUnfinishedWork(records)) { + return yield* new ScratchThreadPromotionError({ + reason: `"${candidate.title}" is still working. Finish or stop it first.`, + }); + } + } + + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const projectId = yield* createProject(thread.title, folder); + yield* scheduledTasks + .moveThreadTasks({ threadIds: [...moveSet.keys()], projectId }) + .pipe(Effect.mapError(fail("Could not move the thread's scheduled tasks."))); + for (const candidate of moveSet.values()) { + const commandId = CommandId.make(`scratch-promotion:${yield* randomId}`); + yield* threads + .dispatch({ + type: "thread.project.adopt-folder", + commandId, + threadId: candidate.id, + projectId, + }) + .pipe(Effect.mapError(fail(`Could not move "${candidate.title}". Try again.`))); + } + return { projectId }; + }), + ); + }); + + const randomId = crypto + .randomUUIDv4() + .pipe(Effect.mapError(fail("Could not create an id for the project."))); + + // bootstrap returns the project that already owns the folder, so retries reuse it. + const createProject = Effect.fn("ScratchThreadPromotion.createProject")(function* ( + title: string, + workspaceRoot: string, + ) { + const id = yield* randomId; + return yield* projects + .bootstrap({ + commandId: CommandId.make(`scratch-promotion-project:${id}`), + projectId: ProjectId.make(id), + title, + workspaceRoot, + }) + .pipe( + Effect.map(({ project }) => project.id), + // bootstrap reads before locking; racing creates return the winner in a conflict. + Effect.catchTags({ + ProjectConflictError: (conflict) => Effect.succeed(conflict.conflictingProjectId), + }), + Effect.mapError(fail("Could not create the project.")), + ); + }); + + return ScratchThreadPromotion.of({ promote }); +}); + +export const layer = Layer.effect(ScratchThreadPromotion, make); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index c1dcdd22abbae..82d165972750f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -402,3 +402,77 @@ it.effect("thread cleanup notifies subscribers and a finishing run cannot recrea }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("a bound run follows its thread when promotion moves it mid-send", () => + Effect.gen(function* () { + const scratch = ProjectId.make("project:scratch"); + const promoted = ProjectId.make("project:promoted"); + const sends = yield* Ref.make>([]); + // Rebind lands first, then the promotion commits between the shell read and the retry. + const shellProjects = yield* Ref.make([scratch, promoted]); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: (input) => + Ref.updateAndGet(sends, (all) => [ + ...all, + { projectId: input.projectId, commandId: input.commandId }, + ]).pipe( + Effect.flatMap((all) => + all.length < 3 + ? Effect.fail( + new ThreadManagementService.ThreadManagementThreadNotFoundError({ + projectId: input.projectId, + threadId: input.threadId, + }), + ) + : Effect.succeed({} as never), + ), + ), + getThreadShell: () => + Ref.modify(shellProjects, ([next, ...rest]) => [ + { projectId: next ?? promoted } as never, + rest, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const threadId = ThreadId.make("thread:promoted-during-run"); + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:promoted-during-run"), + title: "Check in", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: promoted, + threadId, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + + const result = yield* service.runNow({ id: task.id }); + + assert.equal(result.threadId, threadId); + const attempts = yield* Ref.get(sends); + assert.deepEqual( + attempts.map((attempt) => attempt.projectId), + [promoted, scratch, promoted], + ); + assert.equal(new Set(attempts.map((attempt) => attempt.commandId)).size, 1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 3dfedb0dfeaf4..b3aa9440944bd 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -2,6 +2,7 @@ import { CommandId, MessageId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type ProjectId, ScheduledTask, ScheduledTaskError, ScheduledTaskId, @@ -240,6 +241,11 @@ export class ScheduledTaskService extends Context.Service< ) => Effect.Effect; /** Deletes only tasks that still target this thread, including their webhook deliveries. */ readonly deleteForThread: (threadId: ThreadId) => Effect.Effect; + /** Points tasks bound to these threads at the project the threads moved to. */ + readonly moveThreadTasks: (input: { + readonly threadIds: ReadonlyArray; + readonly projectId: ProjectId; + }) => Effect.Effect; /** The dispatched thread, or null if dispatch failed; leaves the task's binding unchanged. */ readonly runNow: ( input: ScheduledTaskRunNowInput, @@ -808,6 +814,42 @@ export const layer = Layer.effect( // A webhook prompt was rendered from the row when the request arrived. const prompt = webhook?.prompt ?? active.prompt; + // A bound thread can move to another project (No project to a project) while + // this fires. Follow it; one move needs at most two retries. Same ids replay safely. + const sendBound = ( + threadId: ThreadId, + projectId: ProjectId, + retries: number, + ): ReturnType => + threadManagement + .sendToThread({ + projectId, + commandId, + threadId, + messageId, + scheduledTaskId: active.id, + text: prompt, + attachments: [], + mode: "queue", + createdBy: active.createdBy, + creationSource: active.creationSource, + }) + .pipe( + Effect.catchTags({ + ThreadManagementThreadNotFoundError: (error) => + retries === 0 + ? Effect.fail(error) + : threadManagement.getThreadShell(threadId).pipe( + Effect.orElseSucceed(() => null), + Effect.flatMap((shell) => + shell === null || shell.projectId === projectId + ? Effect.fail(error) + : sendBound(threadId, shell.projectId, retries - 1), + ), + ), + }), + ); + // Capture defects and interrupts too so dispatch failures still reach markCompleted. const result = active.threadId === null @@ -835,20 +877,9 @@ export const layer = Layer.effect( : yield* Effect.exit( // The stored selection only seeds fresh threads. A bound run // follows the thread's current provider, so it passes none. - threadManagement - .sendToThread({ - projectId: active.projectId, - commandId, - threadId: ThreadId.make(active.threadId), - messageId, - scheduledTaskId: active.id, - text: prompt, - attachments: [], - mode: "queue", - createdBy: active.createdBy, - creationSource: active.creationSource, - }) - .pipe(Effect.as(ThreadId.make(active.threadId))), + sendBound(ThreadId.make(active.threadId), active.projectId, 2).pipe( + Effect.as(ThreadId.make(active.threadId)), + ), ); const runSucceeded = result._tag === "Success"; @@ -1262,6 +1293,22 @@ export const layer = Layer.effect( if (removed.length > 0) yield* notifyChanged; }); + const moveThreadTasks: ScheduledTaskService["Service"]["moveThreadTasks"] = Effect.fn( + "ScheduledTaskService.moveThreadTasks", + )(function* (input) { + if (input.threadIds.length === 0) return; + const moved = yield* sql<{ task_id: ScheduledTaskId }>` + UPDATE scheduled_tasks SET project_id = ${input.projectId} + WHERE project_id != ${input.projectId} AND thread_id IN ${sql.in(input.threadIds)} + RETURNING task_id + `.pipe( + Effect.mapError((cause) => + taskError("Could not move the thread's schedule tasks.", { cause }), + ), + ); + if (moved.length > 0) yield* notifyChanged; + }); + const runNow: ScheduledTaskService["Service"]["runNow"] = Effect.fn( "ScheduledTaskService.runNow", )(function* (input: ScheduledTaskRunNowInput) { @@ -2138,6 +2185,7 @@ export const layer = Layer.effect( setEnabled, delete: deleteTask, deleteForThread, + moveThreadTasks, runNow, rotateWebhookToken, listWebhookDeliveries, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ac60becc2d78a..bce9450778736 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -49,6 +49,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -593,6 +594,7 @@ it.effect( scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ getDescriptor: Effect.succeed({ environmentId: EnvironmentId.make("config-test"), @@ -769,6 +771,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ getDescriptor: Effect.succeed({ environmentId: EnvironmentId.make("config-test"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 34eb11815b13f..8c8509550bc11 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -214,6 +214,7 @@ import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts import * as ProjectService from "./project/ProjectService.ts"; import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; import { projectMutationOperation } from "./project/ProjectMutation.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -1238,6 +1239,7 @@ export const layerWsRpc = ( const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const scratchThreadPromotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; const eventLoopMonitor = yield* EventLoopMonitor.EventLoopMonitor; const threadSearch = yield* ThreadSearch.ThreadSearch; @@ -2716,6 +2718,14 @@ export const layerWsRpc = ( (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), ), + [WS_METHODS.projectsCreateFromThread]: (input) => + scratchThreadPromotion + .promote(input) + .pipe( + Effect.mapError( + (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), + ), + ), [WS_METHODS.projectCloneCancel]: (input) => projectCloneTracker.cancel(input.projectId).pipe(Effect.map((applied) => ({ applied }))), [WS_METHODS.projectCloneRetry]: (input) => diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 62934576e365b..de16be6d2dcfb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -115,7 +115,11 @@ import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; -import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; +import { + canTurnThreadIntoProject, + useAcknowledgeThreadWoke, + useThreadActions, +} from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; @@ -897,6 +901,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve unpinThread, confirmAndUnpinThread, setThreadAutoSettle, + turnThreadIntoProject, reorderPinnedThread, reorderActiveThread, markThreadUnread, @@ -2558,6 +2563,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve canShareWithGuest: appAtomRegistry.get( canShareWithGuestAtom(threadRef.environmentId), ), + canTurnIntoProject: canTurnThreadIntoProject(threadRef), supports: { settlement: configLoaded, autoSettleOptOut: supportsAutoSettleOptOut, @@ -2638,6 +2644,26 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "unpin": attemptUnpin(threadRef); return; + case "turn-into-project": { + const result = await turnThreadIntoProject(threadRef); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Project created", + description: thread.title, + }); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not turn thread into a project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "auto-settle:enabled": case "auto-settle:disabled": { const result = await setThreadAutoSettle( diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 039c684dcdc36..bd9dfdcdcc4b6 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,10 +65,13 @@ describe("buildThreadActionMenuItems", () => { }, ); - it("disables archive while the thread is running", () => { - const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( - (item) => item.id === "archive", - ); - expect(archiveItem?.disabled).toBe(true); + it("disables archive and turning into a project while the thread is running", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + isRunning: true, + canTurnIntoProject: true, + }); + expect(items.find((item) => item.id === "archive")?.disabled).toBe(true); + expect(items.find((item) => item.id === "turn-into-project")?.disabled).toBe(true); }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 7779a9db4c3ed..3c4626c1ea1ad 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -4,6 +4,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" export type ThreadActionMenuId = | "new-thread-on-branch" | "filter-by-project" + | "turn-into-project" | "project-settings" | "pin" | "unpin" @@ -90,6 +91,8 @@ export interface ThreadActionMenuState { readonly snoozePresets: ReadonlyArray; /** Guest links need their own grant, so the caller checks it instead of `canOperate`. */ readonly canShareWithGuest?: boolean; + /** A No project thread with its own folder, on a server that can promote it. */ + readonly canTurnIntoProject?: boolean; } /** Local navigation, read markers, and copying remain available to read-only clients. */ @@ -183,6 +186,16 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.canTurnIntoProject + ? [ + { + id: "turn-into-project" as const, + label: "Turn into project", + icon: "folder-plus", + disabled: state.isRunning, + }, + ] + : []), ...(state.supports.autoSettleOptOut ? [ { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 56e85545e6f1e..eece920cba664 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -124,6 +124,16 @@ const ICON_PATHS: Record ({ }), })); vi.mock("./useThreadActions", () => ({ + canTurnThreadIntoProject: () => false, useThreadActions: () => Object.fromEntries( [ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index b7bec9d5e988d..0e25cebeef878 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -45,7 +45,7 @@ import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; -import { useThreadActions } from "./useThreadActions"; +import { canTurnThreadIntoProject, useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) { toastManager.add( @@ -84,6 +84,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, markThreadUnread, + turnThreadIntoProject, } = useThreadActions(); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -144,6 +145,7 @@ export function useThreadActionMenu(input: { supports, snoozePresets, canShareWithGuest, + canTurnIntoProject: canTurnThreadIntoProject(threadRef), }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -254,6 +256,19 @@ export function useThreadActionMenu(input: { case "share-guest": openGuestShareDialog(threadRef); return; + case "turn-into-project": { + const result = await turnThreadIntoProject(threadRef); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Project created", + description: thread.title, + }); + } else if (!isAtomCommandInterrupted(result)) { + failureToast("Could not turn thread into a project", squashAtomCommandFailure(result)); + } + return; + } case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 211dc1bef67ef..32020316496ed 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -29,6 +29,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { orchestrationEnvironment } from "../state/orchestration"; +import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -228,6 +229,20 @@ export function useAcknowledgeThreadWoke() { }; } +/** A No project thread that already has its own folder, on a server that can promote it. */ +export function canTurnThreadIntoProject(threadRef: ScopedThreadRef): boolean { + const thread = readThreadShell(threadRef); + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(threadRef.environmentId); + if (!thread?.worktreePath || config?.environment.capabilities.threadProjectFromScratch !== true) { + return false; + } + const project = readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + return project !== null && isScratchProject(project, config.scratchWorkspaceRoot); +} + function threadOperationFailure(target: ScopedThreadRef) { return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) ? null @@ -279,6 +294,9 @@ export function useThreadActions() { reportFailure: false, }); const markThreadUnread = useMarkThreadUnread(); + const createProjectFromThread = useAtomCommand(projectEnvironment.createFromThread, { + reportFailure: false, + }); const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -925,5 +943,10 @@ export function useThreadActions() { reorderActiveThread, markThreadUnread, setThreadAutoSettle, + turnThreadIntoProject: (target: ScopedThreadRef) => + createProjectFromThread({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }), }; } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ce16b9df0374b..dfc7b1245ef21 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -37,6 +37,12 @@ folder, so the files the agent wrote stay until you delete them. Branch, worktre these folders are not Git repositories. This is unavailable when the data directory itself sits inside a Git checkout. +When the work grows into something real, pick **Turn into project** from the +thread's menu. The thread's folder becomes a project where it is, so nothing +moves and the agent keeps working in the same place. Other threads in that folder +move with it. Rename the project in its settings, and use **Initialize Git** to +get branch and diff controls. + ### Start in the background In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index ede83d82957a8..03db9b869cc06 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -201,6 +201,12 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + createFromThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-from-thread", + tag: WS_METHODS.projectsCreateFromThread, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 3cd80feea6b73..f9564b2e2441c 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -33,6 +33,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, // Signing in replaces the server's GitHub credential, as saving a token in Settings does. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a65a91e0d3921..fbbffeddc924d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -110,6 +110,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinReorder: Schema.optionalKey(Schema.Boolean), threadActiveReorder: Schema.optionalKey(Schema.Boolean), threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), + threadProjectFromScratch: Schema.optionalKey(Schema.Boolean), threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), threadVisitedTracking: Schema.optionalKey(Schema.Boolean), threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 498f81a0e848a..747d05e7ce4eb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3335,6 +3335,13 @@ const OrchestrationV2InternalCommand = Schema.Union([ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), }), + /** Moves a thread to a project rooted at the folder it already works in, so its cwd never changes. */ + Schema.Struct({ + type: Schema.Literal("thread.project.adopt-folder"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + }), Schema.Struct({ type: Schema.Literal("thread.pull-request-watch.sync"), commandId: CommandId, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 12934a7f8ed70..79e164b71e93b 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -10,6 +10,7 @@ import { NonNegativeInt, PositiveInt, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, type UnknownUnionMember, @@ -527,6 +528,16 @@ export const ProjectCreateNewResult = Schema.Struct({ }); export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; +export const ProjectCreateFromThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ProjectCreateFromThreadInput = typeof ProjectCreateFromThreadInput.Type; + +export const ProjectCreateFromThreadResult = Schema.Struct({ + projectId: ProjectId, +}); +export type ProjectCreateFromThreadResult = typeof ProjectCreateFromThreadResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 658f6276e247d..aae86bd9db465 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -230,6 +230,8 @@ import { import { ProjectCreateNewInput, ProjectCreateNewResult, + ProjectCreateFromThreadInput, + ProjectCreateFromThreadResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -405,6 +407,7 @@ export const WS_METHODS = { projectsMutate: "projects.mutate", projectsEnsureScratch: "projects.ensureScratch", projectsCreateNew: "projects.createNew", + projectsCreateFromThread: "projects.createFromThread", shellOpenInEditor: "shell.openInEditor", @@ -1313,6 +1316,12 @@ const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +const WsProjectsCreateFromThreadRpc = Rpc.make(WS_METHODS.projectsCreateFromThread, { + payload: ProjectCreateFromThreadInput, + success: ProjectCreateFromThreadResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -2043,6 +2052,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, WsProjectsCreateNewRpc, + WsProjectsCreateFromThreadRpc, WsProjectsWriteFileRpc, WsProjectsMutateRpc, WsShellOpenInEditorRpc, From 4432fbef71817c279290f86549be660af25005f2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 03:10:52 -0700 Subject: [PATCH 039/144] feat(antigravity): Claude and GPT models, thinking levels, and diff edits (#26876) Co-authored-by: Claude Opus 5.5 --- .../Adapters/AntigravityAdapterV2.test.ts | 80 ++++++------------- .../Adapters/AntigravityAdapterV2.ts | 16 +--- .../src/provider/AntigravityInstallation.ts | 1 - .../src/provider/AntigravityProvider.test.ts | 44 ++++++++++ .../src/provider/AntigravityProvider.ts | 45 ++++++++++- .../src/provider/Drivers/AntigravityDriver.ts | 17 +++- .../acp/AntigravityAcpSupport.test.ts | 36 ++++++++- .../src/provider/acp/AntigravityAcpSupport.ts | 50 ++++++++++-- .../provider/acp/AntigravityClientFiles.ts | 19 ----- .../provider/acp/AntigravityProtocol.test.ts | 7 +- .../src/provider/acp/AntigravityProtocol.ts | 12 ++- .../server/src/provider/antigravityRelease.ts | 63 ++++++++------- apps/server/src/provider/model-manifest.json | 8 +- .../provider/providerCompatibility.test.ts | 18 +++++ .../AntigravityTextGeneration.test.ts | 1 + .../AntigravityTextGeneration.ts | 1 + docs/user/providers-antigravity.md | 12 ++- packages/provider-acp/src/server/adapter.ts | 17 ++-- 18 files changed, 298 insertions(+), 149 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 4876465a960d3..f69e3696fc45c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -127,7 +127,7 @@ const layerSession = Layer.mergeAll( ); describe("AntigravityAdapterV2 client file system", () => { - it.effect("confines agent file requests to the workspace under full access", () => + it.effect("serves confined reads and leaves edits to the agent's diff tools", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -140,6 +140,8 @@ describe("AntigravityAdapterV2 client file system", () => { // effect-acp keeps the last handler registered per method; so does this. let readTextFile: Parameters[0] | undefined; let writeTextFile: Parameters[0] | undefined; + const logs = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-antigravity-log-" }); + const requestLog = path.join(logs, "requests.ndjson"); const crypto = yield* Crypto.Crypto; const instanceId = ProviderInstanceId.make("antigravity-containment-test"); const adapter = yield* makeAntigravityAdapterV2({ @@ -153,7 +155,7 @@ describe("AntigravityAdapterV2 client file system", () => { command: process.execPath, args: [mockAgentPath], cwd: input.cwd, - env: { T3_ACP_ANTIGRAVITY: "1" }, + env: { T3_ACP_ANTIGRAVITY: "1", T3_ACP_REQUEST_LOG_PATH: requestLog }, }, }).pipe( Effect.provideService(Crypto.Crypto, crypto), @@ -198,17 +200,31 @@ describe("AntigravityAdapterV2 client file system", () => { runtimePolicy, }); yield* session.ensureThread({ threadId, modelSelection, runtimePolicy }); - if (readTextFile === undefined || writeTextFile === undefined) { - return yield* Effect.die("Antigravity sessions must serve client file requests"); + const initialize = (yield* fileSystem.readFileString(requestLog)) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + method?: string; + params?: { clientCapabilities?: { fs?: unknown }; clientInfo?: { name?: string } }; + }, + ) + .find((request) => request.method === "initialize"); + assert.deepEqual(initialize?.params?.clientCapabilities?.fs, { + readTextFile: true, + writeTextFile: false, + }); + assert.equal(initialize?.params?.clientInfo?.name, "zed"); + assert.isUndefined(writeTextFile); + if (readTextFile === undefined) { + return yield* Effect.die("Antigravity sessions must serve client file reads"); } const context = (method: string) => ({ requestId: `test-${method}`, method }); const insidePath = path.join(workspace, "src", "inside.ts"); - yield* writeTextFile( - { sessionId: "mock-session-1", path: insidePath, content: "inside" }, - context("fs/write_text_file"), - ); - assert.equal(yield* fileSystem.readFileString(insidePath), "inside"); + yield* fileSystem.makeDirectory(path.dirname(insidePath), { recursive: true }); + yield* fileSystem.writeFileString(insidePath, "inside"); const pasted = yield* readTextFile( { sessionId: "mock-session-1", path: attachment }, context("fs/read_text_file"), @@ -220,13 +236,6 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/read_text_file"), ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(outsideRead)); - const outsideWrite = yield* writeTextFile( - { sessionId: "mock-session-1", path: path.join(outside, "planted.txt"), content: "x" }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(outsideWrite)); - assert.isFalse(yield* fileSystem.exists(path.join(outside, "planted.txt"))); - const linkPath = path.join(workspace, "linked-secret.txt"); yield* fileSystem.symlink(outsideFile, linkPath); const linkedRead = yield* readTextFile( @@ -234,45 +243,6 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/read_text_file"), ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(linkedRead), "a read through an escaping symlink is denied"); - const linkedWrite = yield* writeTextFile( - { sessionId: "mock-session-1", path: linkPath, content: "overwritten" }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(linkedWrite), "a write through an escaping symlink is denied"); - assert.equal(yield* fileSystem.readFileString(outsideFile), "secret"); - const plantedTarget = path.join(outside, "created-through-link.txt"); - const danglingLink = path.join(workspace, "dangling.txt"); - yield* fileSystem.symlink(plantedTarget, danglingLink); - const danglingWrite = yield* writeTextFile( - { sessionId: "mock-session-1", path: danglingLink, content: "planted" }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(danglingWrite), "a write through a dangling symlink is denied"); - assert.isFalse(yield* fileSystem.exists(plantedTarget)); - yield* fileSystem.symlink(outside, path.join(workspace, "linked-dir")); - const linkedDirWrite = yield* writeTextFile( - { - sessionId: "mock-session-1", - path: path.join(workspace, "linked-dir", "new.txt"), - content: "planted", - }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(linkedDirWrite), "a write under an escaping directory link"); - assert.isFalse(yield* fileSystem.exists(path.join(outside, "new.txt"))); - const nestedLinkedDirWrite = yield* writeTextFile( - { - sessionId: "mock-session-1", - path: path.join(workspace, "linked-dir", "a", "b", "file.txt"), - content: "planted", - }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue( - Exit.isFailure(nestedLinkedDirWrite), - "a write to missing directories under an escaping directory link", - ); - assert.isFalse(yield* fileSystem.exists(path.join(outside, "a"))); const insideLink = path.join(workspace, "inside-link.ts"); yield* fileSystem.symlink(insidePath, insideLink); const viaInsideLink = yield* readTextFile( diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 2c41e007edf65..36c58a29ecaee 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -23,10 +23,7 @@ import { buildAntigravityPrompt, type AntigravityAcpRuntimeInput, } from "../../provider/acp/AntigravityAcpSupport.ts"; -import { - readAntigravityClientTextFile, - writeAntigravityClientTextFile, -} from "../../provider/acp/AntigravityClientFiles.ts"; +import { readAntigravityClientTextFile } from "../../provider/acp/AntigravityClientFiles.ts"; import { antigravityApprovalOptions, antigravitySubagentOutput, @@ -82,7 +79,7 @@ export interface AntigravityAdapterV2Options { readonly continuationRequests?: Parameters[0]["continuationRequests"]; } -/** ACP 1.1.1 reports `start_subagent` tools without child session ids. */ +/** Native subagent tools report no child session ids. */ const extractAntigravitySubagentUpdate: NonNullable = ( toolCall, ) => { @@ -126,7 +123,7 @@ export function makeAntigravityAcpAdapterFlavor( Scope.close(scope, Exit.void), options.makeRuntime({ ...input, - clientFileSystem: true, + clientFileReads: true, additionalDirectories: [options.host.paths.attachmentsDir], }), ) @@ -191,13 +188,6 @@ export function makeAntigravityAcpAdapterFlavor( allowedRoots: antigravityClientFileRoots(cwd), request, }), - writeTextFile: (request, cwd) => - writeAntigravityClientTextFile({ - fileSystem: options.fileSystem, - path: options.path, - allowedRoots: antigravityClientFileRoots(cwd), - request, - }), }, approvalOptions: antigravityApprovalOptions, extractPermissionQuestion: (request) => { diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 3d590abda0bdb..902e6508ca25d 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -398,7 +398,6 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma }), ) : spawner, - clientInfo: { name: "t3-code", version: "0.0.0" }, }); const initialized = yield* runtime.initialize(); if ( diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index ade033551f655..35f279f29ddcc 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -214,6 +214,50 @@ describe("Antigravity model catalog", () => { "gemini-pro-agent", ); }); + + it("offers each model the thinking levels it reported when selected", () => { + const thinking = (currentValue: string) => + ({ + id: "thinking_level", + name: "Thinking Level", + category: "thought_level", + type: "select", + currentValue, + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }) satisfies EffectAcpSchema.SessionConfigOption; + const models = buildAntigravityModelsFromSession( + { + configOptions: [ + { + ...modelConfig, + currentValue: "gemini", + options: [ + { value: "gemini", name: "Gemini" }, + { value: "claude", name: "Claude" }, + { value: "plain", name: "Plain" }, + { value: "unseen", name: "Unseen" }, + ], + }, + thinking("high"), + ], + }, + new Map([ + ["claude", thinking("low")], + ["plain", null], + ]), + ); + const thinkingOf = (slug: string) => + models.find((model) => model.slug === slug)?.capabilities?.optionDescriptors?.[0]; + + expect(thinkingOf("gemini")).toMatchObject({ id: "thinking_level", currentValue: "high" }); + expect(thinkingOf("claude")).toMatchObject({ id: "thinking_level", currentValue: "low" }); + expect(thinkingOf("plain")).toBeUndefined(); + expect(thinkingOf("unseen")).toMatchObject({ id: "thinking_level" }); + expect(thinkingOf("unseen")).not.toHaveProperty("currentValue"); + }); }); it.layer(layerTest)("Antigravity provider snapshots", (it) => { diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index c6809c3577132..988661a7ce614 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -20,6 +20,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { makeManualOnlyProviderMaintenanceCapabilities, @@ -32,6 +33,8 @@ import { } from "@t3tools/provider-core/server/snapshotProbe"; import { resolveUsageLimitsAfterProbe } from "@t3tools/provider-core/server/usageLimits"; +import { antigravityThinkingConfig } from "./acp/AntigravityAcpSupport.ts"; + const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); const MAX_WORKSPACE_SNAPSHOTS = 32; const HEALTH_CHECK_TIMEOUT = "90 seconds"; @@ -44,8 +47,24 @@ type SessionSetupResult = Pick< "configOptions" | "models" >; +type ThinkingConfig = NonNullable>; + +/** Thinking options each model reported when selected; `null` means it has none. */ +export type AntigravityThinkingLevels = ReadonlyMap; + +function thinkingCapabilities(config: ThinkingConfig | null | undefined, observed: boolean) { + const descriptor = config + ? acpProviderOptionDescriptors({ configOptions: [config], modeState: undefined })[0] + : undefined; + if (descriptor?.type !== "select") return EMPTY_MODEL_CAPABILITIES; + // Another model's selected level is not this model's default. + const { currentValue: _currentValue, ...unobserved } = descriptor; + return createModelCapabilities({ optionDescriptors: [observed ? descriptor : unobserved] }); +} + export function buildAntigravityModelsFromSession( setup: SessionSetupResult, + thinkingLevels: AntigravityThinkingLevels = new Map(), ): ReadonlyArray { const config = setup.configOptions?.find( (option) => option.id === "model" || option.category === "model", @@ -61,10 +80,13 @@ export function buildAntigravityModelsFromSession( name: model.name, })) ?? []) : []; + const currentThinking = antigravityThinkingConfig(setup.configOptions); const seen = new Set(); return entries.flatMap((entry): ServerProviderModel[] => { if (!entry.value.trim() || seen.has(entry.value)) return []; seen.add(entry.value); + const observed = + entry.value === currentValue ? (currentThinking ?? null) : thinkingLevels.get(entry.value); return [ { slug: entry.value, @@ -73,7 +95,10 @@ export function buildAntigravityModelsFromSession( ...(entry.value === currentValue ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } : {}), - capabilities: EMPTY_MODEL_CAPABILITIES, + capabilities: + observed === undefined + ? thinkingCapabilities(currentThinking, false) + : thinkingCapabilities(observed, true), }, ]; }); @@ -162,6 +187,17 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); // Preserve disk-discovered skills when callbacks replace workspace commands; the registry will not rescan them. const discoveredSkills = new Map(); + const thinkingLevels = new Map(); + const observeThinkingLevels = ( + configOptions: ReadonlyArray | null | undefined, + ) => { + const model = configOptions?.find( + (option) => option.id === "model" || option.category === "model", + ); + if (model?.type === "select" && model.currentValue) { + thinkingLevels.set(model.currentValue, antigravityThinkingConfig(configOptions) ?? null); + } + }; const getSnapshot = SubscriptionRef.get(metadata).pipe( Effect.flatMap((state) => options.stampIdentity(state.draft)), ); @@ -267,6 +303,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func cwd?: string, ) { if (!settings.enabled) return; + observeThinkingLevels(started.sessionSetupResult.configOptions); const before = yield* SubscriptionRef.get(metadata); const supportsTextGeneration = yield* options.supportsTextGeneration; const usageLimits = @@ -301,7 +338,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func published: draft.usageLimits, probed: usageLimits ?? draft.usageLimits, }), - models: buildAntigravityModelsFromSession(started.sessionSetupResult), + models: buildAntigravityModelsFromSession(started.sessionSetupResult, thinkingLevels), supportsTextGeneration, ...(cwd ? { @@ -324,7 +361,8 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( configOptions: ReadonlyArray, ) { - const models = buildAntigravityModelsFromSession({ configOptions }); + observeThinkingLevels(configOptions); + const models = buildAntigravityModelsFromSession({ configOptions }, thinkingLevels); yield* SubscriptionRef.update(metadata, (state) => { if (state.draft.auth.status !== "authenticated") return state; return { ...state, draft: { ...state.draft, models } }; @@ -391,6 +429,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }) satisfies AntigravityProviderState, ); discoveredSkills.clear(); + thinkingLevels.clear(); }); const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fd5725c6cd798..f1f39e104b44c 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -38,6 +38,8 @@ import { type AntigravityAuthConfig, } from "../antigravityAuthSupport.ts"; import { + antigravityModelOptions, + antigravityThinkingConfig, makeAntigravityAcpRuntime, type AntigravityAcpRuntimeInput, } from "../acp/AntigravityAcpSupport.ts"; @@ -294,7 +296,6 @@ export const AntigravityDriver: ProviderDriver< ); const runtime = yield* makeRuntime({ cwd, - clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, mcpServers: [], ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), }); @@ -313,7 +314,10 @@ export const AntigravityDriver: ProviderDriver< const publishCatalog = Effect.fn("AntigravityDriver.publishCatalog")(function* ( started: AcpSessionRuntime.AcpSessionRuntimeStartResult, - runtime: Pick, + runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getEvents" | "drainEvents" | "setModel" + >, ) { yield* provider.onSessionStarted(started); yield* Stream.runForEach(runtime.getEvents(), (event) => { @@ -327,6 +331,14 @@ export const AntigravityDriver: ProviderDriver< ? provider.onAvailableCommands(event.availableCommands) : Effect.void; }).pipe(Effect.forkScoped); + // Thinking levels differ per model and are reported only for the selected one. + const configOptions = started.sessionSetupResult.configOptions ?? []; + const current = configOptions.find((option) => option.id === "model")?.currentValue; + if (antigravityThinkingConfig(configOptions) !== undefined && typeof current === "string") { + for (const model of [...antigravityModelOptions(configOptions), { value: current }]) { + yield* runtime.setModel(model.value).pipe(Effect.ignore); + } + } yield* runtime.drainEvents; }, Effect.scoped); @@ -439,7 +451,6 @@ export const AntigravityDriver: ProviderDriver< makeRuntime: (cwd) => makeRuntime({ cwd, - clientInfo: { name: "t3-code-text", version: "0.0.0" }, mcpServers: [], }), }); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index bd48ee767ee46..8e0eed1371c5b 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -38,8 +38,18 @@ function makeModelRuntime( if (failure) return yield* failure; selections.push(model); }); + const setConfigOption = Effect.fn("AntigravityAcpSupportTest.setConfigOption")(function* ( + configId: string, + value: string | boolean, + options?: { readonly allowUnlistedValue?: boolean }, + ) { + selections.push( + `${configId}=${String(value)}${options?.allowUnlistedValue === true ? " (unlisted)" : ""}`, + ); + return { configOptions }; + }); return { - runtime: { getConfigOptions: Effect.succeed(configOptions), setModel }, + runtime: { getConfigOptions: Effect.succeed(configOptions), setModel, setConfigOption }, selections, }; } @@ -137,6 +147,30 @@ describe("applyAntigravityAcpModelSelection", () => { expect(selections).toEqual(["gemini-saved"]); }), ); + + it.effect("passes a model ID saved on runtime 1.3 to the agent as its 1.4 family", () => + Effect.gen(function* () { + // The cold-resume model has no thinking option, so the level is unknown locally. + const { runtime, selections } = makeModelRuntime([ + { + ...modelConfig, + currentValue: "gpt-oss-120b-medium", + options: [ + { value: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + { value: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)" }, + ], + }, + ]); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: "gemini-3.8-flash-low", + mapError: (cause) => cause, + }); + + expect(model).toBe("gemini-3.8-flash"); + expect(selections).toEqual(["model=gemini-3.8-flash-low (unlisted)"]); + }), + ); }); const imageAttachment = { diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index ac64c9da2a469..a57fc08f9b7af 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -31,6 +31,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< | "authenticateEagerly" | "cancelBehavior" | "clientCapabilities" + | "clientInfo" | "onStderr" | "resumeMethod" | "transformSessionUpdate" @@ -38,7 +39,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly onAuthorizationUrl?: (url: string) => Effect.Effect; - readonly clientFileSystem?: boolean; + readonly clientFileReads?: boolean; readonly authMethod?: AntigravityAuthMethod; } @@ -56,11 +57,11 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( authenticateEagerly: true, resumeMethod: "resume", cancelBehavior: "wait-for-prompt", + // The agent offers non-Gemini models only to allowlisted clients such as Zed. + clientInfo: { name: "zed", version: "0.0.0" }, clientCapabilities: { - fs: { - readTextFile: input.clientFileSystem === true, - writeTextFile: input.clientFileSystem === true, - }, + // Advertising writes swaps the agent's diff edits for whole-file client_edit_file. + fs: { readTextFile: input.clientFileReads === true, writeTextFile: false }, terminal: false, }, transformStdout: makeAntigravityStdoutTransform( @@ -91,7 +92,7 @@ export function antigravityPermissionMode(runtimeMode: RuntimeMode): string { } } -function antigravityModelOptions( +export function antigravityModelOptions( configOptions: ReadonlyArray, ) { const model = configOptions.find((option) => option.id === "model"); @@ -99,6 +100,28 @@ function antigravityModelOptions( return model.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)); } +/** Runtime 1.4 reports thinking as its own option; 1.3 encoded it in the model ID. */ +export function antigravityThinkingConfig( + configOptions: ReadonlyArray | null | undefined, +) { + const option = configOptions?.find((candidate) => candidate.category === "thought_level"); + return option?.type === "select" ? option : undefined; +} + +/** + * Runtime 1.4 lists `gemini-3.8-flash` where 1.3 listed `gemini-3.8-flash-high`. The agent + * still accepts the old IDs, so pass them through; the current model may have no thinking option. + */ +function legacyAntigravityFamily( + configOptions: ReadonlyArray, + model: string, +) { + return antigravityModelOptions(configOptions) + .map((option) => option.value) + .filter((family) => model.startsWith(`${family}-`)) + .toSorted((left, right) => right.length - left.length)[0]; +} + function resolveAntigravityModel(input: { readonly configOptions: ReadonlyArray; readonly model: string | null | undefined; @@ -108,7 +131,9 @@ function resolveAntigravityModel(input: { const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; if (input.model && input.model !== ANTIGRAVITY_DEFAULT_MODEL) return input.model; const options = antigravityModelOptions(input.configOptions); - return input.defaultModel && options.some((option) => option.value === input.defaultModel) + return input.defaultModel && + (options.some((option) => option.value === input.defaultModel) || + legacyAntigravityFamily(input.configOptions, input.defaultModel) !== undefined) ? input.defaultModel : current; } @@ -118,7 +143,7 @@ export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpM function* (input: { readonly runtime: Pick< AcpSessionRuntime.AcpSessionRuntime["Service"], - "getConfigOptions" | "setModel" + "getConfigOptions" | "setModel" | "setConfigOption" >; readonly model: string | null | undefined; readonly defaultModel?: string | undefined; @@ -135,6 +160,15 @@ export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpM const explicit = Boolean(input.model) && input.model !== ANTIGRAVITY_DEFAULT_MODEL; if (resolved === undefined || (!explicit && resolved === current)) return current; const options = antigravityModelOptions(configOptions); + const legacyFamily = options.some((option) => option.value === resolved) + ? undefined + : legacyAntigravityFamily(configOptions, resolved); + if (legacyFamily !== undefined) { + yield* input.runtime + .setConfigOption("model", resolved, { allowUnlistedValue: true }) + .pipe(Effect.mapError(input.mapError)); + return legacyFamily; + } if (!options.some((option) => option.value === resolved)) { return yield* Effect.fail( input.mapError( diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index 4efbc28a80ce2..c6079234d916c 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -5,7 +5,6 @@ import type * as Path from "effect/Path"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -/** The agent requests permission for every write; check only path containment here. */ const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024; function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean { @@ -98,21 +97,3 @@ export const readAntigravityClientTextFile = Effect.fn("AntigravityClientFiles.r return { content: lines.slice(start, end).join("\n") }; }, ); - -export const writeAntigravityClientTextFile = Effect.fn("AntigravityClientFiles.writeTextFile")( - function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly allowedRoots: ReadonlyArray; - readonly request: EffectAcpSchema.WriteTextFileRequest; - }): Effect.fn.Return { - const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path }); - yield* input.fileSystem.makeDirectory(input.path.dirname(filePath), { recursive: true }).pipe( - Effect.andThen(input.fileSystem.writeFileString(filePath, input.request.content)), - Effect.mapError(() => - EffectAcpErrors.AcpRequestError.internalError(`Could not write '${input.request.path}'.`), - ), - ); - return {}; - }, -); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 192c8b43ffb14..66c83e0c48249 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -24,7 +24,12 @@ const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); describe("native Antigravity subagent tools", () => { it("recognizes only native invocation titles and excludes MCP tools", () => { const toolCall = { toolCallId: "trajectory:4", kind: "other", data: {} }; - for (const title of ["Running start_subagent", "Run start_subagent?"]) { + for (const title of [ + "Running start_subagent", + "Run start_subagent?", + "Running invoke_subagent", + "Run invoke_subagent?", + ]) { expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBe("subagent"); expect( classifyAntigravitySubagentToolCall( diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 7e2f24a31d3b9..ac65db6373add 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -324,14 +324,22 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo }; } -/** ACP 1.1.1 reports subagent calls as ordinary tools without child IDs or models. */ +const ANTIGRAVITY_SUBAGENT_TITLES = new Set([ + "Running start_subagent", + "Run start_subagent?", + "Running invoke_subagent", + "Run invoke_subagent?", +]); + +/** The ACP agent reports subagent calls as ordinary tools without child IDs or models. */ export function classifyAntigravitySubagentToolCall( toolCall: AcpToolCallState, rawPayload?: unknown, ): "subagent" | "mcp" | undefined { if ( (toolCall.kind !== undefined && toolCall.kind !== "other") || - (toolCall.title !== "Running start_subagent" && toolCall.title !== "Run start_subagent?") + toolCall.title === undefined || + !ANTIGRAVITY_SUBAGENT_TITLES.has(toolCall.title) ) return undefined; const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index f00185865b05a..17173760d5e3d 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,4 +1,5 @@ -const ANTIGRAVITY_RELEASE_VERSION = "1.3.0"; +// 1.4.2 through 1.4.4 write skills/system/run-workflow/SKILL.md into every session workspace. +const ANTIGRAVITY_RELEASE_VERSION = "1.4.1"; export interface AntigravityReleaseAsset { readonly version: string; @@ -20,66 +21,66 @@ const releaseAssets = new Map([ "darwin-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.3.0-darwin-arm64.zip", - sha256: "7cd97045f7b4fe81175a107cdf16f9c51484e3c78a5162cae415338bb6aa5b88", - archiveBytes: 111_456_962, - executable: { name: "agy_acp_server.par", bytes: 278_535_456 }, - harness: { name: "localharness_external", bytes: 118_611_392 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.1-darwin-arm64.zip", + sha256: "f9bdd68291ae78c38d2141e63bc86b84e6cc7b9f60f71652c68435a7943deb17", + archiveBytes: 112_737_434, + executable: { name: "agy_acp_server.par", bytes: 280_996_576 }, + harness: { name: "localharness_external", bytes: 120_246_928 }, }, ], [ "darwin-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.3.0-darwin-x86_64.zip", - sha256: "bb23956b89984bf5d354af2c3725e6c57f0cc1b7228e77a0e91c9c2bc1d47646", - archiveBytes: 117_245_544, - executable: { name: "agy_acp_server.par", bytes: 282_840_688 }, - harness: { name: "localharness_external", bytes: 124_175_392 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.1-darwin-x86_64.zip", + sha256: "27e31f096874704f14167b07ef9e84cc77134615ee41d024f7b1c957add85307", + archiveBytes: 118_349_036, + executable: { name: "agy_acp_server.par", bytes: 285_186_624 }, + harness: { name: "localharness_external", bytes: 124_910_048 }, }, ], [ "linux-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.3.0-linux-x86_64.zip", - sha256: "9fb60956af0a9d76220a4db91ca9ac88e2a2372ad68f985ab5fceace6b825b96", - archiveBytes: 333_727_150, - executable: { name: "agy_acp_server.par", bytes: 926_533_965 }, - harness: { name: "localharness_external", bytes: 130_388_040 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.1-linux-x86_64.zip", + sha256: "2e37012ceb2a5c7fc7309b6e6596234fb637ee3c62a5199f65d4cbe777871384", + archiveBytes: 336_170_339, + executable: { name: "agy_acp_server.par", bytes: 930_826_753 }, + harness: { name: "localharness_external", bytes: 131_187_512 }, }, ], [ "linux-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.3.0-linux-arm64.zip", - sha256: "500b0bc0fb858e88f4df404d4cedf80bf9298c178291e39e383d6c50b111cbdf", - archiveBytes: 321_690_363, - executable: { name: "agy_acp_server.par", bytes: 930_848_992 }, - harness: { name: "localharness_external", bytes: 123_224_968 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.1-linux-arm64.zip", + sha256: "609336a1c97545487e3ddd55dbd153dc0f40f4e6534486cc1c5f4c0ebb85c2da", + archiveBytes: 321_922_507, + executable: { name: "agy_acp_server.par", bytes: 933_416_796 }, + harness: { name: "localharness_external", bytes: 124_895_000 }, }, ], [ "win32-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.3.0-windows-x86_64.zip", - sha256: "65215e0688681fa3116e048a9eab27ef53af1bbd6f3da3f1c52bd4911d8b17f9", - archiveBytes: 124_509_787, - executable: { name: "agy_acp_server.exe", bytes: 81_437_336 }, - harness: { name: "localharness_external.exe", bytes: 145_548_952 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.1-windows-x86_64.zip", + sha256: "5f5b7d0328df17c128b2be338d4567b0d24e56ace8196e88794fec37d1372554", + archiveBytes: 129_599_308, + executable: { name: "agy_acp_server.exe", bytes: 86_098_784 }, + harness: { name: "localharness_external.exe", bytes: 146_036_888 }, }, ], [ "win32-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.3.0-windows-arm64.zip", - sha256: "4a0f469720e9beb9438a979f543fdbfad5022ebe0992c052c590bd78b3144ca3", - archiveBytes: 124_654_803, - executable: { name: "agy_acp_server.exe", bytes: 85_893_472 }, - harness: { name: "localharness_external.exe", bytes: 135_640_216 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.1-windows-arm64.zip", + sha256: "24c52bbc12aae20f55eb53d373669412d95a9c44fd59be4ea4e355918b91fbf7", + archiveBytes: 132_507_344, + executable: { name: "agy_acp_server.exe", bytes: 93_197_744 }, + harness: { name: "localharness_external.exe", bytes: 136_970_904 }, }, ], ]); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index b42932fcc6668..87ed61776ea9c 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -75,8 +75,12 @@ { "driver": "antigravity", "t3CodeRange": ">=0.0.42", - "recommendedRange": "=1.1.1", - "ranges": [{ "range": "=1.1.1", "status": "supported" }] + "recommendedRange": "=1.4.1", + "ranges": [ + { "range": "=1.4.1", "status": "supported" }, + { "range": "=1.3.0", "status": "supported" }, + { "range": "=1.1.1", "status": "supported" } + ] }, { "driver": "pi", diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index e3d4f672016c0..12c541fbd776d 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -21,7 +21,12 @@ import { ProviderCompatibilityPolicy, resolveProviderCompatibility, } from "./providerCompatibility.ts"; +import { resolveAntigravityReleaseAsset } from "./antigravityRelease.ts"; +import bundledManifest from "./model-manifest.json" with { type: "json" }; +const decodeCompatibilityPolicies = Schema.decodeUnknownSync( + Schema.Array(ProviderCompatibilityPolicy), +); const driver = ProviderDriverKind.make("codex"); const policy: ProviderCompatibilityPolicy = { driver, @@ -127,6 +132,19 @@ describe("provider compatibility", () => { } }); + it("supports the managed Antigravity runtime in the bundled manifest", () => { + const policies = decodeCompatibilityPolicies(bundledManifest.compatibility); + const release = resolveAntigravityReleaseAsset("darwin", "arm64"); + assert.strictEqual( + resolveProviderCompatibility( + policies, + ProviderDriverKind.make("antigravity"), + `agy_acp_server_${release?.version}`, + )?.status, + "supported", + ); + }); + it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { for (const [version, expected] of [ ["0.9.9", "broken"], diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 395a24eeedc8d..3fcafcc151789 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -170,6 +170,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* Effect.sync(() => { state.selectedModels.push(model); }), + setConfigOption: () => Effect.succeed({ configOptions: [] }), prompt: (request) => Effect.gen(function* () { state.prompts.push(request); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 670c2eb28d513..921a2f118e34a 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -31,6 +31,7 @@ type AntigravityTextRuntime = Pick< | "getConfigOptions" | "getEvents" | "setModel" + | "setConfigOption" | "prompt" | "cancel" | "handleSessionUpdate" diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 0b1cd2f2c6da1..76bbc8266a941 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -68,9 +68,15 @@ Leave the field blank to use the managed runtime, or a compatible executable on ## Models and threads -The model list comes from your Antigravity account and can differ from other -Antigravity apps. A resumed thread keeps its selected model. If access to that -model ends, select another available model before continuing. +The model list comes from your Antigravity account, including Claude and GPT models +when your plan has them. Pick a thinking level in the composer's effort menu; it +shows only the levels the selected model supports. A resumed thread keeps its +selected model. If access to that model ends, select another available model +before continuing. + +Antigravity creates empty `.gemini/skills` and `.agents/skills` folders in the +workspace when a session starts. Git does not track empty folders, so they never +appear in commits. Use Antigravity's native `/plan` command for planning. T3 Code's separate Plan mode is unavailable. Tool approvals follow [Permission modes](./permission-modes.md). diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 1cdbd20f5508b..398eac2170e70 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -288,7 +288,7 @@ export interface AcpAdapterV2Flavor { request: EffectAcpSchema.ReadTextFileRequest, cwd: string | null, ) => Effect.Effect; - readonly writeTextFile: ( + readonly writeTextFile?: ( request: EffectAcpSchema.WriteTextFileRequest, cwd: string | null, ) => Effect.Effect; @@ -1993,7 +1993,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( clientCapabilities: { fs: { readTextFile: flavor.clientFileSystem !== undefined, - writeTextFile: flavor.clientFileSystem !== undefined, + writeTextFile: flavor.clientFileSystem?.writeTextFile !== undefined, }, terminal: clientTerminals !== undefined, elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, @@ -5958,11 +5958,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), ), ); - yield* targetRuntime.handleWriteTextFile((request) => - clientPolicyContext.pipe( - Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), - ), - ); + const writeTextFile = clientFileSystem.writeTextFile; + if (writeTextFile !== undefined) { + yield* targetRuntime.handleWriteTextFile((request) => + clientPolicyContext.pipe( + Effect.flatMap(({ policy }) => writeTextFile(request, policy.cwd)), + ), + ); + } } if (handlerOptions.mcp !== false) { yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); From d7935f135f50f3798c1e247ebe8b88587dd62adf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 03:28:56 -0700 Subject: [PATCH 040/144] feat(web): choose what the thread details card shows (#26904) Co-authored-by: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 19 +- apps/web/src/components/GitActionsControl.tsx | 25 +- .../chat/OpenInPicker.browser.test.tsx | 13 + .../components/chat/OpenInPickerShortcut.ts | 40 ++- .../chat/ThreadAutomationsPanel.tsx | 12 +- .../chat/ThreadDetailsCard.browser.test.tsx | 26 +- .../src/components/chat/ThreadDetailsCard.tsx | 16 +- .../chat/ThreadDetailsItemsMenu.tsx | 45 +++ .../components/chat/ThreadDetailsPanel.tsx | 262 ++++++++++++------ .../components/chat/ThreadDetailsSection.tsx | 7 +- .../components/chat/ThreadDetailsUsage.tsx | 2 +- .../chat/threadDetailsItems.test.ts | 35 +++ .../src/components/chat/threadDetailsItems.ts | 60 ++++ .../settings/AppearanceSettingsPanel.tsx | 2 + .../components/settings/LabsSettingsPanel.tsx | 21 -- .../settings/ThreadDetailsItemsRow.tsx | 53 ++++ .../src/components/settings/settingsSearch.ts | 14 +- docs/user/thread-sidebar.md | 5 + docs/user/usage.md | 2 +- packages/contracts/src/settings.ts | 7 + 21 files changed, 514 insertions(+), 153 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx create mode 100644 apps/web/src/components/chat/threadDetailsItems.test.ts create mode 100644 apps/web/src/components/chat/threadDetailsItems.ts create mode 100644 apps/web/src/components/settings/ThreadDetailsItemsRow.tsx diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c80498c14c1fb..ec5062f9105bf 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -65,6 +65,7 @@ const clientSettings: ClientSettings = { boringSidebarEnabled: false, boringSidebarMultilineRows: false, threadDetailsUsageEnabled: false, + threadDetailsItems: {}, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b9104836b62ee..de76f47844bfa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1228,9 +1228,6 @@ export default function ChatView(props: ChatViewProps) { const canChangeEnvironment = hasMultipleEnvironments && !envLocked && draftId !== null && !draftThread?.promotedTo; - const threadDetailsUsageEnabled = useClientSettings( - (settings) => settings.threadDetailsUsageEnabled, - ); const loadBalancingSettings = useClientSettings((settings) => ({ loadBalancingEnabled: settings.loadBalancingEnabled, loadBalancingWeights: settings.loadBalancingWeights, @@ -3773,16 +3770,12 @@ export default function ChatView(props: ChatViewProps) { onAddProjectScript={saveProjectScriptStable} onUpdateProjectScript={updateProjectScriptStable} onDeleteProjectScript={deleteProjectScriptStable} - usage={ - threadDetailsUsageEnabled - ? { - providerInstanceId: activeProviderInstanceId, - providers: providerStatuses, - sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, - contextWindow: activeContextWindow, - } - : undefined - } + usage={{ + providerInstanceId: activeProviderInstanceId, + providers: providerStatuses, + sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, + contextWindow: activeContextWindow, + }} /> void; } @@ -926,6 +928,8 @@ export default function GitActionsControl({ activeThreadRef, draftId, compact = false, + showActions = true, + showChanges = true, onOpenChanges, }: GitActionsControlProps) { const panelAnchorRef = useRef(null); @@ -1476,7 +1480,7 @@ export default function GitActionsControl({ return ( <> - {!isRepo ? ( + {!showActions ? null : !isRepo ? ( )} - {isRepo && !quietEmptyDraft ? ( + {showChanges && isRepo && !quietEmptyDraft ? ( - + {/* Its discovery query should stop when the Git actions row that opens it is hidden. */} + {showActions ? ( + + ) : null} { }); }); +describe("remote editor links", () => { + it("opens the link from the fallback when the picker is not mounted", async () => { + await using fixture = await setup(); + const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture; + state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }; + await act(async () => screen.rerender()); + + expect((await pressOpenFavorite()).defaultPrevented).toBe(true); + expect(state.openUrl).toHaveBeenCalledWith(expect.stringContaining("ssh-remote+sol")); + expect(state.run).not.toHaveBeenCalled(); + }); +}); + describe("open in editor announcement", () => { it("speaks only after the shortcut opened the editor, from the picker or the fallback", async () => { await using fixture = await setup(); diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 8c8249c67b1a0..833c3f3784987 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -1,11 +1,16 @@ -import type { EditorId, EnvironmentId, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + type EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { useEffect } from "react"; import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; import { announce } from "../../lib/announce"; -import { useRemoteOpenState } from "../../remoteOpen"; +import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenState } from "../../remoteOpen"; import { registerCommands } from "../../shortcuts/dispatcher"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -49,18 +54,39 @@ export function useOpenFavoriteEditorShortcut({ openInCwd: string | null; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const [preferredEditor] = usePreferredEditor(availableEditors); const remote = useRemoteOpenState(environmentId); + const remoteCapableEditors = useRemoteCapableEditors(); + // Matches the picker: remote editors run on the viewing machine. + const [preferredEditor] = usePreferredEditor( + remote.mode === "local-exec" ? availableEditors : remoteCapableEditors, + ); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled locally + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled useEffect(() => { - if (!enabled || remote.mode !== "local-exec") return; + if (!enabled || remote.mode === "remote-unavailable") return; const handler = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || !isOpenFavoriteEditorShortcut(event, keybindings)) return; + if ( + event.repeat || + event.defaultPrevented || + !isOpenFavoriteEditorShortcut(event, keybindings) + ) + return; if (!openInCwd || !preferredEditor) return; event.preventDefault(); const label = editorLabelForPlatform(preferredEditor, navigator.platform); + if (remote.mode === "remote-links") { + const url = buildRemoteOpenUrl({ + editor: preferredEditor, + ...remote.host, + absolutePath: openInCwd, + }); + if (url === undefined) return; + void openRemoteEditorUrl(url).then((opened) => { + if (opened) announce(`Opened in ${label}`); + }); + return; + } void openInEditorMutation({ environmentId, input: { @@ -80,6 +106,6 @@ export function useOpenFavoriteEditorShortcut({ openInCwd, openInEditorMutation, preferredEditor, - remote.mode, + remote, ]); } diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 20ecde3272276..ca2497bd20ee8 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -153,12 +153,6 @@ export function ThreadAutomationsPanel(props: {

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 Q_aFcO00000000000Hjpk9RL6T literal 268254 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 z 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 r3l$6=lw*yN$RZ6BYt7G98TVvLXu7J;>OP Date: Thu, 29 Oct 2026 02:17:51 -0700 Subject: [PATCH 037/144] fix(web): undo notice moves to the sidebar footer so it never covers a thread (#26874) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 9 +--- .../src/components/sidebar/SidebarChrome.tsx | 8 +--- .../components/sidebar/SidebarListMarkers.tsx | 6 --- .../sidebar/SidebarThreadUndoNotice.tsx | 46 +++++++++++-------- apps/web/src/hooks/showThreadUndoNotice.ts | 2 +- 5 files changed, 31 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c4ba300a030c3..62934576e365b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3371,7 +3371,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve className="mt-auto" label="Working" count={workingThreads.length} - hostsUndoNotice toggle={{ expanded: workingShelfExpanded, onToggle: toggleWorkingShelf, @@ -3387,7 +3386,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve className={cn(workingThreads.length === 0 && "mt-auto")} label="Snoozed" count={snoozedThreads.length} - hostsUndoNotice={workingThreads.length === 0} toggle={{ expanded: snoozedShelfExpanded, onToggle: toggleSnoozedShelf, @@ -3405,9 +3403,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve )} label="Settled" count={settledCount} - hostsUndoNotice={ - workingThreads.length + snoozedThreads.length === 0 - } dragging={from !== null} isDropTarget={dragTargetSection === "settled"} toggle={{ @@ -3502,9 +3497,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : null} - + ); } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ec161e9998548..e233a4144d435 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -223,19 +223,15 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { /> )} + ); }); -export const SidebarChromeFooter = memo(function SidebarChromeFooter({ - showUndoNotice = false, -}: { - showUndoNotice?: boolean; -}) { +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - {showUndoNotice ? : null} diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 3a78876fa5832..104bda5582b4d 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -9,7 +9,6 @@ import { type SidebarListMarker, } from "../Sidebar.logic"; import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; -import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; function SortableSidebarMarker(props: { marker: SidebarListMarker; @@ -117,8 +116,6 @@ export function SidebarSectionHeader(props: { dragging?: boolean; isDropTarget?: boolean; toggle: { expanded: boolean; onToggle: () => void }; - /** The first shelf at the bottom of the list hosts the undo notice just above itself. */ - hostsUndoNotice?: boolean; }) { const shelf = props.marker === "working-header" @@ -132,9 +129,6 @@ export function SidebarSectionHeader(props: { data-testid={`sidebar-${props.marker}`} className={cn("relative mx-0.5 h-8", props.className)} > - {props.hostsUndoNotice ? ( - - ) : null} state.notice); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const summary = notice ? threadUndoNoticeSummary(notice) : null; + // Short enough to fit beside the utility icons; screen readers hear the full summary. + const summary = notice + ? notice.count === 1 + ? notice.action + : `${notice.action} ${notice.count}` + : null; // Keeps the last text on screen while it fades out, then unmounts on transitionend. const [shownSummary, setShownSummary] = useState(summary); @@ -23,21 +29,23 @@ export function SidebarThreadUndoNotice({ className }: { className?: string }) { if (!text) return null; return ( -

} > - {tasksQuery.error !== null ? ( -

- Could not load automations: {tasksQuery.error} -

- ) : null} -
    {boundTasks.map((task) => (
  • ))}
+ {/* Kept last: the details card treats a section whose last child is empty as empty. */} + {tasksQuery.error !== null ? ( +

+ Could not load automations: {tasksQuery.error} +

+ ) : null} ); } diff --git a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx index 37110875ed0ea..a8728d7879dd4 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx @@ -42,10 +42,13 @@ function Card({ threadRef, inline, fixture, + rows = 1, }: { threadRef: ScopedThreadRef; inline: boolean; fixture: ReturnType; + /** Rows of 100px shown at full density. Compact always takes 50px. */ + rows?: number; }) { return ( // The web root renders in StrictMode, which re-runs ref callbacks on every mount. @@ -63,8 +66,17 @@ function Card({ detailsCardPlacement: inline ? placement : null, }} > - - {() =>

Details for {threadRef.threadId}

} + + {(density) => ( +

+ Details for {threadRef.threadId} +

+ )}
@@ -136,3 +148,13 @@ it("reopens after a normal close", async () => { await setOpen(true); await expect.element(page.getByText("Details for thread-b")).toBeVisible(); }); + +it("returns to full density when hidden items make the content fit", async () => { + using fixture = setup(); + await using screen = await render(); + const card = page.getByRole("region", { name: "Thread details" }); + await expect.element(card).toHaveAttribute("data-density", "compact"); + + await screen.rerender(); + await expect.element(card).toHaveAttribute("data-density", "full"); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 4add56b80eb08..a5df136f14615 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -1,5 +1,11 @@ import { workspaceAreaProps } from "../../lib/workspaceFocus"; -import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { + useLayoutEffect, + useState, + type MouseEventHandler, + type ReactNode, + type RefObject, +} from "react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { ScrollArea } from "../ui/scroll-area"; import { cn } from "../../lib/utils"; @@ -17,11 +23,16 @@ export function ThreadDetailsCard({ threadRef, anchor, handle, + contentKey, + onContextMenu, children, }: { threadRef: ScopedThreadRef; anchor: RefObject; handle: ReturnType; + /** Changes when the set of rendered items changes, so cached heights are measured again. */ + contentKey: string; + onContextMenu?: MouseEventHandler; children: (density: "full" | "compact" | "essential") => ReactNode; }) { const detailsArea = workspaceAreaProps("details", 3, "Thread details"); @@ -36,7 +47,7 @@ export function ThreadDetailsCard({ const popoverOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"), ); - const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}`; + const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}:${contentKey}`; const [measurements, setMeasurements] = useState({ key: measurementKey, heights: { full: 0, compact: 0 }, @@ -109,6 +120,7 @@ export function ThreadDetailsCard({ "max-h-[min(calc(100dvh-6.5rem),calc(var(--available-height,100dvh)-1rem))]", )} style={placement ? { maxHeight: height } : undefined} + onContextMenu={onContextMenu} data-thread-details-card > diff --git a/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx new file mode 100644 index 0000000000000..d84c13809eaf6 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx @@ -0,0 +1,45 @@ +import { + getClientSettings, + updateClientSettings, + useClientSettings, +} from "../../hooks/useSettings"; +import { MenuCheckboxItem, MenuGroup, MenuGroupLabel, MenuItem, MenuSeparator } from "../ui/menu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + setThreadDetailsItem, + THREAD_DETAILS_ITEMS, +} from "./threadDetailsItems"; + +/** Menu body that shows and hides thread details items. The card's context menu and Settings share it. */ +export function ThreadDetailsItemsMenuItems() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + return ( + <> + + Show in thread details + {THREAD_DETAILS_ITEMS.map((item) => ( + + updateClientSettings({ + threadDetailsItems: setThreadDetailsItem(getClientSettings(), item, checked), + }) + } + > + {item.label} + + ))} + + + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + > + Reset to default + + + ); +} diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 77a6d15763904..35a954db9d43e 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -16,7 +16,7 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { memo, type ComponentProps } from "react"; +import { memo, useState, type ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; @@ -24,6 +24,17 @@ import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; import { ThreadServersPanel } from "./ThreadServersPanel"; import { ThreadDetailsUsage, type ThreadDetailsUsageInput } from "./ThreadDetailsUsage"; +import { ThreadDetailsItemsMenuItems } from "./ThreadDetailsItemsMenu"; +import { resolveThreadDetailsVisibility, THREAD_DETAILS_ITEMS } from "./threadDetailsItems"; +import { useClientSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { SlidersHorizontalIcon } from "lucide-react"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, +} from "./threadDetailsPanelStyles"; +import { Menu, MenuPopup } from "../ui/menu"; const GitActionsControl = memo(BaseGitActionsControl); @@ -68,14 +79,15 @@ interface ThreadDetailsPanelProps extends Pick< input: NewProjectScriptInput, ) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; - /** Set only while the Labs usage readout is on. */ - usage?: ThreadDetailsUsageInput | undefined; + usage: ThreadDetailsUsageInput; } export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { + const visible = useClientSettings(resolveThreadDetailsVisibility); + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); const fileScripts = useT3ProjectFileScripts( props.environmentId, - props.activeProjectScripts ? props.gitCwd : null, + props.activeProjectScripts && visible.scripts ? props.gitCwd : null, ); const activeThreadRef = { environmentId: props.environmentId, threadId: props.threadId }; const branchToolbarProps = { @@ -109,99 +121,181 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} + contentKey={THREAD_DETAILS_ITEMS.filter((item) => visible[item.id]) + .map((item) => item.id) + .join(",")} + onContextMenu={(event) => { + // Popups the card opens bubble here through React; they, fields, links, and selected text + // keep their own menus. + const target = event.target; + const selection = window.getSelection(); + if ( + !(target instanceof Element) || + !event.currentTarget.contains(target) || + target.closest("input, textarea, [contenteditable], a[href]") || + (selection && + !selection.isCollapsed && + (event.currentTarget.contains(selection.anchorNode) || + event.currentTarget.contains(selection.focusNode))) + ) + return; + event.preventDefault(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + }} > - {(density) => ( - <> - -
- {density === "full" ? ( - + {(density) => { + // Section flags follow the rows that will render, so no empty section steals the divider rule. + const show = { + environment: density === "full" && visible.environment, + openIn: density !== "essential" && visible.openIn && props.showOpenInPicker, + scripts: visible.scripts && props.activeProjectScripts !== undefined, + branch: props.gitCwd !== null && props.isGitRepo && visible.branch, + git: + props.gitCwd !== null && + props.activeProjectName !== undefined && + (visible.gitActions || (visible.changes && props.isGitRepo)), + }; + return ( + <> +
+ {show.environment || show.openIn || show.scripts ? ( + +
+ {show.environment ? ( + + ) : null} + + {show.openIn ? ( + + ) : null} + + {show.scripts && props.activeProjectScripts ? ( + + ) : null} +
+
+ ) : null} + + {props.gitCwd && (show.branch || show.git) ? ( + +
+ {show.branch ? ( + + ) : null} + {show.git ? ( + + ) : null} +
+
+ ) : null} + + {density === "full" && !props.draftId && visible.servers ? ( + ) : null} - {density !== "essential" && props.showOpenInPicker ? ( - ) : null} - {props.activeProjectScripts ? ( - ) : null} -
- - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} -
-
- ) : null} - - {density === "full" && !props.draftId ? ( - - ) : null} - - {density === "full" && !props.draftId ? ( - - ) : null} + {visible.usage ? : null} - {density === "full" && !props.draftId ? ( - - ) : null} + {/* Shown only when no section above has content, so an emptied card can be refilled. */} +
+ { + const rect = event.currentTarget.getBoundingClientRect(); + setMenuPosition({ x: rect.left, y: rect.bottom }); + }} + > + + + Choose what to show + + +
+
- {props.usage ? : null} - - )} + !open && setMenuPosition(null)} + > + + new DOMRect(menuPosition.x, menuPosition.y, 0, 0), + } + : undefined + } + align="start" + side="bottom" + > + + + + + ); + }}
); } diff --git a/apps/web/src/components/chat/ThreadDetailsSection.tsx b/apps/web/src/components/chat/ThreadDetailsSection.tsx index 00aacd5a7b152..de5ce6c63825d 100644 --- a/apps/web/src/components/chat/ThreadDetailsSection.tsx +++ b/apps/web/src/components/chat/ThreadDetailsSection.tsx @@ -22,7 +22,12 @@ export function ThreadDetailsSection({ {...props} aria-labelledby={showHeading ? headingId : undefined} aria-label={showHeading ? undefined : title} - className={cn("px-2 pt-2 pb-2.5", separated && "border-t border-border/65")} + // A row can still render nothing (a fresh draft quiets Git actions), so an empty section + // collapses, and a divider only follows a section that has content. + className={cn( + "px-2 pt-2 pb-2.5 has-[>div:last-child:empty]:hidden", + separated && "border-border/65 [section:not(:has(>div:last-child:empty))~&]:border-t", + )} >
item.id === "usage")!; +const servers = THREAD_DETAILS_ITEMS.find((item) => item.id === "servers")!; + +describe("thread details items", () => { + it.each([ + { legacy: false, items: {}, usage: false }, + { legacy: true, items: {}, usage: true }, + { legacy: true, items: { usage: false }, usage: false }, + { legacy: false, items: { usage: true, retired: false }, usage: true }, + ])("resolves Usage from overrides and the retired Labs flag: %o", (row) => { + const visibility = resolveThreadDetailsVisibility({ + threadDetailsItems: row.items, + threadDetailsUsageEnabled: row.legacy, + }); + expect(visibility.usage).toBe(row.usage); + expect(visibility.servers).toBe(true); + }); + + it("stores only choices that differ from the default", () => { + const legacyOn = { threadDetailsItems: {}, threadDetailsUsageEnabled: true }; + expect(setThreadDetailsItem(legacyOn, usage, false)).toEqual({ usage: false }); + expect(setThreadDetailsItem(legacyOn, usage, true)).toEqual({}); + + const hidden = { threadDetailsItems: { servers: false }, threadDetailsUsageEnabled: false }; + expect(setThreadDetailsItem(hidden, servers, true)).toEqual({}); + }); +}); diff --git a/apps/web/src/components/chat/threadDetailsItems.ts b/apps/web/src/components/chat/threadDetailsItems.ts new file mode 100644 index 0000000000000..7502a4c0b1dce --- /dev/null +++ b/apps/web/src/components/chat/threadDetailsItems.ts @@ -0,0 +1,60 @@ +import type { ClientSettings } from "@t3tools/contracts"; + +// Ids are stored in client settings, so never rename one. +export const THREAD_DETAILS_ITEMS = [ + { id: "environment", label: "Environment and worktree", defaultVisible: true }, + { id: "openIn", label: "Open in editor", defaultVisible: true }, + { id: "scripts", label: "Project scripts", defaultVisible: true }, + { id: "branch", label: "Branch and pull request", defaultVisible: true }, + { id: "gitActions", label: "Git actions", defaultVisible: true }, + { id: "changes", label: "Changes", defaultVisible: true }, + { id: "servers", label: "Servers", defaultVisible: true }, + { id: "automations", label: "Automations", defaultVisible: true }, + { id: "lineage", label: "Lineage", defaultVisible: true }, + { id: "usage", label: "Usage", defaultVisible: false }, +] as const; + +export type ThreadDetailsItemId = (typeof THREAD_DETAILS_ITEMS)[number]["id"]; +export type ThreadDetailsVisibility = Readonly>; + +type ThreadDetailsItem = (typeof THREAD_DETAILS_ITEMS)[number]; +type ThreadDetailsItemSettings = Pick< + ClientSettings, + "threadDetailsItems" | "threadDetailsUsageEnabled" +>; + +// People who turned on the retired Labs toggle keep Usage until they change it here. +function defaultVisible(item: ThreadDetailsItem, settings: ThreadDetailsItemSettings) { + return item.id === "usage" ? settings.threadDetailsUsageEnabled : item.defaultVisible; +} + +export function resolveThreadDetailsVisibility( + settings: ThreadDetailsItemSettings, +): ThreadDetailsVisibility { + return Object.fromEntries( + THREAD_DETAILS_ITEMS.map((item) => [ + item.id, + settings.threadDetailsItems[item.id] ?? defaultVisible(item, settings), + ]), + ) as Record; +} + +/** Stores only choices that differ from the default, so a later default change reaches everyone else. */ +export function setThreadDetailsItem( + settings: ThreadDetailsItemSettings, + item: ThreadDetailsItem, + visible: boolean, +): ClientSettings["threadDetailsItems"] { + const { [item.id]: _previous, ...rest } = settings.threadDetailsItems; + return visible === defaultVisible(item, settings) ? rest : { ...rest, [item.id]: visible }; +} + +export function isDefaultThreadDetailsVisibility(visibility: ThreadDetailsVisibility) { + return THREAD_DETAILS_ITEMS.every((item) => visibility[item.id] === item.defaultVisible); +} + +/** Also clears the retired Labs flag, so Usage returns to off. */ +export const RESET_THREAD_DETAILS_ITEMS = { + threadDetailsItems: {}, + threadDetailsUsageEnabled: false, +} satisfies Partial; diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index f47ee3f52c793..1c5812a2bda0d 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -34,6 +34,7 @@ import { searchableSetting } from "./settingsSearch"; import { AdvancedSettings } from "./AdvancedSettings"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; import { TypographySection } from "./TypographySettings"; +import { ThreadDetailsItemsRow } from "./ThreadDetailsItemsRow"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -273,6 +274,7 @@ export function AppearanceSettingsPanel() {
} /> + - {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -70,26 +69,6 @@ function BoringSidebarMultilineRow() { ); } -function ThreadDetailsUsageRow() { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - return ( - - updateSettings({ threadDetailsUsageEnabled: Boolean(checked) }) - } - aria-label="Usage in thread details" - /> - } - /> - ); -} - /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx new file mode 100644 index 0000000000000..a142155d0357d --- /dev/null +++ b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx @@ -0,0 +1,53 @@ +import { ChevronDownIcon } from "lucide-react"; + +import { updateClientSettings, useClientSettings } from "../../hooks/useSettings"; +import { ThreadDetailsItemsMenuItems } from "../chat/ThreadDetailsItemsMenu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + THREAD_DETAILS_ITEMS, +} from "../chat/threadDetailsItems"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +export function ThreadDetailsItemsRow() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + const enabled = THREAD_DETAILS_ITEMS.filter((item) => visibility[item.id]).length; + return ( + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + /> + ) + } + control={ + + + } + > + {enabled} of {THREAD_DETAILS_ITEMS.length} on + + + + + + + } + /> + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0d87e9bd32c83..1d5c13e72a061 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -315,6 +315,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "thread-details-items", + title: "Thread details", + to: "/settings/appearance", + searchTerms: [ + "customize show hide panel card environment editor scripts branch pull request git changes servers automations lineage usage limits 5 hour weekly quota tokens context window", + ], + }, { id: "effort-display", title: "Effort display", @@ -437,12 +445,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], }, - { - id: "thread-details-usage", - title: "Usage in thread details", - to: "/settings/labs", - searchTerms: ["beta limits 5 hour weekly context window tokens quota rate limit"], - }, { id: "guest-links", title: "Guest links", diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index dfc7b1245ef21..fb5e12b08372b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -277,6 +277,11 @@ Select a server to open it in the browser panel. **Stop** ends the server's job and keeps the agent, the terminal, and T3 Code running. Stop needs terminal access to the environment and is not available on Windows. +To choose what the thread details card shows, right-click the card or open +**Settings โ†’ Appearance โ†’ Thread details**. Hidden rows keep their keyboard +shortcuts, such as Open in editor and project scripts. **Reset to default** brings +back the standard rows. + ## Snooze until later To change the choices in the snooze menu, open **Settings โ†’ Threads โ†’ Snooze options**. diff --git a/docs/user/usage.md b/docs/user/usage.md index 8064380c5e717..155175351c550 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -116,7 +116,7 @@ current model's limits without leaving the conversation. The result opens above closes when you dismiss it or send your next message. It uses the same snapshot as **Usage โ†’ Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage โ†’ Limits**. -To keep them in view, turn on **Settings โ†’ Labs โ†’ Usage in thread details** on web and desktop. +To keep them in view on web and desktop, turn on **Usage** in **Settings โ†’ Appearance โ†’ Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the current provider account and in the thread's context window. Some windows, such as a weekly Opus limit, count only one model. Hover a row to see when it resets, your pace, or diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d789770f1616a..16eb51aa1769c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -477,7 +477,13 @@ export const ClientSettingsSchema = Schema.Struct({ boringSidebarMultilineRows: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + // Retired Labs toggle. Still read as the default for the Usage item in threadDetailsItems. threadDetailsUsageEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Sparse show/hide overrides for thread details card items. Missing ids use the client's + // default, so new items appear for everyone and unknown ids survive a downgrade. + threadDetailsItems: Schema.Record(Schema.String, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), // Opt-in for the removed legacy sidebar. Still read so those users land in // the boring sidebar; the boring sidebar toggle clears it. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1722,6 +1728,7 @@ export const ClientSettingsPatch = Schema.Struct({ boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), + threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), From f2943ee90ef76b1db3fc1c1cd3808252418f5850 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 03:54:18 -0700 Subject: [PATCH 041/144] feat(updates): switch your whole fleet between Stable and Nightly in one step (#26288) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/ipc/methods/updates.ts | 18 +- apps/desktop/src/preload.ts | 7 +- .../src/settings/DesktopAppSettings.ts | 4 +- .../src/updates/DesktopRemoteUpdates.test.ts | 269 ++++++++++++++++++ .../src/updates/DesktopRemoteUpdates.ts | 94 +++++- .../src/updates/DesktopUpdates.test.ts | 170 +++++++++++ apps/desktop/src/updates/DesktopUpdates.ts | 92 +++++- apps/desktop/src/updates/releaseNotes.ts | 4 +- apps/desktop/src/updates/updateChannels.ts | 7 - .../desktop/src/updates/updatesTestHarness.ts | 29 +- apps/server/src/cloud/selfUpdate.test.ts | 33 +++ apps/server/src/cloud/selfUpdate.ts | 42 ++- .../server/src/cloud/servicePreflight.test.ts | 58 ++++ apps/server/src/cloud/servicePreflight.ts | 26 ++ .../desktopUpdate/DesktopAppUpdate.test.ts | 18 ++ .../src/desktopUpdate/DesktopAppUpdate.ts | 18 +- .../src/environment/ServerEnvironment.ts | 5 +- .../server/src/persistence/Migrations.test.ts | 20 ++ .../src/persistence/databaseMigrationHead.ts | 49 ++++ .../DesktopTelemetryReceiver.ts | 11 +- .../ServerUpdateAction.browser.test.tsx | 25 ++ .../web/src/components/ServerUpdateAction.tsx | 53 +++- .../components/settings/SettingsPanels.tsx | 104 ++++--- .../settings/UpdateTrackSwitchDialog.tsx | 178 ++++++++++++ .../settings/updateTrackSwitch.logic.test.ts | 76 +++++ .../settings/updateTrackSwitch.logic.ts | 50 ++++ .../components/sidebar/SidebarUpdatePill.tsx | 7 +- .../sidebar/UpdateEverythingDialog.tsx | 32 ++- .../sidebar/updateEverything.logic.test.ts | 167 ++++++++++- .../sidebar/updateEverything.logic.ts | 216 +++++++++++++- .../components/sidebar/useUpdateEverything.ts | 54 ++-- apps/web/src/state/npmRelease.ts | 42 ++- docs/user/updating.md | 23 ++ packages/client-runtime/src/state/server.ts | 11 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/ipc.ts | 9 +- packages/contracts/src/resourceTelemetry.ts | 5 +- packages/contracts/src/server.ts | 5 + packages/shared/src/cliRelease.ts | 42 +++ scripts/build-npm-launcher.test.ts | 3 + scripts/build-npm-launcher.ts | 23 +- 41 files changed, 1959 insertions(+), 144 deletions(-) create mode 100644 apps/server/src/persistence/databaseMigrationHead.ts create mode 100644 apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx create mode 100644 apps/web/src/components/settings/updateTrackSwitch.logic.test.ts create mode 100644 apps/web/src/components/settings/updateTrackSwitch.logic.ts diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index b2212609030d1..ca85ee8b0c417 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -23,11 +23,14 @@ export const getUpdateState = DesktopIpc.makeIpcMethod({ export const setUpdateChannel = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, - payload: DesktopUpdateChannelSchema, + payload: Schema.Struct({ + channel: DesktopUpdateChannelSchema, + minimumVersion: Schema.optional(Schema.String), + }), result: DesktopUpdateStateSchema, - handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { + handler: Effect.fn("desktop.ipc.updates.setChannel")(function* ({ channel, minimumVersion }) { const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.setChannel(channel); + return yield* updates.setChannel(channel, { minimumVersion }); }), }); @@ -43,11 +46,14 @@ export const downloadUpdate = DesktopIpc.makeIpcMethod({ export const installUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_INSTALL_CHANNEL, - payload: Schema.Void, + /** A version installs only that downloaded build, never one that replaced it. */ + payload: Schema.UndefinedOr(Schema.String), result: DesktopUpdateActionResultSchema, - handler: Effect.fn("desktop.ipc.updates.install")(function* () { + handler: Effect.fn("desktop.ipc.updates.install")(function* (expectedVersion) { const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.install; + if (expectedVersion === undefined) return yield* updates.install; + const { accepted, completed, state } = yield* updates.installPrepared(expectedVersion); + return { accepted, completed, state }; }), }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a3e1e711cb2d3..965b7db5d6d40 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -261,11 +261,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), - setUpdateChannel: (channel) => - ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), + setUpdateChannel: (channel, minimumVersion) => + ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, { channel, minimumVersion }), checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), - installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + installUpdate: (expectedVersion) => + ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL, expectedVersion), cliCommand: { getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 5767057b4a35d..a0624ced669ea 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -22,7 +22,7 @@ import { normalizeLinuxPasswordStorePreference, type LinuxPasswordStorePreference, } from "../linuxSecretStorage.ts"; -import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { @@ -180,7 +180,7 @@ export class DesktopAppSettings extends Context.Service< export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { return { ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + updateChannel: desktopUpdateChannelOf(appVersion), }; } diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 253a015d8ec9e..1f5321148b77b 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -119,6 +119,239 @@ describe("DesktopRemoteUpdates", () => { ); }); + it.effect("switches to the requested track before preparing its release", () => { + // In-memory settings keep the channel write off real disk I/O. + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-switch"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + assert.equal((yield* updates.getState).channel, "nightly"); + + harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + + const terminals = terminalReports(reports); + assert.deepEqual( + terminals.map((report) => [report.requestId, report.outcome]), + [["req-switch", "ready-to-install"]], + ); + assert.equal(terminals[0]?.state.downloadedVersion, "1.2.4-nightly.20261027.1"); + }), + ); + }); + + it.effect( + "refuses a crossing whose feed has only an older build, and never offers it later", + () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const older = "1.2.4-nightly.20261020.1"; + yield* Queue.offer(requests, { + ...request("req-older"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + // Answers the track change's check, then the request's own check. + harness.emit("update-available", { version: older }); + yield* settle; + harness.emit("update-available", { version: older }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.4-nightly.20261027.1."]], + ); + // The saved track is the user's intent; a retry picks it up from here. + assert.equal((yield* updates.getState).channel, "nightly"); + + // A plain update afterwards cannot reach the build the switch refused. + yield* Queue.offer(requests, request("req-plain")); + yield* settle; + harness.emit("update-available", { version: older }); + yield* settle; + assert.isNull((yield* updates.getState).availableVersion); + assert.equal(harness.downloadCount(), 0); + }), + ); + }, + ); + + it.effect("only saves the track on a host already running the release", () => { + const harness = makeHarness({ savedChannel: "nightly" }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-track-only"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + // Even with a newer build on the feed, the host only saves its track. + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["up-to-date"], + ); + assert.equal((yield* updates.getState).channel, "latest"); + assert.equal(harness.downloadCount(), 0); + }), + ); + }); + + it.effect("drops a retained download below the switch's checked release", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + // Downloaded earlier on this Stable host, before the switch checked 1.2.5. + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.equal((yield* updates.getState).downloadedVersion, "1.2.4"); + + yield* Queue.offer(requests, { + ...request("req-floor"), + channel: "latest", + targetVersion: "1.2.5", + }); + yield* settle; + harness.emit("update-available", { version: "1.2.5" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.5" }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [ + report.outcome, + report.state.downloadedVersion, + ]), + [["ready-to-install", "1.2.5"]], + ); + }), + ); + }); + + it.effect("refuses to cross tracks remotely while WSL backends need their own checks", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void, wslBackend: true }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-wsl"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["failed"], + ); + assert.include(terminalReports(reports)[0]?.reason ?? "", "Switch it on that machine"); + assert.equal((yield* updates.getState).channel, "latest"); + }), + ); + }); + + it.effect("fails a switch that leaves a same-track host below the checked release", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-behind"), + channel: "latest", + targetVersion: "1.2.5", + }); + yield* settle; + // The feed lags npm and has nothing newer than this host's 1.2.3. + harness.emit("update-not-available"); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.5."]], + ); + }), + ); + }); + + it.effect("fails a crossing to an older-numbered Stable that the feed does not offer", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-to-stable"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + // Still on Nightly, though the target is numbered below this build. + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.3."]], + ); + }), + ); + }); + + it.effect("accepts a newer build than the checked release on the requested track", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const newer = "1.2.4-nightly.20261028.2"; + yield* Queue.offer(requests, { + ...request("req-newer"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + harness.emit("update-available", { version: newer }); + yield* settle; + harness.emit("update-downloaded", { version: newer }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [ + report.outcome, + report.state.downloadedVersion, + ]), + [["ready-to-install", newer]], + ); + }), + ); + }); + it.effect("reports a failed outcome when quitAndInstall fails", () => { const harness = makeHarness({ quitAndInstall: Effect.fail( @@ -525,6 +758,42 @@ describe("DesktopRemoteUpdates", () => { ); }); + it.effect("cancels a track switch while its own feed check is still running", () => { + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ + beforeSetUpdateChannel: Effect.void, + checkForUpdates: Deferred.await(releaseCheck), + }); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-switch-cancel"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-switch-cancel", + }); + yield* settle; + // The next request starts without waiting for the cancelled switch's feed check. + yield* Queue.offer(requests, { + ...request("req-switch-back"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + + assert.equal((yield* updates.getState).channel, "latest"); + yield* Deferred.succeed(releaseCheck, undefined); + }), + ); + }); + it.effect("cancels an active preparation so the next request can run", () => { const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb606d8fbd253..560e2e78d4bb1 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -10,10 +10,13 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { @@ -44,9 +47,13 @@ type CommitClaim = export const listen: Effect.Effect< void, never, - DesktopUpdates.DesktopUpdates | DesktopTelemetryPublisher.DesktopTelemetryPublisher | Scope.Scope + | DesktopUpdates.DesktopUpdates + | DesktopTelemetryPublisher.DesktopTelemetryPublisher + | DesktopBackendPool.DesktopBackendPool + | Scope.Scope > = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + const backends = yield* DesktopBackendPool.DesktopBackendPool; const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; const activeRequestIdRef = yield* Ref.make(Option.none()); const requestControlRef = yield* Ref.make<{ @@ -171,7 +178,59 @@ export const listen: Effect.Effect< return; } yield* Ref.set(activeRequestIdRef, Option.some(request.requestId)); - yield* logInfo("remote update requested", { requestId: request.requestId }); + yield* logInfo("remote update requested", { + requestId: request.requestId, + channel: request.channel, + }); + // A switch saves its track first, so the step machine never sees the old track's offer. + // The saved track is the user's intent and stays even if this request fails: a track saved + // toward Stable never offers an older build outside a guarded switch. + if (request.channel !== undefined) { + // A reinstall restarts every backend, but the requesting server checked only its own + // database. A WSL backend keeps its own, so cross tracks only where all can be checked. + const current = yield* updates.getState; + if ( + desktopUpdateChannelOf(current.currentVersion) !== request.channel && + (yield* backends.list).length > 1 + ) { + yield* publishReport( + current, + { + outcome: "failed", + reason: + "This desktop app also runs WSL backends, whose databases a remote switch cannot check. Switch it on that machine.", + }, + request.requestId, + ); + return; + } + const switched = yield* Effect.raceFirst( + updates.setChannel(request.channel, { minimumVersion: request.targetVersion }).pipe( + Effect.as(true), + Effect.catch((error) => + updates.getState.pipe( + Effect.flatMap((state) => + publishReport( + state, + { outcome: "failed", reason: error.message }, + request.requestId, + ), + ), + Effect.as(false), + ), + ), + ), + Deferred.await(cancellation).pipe(Effect.as(false)), + ); + if (!switched) return; + // A host already running the target only needed its track saved; installing a newer + // build here would restart it without the dialog having said so. + const state = yield* updates.getState; + if (request.targetVersion === state.currentVersion) { + yield* publishReport(state, { outcome: "up-to-date" }, request.requestId); + return; + } + } const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; @@ -184,6 +243,12 @@ export const listen: Effect.Effect< Effect.forkScoped, ); + // A host leaving its build's track may need an older build, down to the checked release; + // the updater offers nothing below it. + const crossing = (state: DesktopUpdateState) => + request.channel !== undefined && + request.targetVersion !== undefined && + desktopUpdateChannelOf(state.currentVersion) !== request.channel; const step = Effect.fn("DesktopRemoteUpdates.step")(function* ( state: DesktopUpdateState, ): Effect.fn.Return { @@ -193,7 +258,7 @@ export const listen: Effect.Effect< return false; case "check": attempts = { ...attempts, checks: attempts.checks + 1 }; - yield* updates.check("remote-update").pipe( + yield* updates.check("remote-update", { allowDowngrade: crossing(state) }).pipe( Effect.flatMap((result) => { if (result.checked) return Effect.void; attempts = { ...attempts, checks: attempts.checks - 1 }; @@ -246,21 +311,34 @@ export const listen: Effect.Effect< yield* logInfo("remote update prepared", { requestId: request.requestId }); return true; } - case "done": + case "done": { + // A switch that leaves the host on its old track, or below its checked release, did + // not reach it; a track-only host already at the release is done. + const done = + request.targetVersion !== undefined && + next.outcome === "up-to-date" && + (crossing(state) || + compareSemverVersions(state.currentVersion, request.targetVersion) < 0) + ? { + outcome: "failed" as const, + reason: `The desktop app found no build of ${request.targetVersion}.`, + } + : next; yield* publishReport( state, { - outcome: next.outcome, - ...(next.reason === undefined ? {} : { reason: next.reason }), + outcome: done.outcome, + ...(done.reason === undefined ? {} : { reason: done.reason }), }, request.requestId, ); yield* logInfo("remote update finished", { requestId: request.requestId, - outcome: next.outcome, - reason: next.reason ?? null, + outcome: done.outcome, + reason: done.reason ?? null, }); return true; + } } }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index d2f24143fc209..c6bcd46b8c206 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -694,6 +695,65 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("ignores a finished download from a track it no longer follows", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + // A backed-out switch's download keeps reporting progress until it finishes. + harness.emit("download-progress", { percent: 40 }); + yield* flushCallbacks; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.downloadedVersion, null); + assert.notEqual(state.status, "downloading"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("offers older releases only to checks that leave the running build's track", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const before = harness.checkAllowedDowngrade.length; + yield* updates.setChannel("nightly"); + yield* updates.setChannel("latest"); + yield* updates.check("remote-update", { allowDowngrade: true }); + + // Back on its own Stable track the build never needs an older release. + assert.deepEqual(harness.checkAllowedDowngrade.slice(before), [true, false, true]); + assert.isFalse(harness.allowDowngrade()); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("re-checks a saved track that differs from the running build's track", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.setChannel("latest"); + const checks = harness.checkCount(); + + yield* updates.setChannel("latest"); + + assert.equal(harness.checkCount(), checks + 1); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("does not persist an unchanged update channel as a user preference", () => { const harness = makeHarness(); @@ -713,6 +773,116 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("offers nothing below a track switch's checked release", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.setChannel("latest", { minimumVersion: "1.2.3" }); + + harness.emit("update-available", { version: "1.2.2" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).availableVersion); + harness.emit("update-downloaded", { version: "1.2.2" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).downloadedVersion); + + harness.emit("update-available", { version: "1.2.3" }); + yield* flushCallbacks; + assert.equal((yield* updates.getState).availableVersion, "1.2.3"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps a saved Stable track across a restart without offering an older release", () => { + // A Nightly build whose switch to Stable did not finish before the app quit. + const harness = makeHarness({ appVersion: "1.2.4-nightly.20261027.1", savedChannel: "latest" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("startup"); + + assert.equal((yield* updates.getState).channel, "latest"); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + // Semver ranks Stable 1.2.4 above this Nightly, but a Nightly built before the release's + // version bump can hold migrations 1.2.4 lacks. Crossing needs a guarded switch. + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).availableVersion); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps a saved Nightly track across a restart without crossing on its own", () => { + // A Stable build whose switch to Nightly was refused before the app quit; its floor is gone. + const harness = makeHarness({ savedChannel: "nightly" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("startup"); + harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" }); + yield* flushCallbacks; + + assert.equal((yield* updates.getState).channel, "nightly"); + assert.isNull((yield* updates.getState).availableVersion); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("never offers an older Nightly to a Nightly build's own checks", () => { + const harness = makeHarness({ appVersion: "1.2.4-nightly.20261027.1" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("poll"); + + assert.equal((yield* updates.getState).channel, "nightly"); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("waits out a background check instead of refusing a track change", () => + Effect.gen(function* () { + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const harness = makeHarness({ + beforeSetUpdateChannel: Effect.void, + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("poll").pipe(Effect.forkScoped); + yield* Deferred.await(checkStarted); + + const channelFiber = yield* updates.setChannel("nightly").pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseCheck, undefined); + yield* TestClock.adjust(Duration.seconds(2)); + const state = yield* Fiber.join(channelFiber); + + assert.equal(state.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it.effect("rejects checks while an update channel change is being persisted", () => Effect.gen(function* () { const channelChangeStarted = yield* Deferred.make(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aeda73f2bfad3..4da742f453aa6 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -19,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -34,7 +35,8 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -49,6 +51,8 @@ import { resolveNotAppliedInstallVersion, } from "./updateMachine.ts"; +const CHANNEL_CHANGE_RETRY_DELAY = Duration.seconds(1); +const CHANNEL_CHANGE_RETRIES = 30; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); @@ -179,10 +183,16 @@ export class DesktopUpdates extends Context.Service< readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; + /** `minimumVersion` is a track switch's checked release; nothing older is offered after. */ readonly setChannel: ( channel: DesktopUpdateChannel, + options?: { readonly minimumVersion?: string | undefined }, ) => Effect.Effect; - readonly check: (reason: string) => Effect.Effect; + /** `allowDowngrade` is for a guarded track switch, which checked that older release. */ + readonly check: ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; readonly installPrepared: ( @@ -351,6 +361,20 @@ export const make = Effect.gen(function* () { const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const notAppliedVersionRef = yield* Ref.make(Option.none()); + // The release a track switch's database check cleared. A build crosses to another track only + // through such a switch, and never below that release: version order alone cannot tell whether + // a Stable knows a Nightly's migrations. + const releaseFloorRef = yield* Ref.make(Option.none()); + const offerable = (version: string, state: DesktopUpdateState) => + Ref.get(releaseFloorRef).pipe( + Effect.map( + (floor) => + desktopUpdateChannelOf(version) === state.channel && + (Option.isSome(floor) + ? compareSemverVersions(version, floor.value) >= 0 + : desktopUpdateChannelOf(environment.appVersion) === state.channel), + ), + ); const updateStateRef = yield* Ref.make( createInitialDesktopUpdateState( environment.appVersion, @@ -454,12 +478,13 @@ export const make = Effect.gen(function* () { const allowsPrerelease = channel === "nightly"; yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); - yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + // An older build can lack migrations this one ran. Only a guarded track switch's own check + // may offer one. + yield* electronUpdater.setAllowDowngrade(false); yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, - allowDowngrade: allowsPrerelease, fullChangelog: allowsPrerelease, }); }); @@ -467,6 +492,7 @@ export const make = Effect.gen(function* () { const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( reason: string, actionReservation: "acquire" | "held" = "acquire", + allowDowngrade = false, ) { yield* Effect.annotateCurrentSpan({ reason }); if (yield* Ref.get(desktopState.quitting)) return false; @@ -488,7 +514,11 @@ export const make = Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); yield* logUpdaterInfo("checking for updates", { reason }); + // The flag is shared updater state, so it flips only while this check holds the reservation. + const restoreDowngrade = yield* electronUpdater.allowDowngrade; + if (allowDowngrade) yield* electronUpdater.setAllowDowngrade(true); return yield* electronUpdater.checkForUpdates.pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(restoreDowngrade).pipe(Effect.ignore)), Effect.as(true), Effect.catchTags({ ElectronUpdaterCheckForUpdatesError: Effect.fn( @@ -849,8 +879,8 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { const state = yield* Ref.get(updateStateRef); - if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { - yield* logUpdaterInfo("ignoring update that does not match selected channel", { + if (!(yield* offerable(info.version, state))) { + yield* logUpdaterInfo("ignoring update outside the selected channel or its floor", { version: info.version, channel: state.channel, }); @@ -986,6 +1016,20 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { const state = yield* Ref.get(updateStateRef); + // A download can finish after its track switch moved on; never offer that build. + if (!(yield* offerable(info.version, state))) { + yield* logUpdaterInfo("ignoring download outside the selected channel or its floor", { + version: info.version, + channel: state.channel, + }); + // Its progress events left the updater "downloading", which would block every check. + if (state.status === "downloading") { + yield* setState( + reduceDesktopUpdateStateOnNoUpdate(state, yield* currentIsoTimestamp), + ); + } + return; + } yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), @@ -1092,9 +1136,17 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.updates.configure")), setChannel: Effect.fn("desktop.updates.setChannel")(function* ( nextChannel: DesktopUpdateChannel, + options?: { readonly minimumVersion?: string | undefined }, ) { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); - const activeAction = yield* tryStartChannelChange; + // A background check holds the updater only briefly, so wait it out instead of refusing. + const activeAction = yield* tryStartChannelChange.pipe( + Effect.repeat({ + while: (active) => Option.isSome(active) && active.value === "check", + schedule: Schedule.spaced(CHANNEL_CHANGE_RETRY_DELAY), + times: CHANNEL_CHANGE_RETRIES, + }), + ); if (Option.isSome(activeAction)) { return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value === "install-recovery" ? "install" : activeAction.value, @@ -1103,8 +1155,16 @@ export const make = Effect.gen(function* () { } return yield* Effect.gen(function* () { + yield* Ref.set(releaseFloorRef, Option.fromNullishOr(options?.minimumVersion)); const state = yield* Ref.get(updateStateRef); - if (nextChannel === state.channel) { + const retained = state.downloadedVersion ?? state.availableVersion; + // A build from another track re-checks, so retrying a track switch finds an older release. + // So does a retained offer below the new floor, which must not install. + if ( + nextChannel === state.channel && + nextChannel === desktopUpdateChannelOf(environment.appVersion) && + (retained === null || (yield* offerable(retained, state))) + ) { return state; } @@ -1124,15 +1184,19 @@ export const make = Effect.gen(function* () { } yield* applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = yield* electronUpdater.allowDowngrade; - yield* electronUpdater.setAllowDowngrade(true); - yield* checkForUpdates("channel-change", "held").pipe( - Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), + // Only leaving the running build's track can need an older release. + yield* checkForUpdates( + "channel-change", + "held", + nextChannel !== desktopUpdateChannelOf(environment.appVersion), ); return yield* Ref.get(updateStateRef); }).pipe(Effect.ensuring(finishUpdateAction("channel"))); }), - check: Effect.fn("desktop.updates.check")(function* (reason: string) { + check: Effect.fn("desktop.updates.check")(function* ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) { yield* Effect.annotateCurrentSpan({ reason }); if (!(yield* Ref.get(updaterConfiguredRef))) { return { @@ -1140,7 +1204,7 @@ export const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; } - const checked = yield* checkForUpdates(reason); + const checked = yield* checkForUpdates(reason, "acquire", options?.allowDowngrade === true); return { checked, state: yield* Ref.get(updateStateRef), diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69d710ff7b269..8698d8d499118 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,6 +1,6 @@ import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -134,7 +134,7 @@ export function normalizeDesktopUpdateReleaseNotes( : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) : [] - ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); + ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { const { items, totalItems } = extractReleaseNoteItems(entry.note); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index d79a374a668a5..b07735ceb7a29 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,13 +1,6 @@ -import type { DesktopUpdateChannel } from "@t3tools/contracts"; - -const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; // Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { return PRERELEASE_VERSION_PATTERN.test(version); } - -export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest"; -} diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 1d8c21a42dfe5..d8d16b7dbfd62 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -35,6 +35,11 @@ export interface UpdatesHarnessOptions { readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; readonly installRecord?: string | undefined; + readonly appVersion?: string; + /** Starts from in-memory settings with this track saved, as after a restart. */ + readonly savedChannel?: DesktopAppSettings.DesktopSettings["updateChannel"]; + /** Adds a WSL backend beside the primary, as on a Windows host with WSL enabled. */ + readonly wslBackend?: boolean; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -42,6 +47,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let quitAndInstallCount = 0; let downloadCount = 0; let allowDowngrade = false; + const checkAllowedDowngrade: boolean[] = []; let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); @@ -86,6 +92,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; + checkAllowedDowngrade.push(allowDowngrade); }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.sync(() => { downloadCount += 1; @@ -149,7 +156,17 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { waitForReady: () => Effect.succeed(true), }; const layerBackend = DesktopBackendPool.layerTest( - options.backendInstances ?? [stubBackendInstance], + options.backendInstances ?? + (options.wslBackend + ? [ + stubBackendInstance, + { + ...stubBackendInstance, + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: Effect.succeed("Ubuntu"), + }, + ] + : [stubBackendInstance]), ); const layerEnvironment = DesktopEnvironment.layer({ @@ -157,7 +174,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, platform: options.platform ?? "darwin", processArch: "x64", - appVersion: "1.2.3", + appVersion: options.appVersion ?? "1.2.3", appPath: "/repo", isPackaged: true, resourcesPath: "/missing/resources", @@ -178,10 +195,13 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let testSettings: DesktopAppSettings.DesktopSettings = { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + ...(options.savedChannel === undefined + ? {} + : { updateChannel: options.savedChannel, updateChannelConfiguredByUser: true }), }; const setUpdateChannelError = options.setUpdateChannelError; const layerSettings = - setUpdateChannelError || options.beforeSetUpdateChannel + setUpdateChannelError || options.beforeSetUpdateChannel || options.savedChannel ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => testSettings), load: Effect.sync(() => testSettings), @@ -279,6 +299,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { downloadCount: () => downloadCount, feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, fullChangelog: () => fullChangelog, + /** Whether each feed check could offer an older release, and the flag now. */ + checkAllowedDowngrade, + allowDowngrade: () => allowDowngrade, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 84877d41a70c9..1e3157adfff59 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -313,3 +313,36 @@ it.layer(NodeServices.layer)("pruning runtimes on start", (it) => { }), ); }); + +it("a host crosses tracks only to a release that knows its whole database", () => { + const cross = ( + serverVersion: string, + targetVersion: string, + targetDatabaseMigration: number | undefined, + databaseMigration = 72, + ) => + ServerSelfUpdate.trackSwitchBlockReason({ + serverVersion, + targetVersion, + targetDatabaseMigration, + databaseMigration: () => databaseMigration, + }); + const nightly = "0.1.4-nightly.20261027.3796"; + + expect(cross(nightly, "0.1.4", 72)).toBeNull(); + // Another client moved this host to a newer Nightly after the switch was planned. + expect(cross(nightly, "0.1.4", 72, 73)).toContain("does not support yet"); + expect(cross(nightly, "0.1.4", undefined)).toContain("Cannot confirm"); + // A Stable tag can carry a migration the latest Nightly lacks. + expect(cross("0.1.4", nightly, 72, 73)).toContain("does not support yet"); + expect(cross("0.1.3", "0.1.4", undefined)).toBeNull(); + // A database that exists but cannot be read proves nothing. + expect( + ServerSelfUpdate.trackSwitchBlockReason({ + serverVersion: nightly, + targetVersion: "0.1.4", + targetDatabaseMigration: 72, + databaseMigration: () => undefined, + }), + ).toContain("Cannot confirm"); +}); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index ab6a69fc50a83..d72c5e9218df2 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -20,11 +20,12 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; -import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; +import { CLI_RELEASE_BASE_URL_ENV, crossesReleaseTrack } from "@t3tools/shared/cliRelease"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; +import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, @@ -93,6 +94,26 @@ export const pruneRuntimesOnStart = Effect.fn("cloud.server_self_update.prune_on }, ); +/** The client checks this when planning; the host checks again because its database may have + * moved on since (another client updated it). Stable and Nightly each build their own commits, + * so crossing tracks needs the target release to know every migration this database holds. */ +export function trackSwitchBlockReason(input: { + readonly serverVersion: string; + readonly targetVersion: string; + readonly targetDatabaseMigration: number | undefined; + /** Read only for a crossing; undefined when the database cannot be read. */ + readonly databaseMigration: () => number | undefined; +}): string | null { + if (!crossesReleaseTrack(input.serverVersion, input.targetVersion)) return null; + const databaseMigration = input.databaseMigration(); + if (input.targetDatabaseMigration === undefined || databaseMigration === undefined) { + return `Cannot confirm this database works with t3@${input.targetVersion}. Switch tracks from Settings โ†’ About โ†’ Update track, which checks it first.`; + } + return input.targetDatabaseMigration < databaseMigration + ? `This database has changes t3@${input.targetVersion} does not support yet; it stays on this release.` + : null; +} + export const make = Effect.fn("cloud.server_self_update.make")(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate; @@ -119,12 +140,27 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ? new ServerSelfUpdateError({ reason }) : new ServerSelfUpdateError({ reason, cause }); + const crossesTrackUnsafely = (input: ServerSelfUpdateInput) => + trackSwitchBlockReason({ + serverVersion: packageJson.version, + targetVersion: input.targetVersion, + targetDatabaseMigration: input.targetDatabaseMigration, + databaseMigration: () => databaseMigrationHead(serverConfig.dbPath), + }); + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { if (capability === "desktop-managed") { + const unsafeCrossing = input.channel === undefined ? null : crossesTrackUnsafely(input); + if (unsafeCrossing !== null) return yield* failWith(unsafeCrossing); if (desktopAppUpdate.available) { - return yield* desktopAppUpdate.run(reportProgress); + return yield* desktopAppUpdate.run( + reportProgress, + input.channel === undefined + ? undefined + : { channel: input.channel, targetVersion: input.targetVersion }, + ); } return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", @@ -136,6 +172,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ); } + const unsafeCrossing = crossesTrackUnsafely(input); + if (unsafeCrossing !== null) return yield* failWith(unsafeCrossing); const targetVersion = input.targetVersion.trim(); if (!isExactServiceVersion(targetVersion)) { return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 18a5323cd590c..efc37266be8b0 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -1,4 +1,16 @@ +import * as NodeSqlite from "node:sqlite"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + BUILD_MIGRATION_HEAD, + databaseMigrationHead, +} from "../persistence/databaseMigrationHead.ts"; +import { migrationManifest } from "../persistence/Migrations.ts"; import { runServicePreflight } from "./servicePreflight.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; @@ -20,3 +32,49 @@ it.each([1])("blocks legacy launcher protocol %i with exact recovery steps", (la expect(reason).toContain("t3 --version"); expect(reason).toContain("which -a t3"); }); + +it.layer(NodeServices.layer)("service preflight database", (it) => { + it.effect("blocks a database that a newer build migrated", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const databasePath = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-preflight-" }), + "state.sqlite", + ); + const db = new NodeSqlite.DatabaseSync(databasePath); + db.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER PRIMARY KEY, name TEXT)"); + const insert = db.prepare("INSERT INTO effect_sql_migrations VALUES (?, ?)"); + for (const [id, name] of migrationManifest) insert.run(id, name); + const preflight = () => + runServicePreflight({ databasePath, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }); + + expect(preflight().status).toBe("ready"); + expect(databaseMigrationHead(databasePath)).toBe(BUILD_MIGRATION_HEAD); + + const newerId = Math.max(...migrationManifest.map(([id]) => id)) + 1; + insert.run(newerId, "FromNightly"); + db.close(); + const result = preflight(); + expect(result.status).toBe("blocked"); + expect(result.status === "blocked" ? result.reason : "").toContain(`${newerId}_FromNightly`); + // Servers report this head, so clients see a database a newer build migrated. + expect(databaseMigrationHead(databasePath)).toBe(newerId); + + // A database that exists but cannot be read has an unknown history, so it blocks. + const unreadable = path.join(path.dirname(databasePath), "unreadable.sqlite"); + yield* fs.writeFileString(unreadable, "not a database"); + expect(databaseMigrationHead(unreadable)).toBeUndefined(); + expect( + runServicePreflight({ + databasePath: unreadable, + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + }).status, + ).toBe("blocked"); + // A missing database is a fresh install. + expect(databaseMigrationHead(path.join(path.dirname(databasePath), "missing.sqlite"))).toBe( + BUILD_MIGRATION_HEAD, + ); + }), + ); +}); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index 8865ce7b3d11f..9479f3f4a8194 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -1,4 +1,5 @@ import packageJson from "../../package.json" with { type: "json" }; +import { BUILD_MIGRATION_HEAD, recordedMigrations } from "../persistence/databaseMigrationHead.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; export type ServicePreflightResult = @@ -34,9 +35,34 @@ export function runServicePreflight(input: { }; } + const newer = newerRecordedMigrations(input.databasePath); + if (newer === null) { + return { + status: "blocked", + version, + reason: `Could not read this database to confirm t3@${version} supports it.`, + }; + } + if (newer.length > 0) { + return { + status: "blocked", + version, + reason: `This database has migrations that t3@${version} does not know: ${newer.join(", ")}. A newer build wrote them, and running t3@${version} on this database is not safe. Stay on the newer release, or wait for a release that includes these migrations.`, + }; + } + return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; } +/** Migrations a newer build recorded, or null when the database exists but cannot be read. + * Startup already handles renamed ids this build knows. */ +function newerRecordedMigrations(databasePath: string): ReadonlyArray | null { + const recorded = recordedMigrations(databasePath); + return recorded === null + ? null + : recorded.filter(({ id }) => id > BUILD_MIGRATION_HEAD).map(({ id, name }) => `${id}_${name}`); +} + export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined { if (typeof value !== "object" || value === null) { return undefined; diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts index 86d445d1a52c4..f90bef39fcf03 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts @@ -132,6 +132,24 @@ it.layer(NodeServices.layer)("desktop app update", (it) => { }), ); + it.effect("succeeds without an install when a track switch finds the host current", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "up-to-date", currentVersion: "1.2.3" }), { + outcome: "up-to-date", + }), + ], + }); + const trackSwitch = { channel: "latest" as const, targetVersion: "1.2.3" }; + + expect(yield* service.run(() => Effect.void, trackSwitch)).toEqual({ + targetVersion: "1.2.3", + method: "desktop-app", + }); + }), + ); + it.effect("replays a retained commit failure for the preparation token", () => Effect.gen(function* () { const { service } = yield* makeHarness({ diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index d02c3108777dd..e901b04cfb5d7 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -44,6 +44,7 @@ export class DesktopAppUpdate extends Context.Service< reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, + trackSwitch?: DesktopTelemetryReceiver.DesktopTrackSwitch, ) => Effect.Effect; /** Success stops the server; returns only on installation failure or timeout. */ readonly commit: ( @@ -71,6 +72,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, + trackSwitch: boolean, ) { const lastStage = yield* Ref.make(null); const emitStage = ( @@ -122,6 +124,11 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId }; } if (report.outcome === "up-to-date") { + // A switch's host already on the newest build of its track only saved the track. + if (trackSwitch) { + yield* Ref.set(inFlight, false); + return { targetVersion: report.state.currentVersion, method: "desktop-app" as const }; + } return yield* failWith( `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`, ); @@ -132,7 +139,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( }); const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")( - function* (reportProgress) { + function* (reportProgress, trackSwitch) { if (!available) { return yield* failWith( "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.", @@ -154,13 +161,18 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( // Subscribe before requesting to catch fast reports. const { changes } = yield* receiver.desktopUpdates; yield* receiver - .requestDesktopUpdate(requestId) + .requestDesktopUpdate(requestId, trackSwitch) .pipe( Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app on this machine.", error), ), ); - return yield* consumeReports(requestId, changes, reportProgress).pipe( + return yield* consumeReports( + requestId, + changes, + reportProgress, + trackSwitch !== undefined, + ).pipe( Effect.onInterrupt(() => receiver.cancelDesktopUpdate(requestId).pipe(Effect.ignore)), ); }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 90ae4519bed92..cef18fbe325c3 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -20,6 +20,7 @@ import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; +import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; @@ -203,6 +204,7 @@ export const make = Effect.gen(function* () { const desktopAppUpdate = serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; + const databaseMigration = databaseMigrationHead(serverConfig.dbPath); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, label, @@ -213,6 +215,7 @@ export const make = Effect.gen(function* () { }, serverVersion: packageJson.version, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, + ...(databaseMigration === undefined ? {} : { databaseMigration }), capabilities: { repositoryIdentity: true, connectionProbe: true, @@ -266,7 +269,7 @@ export const make = Effect.gen(function* () { ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { serverSelfUpdateProgress: true } : {}), - ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), + ...(desktopAppUpdate ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } : {}), serverBrowser: true, peerLinks: true, mcpModeLimitHeader: true, diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 67f238b7484f5..54e4a711aa8a1 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -1,7 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; import { migrationManifest, runMigrations } from "./Migrations.ts"; @@ -123,3 +126,20 @@ describe("runMigrations", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); }); + +// The npm launcher reads the highest migration id from these file names. +it.effect("registers every migration file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(import.meta.dirname, "Migrations"); + const files = (yield* fs.readDirectory(dir)).flatMap((name) => { + const match = /^(\d+)_(\w+)\.ts$/.exec(name); + return match ? [[Number(match[1]), match[2]] as const] : []; + }); + assert.deepStrictEqual( + files.toSorted(([a], [b]) => a - b), + migrationManifest, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/persistence/databaseMigrationHead.ts b/apps/server/src/persistence/databaseMigrationHead.ts new file mode 100644 index 0000000000000..046039a703410 --- /dev/null +++ b/apps/server/src/persistence/databaseMigrationHead.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off - read synchronously by the preflight subprocess and at startup. +import * as NodeFS from "node:fs"; +import * as NodeSqlite from "node:sqlite"; + +import { migrationManifest } from "./Migrations.ts"; + +export const BUILD_MIGRATION_HEAD = Math.max(...migrationManifest.map(([id]) => id)); + +/** Migrations the database records, read-only. A missing or new database records none; one that + * exists but cannot be read is null, since its history is unknown. */ +export function recordedMigrations( + databasePath: string, +): ReadonlyArray<{ readonly id: number; readonly name: string }> | null { + let db: NodeSqlite.DatabaseSync; + try { + db = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + } catch { + return NodeFS.existsSync(databasePath) ? null : []; + } + try { + const hasTable = db + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations'", + ) + .get(); + if (hasTable === undefined) return []; + return db + .prepare("SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id") + .all() + .map((row) => ({ id: Number(row.migration_id), name: String(row.name) })); + } catch { + return null; + } finally { + try { + db.close(); + } catch { + // A read-only handle has nothing to flush; the result above already stands. + } + } +} + +/** Highest migration the database holds once this build's startup migrations have run. A newer + * build may have recorded more than this build knows. Undefined when the database is unreadable. */ +export function databaseMigrationHead(databasePath: string): number | undefined { + const recorded = recordedMigrations(databasePath); + return recorded === null + ? undefined + : Math.max(BUILD_MIGRATION_HEAD, ...recorded.map(({ id }) => id)); +} diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 770359b3e9307..70dc2e8d750ef 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -8,6 +8,7 @@ import { type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, type DesktopHostTelemetrySnapshot, DesktopTelemetryControlMessage, + type DesktopUpdateChannel, type DesktopUpdateStatusReport, type ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; @@ -149,6 +150,12 @@ export interface DesktopTelemetryReceiverHealth { readonly lastError: Option.Option; } +/** Moves the desktop app to `channel` and installs only `targetVersion`. */ +export interface DesktopTrackSwitch { + readonly channel: DesktopUpdateChannel; + readonly targetVersion: string; +} + export class DesktopTelemetryReceiver extends Context.Service< DesktopTelemetryReceiver, { @@ -176,6 +183,7 @@ export class DesktopTelemetryReceiver extends Context.Service< ) => Effect.Effect; readonly requestDesktopUpdate: ( requestId: string, + trackSwitch?: DesktopTrackSwitch, ) => Effect.Effect; readonly commitDesktopUpdate: ( requestId: string, @@ -633,11 +641,12 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( - function (requestId) { + function (requestId, trackSwitch) { return sendControlMessage({ version: 1, type: "requestDesktopUpdate", requestId, + ...trackSwitch, }); }, ), diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 5f793b3ca90e4..ed3a723652a56 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -14,6 +14,7 @@ const testState = vi.hoisted(() => ({ toast: vi.fn(), clipboard: vi.fn(), sessionAtom: null as unknown, + serverVersions: new Map(), })); vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({ @@ -36,6 +37,16 @@ vi.mock(import("~/state/session"), async (original) => { }, }; }); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useServerConfigs: (() => + new Map( + [...testState.serverVersions].map(([id, serverVersion]) => [ + id, + { environment: { serverVersion } }, + ]), + )) as never, +})); vi.mock(import("~/state/use-atom-command"), async (original) => ({ ...(await original()), useAtomCommand: (() => testState.updateServer) as never, @@ -84,6 +95,7 @@ function setup(): Disposable { testState.updateServer.mockReset(); testState.toast.mockReset(); testState.clipboard.mockReset(); + testState.serverVersions.clear(); sessionAtom = Atom.make(AsyncResult.success(currentSession)); testState.sessionAtom = sessionAtom; resetConfirmDialogForTests(); @@ -212,4 +224,17 @@ describe("ServerUpdatesAction", () => { ); expect(button(screen, "Update all")!.disabled).toBe(false); }); + + it("skips a machine whose update would cross Stable and Nightly", async () => { + using _state = setup(); + testState.serverVersions.set("batch-a", "0.0.30-nightly.20261001.1"); + testState.updateServer.mockResolvedValue(success()); + await using screen = await mount(); + await click(screen, "Update all"); + + expect(testState.updateServer).toHaveBeenCalledTimes(1); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "success", title: "Office updated" }), + ); + }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 3f7675cd96631..141f139aefd7b 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -7,6 +7,7 @@ import { import type { AsyncResult } from "effect/reactivity"; import { environmentSession } from "~/state/session"; import type { + DesktopUpdateChannel, EnvironmentId, ServerInstallation, ServerSelfUpdateCapability, @@ -26,6 +27,8 @@ import { serverEnvironment, updateOutdatedServer } from "~/state/server"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; +import { useServerConfigs } from "~/state/entities"; +import { crossesReleaseTrack } from "@t3tools/shared/cliRelease"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -41,6 +44,29 @@ export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } +const CROSS_TRACK_NOTE = "Switch tracks in About โ†’ Update track"; + +/** Hosts refuse a Stable/Nightly crossing outside a track switch, which checks the database first. */ +function refusedCrossing( + target: { + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly targetVersion: string; + readonly channel?: DesktopUpdateChannel | undefined; + }, + serverVersion: string | undefined, +): boolean { + return ( + target.channel === undefined && + target.selfUpdate !== null && + serverVersion !== undefined && + crossesReleaseTrack(serverVersion, target.targetVersion) + ); +} + +function CrossTrackNote() { + return {CROSS_TRACK_NOTE}; +} + function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } @@ -52,6 +78,12 @@ export interface ServerUpdateTarget { readonly installation?: ServerInstallation | undefined; readonly desktopAppUpdate?: boolean; readonly targetVersion: string; + /** Set only by a track switch; the host's desktop app moves to this track. */ + readonly channel?: DesktopUpdateChannel; + /** A track switch's checked release migration; the host re-checks its own database. */ + readonly targetDatabaseMigration?: number; + /** The host already runs its track's newest build and only saves the track. */ + readonly trackOnly?: boolean; } type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { @@ -79,7 +111,13 @@ export function useServerUpdate() { pendingUpdateEnvironmentIds.add(environmentId); const result = await updateServer({ environmentId, - input: { targetVersion }, + input: { + targetVersion, + ...(target.channel === undefined ? {} : { channel: target.channel }), + ...(target.targetDatabaseMigration === undefined + ? {} + : { targetDatabaseMigration: target.targetDatabaseMigration }), + }, }); pendingUpdateEnvironmentIds.delete(environmentId); if (result._tag === "Failure") { @@ -118,12 +156,14 @@ export function ServerUpdatesAction({ readonly targets: ReadonlyArray; }) { const update = useServerUpdate(); + const serverConfigs = useServerConfigs(); const pending = useRef(false); const [isPending, setIsPending] = useState(false); const eligible = targets.filter( (target) => target.selfUpdate !== null && - (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), + (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate) && + !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion), ); const handleUpdate = async () => { if (pending.current) return; @@ -198,6 +238,7 @@ export function ServerUpdateAction({ installation, desktopAppUpdate = false, targetVersion, + channel, label = "Update", variant = "outline", size = "xs", @@ -205,6 +246,7 @@ export function ServerUpdateAction({ appearance = "button", }: ServerUpdateTarget & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const serverVersion = useServerConfigs().get(environmentId)?.environment.serverVersion; const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); const update = useServerUpdate(); @@ -264,6 +306,10 @@ export function ServerUpdateAction({ ); } + if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) { + return ; + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; const actionLabel = @@ -351,6 +397,9 @@ export function OutdatedServerUpdateAction({ description: `Reconnected on t3@${result.value.targetVersion}.`, }); }; + if (fromVersion !== undefined && crossesReleaseTrack(fromVersion, targetVersion)) { + return ; + } return ( + + ) : ( + "Switches this app and every connected machine whose database the target release supports." + ) + } control={ update({ name: event.target.value })} + maxLength={64} + aria-invalid={nameError !== undefined} + aria-describedby={ + nameError !== undefined ? "secret-provider-name-error" : undefined + } + autoFocus + /> + {nameError !== undefined ? ( + + ) : null} + +
+ + update({ account: event.target.value })} + maxLength={253} + spellCheck={false} + aria-invalid={accountError !== undefined} + aria-describedby={ + accountError !== undefined ? "secret-provider-account-error" : undefined + } + /> + {accountError !== undefined ? ( + + ) : null} +

+ The account shorthand, sign-in address without https://, or ID that op account list + shows. +

+
+ + + + + + + + + ); +} diff --git a/apps/web/src/components/settings/SecretsSettings.tsx b/apps/web/src/components/settings/SecretsSettings.tsx new file mode 100644 index 0000000000000..e9efe2d85f6ed --- /dev/null +++ b/apps/web/src/components/settings/SecretsSettings.tsx @@ -0,0 +1,220 @@ +import type { SecretProviderConfig, SecretProviderId } from "@t3tools/contracts"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import type { EnvironmentPresentation } from "../../state/environments"; +import { useEnvironmentSessionState } from "../../state/session"; +import { OnePasswordIcon, type Icon } from "../Icons"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { SecretProviderDialog } from "./SecretProviderDialog"; +import { classifySecretProvider } from "./SecretProviderDialog.logic"; +import { + classifyProviderEnvironmentAccess, + resolveRemoteOperateAccess, +} from "./ProviderSettingsPanel.logic"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { searchableSetting } from "./settingsSearch"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const SECRET_PROVIDER_KINDS: Readonly< + Record +> = { + one_password: { name: "1Password", icon: OnePasswordIcon }, +}; + +export function SecretsSettingsPanel() { + const { scope, environment } = useSettingsScope(); + return ( + + {environment?.connection.phase === "connected" ? ( + + ) : ( + +

+ Connect an environment to manage the secret providers its provider variables read from. +

+
+ )} +
+ ); +} + +function EnvironmentSecretProviders({ + environment, + projectScoped, +}: { + readonly environment: EnvironmentPresentation; + readonly projectScoped: boolean; +}) { + const environmentId = environment.environmentId; + const sessionState = useEnvironmentSessionState(environmentId); + // The server requires the same grant for secret providers as for provider settings. + const access = classifyProviderEnvironmentAccess({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + operateAccess: resolveRemoteOperateAccess({ + session: sessionState.data, + isPending: sessionState.isPending, + hasError: sessionState.hasError, + }), + }); + const readOnly = access.kind !== "editable" || projectScoped; + const providers = useEnvironmentSettings(environmentId, (settings) => settings.secretProviders); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [dialog, setDialog] = useState< + null | { readonly mode: "add" } | { readonly mode: "edit"; readonly id: SecretProviderId } + >(null); + const entries = Object.entries(providers) as Array<[SecretProviderId, SecretProviderConfig]>; + const editingConfig = dialog?.mode === "edit" ? providers[dialog.id] : undefined; + const editing = + dialog?.mode === "edit" && editingConfig !== undefined + ? { id: dialog.id, config: editingConfig } + : undefined; + return ( + <> + setDialog({ mode: "add" })}> + + Add provider + + ) : null + } + > + {projectScoped ? ( +

+ Environment-wide setting. Select an environment to change it. +

+ ) : null} + {entries.length === 0 ? ( + + ) : ( + entries.map(([id, config]) => { + const classified = classifySecretProvider(config); + const kind = + classified.kind === "unsupported" + ? undefined + : SECRET_PROVIDER_KINDS[classified.kind]; + return ( + + {kind ? ( + + ) : null} + {config.label} + + } + description={ + classified.kind === "one_password" && kind ? ( + + {kind.name} ยท + + + ) : ( + `Unsupported provider (${config.kind})` + ) + } + control={ + !readOnly ? ( +
+ {kind ? ( + + ) : null} + updateSettings({ secretProviders: { [id]: null } })} + /> +
+ ) : null + } + /> + ); + }) + )} +
+ {dialog !== null && !readOnly && (dialog.mode === "add" || editing !== undefined) ? ( + setDialog(null)} + environmentId={environmentId} + environmentLabel={environment.label} + existingIds={new Set(Object.keys(providers))} + {...(editing === undefined ? {} : { existing: editing })} + /> + ) : null} + + ); +} + +function RemoveSecretProviderButton({ + label, + onConfirm, +}: { + readonly label: string; + readonly onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Remove {label}? + + Provider variables that read from {label} stop resolving until a provider with the + same name is added again. The secrets themselves are untouched. + + + + }>Cancel + + + + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index b58f56a162324..ee061be1ebf78 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -22,6 +22,7 @@ import { MessagesSquareIcon, PanelsTopLeftIcon, KeyboardIcon, + KeyRoundIcon, PaletteIcon, SearchIcon, ServerIcon, @@ -102,6 +103,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/worktrees": FolderTreeIcon, "/settings/connections": ServerIcon, "/settings/access": ShieldCheckIcon, + "/settings/secrets": KeyRoundIcon, "/settings/maintenance": WrenchIcon, "/settings/labs": FlaskConicalIcon, "/settings/about": InfoIcon, diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 3013298c6ecb6..07d1d5dd6c8b9 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -14,12 +14,18 @@ import { isSettingsSearchScopeAvailable, movedSettingsPath, searchSettings, + SETTINGS_NAV_GROUPS, SETTINGS_SEARCH_ITEMS, sourceControlHostSearchItems, sourceControlHostSettingsSearchId, type SettingsSearchItem, } from "./settingsSearch"; +vi.mock("~/env", async (importOriginal) => ({ + ...(await importOriginal()), + isSecretsFeatureEnabled: false, +})); + const ITEMS: ReadonlyArray = [ { id: "word-wrap", @@ -60,6 +66,11 @@ describe("searchSettings", () => { expect(searchSettings("long lines", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); }); + it("hides the Secrets page from nav and search while the secrets feature is disabled", () => { + expect(SETTINGS_NAV_GROUPS.flatMap((group) => group.paths)).not.toContain("/settings/secrets"); + expect(searchSettings("1password").map((item) => item.to)).not.toContain("/settings/secrets"); + }); + it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1d5c13e72a061..dc494caf5344d 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,5 +1,5 @@ import { HOSTED_APP_CHANNEL } from "~/branding"; -import { isElectron } from "~/env"; +import { isElectron, isSecretsFeatureEnabled } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -33,6 +33,7 @@ export type SettingsPath = | "/settings/worktrees" | "/settings/connections" | "/settings/access" + | "/settings/secrets" | "/settings/maintenance" | "/settings/labs" | "/settings/about" @@ -57,6 +58,7 @@ export interface SettingsSearchItem { readonly macOnly?: boolean; readonly windowsOnly?: boolean; readonly releaseChannelOnly?: boolean; + readonly secretsFeatureOnly?: boolean; readonly cloudOnly?: boolean; readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; @@ -101,6 +103,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/worktrees": "Worktrees", "/settings/connections": "Environments", "/settings/access": "Sharing & access", + "/settings/secrets": "Secrets", "/settings/maintenance": "Maintenance", "/settings/labs": "Labs", "/settings/about": "About", @@ -137,7 +140,14 @@ export const SETTINGS_NAV_GROUPS: ReadonlyArray<{ heading: "Code", paths: ["/settings/projects", "/settings/source-control", "/settings/worktrees"], }, - { heading: "Connections", paths: ["/settings/connections", "/settings/access"] }, + { + heading: "Connections", + paths: [ + "/settings/connections", + "/settings/access", + ...(isSecretsFeatureEnabled ? (["/settings/secrets"] as const) : []), + ], + }, { heading: null, paths: ["/settings/maintenance", "/settings/labs", "/settings/about", "/settings/archived"], @@ -1142,6 +1152,21 @@ export const SETTINGS_SEARCH_ITEMS = [ `pull request routing trusted environments shared credentials permissions read actions source control ${SOURCE_CONTROL_HOST_LABELS}`, ], }, + { + id: "secret-providers", + title: "Secret providers", + to: "/settings/secrets", + searchTerms: ["1password vault credentials api key"], + secretsFeatureOnly: true, + }, + { + id: "secret-providers-add", + title: "Add secret provider", + to: "/settings/secrets", + targetId: "secret-providers", + searchTerms: ["new 1password vault op cli account"], + secretsFeatureOnly: true, + }, { id: "archive", title: "Archived threads", @@ -1171,6 +1196,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly (isElectron || item.desktopOnly !== true) && (!item.releaseChannelOnly || isElectron || HOSTED_APP_CHANNEL !== null) && + (!item.secretsFeatureOnly || isSecretsFeatureEnabled) && (!item.macOnly || isMacPlatform(platform)) && (!item.windowsOnly || isWindowsPlatform(platform)), ); diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index be2a563d736bd..a0a28c5d7fd60 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -1,2 +1,6 @@ // Preload installs desktopBridge before web code runs, so module-load detection is reliable. export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined; + +export const isSecretsFeatureEnabled = ["1", "true"].includes( + import.meta.env.T3CODE_FEATURE_SECRETS?.trim().toLowerCase() ?? "", +); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d92ea6110863e..0e26ab6edd879 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ChatSettingsThreadsRouteImport } from './routes/_chat.settings import { Route as ChatSettingsStorageRouteImport } from './routes/_chat.settings.storage' import { Route as ChatSettingsSourceControlRouteImport } from './routes/_chat.settings.source-control' import { Route as ChatSettingsSnapShotRouteImport } from './routes/_chat.settings.snap-shot' +import { Route as ChatSettingsSecretsRouteImport } from './routes/_chat.settings.secrets' import { Route as ChatSettingsScheduledTasksRouteImport } from './routes/_chat.settings.scheduled-tasks' import { Route as ChatSettingsProvidersRouteImport } from './routes/_chat.settings.providers' import { Route as ChatSettingsProjectsRouteImport } from './routes/_chat.settings.projects' @@ -134,6 +135,11 @@ const ChatSettingsSnapShotRoute = ChatSettingsSnapShotRouteImport.update({ path: '/snap-shot', getParentRoute: () => ChatSettingsRoute, } as any) +const ChatSettingsSecretsRoute = ChatSettingsSecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => ChatSettingsRoute, +} as any) const ChatSettingsScheduledTasksRoute = ChatSettingsScheduledTasksRouteImport.update({ id: '/scheduled-tasks', @@ -283,6 +289,7 @@ export interface FileRoutesByFullPath { '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/secrets': typeof ChatSettingsSecretsRoute '/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute @@ -323,6 +330,7 @@ export interface FileRoutesByTo { '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/secrets': typeof ChatSettingsSecretsRoute '/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute @@ -365,6 +373,7 @@ export interface FileRoutesById { '/_chat/settings/projects': typeof ChatSettingsProjectsRoute '/_chat/settings/providers': typeof ChatSettingsProvidersRoute '/_chat/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/_chat/settings/secrets': typeof ChatSettingsSecretsRoute '/_chat/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/_chat/settings/source-control': typeof ChatSettingsSourceControlRoute '/_chat/settings/storage': typeof ChatSettingsStorageRoute @@ -407,6 +416,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/secrets' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -447,6 +457,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/secrets' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -488,6 +499,7 @@ export interface FileRouteTypes { | '/_chat/settings/projects' | '/_chat/settings/providers' | '/_chat/settings/scheduled-tasks' + | '/_chat/settings/secrets' | '/_chat/settings/snap-shot' | '/_chat/settings/source-control' | '/_chat/settings/storage' @@ -626,6 +638,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsSnapShotRouteImport parentRoute: typeof ChatSettingsRoute } + '/_chat/settings/secrets': { + id: '/_chat/settings/secrets' + path: '/secrets' + fullPath: '/settings/secrets' + preLoaderRoute: typeof ChatSettingsSecretsRouteImport + parentRoute: typeof ChatSettingsRoute + } '/_chat/settings/scheduled-tasks': { id: '/_chat/settings/scheduled-tasks' path: '/scheduled-tasks' @@ -804,6 +823,7 @@ interface ChatSettingsRouteChildren { ChatSettingsProjectsRoute: typeof ChatSettingsProjectsRoute ChatSettingsProvidersRoute: typeof ChatSettingsProvidersRoute ChatSettingsScheduledTasksRoute: typeof ChatSettingsScheduledTasksRoute + ChatSettingsSecretsRoute: typeof ChatSettingsSecretsRoute ChatSettingsSnapShotRoute: typeof ChatSettingsSnapShotRoute ChatSettingsSourceControlRoute: typeof ChatSettingsSourceControlRoute ChatSettingsStorageRoute: typeof ChatSettingsStorageRoute @@ -832,6 +852,7 @@ const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { ChatSettingsProjectsRoute: ChatSettingsProjectsRoute, ChatSettingsProvidersRoute: ChatSettingsProvidersRoute, ChatSettingsScheduledTasksRoute: ChatSettingsScheduledTasksRoute, + ChatSettingsSecretsRoute: ChatSettingsSecretsRoute, ChatSettingsSnapShotRoute: ChatSettingsSnapShotRoute, ChatSettingsSourceControlRoute: ChatSettingsSourceControlRoute, ChatSettingsStorageRoute: ChatSettingsStorageRoute, diff --git a/apps/web/src/routes/_chat.settings.secrets.tsx b/apps/web/src/routes/_chat.settings.secrets.tsx new file mode 100644 index 0000000000000..d97e00fda19f7 --- /dev/null +++ b/apps/web/src/routes/_chat.settings.secrets.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { SecretsSettingsPanel } from "../components/settings/SecretsSettings"; +import { isSecretsFeatureEnabled } from "../env"; + +export const Route = createFileRoute("/_chat/settings/secrets")({ + beforeLoad: () => { + if (!isSecretsFeatureEnabled) throw redirect({ to: "/settings/general", replace: true }); + }, + component: SecretsSettingsPanel, +}); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index fdc350fe81ab0..7758c9ca55919 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -150,7 +150,7 @@ function SettingsRouteLayout() { return ( { // Send every axis so retain middleware recognizes "all" as an explicit target. void navigate({ diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 307b9c5d8284d..63f7cca9cbfc6 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -15,6 +15,7 @@ interface ImportMetaEnv { readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; readonly T3CODE_DESKTOP_UPDATE_REPOSITORY: string; + readonly T3CODE_FEATURE_SECRETS: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 501bac5dba020..da07814b29f6a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -49,6 +49,7 @@ const configuredDesktopUpdateRepository = process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || process.env.GITHUB_REPOSITORY?.trim() || ""; +const configuredFeatureSecrets = process.env.T3CODE_FEATURE_SECRETS?.trim() || ""; const configuredHostedAppUrl = (() => { const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); if (explicitHostedAppUrl) { @@ -316,6 +317,7 @@ export default defineConfig(() => { "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify( configuredDesktopUpdateRepository, ), + "import.meta.env.T3CODE_FEATURE_SECRETS": JSON.stringify(configuredFeatureSecrets), "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 25523c2d0fb4a..fd25d9bb1719e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -15,6 +15,7 @@ import { resolveProjectProviderInstanceEnabled, resolveProviderInstanceEnabled, ServerSettings, + OnePasswordSecretProviderConfig, ServerSettingsPatch, withIdleCompaction, } from "./settings.ts"; @@ -25,6 +26,7 @@ const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const decodeOnePasswordConfig = Schema.decodeUnknownSync(OnePasswordSecretProviderConfig); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("storage cleanup settings", () => { @@ -115,6 +117,54 @@ describe("ServerSettings usage price overrides", () => { }); }); +describe("ServerSettings secret providers", () => { + const onePassword = (account: string) => ({ + kind: "one_password", + label: "Personal", + config: { account }, + }); + + it("keeps an unknown kind or a longer label from invalidating the rest of the settings", () => { + const openbao = { kind: "openbao", label: "V".repeat(65), config: { address: "https://x" } }; + const decoded = decodeServerSettings({ + worktreesDirectory: "/work", + secretProviders: { vault: openbao, personal: onePassword("my.1password.com") }, + }); + expect(decoded.worktreesDirectory).toBe("/work"); + expect(encodeServerSettings(decoded).secretProviders).toEqual({ + vault: openbao, + personal: onePassword("my.1password.com"), + }); + }); + + it("accepts slug ids and null removals but no id that is not a slug", () => { + expect( + decodeServerSettingsPatch({ + secretProviders: { "1password": onePassword("my.1password.com"), work: null }, + }).secretProviders, + ).toEqual({ "1password": onePassword("my.1password.com"), work: null }); + for (const id of ["", "My Vault", "a/b", "-vault"]) { + expect(() => + decodeServerSettingsPatch({ secretProviders: { [id]: onePassword("my.1password.com") } }), + ).toThrow(); + } + expect(() => + decodeServerSettingsPatch({ + secretProviders: { work: { ...onePassword("my.1password.com"), label: "V".repeat(65) } }, + }), + ).toThrow(); + }); + + it("rejects a 1Password account op could misread", () => { + for (const account of ["", "my account", "--help"]) { + expect(() => decodeOnePasswordConfig({ account })).toThrow(); + } + expect(decodeOnePasswordConfig({ account: "my.1password.com" }).account).toBe( + "my.1password.com", + ); + }); +}); + describe("custom model settings", () => { const capabilities = { optionDescriptors: [ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 16eb51aa1769c..1339a9322fa3a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -859,6 +859,56 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +// Final once shipped: a key or field that fails here fails the whole settings decode on older builds. +// Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. +const SecretProviderSlug = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), +); + +export const SecretProviderId = SecretProviderSlug.pipe(Schema.brand("SecretProviderId")); +export type SecretProviderId = typeof SecretProviderId.Type; + +export const SecretProviderKind = SecretProviderSlug.pipe(Schema.brand("SecretProviderKind")); +export type SecretProviderKind = typeof SecretProviderKind.Type; + +const ONE_PASSWORD_ACCOUNT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** + * Any value `op --account` accepts: shorthand, sign-in address, account ID, or user ID. + * It is passed to the CLI as an argument, so it can never start with `-`. + */ +export const OnePasswordAccount = Schema.String.check( + Schema.isMaxLength(253), + Schema.makeFilter((value: string) => + ONE_PASSWORD_ACCOUNT_PATTERN.test(value) + ? true + : "1Password account must be a shorthand, sign-in address, or ID with no spaces.", + ), +).pipe(Schema.brand("OnePasswordAccount")); +export type OnePasswordAccount = typeof OnePasswordAccount.Type; + +export const SecretProviderLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); + +export const OnePasswordSecretProviderConfig = Schema.Struct({ + account: OnePasswordAccount, +}); +export type OnePasswordSecretProviderConfig = typeof OnePasswordSecretProviderConfig.Type; + +/** Kinds decode their own config; unknown-kind envelopes round-trip verbatim across versions. */ +export const SecretProviderConfig = Schema.Struct({ + kind: SecretProviderKind, + label: TrimmedNonEmptyString, + config: Schema.optionalKey(Schema.Unknown), +}); +export type SecretProviderConfig = typeof SecretProviderConfig.Type; + +// Limits that may change later are enforced on writes only, never on the stored envelope. +const SecretProviderConfigWrite = Schema.Struct({ + ...SecretProviderConfig.fields, + label: SecretProviderLabel, +}); + export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; @@ -1313,6 +1363,9 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + secretProviders: Schema.Record(SecretProviderId, SecretProviderConfig).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), cursorKeychainUsageEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1626,6 +1679,9 @@ export const ServerSettingsPatch = Schema.Struct({ usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), + secretProviders: Schema.optionalKey( + Schema.Record(SecretProviderId, Schema.NullOr(SecretProviderConfigWrite)), + ), cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey( @@ -1645,7 +1701,12 @@ export function requiredScopesForServerSettingsPatch( let changesSettings = false; for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue; - if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") { + if ( + key === "providers" || + key === "providerInstances" || + key === "usageLimitSources" || + key === "secretProviders" + ) { changesProviders = true; } else { changesSettings = true; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f9a43b6123a1e..bd315271c78e1 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,8 +1,11 @@ import { DEFAULT_SERVER_SETTINGS, + OnePasswordAccount, ProjectId, ProviderDriverKind, ProviderInstanceId, + SecretProviderId, + SecretProviderKind, UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; @@ -544,6 +547,28 @@ describe("serverSettings helpers", () => { expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); }); + it("upserts and removes secretProviders per entry so concurrent edits cannot clobber", () => { + const personal = SecretProviderId.make("personal"); + const work = SecretProviderId.make("work"); + const onePassword = (label: string, account: string) => ({ + kind: SecretProviderKind.make("one_password"), + label, + config: { account: OnePasswordAccount.make(account) }, + }); + const current = { + ...DEFAULT_SERVER_SETTINGS, + secretProviders: { [personal]: onePassword("Personal", "my.1password.com") }, + }; + + const added = applyServerSettingsPatch(current, { + secretProviders: { [work]: onePassword("Work", "team.1password.com") }, + }); + expect(Object.keys(added.secretProviders)).toEqual([personal, work]); + + const removed = applyServerSettingsPatch(added, { secretProviders: { [personal]: null } }); + expect(Object.keys(removed.secretProviders)).toEqual([work]); + }); + it("replaces and removes individual usage prices without clobbering other models", () => { const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e7c856070658a..11eecc6fe3df8 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -357,6 +357,7 @@ export function applyServerSettingsPatch( worktreeCleanup: worktreeCleanupPatch, // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, + secretProviders: secretProvidersPatch, sourceControlHosts: sourceControlHostsPatch, usagePriceOverrides: usagePriceOverridesPatch, usageModelAliases: usageModelAliasesPatch, @@ -554,6 +555,9 @@ export function applyServerSettingsPatch( ), } : {}), + ...(secretProvidersPatch !== undefined + ? { secretProviders: mergeSettingsEntries(current.secretProviders, secretProvidersPatch) } + : {}), ...(usagePriceOverridesPatch !== undefined ? { usagePriceOverrides: mergeSettingsEntries( From d15278481b984379d1b639a93315c188b7e3fe67 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:39:01 -0500 Subject: [PATCH 056/144] fix(mobile): use monospace in the Android terminal preview (#23401) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../settings/appearance/components/AppearancePreviews.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index 644c6d25ee3d4..afcf4ba30d9a1 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -44,7 +44,7 @@ export function TerminalAppearancePreview(props: { readonly fontSize: number }) const theme = getMobileTerminalTheme(themeId, scheme); const lineHeight = Math.round(props.fontSize * 1.6); const lineStyle = { - fontFamily: "Menlo", + fontFamily: Platform.OS === "android" ? "monospace" : "Menlo", fontSize: props.fontSize, lineHeight, } as const; From ecea6ca3e60cadb95248732d37de0e715ed6ab78 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 29 Oct 2026 23:09:24 +0530 Subject: [PATCH 057/144] fix(web): edit from here uses a pencil icon (#21878) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/web/src/components/chat/TimelineUserMessageRow.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 2b231f6ddfe35..66cbb0bcc5ed4 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -34,7 +34,7 @@ import { type LucideIcon, MessagesSquareIcon, Redo2Icon, - Undo2Icon, + PencilIcon, } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; @@ -744,7 +744,7 @@ function RevertUserMessageButton({ /> } > - + Edit from here From b4da8962fc871e221d00ae2c2790b2984998bc1c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:39:47 -0500 Subject: [PATCH 058/144] fix(desktop): clear Cache Storage when deleting browser profile data (#22002) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/desktop/src/preview/BrowserSession.test.ts | 5 +++++ apps/desktop/src/preview/BrowserSession.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 22af4a829e049..b1f2b7ca0df92 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -280,11 +280,16 @@ describe("BrowserSession", () => { assert.isUndefined(sessions.get(partition)); yield* browserSessions.getSession("scope-untouched"); + const other = yield* browserSessions.getSession("scope-other"); yield* browserSessions.clearCookies([partition]); const created = sessions.get(partition); assert.isDefined(created); assert.strictEqual(created.clearStorageData.mock.calls.length, 1); + assert.deepStrictEqual(created.clearStorageData.mock.calls[0]?.[0], { + storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"], + }); + assert.strictEqual(vi.mocked(other.clearStorageData).mock.calls.length, 0); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 2ea7efdb88ee0..e467c370d0f5b 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -367,7 +367,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { Effect.tryPromise({ try: () => browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"], }), catch: (cause) => new BrowserSessionStorageClearError({ From 3d5baafecf5cb8ad22ca37962327fa4fdc130663 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 29 Oct 2026 17:40:10 +0000 Subject: [PATCH 059/144] fix(web): respect reduced motion in thread loading controls (#22100) Co-authored-by: SAPHID Co-authored-by: maria-rcks --- apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx | 5 +++-- apps/web/src/components/chat/ThreadRelationshipsControl.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 2e00e47ee7da0..f1df96dd92133 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -3,7 +3,7 @@ import type { RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; -import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import { ChevronDownIcon, SquareIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { AgentElapsed } from "./AgentElapsed"; @@ -14,6 +14,7 @@ import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Spinner } from "../ui/spinner"; import { THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, @@ -158,7 +159,7 @@ export function ThreadLineageWorkflowRow({ } > {stopping ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index e107bbc4039c0..69403392b5ce8 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -49,7 +49,6 @@ import { ChevronDownIcon, CornerLeftUpIcon, GitForkIcon, - LoaderCircleIcon, MoreHorizontalIcon, PlusIcon, SquareIcon, @@ -76,6 +75,7 @@ import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; +import { Spinner } from "../ui/spinner"; import { cn } from "~/lib/utils"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, @@ -802,7 +802,7 @@ export function ThreadRelationshipsPanel(props: { onClick={() => void merge()} > {busyAction === "merge" ? ( - + ) : ( )} @@ -841,7 +841,7 @@ export function ThreadRelationshipsPanel(props: { } > {stoppingId === threadId ? ( - + ) : ( )} From 49d014245777bbf47756635886ddf0846d2330d3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:40:33 -0500 Subject: [PATCH 060/144] fix(provider-acp): redact API keys with underscores in ACP stderr logs (#24015) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- packages/provider-acp/src/server/stderr.test.ts | 13 +++++++++++++ packages/provider-acp/src/server/stderr.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/provider-acp/src/server/stderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts index f582f47984ffb..fdf3c68f97e4d 100644 --- a/packages/provider-acp/src/server/stderr.test.ts +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -50,4 +50,17 @@ describe("AcpStderr", () => { expect(excerpt).not.toContain("dXNlcjpwYXNz"); expect(excerpt).not.toContain("ant-api-key-value"); }); + + it("redacts the whole key when its body contains underscores", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "anthropic=sk-ant-api03-AbCdEf_GhIjKlMnOpQr-StUvWx_yz0123456789-AA", + "openai=sk-proj-AbCdEfGhIj_KlMnOpQrStUv-WxYz_0123456789", + ].join("\n"), + "/Users/ada", + {}, + ); + + expect(excerpt).toBe("anthropic=[redacted]\nopenai=[redacted]"); + }); }); diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts index f988f2d632d97..92ae01a4c27ef 100644 --- a/packages/provider-acp/src/server/stderr.ts +++ b/packages/provider-acp/src/server/stderr.ts @@ -6,7 +6,7 @@ const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; const SECRET_TOKEN_PATTERN = - /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; export function appendAcpStderrTail(current: string, chunk: string): string { const next = `${current}${chunk}`; From 67afdd56c7905e9df721c61f5795578bb8c27867 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:40:55 -0500 Subject: [PATCH 061/144] fix(provider-core): redact compound credential keys in provider errors (#23313) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- packages/provider-core/src/server/failure.test.ts | 14 ++++++++++++++ packages/provider-core/src/server/failure.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index 8fb2e153aa109..a50d3dac9599c 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -41,6 +41,20 @@ it("redacts credentials and URL secrets from provider failures", () => { assert.notInclude(failure.message, "sk-abcdefghijklmnop"); }); +it("redacts credentials whose key names end in a credential word", () => { + const failure = makeProviderFailure({ + code: '{"refresh_token":"code value"}', + message: + 'oauth refresh failed: client_secret=client-value refresh_token=refresh-value {"client_secret":"json-value"} clientSecret: camel-value X-Auth-Token: header-value max_tokens=4096', + }); + + assert.equal(failure.code, '{"refresh_token":"[REDACTED]"}'); + assert.equal( + failure.message, + 'oauth refresh failed: client_secret=[REDACTED] refresh_token=[REDACTED] {"client_secret":"[REDACTED]"} clientSecret: [REDACTED] X-Auth-Token: [REDACTED] max_tokens=4096', + ); +}); + it("does not split a surrogate pair at the truncation boundary", () => { const failure = makeProviderFailure({ message: `${"a".repeat(MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - 2)}๐Ÿš€tail`, diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index b0ac1ed8b938d..2dd418ea2dfac 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -194,11 +194,11 @@ function redactProviderFailureText(value: string): string { .replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl) .replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]") .replace( - /(["'](?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, + /(["'][\w-]*(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, "$1[REDACTED]$2", ) .replace( - /(\b(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, + /((? Date: Thu, 29 Oct 2026 12:41:28 -0500 Subject: [PATCH 062/144] fix(web): show rounded diff counts as 1m instead of 1000k (#23723) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/web/src/components/chat/DiffStatLabel.test.ts | 12 ++++++++++++ apps/web/src/components/chat/DiffStatLabel.tsx | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/chat/DiffStatLabel.test.ts diff --git a/apps/web/src/components/chat/DiffStatLabel.test.ts b/apps/web/src/components/chat/DiffStatLabel.test.ts new file mode 100644 index 0000000000000..2ea8bcdb83559 --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatCompactDiffCount } from "./DiffStatLabel"; + +describe("formatCompactDiffCount", () => { + it("promotes counts when rounding reaches the next suffix", () => { + expect(formatCompactDiffCount(999_499)).toBe("999k"); + expect(formatCompactDiffCount(999_500)).toBe("1m"); + expect(formatCompactDiffCount(999_499_999)).toBe("999m"); + expect(formatCompactDiffCount(999_500_000)).toBe("1b"); + }); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 9e4c0f7cef5e9..2b60c35723a6f 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -5,13 +5,13 @@ export function hasNonZeroStat(stat: { additions: number; deletions: number }): return stat.additions > 0 || stat.deletions > 0; } -function formatCompactDiffCount(value: number): string { +export function formatCompactDiffCount(value: number): string { if (value < 1000) return String(value); - if (value < 1_000_000) { + if (value < 999_500) { const k = value / 1000; return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k`; } - if (value < 1_000_000_000) { + if (value < 999_500_000) { const m = value / 1_000_000; return `${m < 10 ? m.toFixed(1).replace(/\.0$/, "") : Math.round(m)}m`; } From 5e35f6ea1cec83da27a456818e5581dd24d325f2 Mon Sep 17 00:00:00 2001 From: tiliakoos <73005126+tiliakoos@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:41:51 -0400 Subject: [PATCH 063/144] fix(desktop): a timed-out login-shell probe no longer skips the server's PATH repair (#21771) Co-authored-by: maria-rcks --- .../src/shell/DesktopShellEnvironment.test.ts | 18 ++++++++++++++++-- .../src/shell/DesktopShellEnvironment.ts | 6 ++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 9d12ed5a07fe6..4804823069024 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -149,7 +149,7 @@ describe("DesktopShellEnvironment", () => { }; const commands: ChildProcess.Command[] = []; - yield* runShellEnvironment({ + const prepared = yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { @@ -167,6 +167,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + assert.isTrue(prepared); }), ); @@ -267,7 +268,7 @@ describe("DesktopShellEnvironment", () => { }; const commands: string[] = []; - yield* runShellEnvironment({ + const prepared = yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { @@ -279,6 +280,19 @@ describe("DesktopShellEnvironment", () => { assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.isFalse(prepared); + }), + ); + + it.effect("leaves macOS preparation incomplete when no login shell returns a PATH", () => + Effect.gen(function* () { + const prepared = yield* runShellEnvironment({ + env: { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }, + platform: "darwin", + handler: () => "", + }); + + assert.isFalse(prepared); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 25c37ecefc051..6e0fdbd38050f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -392,7 +392,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return< - void, + boolean, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { @@ -491,6 +491,8 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } } + + return Option.isSome(trimNonEmpty(shellEnvironment.PATH)); }, ); @@ -505,7 +507,7 @@ const installShellEnvironment = ( return installWindowsEnvironment(config); } if (config.platform === "darwin" || config.platform === "linux") { - return installPosixEnvironment(config).pipe(Effect.as(true)); + return installPosixEnvironment(config); } return Effect.succeed(false); }; From 55e7b5f66d6bd9f3e8e1ba06d20262afcc9ea64c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:42:15 -0500 Subject: [PATCH 064/144] fix(server): keep the previous preview browser version on a shared home (#23771) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../server/src/preview/PreviewBrowser.test.ts | 19 ++++++++++--------- apps/server/src/preview/PreviewBrowser.ts | 7 +++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 0cb1665d4f3d4..a5c241229a377 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -73,6 +73,7 @@ const browserArchive = makeZip([ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( options: { + readonly version?: string; readonly archive?: Buffer; readonly sha256?: string; readonly body?: Stream.Stream; @@ -93,7 +94,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( release: options.unsupported ? null : { - version: "1.2.3", + version: options.version ?? "1.2.3", platform: "fixture", url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip", bytes: archive.byteLength, @@ -121,10 +122,11 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( }); it.layer(NodeServices.layer)("PreviewBrowser", (it) => { - it.effect("installs a verified download with its file modes and then reuses it", () => + it.effect.each(["0.8.0", "1.2.3"])("retains another version when installing %s", (current) => Effect.gen(function* () { - const { browser, fs, path, installRoot, requests } = yield* makeHarness(); - yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true }); + const { browser, fs, path, installRoot, requests } = yield* makeHarness({ version: current }); + yield* fs.makeDirectory(path.join(installRoot, "0.9.0"), { recursive: true }); + yield* fs.makeDirectory(path.join(installRoot, "0.10.0")); const active = path.join(installRoot, ".install-other"); const abandoned = path.join(installRoot, ".install-abandoned"); yield* fs.makeDirectory(active); @@ -135,7 +137,7 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { const executable = yield* browser.executable; - const version = path.join(installRoot, "1.2.3"); + const version = path.join(installRoot, current); expect(executable).toBe(path.join(version, executableName)); expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings"); if (hostPlatform !== "win32") { @@ -145,10 +147,9 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { expect(yield* mode("libEGL.so")).toBe(0o755); expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644); } - expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual([ - ".install-other", - "1.2.3", - ]); + expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual( + [".install-other", "0.10.0", current].toSorted(), + ); expect(yield* browser.executable).toBe(executable); expect(yield* browser.installed).toEqual(Option.some(executable)); expect(requests).toHaveLength(1); diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index d13b495ef5c20..ea5539aad5e02 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -321,11 +321,14 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( yield* fs.remove(destination, { recursive: true, force: true }); yield* fs.rename(unpacked, destination); }).pipe(Effect.scoped); - // Shared homes may have another server installing; preserve staging directories touched within the last hour. + // Shared homes may still use the newest other version or staging directories touched within the last hour. const entries = yield* fs.readDirectory(installRoot); + const previous = entries + .filter((name) => name !== release.version && !name.startsWith(".")) + .toSorted((left, right) => right.localeCompare(left, "en", { numeric: true }))[0]; const now = yield* Clock.currentTimeMillis; yield* Effect.forEach( - entries.filter((name) => name !== release.version), + entries.filter((name) => name !== release.version && name !== previous), (name) => Effect.gen(function* () { const target = path.join(installRoot, name); From ee81ea4ce80ad21f5ee89b810127acf3ac126b1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:42:31 -0700 Subject: [PATCH 065/144] fix(settings): servers check secret provider writes and keep unknown configs to themselves (#27044) Co-authored-by: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 18 ++++++++++++++ apps/server/src/serverSettings.ts | 17 ++++++++++++- apps/server/src/ws.ts | 1 + .../components/settings/SecretsSettings.tsx | 11 ++++++++- packages/contracts/src/server.ts | 2 ++ packages/contracts/src/settings.test.ts | 13 +++++----- packages/contracts/src/settings.ts | 24 +++++++++++++++++-- 7 files changed, 75 insertions(+), 11 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index bd985cab203ac..29dea381e759a 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -235,6 +235,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("sends clients a known secret provider's config but never an unknown kind's", () => + Effect.gen(function* () { + const settings = yield* decodeServerSettings({ + secretProviders: { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault", config: { token: "bao-token" } }, + }, + }); + assert.deepStrictEqual( + ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault" }, + }, + ); + }), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a1e0d34c7dd53..e386b95cf506c 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -7,6 +7,7 @@ import { type ProviderInstanceConfig, type ProviderInstanceMutation, defaultInstanceIdForDriver, + isKnownSecretProviderKind, isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, @@ -128,11 +129,25 @@ const normalizeServerSettings = ( ); export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { - return mapSettingsSecrets(settings, (secret) => + const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); + return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; +} + +// A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. +function withoutUnknownSecretProviderConfigs( + settings: ServerSettings, +): ServerSettings["secretProviders"] { + return Object.fromEntries( + Object.entries(settings.secretProviders).map(([id, entry]) => { + if (isKnownSecretProviderKind(entry.kind)) return [id, entry]; + const { config: _config, ...envelope } = entry; + return [id, envelope]; + }), + ) as ServerSettings["secretProviders"]; } export function applyProviderInstanceMutation( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8c8509550bc11..86c7a4db56af4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1791,6 +1791,7 @@ export const layerWsRpc = ( threadSnapshotPagination: true, threadFind: true, threadFindProgressive: true, + secretProviderSettings: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), diff --git a/apps/web/src/components/settings/SecretsSettings.tsx b/apps/web/src/components/settings/SecretsSettings.tsx index e9efe2d85f6ed..2ff329ca6b73a 100644 --- a/apps/web/src/components/settings/SecretsSettings.tsx +++ b/apps/web/src/components/settings/SecretsSettings.tsx @@ -35,9 +35,18 @@ const SECRET_PROVIDER_KINDS: Readonly< export function SecretsSettingsPanel() { const { scope, environment } = useSettingsScope(); + const connected = environment?.connection.phase === "connected"; return ( - {environment?.connection.phase === "connected" ? ( + {connected && + environment.serverConfig !== null && + environment.serverConfig.secretProviderSettings !== true ? ( + +

+ Update T3 Code on {environment.label} to manage its secret providers. +

+
+ ) : connected ? ( { @@ -155,13 +153,14 @@ describe("ServerSettings secret providers", () => { ).toThrow(); }); - it("rejects a 1Password account op could misread", () => { + it("rejects writes of an unknown kind or an account op could misread", () => { + const write = (entry: unknown) => () => + decodeServerSettingsPatch({ secretProviders: { personal: entry } }); for (const account of ["", "my account", "--help"]) { - expect(() => decodeOnePasswordConfig({ account })).toThrow(); + expect(write(onePassword(account))).toThrow(); } - expect(decodeOnePasswordConfig({ account: "my.1password.com" }).account).toBe( - "my.1password.com", - ); + expect(write({ kind: "one_password", label: "Personal" })).toThrow(); + expect(write({ kind: "openbao", label: "Vault", config: { address: "https://x" } })).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1339a9322fa3a..c7d61aeb5ee21 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -859,7 +859,7 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -// Final once shipped: a key or field that fails here fails the whole settings decode on older builds. +// Final once shipped: older builds drop entries whose key fails here, and erase them on their next save. // Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. const SecretProviderSlug = TrimmedNonEmptyString.check( Schema.isMaxLength(64), @@ -903,11 +903,31 @@ export const SecretProviderConfig = Schema.Struct({ }); export type SecretProviderConfig = typeof SecretProviderConfig.Type; +/** Kinds this build can resolve. Their `config` holds no credentials, so clients may read it. */ +const SECRET_PROVIDER_KIND_CONFIG_GUARDS: Readonly boolean>> = { + one_password: Schema.is(OnePasswordSecretProviderConfig), +}; + +export function isKnownSecretProviderKind(kind: string): boolean { + return Object.hasOwn(SECRET_PROVIDER_KIND_CONFIG_GUARDS, kind); +} + // Limits that may change later are enforced on writes only, never on the stored envelope. +// Writes must be a kind this build knows with a config it accepts, so an older server +// rejects a newer kind instead of storing what it cannot check. const SecretProviderConfigWrite = Schema.Struct({ ...SecretProviderConfig.fields, label: SecretProviderLabel, -}); +}).check( + Schema.makeFilter((entry) => { + if (!isKnownSecretProviderKind(entry.kind)) { + return `Unsupported secret provider kind: ${entry.kind}.`; + } + return SECRET_PROVIDER_KIND_CONFIG_GUARDS[entry.kind]!(entry.config) + ? true + : `Invalid ${entry.kind} secret provider config.`; + }), +); export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; From bfee44655ccbb4ec282f3cdd52e521b6258785db Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:42:37 -0500 Subject: [PATCH 066/144] fix(web): keep a closed split terminal's tab in view (#22106) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/web/src/terminalUiStateStore.test.ts | 22 ++++++++++++++++++++++ apps/web/src/terminalUiStateStore.ts | 8 +++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index 57e8836f27ff5..1ad8ba86c06ae 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -7,6 +7,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore, } from "./terminalUiStateStore"; +import { DEFAULT_THREAD_TERMINAL_ID } from "./types"; const THREAD_ID = ThreadId.make("thread-1"); const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); @@ -138,6 +139,27 @@ describe("terminalUiStateStore actions", () => { ]); }); + it.each([ + ["terminal-3", "terminal-3", DEFAULT_THREAD_TERMINAL_ID], + [DEFAULT_THREAD_TERMINAL_ID, DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ["terminal-3", DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ])("keeps the split's tab when %s is active and %s closes", (activeId, closedId, nextId) => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.newTerminal(THREAD_REF, "terminal-2"); + store.setActiveTerminal(THREAD_REF, DEFAULT_THREAD_TERMINAL_ID); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.setActiveTerminal(THREAD_REF, activeId); + store.closeTerminal(THREAD_REF, closedId); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.activeTerminalId).toBe(nextId); + expect(terminalUiState.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + it("does not import a closed panel terminal from stale metadata", () => { const store = useTerminalUiStateStore.getState(); store.newTerminal(THREAD_REF, "term-2"); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 6629d8a6d99bf..b442e855ca12d 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -413,9 +413,15 @@ function closeThreadTerminal( } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); + const groupTerminalIds = + normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId)) + ?.terminalIds ?? []; + const groupIndex = groupTerminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId - ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + ? (groupTerminalIds[groupIndex + 1] ?? + groupTerminalIds[groupIndex - 1] ?? + remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? "") : normalized.activeTerminalId; From 69310dbd61a91db0f9ad7762a26d3c1b07710888 Mon Sep 17 00:00:00 2001 From: Onkar Pradip Jadhav <118719336+Onkarj012@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:12:59 +0530 Subject: [PATCH 067/144] fix(web): the ~ breadcrumb opens the home folder (#22412) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../web/src/components/files/filePath.test.ts | 28 ++++++++++++++++++- apps/web/src/components/files/filePath.ts | 3 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 22cb582493df2..3bc371b5c1ab9 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileBreadcrumbChildren, fileBreadcrumbs, retainedBreadcrumbTrail } from "./filePath"; +import { + breadcrumbPathOf, + fileBreadcrumbChildren, + fileBreadcrumbs, + isListableHostFolder, + retainedBreadcrumbTrail, +} from "./filePath"; describe("fileBreadcrumbs", () => { it("starts host paths outside the workspace at the filesystem root", () => { @@ -20,6 +26,26 @@ describe("fileBreadcrumbs", () => { "\\\\server\\share\\report.md", ]); }); + + it("builds home-relative breadcrumbs with navigable folders", () => { + expect(fileBreadcrumbs("t3code", "~/.claude/CLAUDE.md")).toEqual([ + { label: "~", path: "~/", kind: "directory" }, + { label: ".claude", path: "~/.claude", kind: "directory" }, + { label: "CLAUDE.md", path: "~/.claude/CLAUDE.md", kind: "file" }, + ]); + expect(isListableHostFolder("~/")).toBe(true); + expect(isListableHostFolder("~/.claude")).toBe(true); + }); + + it("keeps a project folder named ~ inside the project", () => { + expect(fileBreadcrumbs("t3code", "~").map((crumb) => crumb.path)).toEqual(["", "~"]); + }); +}); + +describe("breadcrumbPathOf", () => { + it("keeps the home root listable", () => { + expect(breadcrumbPathOf("~/")).toBe("~/"); + }); }); describe("fileBreadcrumbChildren", () => { diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 76e10839bb2c8..f9f3c9a5ac40a 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -23,7 +23,8 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File return { label: part, // A bare drive (`C:`) means the drive's current directory; its root is `C:\`. - path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : path, + // A bare `~` is not a host path; the home folder is `~/`. + path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : hostPath && path === "~" ? "~/" : path, kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), }; }), From 505d02282fc5ff398797df88be8a4160e56da412 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:43:22 -0500 Subject: [PATCH 068/144] fix(gitlab): accept merge request URLs from self-hosted hosts (#23848) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/pullRequestLinkInput.test.ts | 7 ++++++ .../src/shared/definition.test.ts | 22 +++++++++++++++++++ .../src/shared/definition.ts | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/pullRequestLinkInput.test.ts b/packages/client-runtime/src/pullRequestLinkInput.test.ts index 3747fdc833a4b..79bbc96bdea3b 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.test.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.test.ts @@ -161,6 +161,13 @@ describe("parsePullRequestReference", () => { ).toBe("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"); }); + it.each([ + "https://code.example.com/group/project/-/merge_requests/42", + "http://git.internal/group/sub/project/-/merge_requests/7", + ])("accepts self-hosted GitLab merge request URL %s", (url) => { + expect(parsePullRequestReference(url)).toBe(url); + }); + it("accepts gh pr checkout commands with raw numbers", () => { expect(parsePullRequestReference("gh pr checkout 42")).toBe("42"); }); diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index a05ee047384db..7f9e94fa90887 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -31,6 +31,28 @@ describe("GitLab change request URLs", () => { expect(parse("https://gitlab.com/t3code/-/merge_requests/12")).toBeNull(); }); + it("recognizes only merge request URLs it can parse", () => { + for (const url of [ + "https://gitlab.com/g/p?x=/-/merge_requests/5", + "https://gitlab.com/g/p#/-/merge_requests/5", + "https://gitlab.com?x=/g/p/-/merge_requests/5", + "https://gitlab.com#/g/p/-/merge_requests/5", + "https://gitlab.com/t3code/-/merge_requests/12", + "https://gitlab.com/g//-/merge_requests/5", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(false); + expect(parse(url), url).toBeNull(); + } + for (const url of [ + "https://code.acme.test/a/b/c/-/merge_requests/7?tab=diffs", + "http://git.internal/a/b/c/-/merge_requests/7#note_1", + "https://code.acme.test/a/b/c/-/merge_requests/7/diffs", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(true); + expect(parse(url), url).toMatchObject({ repository: "a/b/c", number: 7 }); + } + }); + it("builds merge request URLs below the repository", () => { expect( definition.changeRequestUrl({ diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 1a3c417641ce4..437320c0d0419 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -11,7 +11,7 @@ import { icon } from "./icon.ts"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = - /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; + /^https?:\/\/[^/?#\s]+\/[^/?#\s]+(?:\/[^/?#\s]+)+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; // Self-managed GitLab runs on any hostname and nests projects in groups. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; From 2c3779762b5ae58c3ef4ae69bc9cb0b97ff74783 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:43:46 -0500 Subject: [PATCH 069/144] fix(server): commits no longer land on a branch switched in mid-action (#24473) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/git/GitManager.test.ts | 21 +++++++++++---------- apps/server/src/git/GitWorkflowService.ts | 10 ++++------ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 7f374a79934ce..5d363826458df 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3906,7 +3906,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { return { repoDir, manager, providerCalls, firstCommit, pullHead }; }); - it.effect.each(["switch", "create-and-switch", "feature-branch"] as const)( + it.effect.each(["switch", "create-and-switch", "feature-branch", "commit"] as const)( "local PR checkout excludes a concurrent %s from a nested project with an alternate root", (operation) => Effect.gen(function* () { @@ -3956,7 +3956,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ...manager, runStackedAction: (input, options) => Effect.sync(() => { - events.push("feature-branch"); + events.push(input.featureBranch ? "feature-branch" : "commit"); NodeFS.writeFileSync( NodePath.join(repoDir, "second-client.txt"), "second client\n", @@ -3993,9 +3993,9 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }) : workflows[1]!.runStackedAction({ cwd: nestedCwd, - actionId: "second-client-feature-branch", + actionId: `second-client-${operation}`, action: "commit", - featureBranch: true, + featureBranch: operation === "feature-branch", commitMessage: "Second client commit", }), { startImmediately: true }, @@ -4008,15 +4008,16 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(events).toEqual([operation]); expect(prepared).toMatchObject({ branch: "feature/pr-safe", isOnPullRequestHead: true }); expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainCommit); - expect((yield* runGit(repoDir, ["rev-parse", "feature/pr-safe"])).stdout.trim()).toBe( - pullHead, - ); + const pullBranch = operation === "commit" ? "feature/pr-safe^" : "feature/pr-safe"; + expect((yield* runGit(repoDir, ["rev-parse", pullBranch])).stdout.trim()).toBe(pullHead); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe( operation === "switch" ? "main" - : "branch" in switched - ? switched.branch.name - : "second-client", + : operation === "commit" + ? "feature/pr-safe" + : "branch" in switched + ? switched.branch.name + : "second-client", ); if ("commit" in switched) { expect(switched.commit.status).toBe("created"); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 7bb098da87862..fdc00849af2d0 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -377,12 +377,10 @@ export const make = Effect.gen(function* () { runStackedAction: Effect.fn("GitWorkflowService.runStackedAction")(function (input, options) { return ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.flatMap((handle) => - input.featureBranch - ? withCheckoutLease( - handle.repository.rootPath, - gitManager.runStackedAction(input, options), - ) - : gitManager.runStackedAction(input, options), + withCheckoutLease( + handle.repository.rootPath, + gitManager.runStackedAction(input, options), + ), ), ); }), From 3b066dad5c7fa1db5803d655da5a803dde9c77d1 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:44:08 -0400 Subject: [PATCH 070/144] fix(server): image attachments report their size when the T3 home is behind a symlink (#24245) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/assets/AssetAccess.test.ts | 27 ++++++++++++++++++++++ apps/server/src/assets/AssetAccess.ts | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 39a6e651b84dd..11cb490901a52 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1199,6 +1199,33 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.skipIf(!symlinksSupported)( + "reports image attachment dimensions when the attachments directory is a symlink", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-attachments-link-" }); + const realAttachmentsDir = path.join(root, "real"); + const linkedAttachmentsDir = path.join(root, "linked"); + yield* fs.makeDirectory(realAttachmentsDir); + yield* fs.symlink(realAttachmentsDir, linkedAttachmentsDir); + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000041"; + yield* fs.writeFile(path.join(realAttachmentsDir, `${attachmentId}.png`), screenshotPng); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }).pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + attachmentsDir: linkedAttachmentsDir, + }), + ); + expect(result.imageDimensions).toEqual({ width: 390, height: 844 }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("serves document attachments inline when a viewer requests it", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 0ca85e6ea146e..8eb7894627230 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -659,7 +659,10 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ? inlinePreviewMimeTypeForExtension(extension) : undefined; if (!isGenericFile) { - imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); + imageDimensions = yield* fileSystem.realPath(attachmentPath).pipe( + Effect.flatMap(readImageDimensionsFromHeader), + Effect.orElseSucceed(() => null), + ); } claims = { version: 1, From ebc34dae6058a0b4ce05c3dc34c8fbf7cbe6e46e Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:44:30 +0000 Subject: [PATCH 071/144] fix(ssh): skip directories in config includes (#25187) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- packages/ssh/src/config.test.ts | 33 +++++++++++++++++++++++++++++++++ packages/ssh/src/config.ts | 1 + 2 files changed, 34 insertions(+) diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 6871ebcfd18c9..82a6319aefd63 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -56,6 +56,39 @@ describe("ssh config", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); + it.effect("skips directory Include matches without losing file aliases or known hosts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* makeTempHomeDir(); + const sshDir = path.join(homeDir, ".ssh"); + yield* fs.makeDirectory(path.join(sshDir, "config.d", "ignored"), { recursive: true }); + yield* fs.makeDirectory(path.join(sshDir, "explicit-directory")); + yield* fs.writeFileString( + path.join(sshDir, "config"), + "Include config.d/*\nInclude explicit-directory\nHost real\n HostName real.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "config.d", "team.conf"), + "Host team\n HostName team.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + "known.example.com ssh-ed25519 AAAA\n", + ); + + const hosts = yield* discoverSshHosts({ homeDir }); + assert.deepEqual( + hosts.map((host) => [host.alias, host.hostname, host.source]), + [ + ["known.example.com", "known.example.com", "known-hosts"], + ["real", "real.example.com", "ssh-config"], + ["team", "team.example.com", "ssh-config"], + ], + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("discovers ssh config hosts across included files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index 634d38aa0f7f9..8b6683a968a38 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -177,6 +177,7 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( return; } if (!(yield* fs.exists(resolvedPath))) return; + if ((yield* fs.stat(resolvedPath)).type === "Directory") return; visited.add(resolvedPath); const directory = path.dirname(resolvedPath); From 80dfe47ea529cc37a1c7833b1aeebed93d3a6976 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:45:04 -0400 Subject: [PATCH 072/144] fix(server): Authorized clients names Edge, Firefox and Opera on phones correctly (#23340) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/auth/utils.test.ts | 29 +++++++++++++++++++++++++++++ apps/server/src/auth/utils.ts | 6 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 63336b29c9005..30784ab33ceb0 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -24,6 +24,35 @@ describe("deriveAuthClientMetadata", () => { }); }); + it.each([ + [ + "Edge on Android", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0", + "Edge", + ], + [ + "Edge on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/129.0.2792.84 Mobile/15E148 Safari/605.1.15", + "Edge", + ], + [ + "Firefox on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/131.0 Mobile/15E148 Safari/605.1.15", + "Firefox", + ], + [ + "Opera on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 OPiOS/5.0.0 Mobile/15E148 Safari/605.1.15", + "Opera", + ], + ])("labels %s by its own browser token", (_name, userAgent, browser) => { + const metadata = deriveAuthClientMetadata({ + request: { headers: { "user-agent": userAgent }, source: {} } as never, + }); + + expect(metadata.browser).toBe(browser); + }); + it("applies client-presented display identity without replacing transport metadata", () => { const metadata = deriveAuthClientMetadata({ request: { diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index dc89a5da81140..29c0c5100a190 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -128,9 +128,9 @@ function inferBrowser(userAgent: string | undefined): string | undefined { return undefined; } const normalized = userAgent.toLowerCase(); - if (/edg\//.test(normalized)) return "Edge"; - if (/opr\//.test(normalized)) return "Opera"; - if (/firefox\//.test(normalized)) return "Firefox"; + if (/edg(?:e|a|ios)?\//.test(normalized)) return "Edge"; + if (/opr\/|opios\//.test(normalized)) return "Opera"; + if (/firefox\/|fxios\//.test(normalized)) return "Firefox"; if (/electron\//.test(normalized)) return "Electron"; if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; From 1d7af6591b8d6011bc449596d2be6184ab6c5c67 Mon Sep 17 00:00:00 2001 From: Jeremiah Jordan Date: Thu, 29 Oct 2026 12:45:29 -0500 Subject: [PATCH 073/144] fix(mobile): fork rows show a readable label instead of a thread ID (#22064) Co-authored-by: Jeremiah Jordan Co-authored-by: GPT-6.1-Sol Co-authored-by: maria-rcks --- apps/mobile/src/lib/threadActivity.test.ts | 35 ++++++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 1 + 2 files changed, 36 insertions(+) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index acdee2249c0d0..f2ced2d689794 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -24,6 +24,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadFeed, + workEntryRowLabel, threadFeedCompactionLabels, createThreadFeedCache, deriveThreadFeedPresentation, @@ -731,6 +732,40 @@ describe("buildThreadFeed", () => { expect(activities.at(-1)?.prominent).toBe(true); }); + it.each([ + ["local", undefined], + ["inherited", undefined], + ["synthetic", undefined], + ["synthetic", "Forked from conversation"], + ] as const)( + "labels a %s fork row with title %s without exposing its target ID", + (visibility, title) => { + const { providerThreadId: _providerThreadId, ...forkBase } = base( + "item-fork", + "2026-06-20T00:00:03.000Z", + 2, + ); + const activity = buildThreadFeed([ + projected( + { + ...forkBase, + type: "fork", + title: title ?? null, + source: { type: "run", threadId: sourceThreadId, runId }, + targetThreadId: threadId, + }, + 0, + visibility, + ), + ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0]!; + + expect(workEntryRowLabel(activity.workEntry)).toBe(title ?? "Thread forked"); + expect(workEntryRowLabel(activity.workEntry, true)).toBe(title ?? "Thread forked"); + expect(activity.canExpand).toBe(true); + expect(activity.getFullDetail()).toContain(threadId); + }, + ); + it("names the fold that hides a progress message, with or without a run", () => { const middle = { ...assistantMessage("2026-06-20T00:00:02.500Z"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e17748d77aa6c..4cc5a842c21f9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -253,6 +253,7 @@ function compactWorkEntryText(value: string): string { } export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = false): string { + if (entry.itemType === "fork") return entry.label; if (expanded && entry.itemType === "reasoning") return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought"; const presentation = resolveWorkEntryToolPresentation(entry); From 1d631342b01d0231cd1380606a0a0c5180322762 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Thu, 29 Oct 2026 18:45:51 +0100 Subject: [PATCH 074/144] fix(markdown): formulas ending in a comma render (#22883) Co-authored-by: maria-rcks --- packages/shared/src/markdownMath.test.ts | 21 +++++++++++++++++++ .../micromark-extension-llm-math@3.1.0.patch | 10 +++++++-- pnpm-lock.yaml | 6 +++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/markdownMath.test.ts b/packages/shared/src/markdownMath.test.ts index cb920c0737972..0bc6941e494c0 100644 --- a/packages/shared/src/markdownMath.test.ts +++ b/packages/shared/src/markdownMath.test.ts @@ -124,8 +124,18 @@ describe("chat math parsing", () => { "It costs $5 and $10.", "Budget $5-$10 per seat, or $20,000-$30,000 a year.", "Plans are $5/$10 monthly.", + "Pay $5,$10 now.", "Run $browser then $deploy on it.", "Set $HOME before running.", + "Set $HOME,$PATH before running.", + "Set $HOME, $PATH before running.", + "Set $HOME,$_PATH before running.", + "Set $home,$path before running.", + "Set $HOME,${PATH} before running.", + "Set $HOME,$? before running.", + "Set $HOME,$@ before running.", + "Set $HOME,$(pwd) before running.", + "Pay $1,000,$2,000 now.", ]) { expect(formulas(prose), prose).toEqual([]); expect(text(prose), prose).toBe(prose); @@ -143,6 +153,17 @@ describe("chat math parsing", () => { } }); + it("reads formulas that end in a comma", () => { + for (const boundary of ["", " ", "\n", ".", ",", ";", ":", ")", "]", "}"]) { + expect(formulas(`$x,$${boundary}`)).toEqual([["inline", "x,"]]); + } + expect(formulas("Where $x = 1,$ with $$a,$$ and $$ b,$$.")).toEqual([ + ["inline", "x = 1,"], + ["display", "a,"], + ["display", " b,"], + ]); + }); + it("leaves code, escapes, links, and citations alone", () => { for (const [markdown, rendered] of [ ["Use `$x$` in code.", "Use in code."], diff --git a/patches/micromark-extension-llm-math@3.1.0.patch b/patches/micromark-extension-llm-math@3.1.0.patch index 335ce4c11d57e..25da4f5f47755 100644 --- a/patches/micromark-extension-llm-math@3.1.0.patch +++ b/patches/micromark-extension-llm-math@3.1.0.patch @@ -235,6 +235,9 @@ diff --git a/dev/lib/math-tex-text.js b/dev/lib/math-tex-text.js diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js --- a/dev/lib/math-text.js +++ b/dev/lib/math-text.js +@@ -10 +10 @@ +-import {markdownLineEnding} from 'micromark-util-character' ++import {markdownLineEnding, markdownLineEndingOrSpace} from 'micromark-util-character' @@ -42,6 +42,13 @@ let size /** @type {Token} */ @@ -299,7 +302,7 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js if ( code === codes.eof || code === codes.space || -@@ -179,9 +207,26 @@ +@@ -179,9 +207,29 @@ // Done! if (size === sizeOpen) { @@ -314,11 +317,14 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js + if (code !== null && code >= codes.digit0 && code <= codes.digit9) { + return nok(code) + } ++ if (charBeforeClose === codes.comma && !openWhitespace && code !== codes.eof && !markdownLineEndingOrSpace(code) && !/[.,;:)\]}]/.test(String.fromCharCode(code))) { ++ return nok(code) ++ } + // Check if closing sequence has matching whitespace pattern + const closeWhitespace = + charBeforeClose === codes.space || + charBeforeClose === codes.horizontalTab || -+ charBeforeClose === codes.comma ++ (charBeforeClose === codes.comma && openWhitespace) + if (openWhitespace === closeWhitespace) { + effects.exit('mathTextSequence') + effects.exit('mathText') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bff27f291a17..196781c388b03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,7 +380,7 @@ patchedDependencies: expo-widgets@58.0.20: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 - micromark-extension-llm-math@3.1.0: 49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9 + micromark-extension-llm-math@3.1.0: 7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 react-native-image-viewing@0.2.2: 419f35ee91eac88ced9438058281db5eb09d9c799d8974c1172926068c9448cf @@ -20056,7 +20056,7 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9): + micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a): dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 @@ -21348,7 +21348,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 mdast-util-math: 3.0.0(patch_hash=afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c)(supports-color@8.1.1) - micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9) + micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a) unified: 11.0.5 transitivePeerDependencies: - supports-color From 20490521fa4adc3b16a652bc3592405d0ec4a790 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:46:14 -0400 Subject: [PATCH 075/144] fix(scripts): dev runner expands ~ in --home-dir and T3CODE_HOME (#25560) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- scripts/dev-runner.test.ts | 31 +++++++++++++++++++++++++++++++ scripts/dev-runner.ts | 6 ++++++ 2 files changed, 37 insertions(+) diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 895144079fc5b..d522bfdb15794 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -412,6 +412,37 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("expands a home-relative T3 home the way the server does", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homeFor = (t3Home: string) => + createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }).pipe( + Effect.provideService(HostProcess.HomeDirectory, "/home/dev"), + Effect.map((env) => env.T3CODE_HOME), + ); + + assert.equal(yield* homeFor("~/t3-dev"), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor("~"), "/home/dev"); + assert.equal(yield* homeFor("~\\t3-dev"), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor(" ~/t3-dev "), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor("~someone/t3-dev"), path.resolve("~someone/t3-dev")); + assert.equal(yield* homeFor("relative/t3-dev"), path.resolve("relative/t3-dev")); + assert.equal(yield* homeFor("/tmp/t3-dev"), path.resolve("/tmp/t3-dev")); + }), + ); + it.effect("rejects a specific non-loopback --host for browser dev modes", () => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 0495c513dc685..5903cb7686e9c 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -272,6 +272,12 @@ const resolveBaseDir = Effect.fn("dev-runner.resolveBaseDir")(function* ( const path = yield* Path.Path; const configured = baseDir?.trim(); + if (configured === "~") { + return yield* HostProcess.HomeDirectory; + } + if (configured?.startsWith("~/") || configured?.startsWith("~\\")) { + return path.join(yield* HostProcess.HomeDirectory, configured.slice(2)); + } if (configured) { return path.resolve(configured); } From 1dd8c676e43f1fd6dee98fa81bdec4a5c3750bab Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:46:36 -0500 Subject: [PATCH 076/144] fix(desktop): keep development protocol requests on the configured server (#22010) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/electron/ElectronProtocol.test.ts | 33 +++++++++++++++++++ apps/desktop/src/electron/ElectronProtocol.ts | 5 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 45d1771f26f07..aec3a2d5b45c0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -130,6 +130,39 @@ describe("ElectronProtocol", () => { }).pipe(Effect.provide(layerProtocol)), ); + it.effect("keeps proxied paths on the configured server", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async () => new Response("ok")); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:5733/"), + clerkFrontendApiHostname: undefined, + }); + yield* Effect.promise(() => + handler!(new Request("t3code-dev://app//example.com/src/main.tsx?t=1")), + ); + yield* Effect.promise(() => handler!(new Request("t3code-dev://app/\\example.com/"))); + }), + ); + + assert.deepEqual( + netFetchMock.mock.calls.map((call) => call[0]), + [ + "http://127.0.0.1:5733//example.com/src/main.tsx?t=1", + "http://127.0.0.1:5733//example.com/", + ], + ); + }).pipe(Effect.provide(layerProtocol)), + ); + it.effect("rejects custom protocol requests for another host", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d84b6f360c89e..27af3f2cfd8ae 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -167,7 +167,10 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( return new Response(null, { status: 404 }); } - const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); + // Assign the path so leading slashes cannot replace the configured host. + const targetUrl = new URL(targetOrigin); + targetUrl.pathname = requestUrl.pathname; + targetUrl.search = requestUrl.search; const headers = new Headers(request.headers); const headersToRemove: string[] = []; for (const name of headers.keys()) { From e0f972e3759c628b8c202930d5d38a53d4569b72 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:46:58 -0500 Subject: [PATCH 077/144] fix(mobile): apply terminal toolbar modifiers to arrow keys (#22161) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../features/terminal/terminalInput.test.ts | 32 +++++++++++++++++++ .../src/features/terminal/terminalInput.ts | 8 +++++ 2 files changed, 40 insertions(+) diff --git a/apps/mobile/src/features/terminal/terminalInput.test.ts b/apps/mobile/src/features/terminal/terminalInput.test.ts index 74240215af08a..17fabe588115c 100644 --- a/apps/mobile/src/features/terminal/terminalInput.test.ts +++ b/apps/mobile/src/features/terminal/terminalInput.test.ts @@ -12,6 +12,38 @@ const ESC = byte(0x1b); const CTRL_V = byte(0x16); describe("resolveModifiedTerminalInput", () => { + it.each(["A", "B", "C", "D"])("encodes ctrl+arrow %s with the cursor modifier", (arrow) => { + expect( + resolveModifiedTerminalInput({ + data: `${ESC}[${arrow}`, + modifier: "ctrl", + hostPlatform: "linux", + }), + ).toEqual({ kind: "write", data: `${ESC}[1;5${arrow}` }); + }); + + it.each(["A", "B", "C", "D"])("encodes meta+arrow %s with the cursor modifier", (arrow) => { + expect( + resolveModifiedTerminalInput({ + data: `${ESC}[${arrow}`, + modifier: "meta", + hostPlatform: "linux", + }), + ).toEqual({ kind: "write", data: `${ESC}[1;3${arrow}` }); + }); + + it.each(["a", "", ESC, `${ESC}[H`, `${ESC}[1;5D`, `${ESC}[D\n`])( + "preserves non-arrow modifier handling for %j", + (data) => { + expect( + resolveModifiedTerminalInput({ data, modifier: "meta", hostPlatform: "linux" }), + ).toEqual({ + kind: "write", + data: `${ESC}${data}`, + }); + }, + ); + it("pastes on ctrl+v for windows, linux, and unknown hosts", () => { for (const hostPlatform of ["windows", "linux", "unknown"] as const) { expect(resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform })).toEqual({ diff --git a/apps/mobile/src/features/terminal/terminalInput.ts b/apps/mobile/src/features/terminal/terminalInput.ts index 09886b41cb2bb..9c5e05dcc24f0 100644 --- a/apps/mobile/src/features/terminal/terminalInput.ts +++ b/apps/mobile/src/features/terminal/terminalInput.ts @@ -45,6 +45,14 @@ export function resolveModifiedTerminalInput(input: { return { kind: "paste" }; } + if ( + input.data.length === 3 && + input.data.startsWith("\u001b[") && + /[ABCD]/.test(input.data.charAt(2)) + ) { + return { kind: "write", data: `\u001b[1;${input.modifier === "ctrl" ? 5 : 3}${input.data[2]}` }; + } + return { kind: "write", data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`, From 40f6f5b8f033628a672b7a9213c27cd64db05cb3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:47:21 -0500 Subject: [PATCH 078/144] fix(server): clean old logs and artifacts under a symlinked T3 home (#24097) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/storageCleanup.test.ts | 36 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c05d4aed4cfbf..9cbf0f092f7bd 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1814,6 +1814,42 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each([ + { linked: "home", outcome: "removed", reason: "Removed 1 rotated logs" }, + { linked: "logs folder", outcome: "kept", reason: "No expired files" }, + ] as const)( + "rotated logs behind a symlinked $linked are $outcome", + ({ linked, outcome, reason }) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const disk = yield* fs.realPath(yield* fs.makeTempDirectoryScoped()); + yield* fs.makeDirectory(`${disk}/real/logs`, { recursive: true }); + if (linked === "home") { + yield* fs.symlink(`${disk}/real`, `${disk}/home`); + } else { + yield* fs.makeDirectory(`${disk}/home`); + yield* fs.symlink(`${disk}/real/logs`, `${disk}/home/logs`); + } + yield* fs.writeFileString(`${disk}/real/logs/server.log.1`, "log"); + yield* fs.utimes(`${disk}/real/logs/server.log.1`, 1, 1); + const { service, enableFiles, disable } = yield* cleanupFixture().pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + logsDir: `${disk}/home/logs`, + }), + ); + disable(); + enableFiles(); + const report = yield* service.runNow; + expect(report.entries.filter((entry) => entry.kind === "logs")).toMatchObject([ + { outcome, reason }, + ]); + expect(yield* fs.exists(`${disk}/real/logs/server.log.1`)).toBe(outcome === "kept"); + }), + ), + ); it.live("reports git failures without aborting the remaining categories", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 526aaffa2abb3..9bf040b0cd17c 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1059,8 +1059,10 @@ const make = Effect.gen(function* () { removed: { files: number; bytes: number }, ) { if (days === null || !(yield* fs.exists(root))) return removed; + // A symlinked parent (a linked T3 home) is fine; a symlinked folder is not. const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return removed; + const realParent = yield* fs.realPath(path.dirname(root)); + if (realRoot !== path.join(realParent, path.basename(root))) return removed; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { From b02b436e26f9c0c6c193ea2af22a50885ffe8aac Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:47:44 +0000 Subject: [PATCH 079/144] fix(server): preserve URL arguments in Android device actions (#25205) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- apps/server/src/device/DeviceActions.test.ts | 38 ++++++++++++++++++++ apps/server/src/device/DeviceActions.ts | 6 +++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 41e1de4304692..1a8cab7f29837 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -1,5 +1,9 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -43,6 +47,40 @@ const makeReady = ( const udid = "SIM-1"; describe("runDeviceAction", () => { + it.live.each([ + "https://example.com/callback?code=1&state=2", + "https://example.com/it's?a=1&b=2", + "https://example.com/a b?key=value", + "https://example.com/plain", + ])("opens the complete Android URL %s", (url) => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "android", { + type: "openUrl", + deviceId: "emulator-5554", + url, + }); + const args = calls[0]!.args; + expect(args.slice(0, 3)).toEqual(["-s", "emulator-5554", "shell"]); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // adb joins arguments verbatim before the Android shell parses them. + const stdout = yield* spawner.string( + ChildProcess.make("/bin/sh", [ + "-c", + `am() { printf '%s\\0' "$@"; }\n${args.slice(3).join(" ")}`, + ]), + ); + expect(stdout.split("\0").slice(0, -1)).toEqual([ + "start", + "-a", + "android.intent.action.VIEW", + "-d", + url, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([ ["emulator-5554", "Physical size: 2560x1600", "portrait", "-9.81:0:0"], ["emulator-5554", "Physical size: 2560x1600", "landscape_left", "0:9.81:0"], diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 002d8cb3e7d28..905a23d5cc726 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -342,13 +342,17 @@ const serveSimPermissions = Effect.fn("DeviceActions.serveSimPermissions")(funct const adb = (run: Runner, serial: string, args: ReadonlyArray, operation: string) => run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation))); +// adb shell joins arguments without escaping before the device's shell parses them. +const androidShellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; + const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( run: Runner, input: DeviceActionInput, ) { const serial = input.deviceId; const shell = (args: ReadonlyArray, operation: string) => - adb(run, serial, ["shell", ...args], operation); + adb(run, serial, ["shell", ...args.map(androidShellWord)], operation); switch (input.type) { case "setAppearance": yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance"); From c839797b6dceaa28cc61b7044fcae36bd7a11462 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:48:06 +0000 Subject: [PATCH 080/144] fix(server): keep terminal history after cancelled sequences (#25193) Co-authored-by: Aditya Garud <153842990+yashranaway@users.noreply.github.com> --- apps/server/src/terminal/Manager.test.ts | 29 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 16 +++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index e30a4cd3d720d..5b166d8b1452b 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -881,6 +881,35 @@ it.layer( }), ); + it.effect.each([ + { name: "OSC", sequence: "\u001b]0;unfinished" }, + { name: "DCS", sequence: "\u001bPunfinished" }, + { name: "PM", sequence: "\u001b^unfinished" }, + { name: "APC", sequence: "\u001b_unfinished" }, + { name: "CSI", sequence: "\u001b[5" }, + { name: "C1 OSC", sequence: "\u009d0;unfinished" }, + { name: "C1 DCS", sequence: "\u0090unfinished" }, + { name: "C1 PM", sequence: "\u009eunfinished" }, + { name: "C1 APC", sequence: "\u009funfinished" }, + { name: "C1 CSI", sequence: "\u009b5" }, + ])("keeps output after a cancelled $name sequence in history", ({ sequence }) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + for (const cancel of ["\u0018", "\u001a"]) { + process.emitData(sequence); + process.emitData(cancel); + process.emitData("next line\n"); + process.emitData(`${sequence}${cancel}next line\n`); + } + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history.match(/next line/g)).toHaveLength(4); + }), + ); + it.effect("handles CSI and DCS query sequences split across output chunks", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4c5229a7bae4f..a08b3e4afe784 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1067,6 +1067,10 @@ function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } +function isControlSequenceCancel(codePoint: number): boolean { + return codePoint === 0x18 || codePoint === 0x1a; +} + function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "n") { return true; @@ -1115,7 +1119,7 @@ function stripStringTerminator(value: string): string { function findStringTerminatorIndex(input: string, start: number): number | null { for (let index = start; index < input.length; index += 1) { const codePoint = input.charCodeAt(index); - if (codePoint === 0x07 || codePoint === 0x9c) { + if (codePoint === 0x07 || codePoint === 0x9c || isControlSequenceCancel(codePoint)) { return index + 1; } if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { @@ -1177,10 +1181,11 @@ function sanitizeTerminalHistoryChunk( if (nextCodePoint === 0x5b) { let cursor = index + 2; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; @@ -1231,10 +1236,11 @@ function sanitizeTerminalHistoryChunk( if (codePoint === 0x9b) { let cursor = index + 1; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; From 5aa81a31718dcd667c502022a568b9ca39c02b7f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:48:28 -0500 Subject: [PATCH 081/144] fix(auth): accept a typed pairing code regardless of letter case (#22089) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../server/src/auth/PairingGrantStore.test.ts | 33 +++++++++++++++++-- apps/server/src/auth/PairingGrantStore.ts | 14 ++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 561633d3524b2..4e940cd36cc51 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,6 +1,7 @@ import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -45,11 +46,11 @@ const layerPairingGrantStore = ( ); it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { - it.effect("issues one-time bootstrap tokens that can only be consumed once", () => + it.effect("accepts lowercase one-time tokens exactly once", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); - const first = yield* bootstrapCredentials.consume(issued.credential); + const first = yield* bootstrapCredentials.consume(issued.credential.toLowerCase()); const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); @@ -59,7 +60,16 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(issued.label).toBe("Julius iPhone"); expect(second._tag).toBe("UnknownBootstrapCredentialError"); expect(second.message).toContain("Unknown bootstrap credential"); - }).pipe(Effect.provide(layerPairingGrantStore())), + }).pipe( + Effect.provide(layerPairingGrantStore()), + Effect.provideServiceEffect( + Crypto.Crypto, + Effect.map(Crypto.Crypto, (crypto) => ({ + ...crypto, + randomBytes: (size) => Effect.succeed(new Uint8Array(size).fill(8)), + })), + ), + ), ); it.effect("atomically consumes a one-time token when multiple requests race", () => @@ -124,6 +134,23 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(layerPairingGrantStore())), ); + it.effect("matches a seeded desktop bootstrap credential exactly", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const exact = yield* bootstrapCredentials.consume("abcdefghjkmn"); + const otherCase = yield* Effect.flip(bootstrapCredentials.consume("ABCDEFGHJKMN")); + + expect(exact.method).toBe("desktop-bootstrap"); + expect(otherCase._tag).toBe("UnknownBootstrapCredentialError"); + }).pipe( + Effect.provide( + layerPairingGrantStore({ + desktopBootstrapToken: "abcdefghjkmn", + }), + ), + ), + ); + it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 4999fef9c49b7..440dbe3788a45 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -254,6 +254,11 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; +const PAIRING_TOKEN_PATTERN = new RegExp( + `^[${PAIRING_TOKEN_ALPHABET}]{${PAIRING_TOKEN_LENGTH}}$`, + "i", +); + const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig.ServerConfig; @@ -532,10 +537,15 @@ const make = Effect.gen(function* () { return yield* seededResult.error; } + // Only credentials in the issued pairing-code format are case-insensitive. + const pairingCredential = PAIRING_TOKEN_PATTERN.test(credential) + ? credential.toUpperCase() + : credential; + // Check scopes in the UPDATE so rejected requests cannot spend one-time links. const consumed = yield* pairingLinks .consumeAvailable({ - credential, + credential: pairingCredential, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, ...(input?.requestedScopes !== undefined ? { requestedScopes: input.requestedScopes } @@ -560,7 +570,7 @@ const make = Effect.gen(function* () { } const matching = yield* pairingLinks - .getByCredential({ credential }) + .getByCredential({ credential: pairingCredential }) .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); if (Option.isNone(matching)) { return yield* new UnknownBootstrapCredentialError({}); From a03e630705bc874dc8d0c297a741af3cf7e3a99f Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Thu, 29 Oct 2026 19:48:50 +0200 Subject: [PATCH 082/144] fix(web): find in thread opens from the thread title menu, so phones can reach it (#22352) Co-authored-by: maria-rcks --- apps/web/src/components/ChatView.tsx | 6 ++++++ apps/web/src/components/chat/ChatHeader.tsx | 4 ++++ .../src/components/threadActionMenu.logic.ts | 6 ++++++ apps/web/src/contextMenuFallback.ts | 7 +++++++ apps/web/src/hooks/useThreadActionMenu.test.ts | 17 ++++++++++++++++- apps/web/src/hooks/useThreadActionMenu.ts | 7 ++++++- 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 665bb2fc5f5cf..1bf6f9b0fc0e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2636,6 +2636,11 @@ export default function ChatView(props: ChatViewProps) { : EMPTY_PROVIDER_SKILLS; const threadFindEnabled = isServerThread && serverConfig?.threadFind === true; const isThreadFindActive = useThreadFindOpen(activeThreadRef, threadFindEnabled); + const openThreadFindStable = useStableCallback((threadRef: ScopedThreadRef) => { + if (threadFindEnabled && scopedThreadKey(threadRef) === activeThreadKey) { + openThreadFind(activeThreadKey); + } + }); useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, @@ -3241,6 +3246,7 @@ export default function ChatView(props: ChatViewProps) { {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} + {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ec92540efd1b0..144402d1dfe9f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -54,6 +55,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; + onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; } function resolveRenameCommit(input: { @@ -80,6 +82,7 @@ export const ChatHeader = memo(function ChatHeader({ rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, + onFindInThread, }: ChatHeaderProps) { const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -196,6 +199,7 @@ export const ChatHeader = memo(function ChatHeader({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, + onFind: onFindInThread, canShareWithGuest, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 3c4626c1ea1ad..ac4ef649ed07c 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -2,6 +2,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; export type ThreadActionMenuId = + | "find" | "new-thread-on-branch" | "filter-by-project" | "turn-into-project" @@ -68,6 +69,7 @@ export function buildDraftActionMenuItems(options: { export interface ThreadActionMenuState { readonly canOperate: boolean; + readonly canFind?: boolean; readonly branch: string | null; readonly projectFilter: { readonly label: string; @@ -98,6 +100,7 @@ export interface ThreadActionMenuState { /** Local navigation, read markers, and copying remain available to read-only clients. */ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean { return ![ + "find", "new-thread-on-branch", "project-settings", "mark-unread", @@ -114,6 +117,9 @@ export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { const items: ReadonlyArray> = [ + ...(state.canFind + ? [{ id: "find" as const, label: "Find in thread", icon: "text-search" }] + : []), ...(state.branch ? [ { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index eece920cba664..b63fc5b3ff7a2 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -150,6 +150,13 @@ const ICON_PATHS: Record +const createMenu = (options: { readonly onFind?: (threadRef: typeof target) => void } = {}) => useThreadActionMenu({ threadRef: target, projectCwd: "/project", onStartRename: () => recordEffect("rename"), + onFind: options.onFind, canShareWithGuest: false, }); @@ -178,4 +179,18 @@ describe("thread menu permissions", () => { expect(state.effects).toEqual([]); }, ); + + it("offers find only to a header that can open it, without task permission", async () => { + createMenu().openMenu(position); + expect(state.show.mock.calls[0]![0].some((item) => item.id === "find")).toBe(false); + state.show.mockResolvedValue("find"); + const onFind = vi.fn(() => recordEffect("find")); + createMenu({ onFind }).openMenu(position); + await state.completed.promise; + const items = state.show.mock.calls[1]![0]; + expect(items[0]).toMatchObject({ id: "find", label: "Find in thread" }); + expect(items[0]?.disabled).not.toBe(true); + expect(state.effects).toEqual(["find"]); + expect(onFind).toHaveBeenCalledExactlyOnceWith(target); + }); }); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 0e25cebeef878..cddef6046cf8b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -61,9 +61,10 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; readonly projectCwd: string | null; readonly onStartRename: () => void; + readonly onFind?: ((threadRef: ScopedThreadRef) => void) | undefined; readonly canShareWithGuest: boolean; }) { - const { threadRef, projectCwd, onStartRename, canShareWithGuest } = input; + const { threadRef, projectCwd, onStartRename, onFind, canShareWithGuest } = input; const router = useRouter(); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -133,6 +134,7 @@ export function useThreadActionMenu(input: { const snoozePresets = resolveSnoozePresets(now, timestampFormat, threadRef.environmentId); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), + canFind: onFind !== undefined, branch: thread.branch ?? null, projectFilter: null, isPinned: thread.pinnedAt != null, @@ -187,6 +189,9 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "find": + onFind?.(threadRef); + return; case "project-settings": { const project = projects.find( (candidate) => From e692729e22a2fa1445b7417f7039f20e0700b0a6 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:49:15 -0500 Subject: [PATCH 083/144] fix(desktop): resolve executable commands when checking CLI shadowing (#22046) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../desktop/src/app/DesktopCliCommand.test.ts | 34 +++++++++++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 14 +++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 3cdfdb6cfa383..7141ebde70537 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterEach, beforeEach, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -183,6 +184,39 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "skips a t3 directory or a file this user cannot execute", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const folder = path.join(home, "folder"); + const plain = path.join(home, "plain"); + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(path.join(folder, "t3"), { recursive: true }); + yield* fs.makeDirectory(plain); + yield* fs.writeFileString(path.join(plain, "t3"), "notes\n"); + // Root can execute mode 0o601; other users must honor the owner permissions. + yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":"); + + const command = yield* commandIn({ home }); + expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3")); + + const error = yield* Effect.flip(command.install); + expect(error.message).toContain(path.join(shadow, "t3")); + + yield* fs.remove(shadow, { recursive: true }); + expect(yield* command.install).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + }).pipe(Effect.scoped), + ); + it.effect("leaves the Windows PATH alone when it cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 2ae070d638263..8a89a366814ed 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.access has no execute check. +import * as NodeFSP from "node:fs/promises"; + import type { DesktopCliCommandState } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -118,10 +121,19 @@ export const make = Effect.gen(function* () { Effect.mapError(() => fail("Could not update your PATH.")), ); + const isExecutableFile = Effect.fn("DesktopCliCommand.isExecutableFile")( + function* (target: string) { + if ((yield* fs.stat(target)).type !== "File") return false; + yield* Effect.tryPromise(() => NodeFSP.access(target, NodeFSP.constants.X_OK)); + return true; + }, + Effect.orElseSucceed(() => false), + ); + const firstOnPath = Effect.gen(function* () { for (const directory of pathEntries(process.env.PATH, ":")) { const candidate = path.join(directory, "t3"); - if (yield* exists(candidate)) return Option.some(candidate); + if (yield* isExecutableFile(candidate)) return Option.some(candidate); } return Option.none(); }); From a8e418885e203ea6014f0ca3f714fca149d4fbd0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:49:39 -0500 Subject: [PATCH 084/144] fix(server): SQLite temp files no longer block the event loop (#24194) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/persistence/Sqlite.test.ts | 45 ++++++++++++++++++++++ apps/server/src/persistence/Sqlite.ts | 3 ++ 2 files changed, 48 insertions(+) diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 45b28834a4ad7..940d626514336 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -5,12 +5,57 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "./Sqlite.ts"; +// Linux names an open, already-deleted file " (deleted)" in /proc/self/fd. +// SQLite names its temp files "etilqs_*" and, without TMPDIR, puts them in +// /var/tmp while os.tmpdir() is /tmp, so match the name, not the directory. +const openDeletedTempFiles = () => + NodeFS.readdirSync("/proc/self/fd").flatMap((fd) => { + try { + const target = NodeFS.readlinkSync(`/proc/self/fd/${fd}`); + return target.includes("/etilqs_") && target.endsWith(" (deleted)") ? [target] : []; + } catch { + return []; + } + }); + +it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "keeps a write transaction's statement journal off the temp directory", + () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-temp-")); + const persistence = SqlitePersistence.layerFromPath( + NodePath.join(tempDir, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE journal_probe(id INTEGER PRIMARY KEY, payload TEXT NOT NULL)`; + for (let id = 0; id < 64; id++) { + yield* sql`INSERT INTO journal_probe VALUES (${id}, ${"x".repeat(4096)})`; + } + const before = new Set(openDeletedTempFiles()); + const opened = yield* sql.withTransaction( + Effect.gen(function* () { + // A multi-row update can fail halfway, so SQLite journals every page + // it changes, here well past the 64 KiB it holds in memory by default. + yield* sql`UPDATE journal_probe SET payload = payload || 'y'`; + return openDeletedTempFiles().filter((target) => !before.has(target)); + }), + ); + assert.deepEqual(opened, []); + }).pipe( + Effect.provide(persistence), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }, +); + it.effect("drops planner statistics a database already has", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-stats-")); const dbPath = NodePath.join(tempDir, "state.sqlite"); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 48c1ab8fc8913..2f21ea0a8a942 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -28,6 +28,9 @@ const layerSetup = Layer.effectDiscard( yield* sql`PRAGMA synchronous = NORMAL;`; // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere. yield* sql`PRAGMA checkpoint_fullfsync = ON;`; + // Keep statement journals and sort spills off the main thread's disk path. + // Large statements and open savepoints can increase peak memory use. + yield* sql`PRAGMA temp_store = MEMORY;`; // PASSIVE checkpoints retain peak WAL size until the last connection closes. yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); From 849d2b5ac8fc9b1a891f0115f030c73d672ec646 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:50:01 -0400 Subject: [PATCH 085/144] fix(server): a branch named like a tag no longer reads as heads/ (#24258) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 17 +++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 31 ++++++++++++-------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 18709ecf0e290..b26f7d82a903b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -404,6 +404,7 @@ describe("pullCurrentBranch", () => { const driver = yield* GitVcsDriver.GitVcsDriver; const fs = yield* FileSystem.FileSystem; const { cwd, updater, initialBranch, remoteHead } = yield* cloneBehindRemote; + yield* git(cwd, ["tag", initialBranch]); yield* git(updater, ["push", "origin", "HEAD:refs/heads/other"]); const remoteName = customMapping ? "team/upstream" : "origin"; const refPrefix = customMapping @@ -2279,6 +2280,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { + it.effect("reads the current branch by name when a tag shares it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "release"]); + yield* git(cwd, ["tag", "release"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const remoteStatus = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + assert.equal(remoteStatus.branch, "release"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const branchRange = preview.sources.find((source) => source.kind === "branch-range"); + assert.equal(branchRange?.headRef, "release"); + }), + ); + it.effect("does not open a project folder laid out as a bare repository", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f0b78aa564fb5..a7ba2b86771ac 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -514,6 +514,15 @@ function classifyGitFailure(stderr: string): GitCommandFailureReason | null { return null; } +// `--short` and `--abbrev-ref` print `heads/` when a tag shares the branch's name, so +// branch reads take the full ref and strip the prefix themselves. +function branchNameFromHeadRef(value: string): string | null { + const ref = value.trim(); + return ref.startsWith("refs/heads/") && ref.length > "refs/heads/".length + ? ref.slice("refs/heads/".length) + : null; +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -1412,7 +1421,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGit( "GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -1443,11 +1452,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), Effect.orElseSucceed(() => undefined), ); - const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = - currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 - ? currentBranchOutput - : null; + currentBranchResult.exitCode === 0 ? branchNameFromHeadRef(currentBranchResult.stdout) : null; return { gitCommonDir, @@ -1938,7 +1944,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], + ["rev-parse", "--symbolic-full-name", "HEAD"], { allowNonZeroExit: true }, ).pipe( Effect.catchTags({ @@ -1960,7 +1966,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetailsRemote.branch", cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], + args: ["rev-parse", "--symbolic-full-name", "HEAD"], }), detail: "Git branch lookup failed.", exitCode: branchResult.exitCode, @@ -1972,12 +1978,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchValue = yield* runGitStdout( "GitVcsDriver.statusDetailsRemote.unbornBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], ); - branch = branchValue.trim() || null; + branch = branchNameFromHeadRef(branchValue); } else { - const branchValue = branchResult.stdout.trim(); - branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + branch = branchNameFromHeadRef(branchResult.stdout); } const currentUpstreamRef = yield* readCurrentUpstreamRef(cwd); let upstreamRef = @@ -2781,9 +2786,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdout( "GitVcsDriver.pullCurrentBranch.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], true, - ).pipe(Effect.map((stdout) => stdout.trim())), + ).pipe(Effect.map(branchNameFromHeadRef)), runGitStdout("GitVcsDriver.pullCurrentBranch.currentSha", cwd, ["rev-parse", "HEAD"]).pipe( Effect.map((stdout) => stdout.trim()), ), From 0ed554001a9be58c2e2080592096f784f8c7a65b Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:50:24 +0000 Subject: [PATCH 086/144] fix(server): cap generated worktree folder names (#25212) Co-authored-by: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 36 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 13 +++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b26f7d82a903b..47b3f3a62b7e9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3488,6 +3488,42 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps generated worktree folders short without changing long branch names", () => + Effect.gen(function* () { + const pathService = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const worktreesDirectory = yield* makeTmpDir("custom-worktrees-"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const folders = new Set(); + const branches = [ + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"c".repeat(90)}`, + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"d".repeat(90)}`, + `feature/${"a".repeat(40)}/5fac`, + `feature/${"a".repeat(40)}/18828`, + `${"a".repeat(22)}๐Ÿ˜€/${"b".repeat(30)}`, + "z".repeat(40), + "z".repeat(41), + ]; + for (const branch of branches) { + const created = yield* driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: branch }, + { worktreesDirectory }, + ); + const folder = pathService.basename(created.worktree.path); + assert.isAtMost(folder.length, 40); + if (branch.length <= 40) assert.equal(folder, branch); + assert.equal(yield* git(created.worktree.path, ["branch", "--show-current"]), branch); + assert.include( + yield* git(cwd, ["-c", "core.quotePath=false", "worktree", "list", "--porcelain"]), + `worktree ${created.worktree.path}`, + ); + folders.add(folder); + } + assert.equal(folders.size, branches.length); + }), + ); + it.effect("resolves the submodule mode from the option, then t3.json", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a7ba2b86771ac..49149d95f58f6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3811,7 +3811,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.refName); yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.newRefName); const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); // Resolve sub/.. before taking the basename. const repoName = path.basename(path.resolve(input.cwd)); let worktreePath = input.path; @@ -3830,7 +3829,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings โ†’ Worktrees.`, }); } - worktreePath = path.join(parentDir, repoName, sanitizedBranch); + let directoryName = targetBranch.replace(/\//g, "-"); + // Leave room for files beneath the worktree on Windows, regardless of branch length. + if (directoryName.length > 40) { + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(targetBranch)) + .pipe(Effect.orDie); + // Avoid splitting a surrogate pair, which Git would list back as a different path. + const prefix = directoryName.slice(0, 23).replace(/[\uD800-\uDBFF]$/, ""); + directoryName = `${prefix}-${Hex.encode(digest).slice(0, 16)}`; + } + worktreePath = path.join(parentDir, repoName, directoryName); } if (input.newRefName) { // Kept apart from the checkout so the shared-config lock only covers the tracking write. From c6f2b7dbae472b1a6ce75bd74bc7071f4c96b789 Mon Sep 17 00:00:00 2001 From: Joseph Vidal <135430578+josephv123@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:50:47 -0400 Subject: [PATCH 087/144] perf(server): checkpoint capture scans the index listing by record instead of by byte (#21329) Co-authored-by: maria-rcks --- apps/server/src/vcs/GitVcsDriver.test.ts | 14 +++++---- apps/server/src/vcs/GitVcsDriver.ts | 36 +++++++++++++++--------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 0e3d2b5063ed2..c7a06c3bdc31f 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -682,9 +682,13 @@ it.effect.each( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect.each(["normal", "flags", "sparse"] as const)( - "checkpoint index inspection handles entries beyond the output cap (index=%s)", - (indexMode) => +it.effect.each( + (["normal", "flags", "sparse"] as const).flatMap((indexMode) => + [1, 3, 4096].map((chunkSize) => ({ indexMode, chunkSize })), + ), +)( + "checkpoint index inspection handles entries beyond the output cap (index=$indexMode, chunk=$chunkSize)", + ({ indexMode, chunkSize }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -720,8 +724,8 @@ it.effect.each(["normal", "flags", "sparse"] as const)( ...input, maxOutputBytes: 8, onStdoutChunk: (chunk) => { - for (let i = 0; i < chunk.length; i++) - input.onStdoutChunk?.(chunk.subarray(i, i + 1)); + for (let i = 0; i < chunk.length; i += chunkSize) + input.onStdoutChunk?.(chunk.subarray(i, i + chunkSize)); }, } : input, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index e3249c70882ec..acbf817462c12 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -899,7 +899,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( let specialFlags = false; let recordStart = true; let skipped = false; - let skippedRecord: number[] = []; + let skippedRecord: Uint8Array[] = []; const skippedPaths: string[] = []; yield* vcsProcess.run({ operation, @@ -909,27 +909,35 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( env: commitEnv, maxOutputBytes: 4_096, outputMode: "truncate", + // Only tags and sparse skip-worktree paths matter; scan NUL-delimited records. onStdoutChunk: (chunk) => { - for (const byte of chunk) { - if (recordStart) skipped = byte === 83; + let start = 0; + while (start < chunk.length) { + const end = chunk.indexOf(0, start); + if (recordStart) { + const tag = chunk[start]!; + skipped = tag === 83; + if ((tag >= 97 && tag <= 122) || (!sparseCheckout && tag === 83)) { + specialFlags = true; + } + } if (skipped && sparseCheckout) { - if (byte !== 0) skippedRecord.push(byte); - else { - if (skippedRecord.at(-1) !== 47) { - const name = Buffer.from(skippedRecord).subarray(2); + skippedRecord.push( + Buffer.from(chunk.subarray(start, end === -1 ? undefined : end)), + ); + if (end !== -1) { + const record = Buffer.concat(skippedRecord); + if (record.at(-1) !== 47) { + const name = record.subarray(2); if (!NodeBuffer.isUtf8(name)) specialFlags = true; else skippedPaths.push(name.toString("utf8")); } skippedRecord = []; } } - if ( - recordStart && - ((byte >= 97 && byte <= 122) || (!sparseCheckout && byte === 83)) - ) { - specialFlags = true; - } - recordStart = byte === 0; + recordStart = end !== -1; + if (!recordStart) break; + start = end + 1; } }, }); From aaba9de94f6e3f8b57ef9b605621afde791e625e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:51:06 -0700 Subject: [PATCH 088/144] fix(server): PR discovery sweeps skip hidden subagent threads (#26983) Co-authored-by: Claude Opus 5.5 --- .../ThreadPullRequestService.test.ts | 122 ++++++++++++++++++ .../ThreadPullRequestService.ts | 13 +- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 5627a6dc37aa9..5f380c7e0ace7 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -179,6 +179,128 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect("leaves delegated child threads to their own refresh instead of the sweep", () => + Effect.scoped( + Effect.gen(function* () { + const parent = { + ...threadShell("parent-thread"), + branch: "feature/parent", + worktreePath: "/workspace/parent", + }; + const child: OrchestrationV2ThreadShell = { + ...threadShell("child-thread"), + branch: "t3/reply-ok", + worktreePath: "/workspace/child", + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent", + }, + }; + const project: OrchestrationProjectShell = { + id: parent.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const domainEvents = yield* Queue.unbounded(); + const sweeps = yield* Queue.unbounded(); + const childLookup = yield* Deferred.make(); + const lookups: Array<{ readonly branch: string; readonly refresh: boolean }> = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.fromQueue(domainEvents), + getShellSnapshot: () => + Queue.offer(sweeps, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [parent, child], + archivedThreads: [], + }), + ), + getThreadEventSequence: () => Effect.succeed(1), + getThreadShell: (threadId) => + Effect.succeed([parent, child].find((thread) => thread.id === threadId) ?? null), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input, options) => + Effect.gen(function* () { + const refresh = options?.refresh ?? false; + lookups.push({ branch: input.branch, refresh }); + if (input.branch === child.branch && refresh) { + yield* Deferred.succeed(childLookup, undefined); + } + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), + }), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + FileSystem.layerNoop({}), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + + // Startup backfill sweep, then one periodic sweep. + yield* Queue.take(sweeps); + yield* service.drain; + yield* TestClock.adjust("1 minute"); + yield* Queue.take(sweeps); + yield* service.drain; + const sweepLookups = lookups.map((lookup) => lookup.branch).toSorted(); + + // The child's own turn still discovers its pull request. + yield* Queue.offer(domainEvents, { + type: "checkpoint.captured", + threadId: child.id, + } as OrchestrationV2DomainEvent); + yield* Deferred.await(childLookup); + yield* service.drain; + + expect(sweepLookups).toEqual([parent.branch, parent.branch]); + expect(lookups.slice(sweepLookups.length)).toEqual([ + { branch: child.branch, refresh: true }, + ]); + }).pipe(Effect.provide(dependencies)); + }), + ), + ); + it.effect.each([ { state: "closed", sameBranch: false }, { state: "merged", sameBranch: false }, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 7b3c09ba15b8a..0db9feac8587e 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -139,9 +139,16 @@ export const make = Effect.gen(function* () { readThreadSnapshot(request), projectStore.listShells(), ]); + // Subagent children refresh after their own runs; sweeping them costs a lookup each per minute. + const snapshotThreads = + request.threadId === null + ? threadSnapshot.threads.filter( + (thread) => thread.lineage.relationshipToParent !== "subagent", + ) + : threadSnapshot.threads; const projects = new Map(projectShells.map((project) => [project.id, project])); if (request.backfill) { - for (const thread of threadSnapshot.threads) { + for (const thread of snapshotThreads) { if ( (thread.settledOverride === "settled" || thread.settledAt !== null) && thread.branchPullRequest == null @@ -151,13 +158,13 @@ export const make = Effect.gen(function* () { } } const visibleThreadIds = new Set( - threadSnapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id), + snapshotThreads.filter((thread) => thread.branch !== null).map((thread) => thread.id), ); const checkedIds = request.threadId === null ? [...pendingBackfill.keys()] : [request.threadId]; for (const threadId of checkedIds) { if (!visibleThreadIds.has(threadId)) pendingBackfill.delete(threadId); } - const threads = threadSnapshot.threads.filter( + const threads = snapshotThreads.filter( (thread) => thread.archivedAt === null && ((thread.settledOverride !== "settled" && thread.settledAt === null) || From 8084e00bced3939c93c87f70301a7135c2f9d794 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:51:10 +0000 Subject: [PATCH 089/144] fix(shared): stop parsing CLI flags after -- (#25204) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../Adapters/ClaudeAdapterV2.test.ts | 15 +++++++++ packages/shared/src/cliArgs.test.ts | 33 ++++++++++++++++++- packages/shared/src/cliArgs.ts | 6 +++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ea5ee249a787f..38253cd82f1d1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -235,6 +235,21 @@ function makeClaudeTestTurnInput(input: { } describe("ClaudeAdapterV2 runtime query policy", () => { + it.each([ + "-- --thinking-display omitted", + "--thinking-display summarized -- --thinking-display omitted", + ])("does not treat operands after -- as SDK flags: %s", (launchArgs) => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "thinking-thread", + resume: false, + cwd: "/workspace", + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs }, + }); + assert.deepEqual(options.thinking, { type: "adaptive", display: "summarized" }); + assert.equal(options.extraArgs?.["thinking-display"], "summarized"); + }); + it.each([ ["--permission-mode acceptEdits", "acceptEdits"], ["--dangerously-skip-permissions", "bypassPermissions"], diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index e4fdfe3c2dbac..d4006e3bf4ebd 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -54,10 +54,41 @@ describe("parseCliArgs", () => { }); }); - it("ignores bare -- with no flag name", () => { + it("accepts a trailing -- without operands", () => { expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] }); }); + it.each([ + { args: "--effort low -- --effort high --chrome" }, + { args: ["--effort", "low", "--", "--effort", "high", "--chrome"] }, + ])("ends option parsing at -- for $args", ({ args }) => { + expect(parseCliArgs(args)).toEqual({ + flags: { effort: "low" }, + positionals: ["--effort", "high", "--chrome"], + }); + }); + + it("preserves all operands after --, including empty strings and another --", () => { + expect(parseCliArgs(["--", "", "--", "--model=example", "-x"])).toEqual({ + flags: {}, + positionals: ["", "--", "--model=example", "-x"], + }); + }); + + it("ends option parsing with explicit boolean flags too", () => { + expect(parseCliArgs(["--chrome", "--", "--verbose"], { booleanFlags: ["chrome"] })).toEqual({ + flags: { chrome: null }, + positionals: ["--verbose"], + }); + }); + + it("keeps -- inside an equals-form option value", () => { + expect(parseCliArgs("--append-system-prompt=-- --chrome")).toEqual({ + flags: { "append-system-prompt": "--", chrome: null }, + positionals: [], + }); + }); + it("boolean flag does not consume next token as value", () => { expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( { diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts index e6e348797a907..253b7191ed83d 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -77,9 +77,13 @@ export function parseCliArgs( for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; + if (token === "--") { + positionals.push(...tokens.slice(i + 1)); + break; + } + if (token.startsWith("--")) { const rest = token.slice(2); - if (!rest) continue; const eqIndex = rest.indexOf("="); if (eqIndex !== -1) { From 293669d759c009ce61b47510eceb0703c4c08b0c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:51:32 +0000 Subject: [PATCH 090/144] fix(server): remove abandoned v1 database copies on startup (#25301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matฤ›j Suchรกnek <16311043+immakermatty@users.noreply.github.com> --- .../persistence/initializeV2Database.test.ts | 26 ++++++++++++++++ .../src/persistence/initializeV2Database.ts | 30 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index 561cd72ea9f71..ec8ded1cab5ac 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -1,4 +1,5 @@ // @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"; @@ -182,3 +183,28 @@ it.effect("starts fresh without V1 and never imports over existing V2 state", () Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), ); }); + +it.effect("removes copies left by a killed start, but not one that is still running", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-abandoned-")); + const killed = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid; + const running = `.v2-import-${process.pid}-Kq81Zx`; + const unrelated = ".v2-import-not-a-copy"; + return Effect.gen(function* () { + for (const name of [`.v2-import-${killed}-Wm3nPa`, ".v2-import-Q3xZ9a", running]) { + NodeFS.mkdirSync(NodePath.join(directory, name)); + NodeFS.writeFileSync(NodePath.join(directory, name, "snapshot.sqlite"), "partial copy"); + } + NodeFS.mkdirSync(NodePath.join(directory, unrelated)); + const destinationPath = NodePath.join(directory, "statev2.sqlite"); + NodeFS.writeFileSync(destinationPath, "existing V2 database"); + yield* initializeV2Database(destinationPath); + assert.deepEqual( + NodeFS.readdirSync(directory).sort(), + [running, unrelated, "statev2.sqlite"].sort(), + ); + assert.equal(NodeFS.readFileSync(destinationPath, "utf8"), "existing V2 database"); + }).pipe( + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), + ); +}); diff --git a/apps/server/src/persistence/initializeV2Database.ts b/apps/server/src/persistence/initializeV2Database.ts index 755907b5076f9..4f6d8d969c3ec 100644 --- a/apps/server/src/persistence/initializeV2Database.ts +++ b/apps/server/src/persistence/initializeV2Database.ts @@ -14,6 +14,33 @@ export class V2DatabaseImportError extends Schema.TaggedError { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +// Legacy names have no PID; newer copies stay while their process is alive. +const removeAbandonedCopies = Effect.fn("removeAbandonedCopies")(function* (directory: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + for (const entry of entries) { + const copy = /^\.v2-import-(?:(\d+)-)?[A-Za-z0-9]{6}$/.exec(entry); + if (copy === null || (copy[1] !== undefined && isProcessAlive(Number(copy[1])))) continue; + yield* fs.remove(path.join(directory, entry), { recursive: true }); + } +}); + /** Its copied legacy tables remain the source for lazy transcript import. */ export const initializeV2Database = Effect.fn("initializeV2Database")(function* ( destinationPath: string, @@ -22,12 +49,13 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* const path = yield* Path.Path; const directory = path.dirname(destinationPath); const sourcePath = path.join(directory, "state.sqlite"); + yield* removeAbandonedCopies(directory).pipe(Effect.ignore({ log: true })); yield* Effect.gen(function* () { if (yield* fs.exists(destinationPath)) return; if (!(yield* fs.exists(sourcePath))) return; const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ directory, - prefix: ".v2-import-", + prefix: `.v2-import-${process.pid}-`, }); const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite"); yield* Effect.tryPromise(async () => { From 6ca95bd92a066108a04f854b858908e942e5b322 Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Thu, 29 Oct 2026 19:51:54 +0200 Subject: [PATCH 091/144] fix(web): Enter no longer skips an unanswered question (#23626) Co-authored-by: maria-rcks --- ...UserInputPanel.navigation.browser.test.tsx | 55 ++++++++++++++++--- .../chat/usePendingThreadRequests.ts | 1 + 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 52800d94a8bca..58ad8e8c0900d 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -9,6 +9,7 @@ import { import { makeThreadRequestsResolvedAnnouncer } from "@t3tools/client-runtime/state/thread-requests"; import { useLayoutEffect, useRef, type ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ ...(await importOriginal()), @@ -73,20 +74,16 @@ function questionProjection(threadId: ThreadId, requestIds = ["first", "second"] } satisfies OrchestrationV2ThreadProjection; } -let requests: ReturnType; - -function PendingRequestsProbe({ - environmentId, - projection, - loading = false, -}: { +interface ProbeProps { environmentId: EnvironmentId; projection: OrchestrationV2ThreadProjection; loading?: boolean; -}) { +} + +function useProbeRequests({ environmentId, projection, loading = false }: ProbeProps) { const promptRef = useRef(""); const composerRef = useRef(null); - const state = usePendingThreadRequests({ + return usePendingThreadRequests({ environmentId, activeThreadId: projection.thread.id, serverProjection: loading ? null : projection, @@ -97,6 +94,12 @@ function PendingRequestsProbe({ composerRef, setThreadError: () => {}, }); +} + +let requests: ReturnType; + +function PendingRequestsProbe(props: ProbeProps) { + const state = useProbeRequests(props); useLayoutEffect(() => { requests = state; }); @@ -138,6 +141,40 @@ it("retains answers, selections and question position after leaving chat, includ expect(requests).toMatchObject(expected); }); +function QuestionPositionProbe(props: ProbeProps) { + const state = useProbeRequests(props); + return ( + <> + Question {state.activePendingQuestionIndex + 1} + + + + ); +} + +it("stays on an unanswered question when the composer submits", async () => { + await using screen = await render( + , + ); + const submit = screen.getByRole("button", { name: "Submit" }); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 1"); + + await userEvent.click(screen.getByRole("button", { name: "Choose Incremental" })); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 2"); +}); + it("isolates drafts by environment, thread and request and discards only resolved requests", async () => { using _timers = fakeTimers(); const environmentId = EnvironmentId.make("scope-environment"); diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 91c5bd0d6c912..8a07b86b5eece 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -473,6 +473,7 @@ export function usePendingThreadRequests(input: { } return; } + if (!activePendingProgress.canAdvance) return; setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }; const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); From d15233544ecfa8c746e5a3d0b7e7c3a08532fc74 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:52:16 -0500 Subject: [PATCH 092/144] fix(web): keep wide characters in terminal links (#24013) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/web/src/terminal/ghostty/core.ts | 3 +- apps/web/src/terminal/ghostty/surface.test.ts | 36 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 23 +++++++++--- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 3bd8a89807e44..60c81478f7436 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -1067,7 +1067,8 @@ export class GhosttyTerminalCore { } } let wide = 0; - if (text.length === 0 && cells.at(-1)?.text.length) { + // A wrap spacer in the final column can follow a spacer tail. + if (text.length === 0 && (cells.at(-1)?.text.length || cells.length === cols - 1)) { this.assertSuccess( "ghostty_render_state_row_cells_get(raw)", this.runtime.call( diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 8816105599b83..5473e3c4ffa1c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -512,6 +512,42 @@ const cell = (text: string): GhosttyCell => ({ }); describe("terminalLinkAtPositionWithRange", () => { + it.each([ + { text: "https://example.com/ๆ—ฅๆœฌ", cols: 80, end: { x: 23, y: 0 } }, + { text: "src/ๆ—ฅๆœฌ/main.ts:3:1", cols: 80, end: { x: 19, y: 0 } }, + { text: "https://example.com/๐Ÿ™‚", cols: 80, end: { x: 21, y: 0 } }, + { text: "https://example.com/ๆ—ฅๆœฌ", cols: 23, end: { x: 1, y: 1 } }, + ])("keeps every cell of $text clickable at $cols columns", async ({ text, cols, end }) => { + const core = await GhosttyTerminalCore.create( + cols, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + try { + core.write(text + " next"); + const rows = core.snapshot().rowData; + for (let y = 0; y <= end.y; y += 1) { + const lastColumn = y === end.y ? end.x : cols - 2; + for (let x = 0; x <= lastColumn; x += 1) { + expect(terminalLinkAtPositionWithRange(rows, y, x)).toEqual({ + text, + range: { start: { x: 0, y: 0 }, end }, + }); + } + } + expect(terminalLinkAtPositionWithRange(rows, end.y, end.x + 1)).toBeNull(); + } finally { + core.dispose(); + } + }); + it("activates a wrapped quoted Windows path from its spaces and position suffix", () => { const row = (text: string, isWrapContinuation: boolean): GhosttyRow => ({ cells: Array.from(text, (character) => cell(character)), diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index cb05acdc5e03d..802d39e594075 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -2,7 +2,9 @@ import { isMacPlatform } from "../../lib/utils"; import { SELECTION_MULTI_CLICK_INTERVAL_MS } from "../../lib/selectionActions"; import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; import { + GHOSTTY_CELL_WIDE, GhosttyTerminalCore, + type GhosttyCell, type GhosttyScrollbar, type GhosttySnapshot, type GhosttyTheme, @@ -201,15 +203,23 @@ function terminalGridCellAt(options: { }; } +function terminalCellText(cell: GhosttyCell): string { + if (cell.wide === GHOSTTY_CELL_WIDE.spacerTail || cell.wide === GHOSTTY_CELL_WIDE.spacerHead) { + return ""; + } + return cell.text || " "; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { - const text = row.cells.map((cell) => cell.text || " ").join(""); + const text = row.cells.map(terminalCellText).join(""); return trimRight ? text.trimEnd() : text; } function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { let offset = 0; for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { - offset += row.cells[cellIndex]?.text.length || 1; + const cell = row.cells[cellIndex]; + offset += cell ? terminalCellText(cell).length : 1; } return offset; } @@ -265,7 +275,8 @@ export function terminalLinkAtPositionWithRange( const lastSegment = wrappedLine.segments.at(-1); const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; - const offset = segment.startIndex + terminalColumnOffset(row, column); + const hitColumn = row.cells[column]?.wide === GHOSTTY_CELL_WIDE.spacerTail ? column - 1 : column; + const offset = segment.startIndex + terminalColumnOffset(row, hitColumn); for (const match of extractTerminalLinks(wrappedLine.text)) { if (offset >= match.start && offset < match.end) { if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; @@ -278,6 +289,7 @@ export function terminalLinkAtPositionWithRange( const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; if (!startSegment || !endSegment || !startRow || !endRow) return null; + const endColumn = terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex); return { text: match.text, range: { @@ -286,7 +298,10 @@ export function terminalLinkAtPositionWithRange( y: startSegment.bufferLineNumber - 1, }, end: { - x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + x: + endRow.cells[endColumn + 1]?.wide === GHOSTTY_CELL_WIDE.spacerTail + ? endColumn + 1 + : endColumn, y: endSegment.bufferLineNumber - 1, }, }, From a49a3f771b4566d79e93a85afc3480a60c33bfed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:52:24 -0700 Subject: [PATCH 093/144] fix(server): session stops no longer trigger storage sweeps unless a deleted worktree waits (#26038) Co-authored-by: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 367 ++++++++++++++++++++++++- apps/server/src/storageCleanup.ts | 77 ++++-- 2 files changed, 412 insertions(+), 32 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 9cbf0f092f7bd..5f945abd1858c 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -16,6 +16,8 @@ import { DEFAULT_SERVER_SETTINGS, type WorktreeKeepWhen, type StorageCleanupSettings, + type TerminalMetadataStreamEvent, + type TerminalSummary, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "./config.ts"; @@ -32,8 +34,11 @@ import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAd import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; import { + EventId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, RunId, RuntimeRequestId, ThreadId, @@ -268,8 +273,11 @@ const cleanupFixture = ( let editBeforeRemoval = false; let openSessionCwd: string | null = null; let sessionLive = true; + let onLiveCheck: Effect.Effect = Effect.void; + let outboxStatus: string | null = null; let fullStatusCalls = 0; let snapshotReads = 0; + let terminalListener: ((event: TerminalMetadataStreamEvent) => Effect.Effect) | undefined; let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); const context = yield* Layer.build(StorageCleanup.layer).pipe( @@ -335,7 +343,8 @@ const cleanupFixture = ( Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { - isLive: () => Effect.sync(() => sessionLive), + isLive: () => + Effect.sync(() => sessionLive).pipe(Effect.tap(() => Effect.suspend(() => onLiveCheck))), } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), Effect.provideService(Orchestrator.OrchestratorV2, { streamDomainEvents: Stream.suspend(() => events), @@ -359,29 +368,39 @@ const cleanupFixture = ( }), }, ] - : strings.join("").includes("deleted_at IS NOT NULL") - ? deletedThreads.map((thread) => ({ - payload_json: JSON.stringify(thread), - workspaceRoot: repo, - })) - : strings.join("").includes('AS "worktreePath"') - ? [ - ...threads.flatMap((thread) => thread.worktreePath ?? []), - ...storedWorktreePaths, - ].map((worktreePath) => ({ worktreePath })) - : [], + : outboxStatus !== null && strings.join("").includes("orchestration_v2_effect_outbox") + ? [{ status: outboxStatus }] + : strings.join("").includes("deleted_at IS NOT NULL") + ? deletedThreads.map((thread) => ({ + payload_json: JSON.stringify(thread), + workspaceRoot: repo, + })) + : strings.join("").includes('AS "worktreePath"') + ? [ + ...threads.flatMap((thread) => thread.worktreePath ?? []), + ...storedWorktreePaths, + ].map((worktreePath) => ({ worktreePath })) + : [], )) as unknown as SqlClient.SqlClient), Effect.provideService(GitManager.GitManager, { invalidateStatus: () => Effect.void, } as unknown as GitManager.GitManager["Service"]), Effect.provideService(TerminalManager.TerminalManager, { - subscribeMetadata: () => Effect.succeed(() => {}), + subscribeMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + ) => + Effect.sync(() => { + terminalListener = listener; + return () => {}; + }), } as unknown as TerminalManager.TerminalManager["Service"]), Effect.provideService(ServerActivation, Deferred.await(activated)), ); return { service: Context.get(context, StorageCleanup.StorageCleanup), repo, + setTerminals: (terminals: TerminalSummary[]) => + terminalListener?.({ type: "snapshot", terminals }) ?? Effect.void, editBeforeRemoval: () => { editBeforeRemoval = true; }, @@ -421,6 +440,12 @@ const cleanupFixture = ( setThreads: (next: typeof threads) => { threads = next; }, + setOnLiveCheck: (hook: Effect.Effect) => { + onLiveCheck = hook; + }, + setOutboxStatus: (status: string | null) => { + outboxStatus = status; + }, setDeletedThreads: (next: OrchestrationV2ThreadShell[]) => { deletedThreads = next; }, @@ -925,6 +950,322 @@ describe("deleted thread worktrees", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); + // Releases one stopped-session event after the startup sweep and returns the snapshot reads it caused. + const stopSessionAfterStartupSweep = ( + fixture: Effect.Success>, + threadId: ThreadId, + beforeStop: Effect.Effect = Effect.void, + cwd = fixture.worktree, + ) => + Effect.gen(function* () { + const startupSwept = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + yield* beforeStop; + const readsBefore = fixture.snapshotReads(); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + return fixture.snapshotReads() - readsBefore; + }); + + it.effect("does not sweep when a live thread's session stops and no deleted worktree waits", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + const reads = yield* stopSessionAfterStartupSweep(fixture, ThreadId.make("thread-1")); + expect(reads, "a live thread's stopped session re-read every thread").toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once its session stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.sync(() => fixture.setSessionLive(false)), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once a live thread's session in it stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + const child = subagent(owner); + fixture.setDeletedThreads([owner]); + fixture.setThreads([child]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + child.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Provider session is still open", + }); + fixture.setSessionLive(false); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree once its terminal closes and its session stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.setTerminals([ + { + threadId: owner.id, + terminalId: "terminal-1", + cwd: fixture.worktree, + worktreePath: fixture.worktree, + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal", + updatedAt: DateTime.formatIso(at(0)), + }, + ]); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Open terminal", + }); + yield* fixture.setTerminals([]); + }), + fixture.repo, + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "retries a deleted thread's worktree kept for another reason when a session in it stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Has uncommitted changes (1 file)", + }); + yield* fixture.fs + .writeFileString(`${fixture.worktree}/tracked.txt`, "original\n") + .pipe(Effect.orDie); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("does not sweep for a kept deleted worktree when a session elsewhere stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + fixture.setDeletedThreads([parent(fixture.worktree)]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.void, + fixture.repo, + ); + expect(reads).toBe(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "does not sweep for a deleted thread whose cleanup effect failed when a session elsewhere stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOutboxStatus("failed"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Thread deletion is still pending", + }); + }), + fixture.repo, + ); + expect(reads).toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree when its session stops during the deletion's sweep", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setThreads([]); + const startupSwept = yield* Deferred.make(); + const sessionChecked = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const deleted = { + id: EventId.make("event:thread-deleted"), + type: "thread.deleted", + threadId: owner.id, + occurredAt: at(0), + payload: owner, + } as unknown as OrchestrationV2DomainEvent; + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId: owner.id, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd: fixture.worktree, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(deleted)), + Stream.concat(Stream.fromEffect(Deferred.await(sessionChecked)).pipe(Stream.drain)), + Stream.concat(Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + fixture.setDeletedThreads([owner]); + fixture.setOpenSessionCwd(fixture.worktree); + // The deletion's sweep sees the session live; it stops before that sweep publishes its result. + fixture.setOnLiveCheck( + Effect.gen(function* () { + fixture.setOnLiveCheck(Effect.void); + yield* Deferred.succeed(sessionChecked, undefined); + yield* Deferred.await(delivered); + fixture.setSessionLive(false); + }), + ); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + it.effect("waits out the settle grace of a subagent that just settled", () => Effect.gen(function* () { yield* TestClock.setTime(NOW_MS); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 9bf040b0cd17c..48154835e7bb4 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -705,7 +705,10 @@ const make = Effect.gen(function* () { now: number, entries: StorageCleanupReportEntry[], ) { - if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; + if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) + return { waiting: false, kept: [] }; + let waiting = false; + const kept: string[] = []; const roots: Array = []; for (const directory of managedWorktreesDirectories( serverSettings, @@ -779,7 +782,15 @@ const make = Effect.gen(function* () { bytes: null, files: null, }; - const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); + const keep = (reason: string) => { + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); + entries.push({ ...entry, reason: sentence(reason) }); + }; + // Reasons a provider session stop or finished deletion work can clear. + const waitForStop = (reason: string) => { + if (deleted && settings.worktreeOnDelete) waiting = true; + return reason; + }; yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); @@ -790,10 +801,10 @@ const make = Effect.gen(function* () { thread.branch === null || !group.every((member) => storageCleanupThreadIdle(member, now)) ) - return keep("thread is running or has pending work"); + return keep(waitForStop("thread is running or has pending work")); if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); - if (hasTerminal(worktreePath)) return keep("open terminal"); + if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); @@ -817,7 +828,7 @@ const make = Effect.gen(function* () { SETTLE_SCRIPT_GRACE_MS, ) ) - return keep("settled moments ago"); + return keep(waitForStop("settled moments ago")); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); const old = !deleted && @@ -924,7 +935,7 @@ const make = Effect.gen(function* () { const latest = (yield* projections.getThreadWorktreePaths({ includeArchived: true, })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); - if (hasTerminal(worktreePath)) return "open terminal"; + if (hasTerminal(worktreePath)) return waitForStop("open terminal"); if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -932,10 +943,13 @@ const make = Effect.gen(function* () { ) return "thread or cleanup settings changed since check"; // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. - const pendingCleanup = yield* sql` - SELECT 1 FROM orchestration_v2_effect_outbox - WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 + const pendingCleanup = yield* sql<{ status: string }>` + SELECT status FROM orchestration_v2_effect_outbox + WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') `.pipe(NodeSqliteClient.readOnly); + // Failed effects never retry, so only running work can finish and free the path. + if (pendingCleanup.some((row) => row.status !== "failed")) + return waitForStop("thread deletion is still pending"); if (pendingCleanup.length > 0) return "thread deletion is still pending"; } const latestShells = yield* Effect.forEach(latest, (entry) => @@ -960,7 +974,7 @@ const make = Effect.gen(function* () { ); }) ) - return "thread activity or shared worktree changed since check"; + return waitForStop("thread activity or shared worktree changed since check"); // Sessions can outlive their run and can be shared across app threads. const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions @@ -976,7 +990,7 @@ const make = Effect.gen(function* () { (cwd === worktreePath || inside(worktreePath, cwd)) && (yield* providerSessions.isLive(session.id)) ) - return "provider session is still open"; + return waitForStop("provider session is still open"); } return null; }); @@ -1036,6 +1050,7 @@ const make = Effect.gen(function* () { outcome: "failed", reason: cleanupFailureReason(error), }); + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); yield* Effect.logWarning("storage cleanup failed for worktree", { threadId: thread.id, error, @@ -1049,6 +1064,7 @@ const make = Effect.gen(function* () { yield* projectStore.list({ includeDeleted: true }), entries, ); + return { waiting, kept }; }); const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* ( @@ -1090,14 +1106,21 @@ const make = Effect.gen(function* () { }); const reportRef = yield* SubscriptionRef.make(null); + // Only deleted threads' worktrees wait on session stops: any stop while one waits, or a stop inside one kept for another reason. + // A deletion stays pending until a sweep that started after it finishes. + let deletedWorktreeWaits = true; + let keptDeletedWorktrees: ReadonlyArray = []; + let deletions = 0; + let sweptDeletions = 0; const sweep = Effect.fn("StorageCleanup.sweep")(function* ( trigger: StorageCleanupReport["trigger"], ) { + const deletionsAtStart = deletions; const now = yield* Clock.currentTimeMillis; const serverSettings = yield* settingsService.getSettings; const settings = serverSettings.storageCleanup; const entries: StorageCleanupReportEntry[] = []; - yield* cleanWorktrees(serverSettings, now, entries).pipe( + const deletedWorktrees = yield* cleanWorktrees(serverSettings, now, entries).pipe( Effect.catch((error) => Effect.gen(function* () { entries.push({ @@ -1112,9 +1135,13 @@ const make = Effect.gen(function* () { files: null, }); yield* Effect.logWarning("worktree cleanup failed", { error }); + return { waiting: true, kept: [] }; }), ), ); + deletedWorktreeWaits = deletedWorktrees.waiting; + keptDeletedWorktrees = deletedWorktrees.kept; + sweptDeletions = deletionsAtStart; for (const category of [ { kind: "browser-artifacts" as const, @@ -1255,14 +1282,26 @@ const make = Effect.gen(function* () { }), ); yield* forkParked( - Stream.runForEach(events, (event) => - (event.type === "thread.deleted" || - (event.type === "provider-session.updated" && - (event.payload.status === "stopped" || event.payload.status === "error"))) && - anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) + Stream.runForEach(events, (event) => { + if (event.type === "thread.deleted") deletions++; + else if ( + event.type !== "provider-session.updated" || + (event.payload.status !== "stopped" && event.payload.status !== "error") + ) + return Effect.void; + else if (!deletedWorktreeWaits && deletions === sweptDeletions) { + const cwd = pathKey(event.payload.cwd); + if ( + !keptDeletedWorktrees.some( + (worktreePath) => pathKey(worktreePath) === cwd || inside(worktreePath, cwd), + ) + ) + return Effect.void; + } + return anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) ? requestSweep - : Effect.void, - ).pipe( + : Effect.void; + }).pipe( Effect.catchCause((cause) => Effect.logWarning("Storage cleanup event stream failed", { cause }), ), From 443edd553fce0f00de480311583579fcc91c529c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:52:42 -0500 Subject: [PATCH 094/144] fix(provider-core): subagent threads no longer inherit the parent's pin (#21271) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../SubagentProjection.test.ts | 61 ++++++++++++++++++- .../src/server/subagentProjection.ts | 2 + 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index d3506649f6a62..95abcbaf5c1a8 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { makeSubagentChildThread, @@ -26,7 +27,9 @@ import { endRunlessRootTurns, } from "@t3tools/provider-core/server/subagentProjection"; -import { emptyProjection } from "./ProjectionStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent"); const childThreadId = ThreadId.make("thread:subagent-awake-child"); @@ -117,8 +120,62 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); +it.effect.each([ + ["sql", ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))], +] as const)( + "%s: a pinned parent's subagent child is born unpinned and settles once idle", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const parentThread = { ...makeParentThread(), pinnedAt: parentCreatedAt, pinOrderKey: "a0" }; + const childThread = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: NodeId.make("node:subagent-parent"), + activeProviderThreadId: null, + providerInstanceId: childProviderInstanceId, + modelSelection: childModelSelection, + title: "Child", + now: childCreatedAt, + createdBy: "agent", + creationSource: "provider", + }); + + assert.equal(parentThread.pinnedAt, parentCreatedAt); + assert.equal(parentThread.pinOrderKey, "a0"); + assert.isNull(childThread.pinnedAt); + assert.isNull(childThread.pinOrderKey); + + for (const thread of [parentThread, childThread]) { + yield* store.apply({ + id: EventId.make(`event:${thread.id}`), + type: "thread.created", + threadId: thread.id, + occurredAt: thread.createdAt, + payload: thread, + }); + } + const savedParent = yield* store.getThread(parentThreadId); + assert.deepEqual(savedParent.pinnedAt, parentCreatedAt); + assert.equal(savedParent.pinOrderKey, "a0"); + const candidates = yield* store.getSettlementCandidates(); + assert.deepEqual( + candidates.map((candidate) => candidate.id), + [childThreadId], + ); + const settledAt = resolveAutoSettlementAt({ + thread: candidates[0]!, + pullRequest: null, + nowMs: DateTime.toEpochMillis(DateTime.add(childCreatedAt, { days: 4 })), + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + }); + assert.equal(settledAt && DateTime.formatIso(settledAt), DateTime.formatIso(childCreatedAt)); + }).pipe(Effect.provide(testLayer)), +); + function taskFixture() { - const projection = emptyProjection({ + const projection = ProjectionStore.emptyProjection({ type: "thread.created", id: EventId.make("task-fixture"), threadId: parentThreadId, diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index f83a0b376233b..be8c4bf98aa54 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -82,6 +82,8 @@ export function makeSubagentChildThread(input: { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, // A guest link belongs to the thread it was made for. guestShare: null, lastVisitedAt: null, From 1b3e330c98f8f063911b730b1befb77c13c7d791 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:53:05 -0500 Subject: [PATCH 095/144] fix(device): report failed Android permission changes (#22358) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/server/src/device/DeviceActions.test.ts | 54 ++++++++++++++++++++ apps/server/src/device/DeviceActions.ts | 11 ++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 1a8cab7f29837..90f46a4b66d33 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -98,6 +98,60 @@ describe("runDeviceAction", () => { expect(calls.at(-1)?.args.at(-1)).toBe(expected); }), ); + + it.effect.each([ + ["camera", "grant", "android.permission.CAMERA"], + ["photos", "revoke", "android.permission.READ_MEDIA_IMAGES"], + ] as const)( + "reports an Android %s %s that no pm command applied", + ([permission, decision, firstPermission]) => + Effect.gen(function* () { + const { ready } = makeReady((call) => ({ + code: 1, + stderr: `Unknown package: ${call.args.at(-1)}`, + })); + const error = yield* Effect.flip( + runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.missing", + permission, + decision, + }), + ); + expect(error._tag).toBe("DeviceOperationError"); + expect(error.operation).toBe("permission"); + expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({ + stderr: `Unknown package: ${firstPermission}`, + }); + }), + ); + + it.effect.each([ + [ + "photos", + ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"], + 1, + ], + ["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], 0], + ] as const)( + "keeps a partially applied %s permission change", + ([permission, permissions, success]) => + Effect.gen(function* () { + const { ready, calls } = makeReady((call) => ({ + code: call.args.at(-1) === permissions[success] ? 0 : 1, + })); + const result = yield* runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.app", + permission, + decision: "grant", + }); + expect(result).toBeUndefined(); + expect(calls.map((call) => call.args.at(-1))).toEqual(permissions); + }), + ); }); describe("readDeviceDetail", () => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 905a23d5cc726..6b734b6b9b484 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -11,6 +11,7 @@ import { type DeviceTextSize, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -432,9 +433,13 @@ const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( }); } const verb = input.decision === "grant" ? "grant" : "revoke"; - for (const permission of permissions) { - yield* shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.ignore); - } + // `pm` rejects a permission the app or system image does not declare, and Photos lists both + // the Android 13 and the older storage permission, so fail only when none of them applied. + const results = yield* Effect.forEach(permissions, (permission) => + shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.result), + ); + const failure = results.find(Result.isFailure); + if (failure && !results.some(Result.isSuccess)) return yield* failure.failure; return; } case "openUrl": From 0d51375ef765636549410286e42b391a9e2b7ca2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 10:53:18 -0700 Subject: [PATCH 096/144] fix(server): thread lists load beyond SQLite variable limit (#25696) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ProjectionStore.test.ts | 64 +++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 14 ++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 2af87ecf4feb7..17b8bb818864a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1374,6 +1374,70 @@ it.effect("lists only active subagent IDs without loading shell data", () => }).pipe(Effect.provide(layerTest)), ); +// regression: #25621 SQLite rejects statements binding more than 32,766 variables +it.effect("lists every thread in the shell snapshot past SQLite's bound variable limit", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const templateId = ThreadId.make("thread:many:00000"); + yield* store.apply({ + id: EventId.make(`event:${templateId}:created`), + type: "thread.created", + threadId: templateId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: templateId, + projectId: ProjectId.make("project:many"), + title: "Many", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: templateId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + const threadCount = 32_767; + yield* sql` + CREATE TEMP TABLE many_threads AS + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${threadCount - 1}) + SELECT t.*, n.i AS clone_index FROM orchestration_v2_projection_threads t, n + WHERE t.thread_id = ${templateId} + `; + yield* sql` + UPDATE many_threads SET + thread_id = printf('thread:many:%05d', clone_index), + payload_json = json_set( + payload_json, + '$.id', printf('thread:many:%05d', clone_index), + '$.lineage.rootThreadId', printf('thread:many:%05d', clone_index) + ) + `; + yield* sql`ALTER TABLE many_threads DROP COLUMN clone_index`; + yield* sql`INSERT INTO orchestration_v2_projection_threads SELECT * FROM many_threads`; + + const [unscoped, active] = yield* Effect.all([ + store.getShellSnapshot(), + store.getShellSnapshot({ location: "active" }), + ]); + assert.strictEqual(unscoped.threads.length, threadCount); + assert.strictEqual(active.threads.length, threadCount); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ProjectionStoreV2", (it) => { // regression: #21969 keep corrupt plan JSON in the typed read error it.effect("returns a typed read error for malformed stored plan JSON", () => diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2a21bfae4bd32..287a30a9d904e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5940,7 +5940,7 @@ export const layer: Layer.Layer = worktreePath === undefined ? sql`` : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` - }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN ${sql.in(threadIds)}`} + }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -5953,14 +5953,14 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, run_id, ordinal FROM orchestration_v2_projection_runs - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellRunItemCounts = (threadIds: ReadonlyArray) => sql` SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE run_id IS NOT NULL AND thread_id IN ${sql.in(threadIds)} + WHERE run_id IS NOT NULL AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id `; @@ -5975,7 +5975,7 @@ export const layer: Layer.Layer = FROM ( SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id ) counts LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_id @@ -5992,7 +5992,7 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, payload_json FROM orchestration_v2_projection_provider_threads - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellPendingTurnItemRows = ( @@ -6008,7 +6008,7 @@ export const layer: Layer.Layer = WHERE i.type IN ('command_execution', 'dynamic_tool', 'subagent') AND i.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') AND (i.run_id IS NULL OR r.status <> 'rolled_back') - ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN ${sql.in(threadIds)}`} + ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ${ activeRunIds.length === 0 ? sql`` @@ -6017,7 +6017,7 @@ export const layer: Layer.Layer = UNION ALL SELECT i.thread_id, i.payload_json, NULL AS owning_provider_instance_id FROM orchestration_v2_projection_turn_items i - WHERE i.run_id IN ${sql.in(activeRunIds)} + WHERE i.run_id IN (SELECT value FROM json_each(${encodeIdList(activeRunIds)})) AND i.type IN ('file_change', 'file_search', 'web_search', 'compaction') AND i.status IN ('running', 'waiting') ` From 1e3e3a8dfd87cfc085828a7ae4de995b7addcb4f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:53:38 +0000 Subject: [PATCH 097/144] fix(server): allow large git status metadata (#25207) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 39 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 29 ++++++++------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 47b3f3a62b7e9..cbc3efcc3b4e7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1003,6 +1003,45 @@ describe("rejected git refs", () => { }); it.layer(layerTest)("GitVcsDriver core integration", (it) => { + it.effect("keeps large change sets available for status and commit preparation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + const files = Array.from( + { length: 6_000 }, + (_, index) => `file-${String(index).padStart(5, "0")}-${"x".repeat(160)}.txt`, + ); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "before\n"), { + concurrency: 16, + discard: true, + }); + yield* git(cwd, ["add", "."]); + const unborn = yield* driver.statusDetailsLocal(cwd); + assert.lengthOf(unborn.workingTree.files, files.length); + assert.equal(unborn.workingTree.insertions, files.length); + const initial = yield* driver.commit(cwd, "initial", ""); + assert.match(initial.commitSha, /^[a-f0-9]{40}$/); + assert.isFalse((yield* driver.statusDetailsLocal(cwd)).hasWorkingTreeChanges); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "after\n"), { + concurrency: 16, + discard: true, + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.isTrue(status.hasWorkingTreeChanges); + assert.lengthOf(status.workingTree.files, files.length); + assert.equal(status.workingTree.insertions, files.length); + assert.equal(status.workingTree.deletions, files.length); + const context = yield* driver.prepareCommitContext(cwd, undefined, { includePatch: false }); + assert.isNotNull(context); + assert.isAbove(context!.stagedSummary.length, 1_000_000); + assert.lengthOf(context!.stagedSummary.split("\n"), files.length); + }), + ); describe("structured errors", () => { it.effect("does not retain git arguments or stderr in command failures", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 49149d95f58f6..c486c6744cb5f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2097,6 +2097,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusArgs, { allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, }, ).pipe( Effect.catchTags({ @@ -2137,24 +2138,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.statusDetails.numstat", cwd, ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], - { allowNonZeroExit: true }, + { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe( Effect.flatMap((result) => { if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); if (isUnbornHeadStderr(result.stderr)) { return Effect.map( Effect.all([ - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ - "diff-files", - "--numstat", - "-z", - ]), - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ - "diff", - "--cached", - "--numstat", - "-z", - ]), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn", + cwd, + ["diff-files", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn.staged", + cwd, + ["diff", "--cached", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), ]), ([unstagedStdout, stagedStdout]) => [ ...parseReviewNumstat(stagedStdout), @@ -2444,7 +2446,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.prepareCommitContext.stagedSummary", cwd, ["diff", "--cached", "--name-status"], - { env }, + { env, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { return null; @@ -2508,6 +2510,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); From 781cc55c2d182566fbf650abc08d0ad6e67a7fbf Mon Sep 17 00:00:00 2001 From: luke <97411838+luke2x@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:54:01 +0000 Subject: [PATCH 098/144] perf(mobile): flatten large file trees without copying descendants (#21276) Co-authored-by: maria-rcks --- .../src/features/files/fileTree.test.ts | 61 +++++++++++++++++++ apps/mobile/src/features/files/fileTree.ts | 8 +-- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/features/files/fileTree.test.ts diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts new file mode 100644 index 0000000000000..d978333d2732f --- /dev/null +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vite-plus/test"; + +import { buildFileTree, flattenFileTree } from "./fileTree"; + +it("flattens a wide expanded directory without exceeding argument limits", () => { + const children = Array.from({ length: 200_000 }, (_, index) => ({ + path: `root/file-${index}`, + name: `file-${index}`, + kind: "file" as const, + children: [], + })); + const nodes = [{ path: "root", name: "root", kind: "directory" as const, children }]; + const rows = flattenFileTree({ nodes, expanded: new Set(["root"]) }); + expect(rows).toHaveLength(children.length + 1); + expect(rows[0]).toEqual({ node: nodes[0], depth: 0 }); + const middleChildIndex = Math.floor(children.length / 2); + expect(rows[middleChildIndex + 1]).toEqual({ + node: children[middleChildIndex], + depth: 1, + }); + expect(rows.at(-1)).toEqual({ node: children.at(-1), depth: 1 }); +}); + +it("prunes unmatched subtrees without losing adjacent rows or their depths", () => { + const nodes = buildFileTree([ + { kind: "file", path: "root/a/hidden.txt" }, + { kind: "file", path: "root/b/match.txt" }, + { kind: "file", path: "root/b/other.txt" }, + { kind: "file", path: "root/c/hidden.txt" }, + { kind: "file", path: "tail.txt" }, + ]); + const expanded = new Set(["root", "root/a", "root/b", "root/c"]); + const rows = (searchResultPaths: ReadonlySet | null) => + flattenFileTree({ nodes, expanded, searchResultPaths }).map( + ({ node, depth }) => `${depth}:${node.path}`, + ); + + expect(rows(new Set(["root/b/match.txt", "tail.txt"]))).toEqual([ + "0:root", + "1:root/b", + "2:root/b/match.txt", + "0:tail.txt", + ]); + expect(rows(new Set(["root/b"]))).toEqual(["0:root", "1:root/b"]); + expect(rows(new Set())).toEqual([]); + expect(rows(null)).toEqual([ + "0:root", + "1:root/a", + "2:root/a/hidden.txt", + "1:root/b", + "2:root/b/match.txt", + "2:root/b/other.txt", + "1:root/c", + "2:root/c/hidden.txt", + "0:tail.txt", + ]); + expect(flattenFileTree({ nodes, expanded: new Set() }).map(({ node }) => node.path)).toEqual([ + "root", + "tail.txt", + ]); +}); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 4e4b4b77171dd..22e8e9bd5bd8d 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -100,11 +100,12 @@ function flattenNode( const isSearching = searchResultPaths !== null; const matches = searchResultPaths?.has(node.path) ?? false; let descendantMatches = false; - const childOutput: VisibleFileTreeNode[] = []; + const start = output.length; + output.push({ node, depth }); if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { for (const child of node.children) { - if (flattenNode(childOutput, child, depth + 1, expanded, searchResultPaths)) { + if (flattenNode(output, child, depth + 1, expanded, searchResultPaths)) { descendantMatches = true; } } @@ -112,11 +113,10 @@ function flattenNode( const visible = !isSearching || matches || descendantMatches; if (!visible) { + output.length = start; return false; } - output.push({ node, depth }); - output.push(...childOutput); return matches || descendantMatches; } From 80ae9b53015bc3df7f1b5d1c2bc13602ee88625b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:54:24 -0500 Subject: [PATCH 099/144] fix(web): show theme conflicts even when another import fails (#23223) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../ThemeImportDialog.browser.test.tsx | 58 +++++++++++++++++++ .../components/settings/ThemeImportDialog.tsx | 11 ++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx index 3a6478434c940..2ee0085bbb1c6 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx @@ -3,6 +3,14 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../../test/render"; +import { + CUSTOM_THEMES_STORAGE_KEY, + getCustomThemes, + installCustomTheme, + invalidateCustomThemes, + parseThemeFile, + THEME_FILE_VERSION, +} from "../../themePalette"; import type { OpenVsxThemeExtension } from "../../openVsxThemes"; import { ThemeImportDialog } from "./ThemeImportDialog"; @@ -54,3 +62,53 @@ describe("ThemeImportDialog", () => { ); }); }); + +const theme = (name: string, accent = "#3b82f6") => ({ + version: THEME_FILE_VERSION, + name, + appearance: "dark", + colors: { accent }, +}); + +const themeFile = (name: string, accent?: string) => + new File([JSON.stringify(theme(name, accent))], `${name}.json`, { type: "application/json" }); + +it.each(["Update existing", "Keep both"])( + "keeps batch failures visible after %s", + async (choice) => { + using _themes = { + [Symbol.dispose]() { + window.localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY); + invalidateCustomThemes(); + }, + }; + installCustomTheme(parseThemeFile(theme("Dusk"))); + const onOpenChange = vi.fn(); + await using _screen = await render( + true} + onImportedMany={() => {}} + />, + ); + const input = document.querySelector('input[type="file"]')!; + await userEvent.upload(input, [ + themeFile("Dawn"), + themeFile("Dusk", "#f97316"), + new File(["{"], "broken.json", { type: "application/json" }), + ]); + + await expect.element(page.getByText("Already installed")).toBeVisible(); + await expect.element(page.getByText(/^broken\.json:/)).toBeVisible(); + expect(getCustomThemes().map((theme) => theme.label)).toContain("Dawn"); + await page.getByRole("button", { name: choice }).click(); + invalidateCustomThemes(); + const label = choice === "Update existing" ? "Dusk" : "Dusk (1)"; + expect(getCustomThemes().find((theme) => theme.label === label)?.colors).toEqual( + parseThemeFile(theme("Dusk", "#f97316")).colors, + ); + await expect.element(page.getByText(/^broken\.json:/)).toBeVisible(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }, +); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 8536498ba5a68..e9d37f45322d1 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -284,11 +284,10 @@ function ThemeImportBody({ } } if (installed.length > 0) onImportedMany(installed, { updated: false }); - if (failures.length > 0) { - setError(failures.join("; ")); - } else if (conflicting.length > 0) { + setError(failures.length > 0 ? failures.join("; ") : null); + if (conflicting.length > 0) { setConflicts(conflicting); - } else if (installed.length > 0) { + } else if (installed.length > 0 && failures.length === 0) { onClose(); } }; @@ -387,8 +386,8 @@ function ThemeImportBody({ } if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" }); setConflicts(null); - if (failures.length > 0) setError(failures.join("; ")); - else onClose(); + if (failures.length > 0) setError([error, ...failures].filter(Boolean).join("; ")); + else if (!error) onClose(); }; const handleSubmit = () => { From 061dc79e3bf7a56709fda2c5dd71b6dcefe4d787 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:54:47 -0500 Subject: [PATCH 100/144] fix(usage): reject cyclic model mappings before saving (#23963) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../components/usage/usagePriceTable.test.ts | 49 ++++++++++++++++++- .../src/components/usage/usagePriceTable.ts | 20 ++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/usagePriceTable.test.ts b/apps/web/src/components/usage/usagePriceTable.test.ts index 6a1c1e71ecb44..2ef6ea31c9b37 100644 --- a/apps/web/src/components/usage/usagePriceTable.test.ts +++ b/apps/web/src/components/usage/usagePriceTable.test.ts @@ -1,6 +1,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { usagePriceCell, usagePriceTableChanges, type UsagePriceDraft } from "./usagePriceTable"; +import { + usagePriceCell, + usagePriceTableChanges, + usagePriceTableErrors, + type UsagePriceDraft, +} from "./usagePriceTable"; import type { UsagePriceTarget } from "./usagePriceTargets"; const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; @@ -116,3 +121,45 @@ describe("price table edits", () => { ).toBe("Map to a different model."); }); }); + +describe("model mapping validation", () => { + const mapping = (model: string, alias: string): UsagePriceDraft => ({ + id: `model:${model}`, + model, + alias, + isNew: false, + values: {}, + }); + + it("rejects a cycle created by several mappings in one save", () => { + const edits = [mapping("a", "b"), mapping("b", "c"), mapping("c", "a")]; + const result = usagePriceTableChanges(target("local", {}), edits); + expect([...result.errors.keys()]).toEqual(["model:a", "model:b", "model:c"]); + }); + + it("checks each environment's saved chain when an edited mapping closes it", () => { + const edits = [mapping("c", "a")]; + const acyclic = target("one", {}, { a: "b" }); + const cyclic = target("two", {}, { a: "b", b: "c" }); + expect(usagePriceTableChanges(acyclic, edits).errors.size).toBe(0); + expect(usagePriceTableErrors([acyclic, cyclic], edits).get("model:c")).toBe( + "Model mappings cannot form a cycle on two.", + ); + }); + + it("validates the final batch so a mapping can be reversed in either row order", () => { + const environment = target("local", {}, { constructor: "__proto__" }); + const clear = mapping("constructor", ""); + const reverse = mapping("__proto__", "constructor"); + for (const edits of [ + [reverse, clear], + [clear, reverse], + ]) { + const result = usagePriceTableChanges(environment, edits); + expect(result.errors.size).toBe(0); + expect(result.changes).toEqual( + edits.map(({ model, alias }) => ({ model, alias: alias || null })), + ); + } + }); +}); diff --git a/apps/web/src/components/usage/usagePriceTable.ts b/apps/web/src/components/usage/usagePriceTable.ts index e6ad3d72da8bb..04b667c2ec30d 100644 --- a/apps/web/src/components/usage/usagePriceTable.ts +++ b/apps/web/src/components/usage/usagePriceTable.ts @@ -115,6 +115,26 @@ export function usagePriceTableChanges( if (USAGE_PRICE_FIELDS.some((field) => original[field.key] !== next[field.key])) changes.push(parsed); } + // Check the final batch so clearing a mapping and reversing it in one save stays valid. + const aliases = new Map(Object.entries(target.aliases ?? {})); + for (const change of changes) { + if (!("alias" in change)) continue; + if (change.alias === null) aliases.delete(change.model); + else aliases.set(change.model, change.alias); + } + for (const draft of drafts) { + if (draft.removed || errors.has(draft.id)) continue; + let model = draft.model.trim(); + const seen = new Set(); + while (aliases.has(model)) { + if (seen.has(model)) { + errors.set(draft.id, `Model mappings cannot form a cycle on ${target.label}.`); + break; + } + seen.add(model); + model = aliases.get(model)!; + } + } return { changes, errors }; } From bae3c1962ce31eefd09e9625586cbd6a904bedc2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:55:10 -0500 Subject: [PATCH 101/144] fix(settings): offer Use existing CLI in managed Codex settings (#23160) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../components/settings/CodexSetupSection.tsx | 12 +++- ...SettingsPanel.environment.browser.test.tsx | 58 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index bab910d5eda82..e7ef1c9a20404 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -914,7 +914,7 @@ function ManagedCodexSetup({ title="ChatGPT account" description={accountDescription} control={ -
+
{authActive ? ( <> {waitingControl} @@ -1001,6 +1001,16 @@ function ManagedCodexSetup({ Use a different account ) : null} + {allowExistingCli ? ( + + ) : null} )}
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 89fa5250a3a09..4ad9b3986bb3a 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -4,6 +4,7 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, + type ProviderAuthState, type ServerProvider, type UnifiedSettings, } from "@t3tools/contracts"; @@ -17,6 +18,8 @@ import { act, render } from "../../../test/render"; const state = vi.hoisted(() => ({ providers: null as unknown as Atom.Writable>, + providerAuthQuery: null as unknown, + providerAuth: null as ProviderAuthState | null, settings: null as unknown as UnifiedSettings, canManageProviders: true, canWriteSettings: true, @@ -40,6 +43,8 @@ vi.mock(import("../../state/server"), async (original) => { const providers = Atom.make>([]).pipe(Atom.keepAlive); state.providers = providers; const config = Atom.make(null); + const providerAuth = Atom.make(null); + state.providerAuthQuery = providerAuth; const commandLabels = { refreshProviders: "refresh", updateProvider: "update", @@ -59,6 +64,7 @@ vi.mock(import("../../state/server"), async (original) => { ...actual.serverEnvironment, ...labelled, configValueAtom: (() => config) as never, + providerAuthState: (() => providerAuth) as never, providersValueAtom: (() => providers) as never, } as never, }; @@ -75,8 +81,8 @@ vi.mock(import("../../state/use-atom-command"), async (original) => ({ })); vi.mock(import("../../state/query"), async (original) => ({ ...(await original()), - useEnvironmentQuery: (() => ({ - data: null, + useEnvironmentQuery: ((query: unknown) => ({ + data: query === state.providerAuthQuery ? state.providerAuth : null, error: null, isPending: false, refresh: vi.fn(), @@ -216,6 +222,7 @@ async function setup() { state.canManageProviders = true; state.canWriteSettings = true; state.searchTargetId = null; + state.providerAuth = null; state.readEnvironmentIds = []; state.updateEnvironmentIds = []; state.mutationEnvironmentIds = []; @@ -302,4 +309,51 @@ describe("EnvironmentProviderSettings routing", () => { input: { instanceId, elicitationId: action.elicitationId }, }); }); + + it("lets a signed-out managed Codex account switch to the existing CLI", async () => { + await setup(); + const codex = ProviderDriverKind.make("codex"); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: codex, + enabled: true, + config: { enabled: true, setupMode: "managed" }, + }, + }, + }; + state.providerAuth = { + instanceId: codexId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [{ id: "chatgpt-profile:work", name: "Work", description: null, type: "agent" }], + }; + await setProviders([ + { + ...provider(), + auth: { status: "unauthenticated" }, + setup: { canAuthenticate: true, canInstall: true }, + }, + ]); + await using _screen = await render(panel({ targetInstanceId: codexId })); + await expect.element(page.getByRole("button", { name: "Reconnect account" })).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Use existing CLI" }).element()); + + expect(state.mutateProviderInstance).toHaveBeenCalledWith( + { + operation: "upsert", + instanceId: codexId, + instance: { + driver: codex, + enabled: true, + config: { enabled: true, setupMode: "existing" }, + }, + }, + {}, + ); + }); }); From 1e95da77c41968eee9e7fce1f6d29b27a9e39339 Mon Sep 17 00:00:00 2001 From: parmartejas <110296251+parmartejass@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:25:32 +0530 Subject: [PATCH 102/144] fix(git): honor project provider settings (#23672) Co-authored-by: Tejas Parmar Co-authored-by: maria-rcks --- .../features/threads/DraftThreadHeader.tsx | 6 ++- apps/server/src/git/GitManager.test.ts | 41 ++++++++++++++++++- apps/server/src/git/GitManager.ts | 24 +++++++---- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/DraftThreadHeader.tsx b/apps/mobile/src/features/threads/DraftThreadHeader.tsx index 509545b58c8bb..5c3d758f45513 100644 --- a/apps/mobile/src/features/threads/DraftThreadHeader.tsx +++ b/apps/mobile/src/features/threads/DraftThreadHeader.tsx @@ -87,7 +87,11 @@ export function DraftThreadHeader(props: { }, onRunAction: async (input) => { if (!canWriteGit || !props.cwd) return null; - const result = await runAction({ ...input, actionId: uuidv4() }); + const result = await runAction({ + ...input, + projectId: props.project.id, + actionId: uuidv4(), + }); if (AsyncResult.isFailure(result)) { Dialog.alert("Git action failed", String(Cause.squash(result.cause))); return null; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 5d363826458df..3c13991426f92 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2599,7 +2599,45 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("applies a V2-native thread's project settings to generated commit text", () => + it.effect("uses an explicit project's provider settings for generated commit text", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nproject\n"); + const projectId = ProjectId.make("project:git-action-settings"); + const disabledInstanceId = DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId; + let generatedModelSelection: + | TextGeneration.CommitMessageGenerationInput["modelSelection"] + | undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [disabledInstanceId]: { enabled: false } }, + }, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedModelSelection = input.modelSelection; + return Effect.succeed({ subject: "Use the project writer", body: "" }); + }, + }, + }); + + yield* manager.runStackedAction({ + actionId: "test-project-action-settings", + cwd: repoDir, + action: "commit", + projectId, + }); + + expect(generatedModelSelection?.instanceId).toBe(ProviderInstanceId.make("claudeAgent")); + }), + ); + + it.effect("prefers a V2-native thread's project settings over an explicit project", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2686,6 +2724,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { cwd: repoDir, action: "commit", threadId, + projectId: ProjectId.make("project:other"), }); expect(generatedModelSelection).toEqual(projectModel); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 53d0b4f5853ff..71e5edb3b697b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -672,19 +672,25 @@ export const make = Effect.gen(function* () { const projectSettingsFor = Effect.fnUntraced(function* (input: { readonly cwd: string; readonly threadId?: ThreadId | undefined; + readonly projectId?: ProjectId | undefined; }) { const settings = yield* serverSettingsService.getSettings; if (!hasProjectSettingsOverrides(settings)) return { settings, projectId: null as ProjectId | null }; - const projectId: ProjectId | null = yield* input.threadId !== undefined - ? threads.getThreadShell(input.threadId).pipe( - Effect.map((thread) => thread?.projectId ?? null), - Effect.orElseSucceed(() => null), - ) - : projects.findActiveByWorkspaceRoot(input.cwd).pipe( - Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), - Effect.orElseSucceed(() => null), - ); + const threadProjectId = + input.threadId === undefined + ? null + : yield* threads.getThreadShell(input.threadId).pipe( + Effect.map((thread) => thread?.projectId ?? null), + Effect.orElseSucceed(() => null), + ); + const projectId = + threadProjectId ?? + input.projectId ?? + (yield* projects.findActiveByWorkspaceRoot(input.cwd).pipe( + Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); return { settings: resolveProjectSettings(settings, projectId).settings, projectId }; }); const readThreadContext = Effect.fnUntraced( From efb940dbaf97674c455f9f74d33ca5fc34a1de83 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:55:54 +0000 Subject: [PATCH 103/144] fix(desktop): force-close stalled Linux MIME cache helpers (#25210) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../src/app/DesktopLinuxUrlHandler.test.ts | 71 +++++++++++++++++++ .../desktop/src/app/DesktopLinuxUrlHandler.ts | 1 + 2 files changed, 72 insertions(+) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index feedbbd580074..9404b0ac3087c 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,7 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -9,6 +13,8 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -56,6 +62,7 @@ const layerHandler = ( recorded: RecordedRegistration, input: { readonly environment?: Record; + readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly updateDesktopDatabaseExitCode?: number; readonly updateDesktopDatabaseStalled?: boolean; readonly updateDesktopDatabaseStarted?: Deferred.Deferred; @@ -106,6 +113,7 @@ const layerHandler = ( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { + if (input.processSpawner) return input.processSpawner.spawn(command); const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; @@ -358,6 +366,69 @@ describe("DesktopLinuxUrlHandler", () => { assert.deepEqual(writeFailed.commands, []); }); }); + + it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "continues registration after a timed-out cache helper ignores SIGTERM", + () => + Effect.gen(function* () { + const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const nodeExecutable = yield* HostProcess.ExecutablePath; + const recorded = emptyRecording(); + const spawned = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const processSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a single registration command"); + } + recorded.commands.push({ command: command.command, args: command.args }); + if (command.command !== "update-desktop-database") return mockProcess(0); + const child = yield* realSpawner.spawn( + ChildProcess.make( + nodeExecutable, + [ + "-e", + // The watchdog makes the unfixed case exit with a distinguishable code. + "process.on('SIGTERM', () => setTimeout(() => process.exit(99), 5000));" + + "process.stdout.write('ready\\n'); setInterval(() => {}, 2147483647);", + ], + { ...command.options, stdout: "pipe" }, + ), + ); + yield* Deferred.succeed(spawned, child); + yield* Stream.runHead(child.stdout); + yield* Deferred.succeed(ready, undefined); + return child; + }), + ); + const registration = yield* runRegister(recorded, { processSpawner }).pipe( + Effect.forkScoped, + ); + const child = yield* Deferred.await(spawned); + yield* Effect.addFinalizer(() => + child.isRunning.pipe( + Effect.flatMap((running) => + running ? child.kill({ killSignal: "SIGKILL" }) : Effect.void, + ), + Effect.ignore, + ), + ); + yield* Deferred.await(ready); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(registration); + + const exit = yield* Effect.exit(child.exitCode); + if (Exit.isFailure(exit)) { + assert.include(Cause.pretty(exit.cause), "SIGKILL"); + } else { + assert.fail(`cache helper exited with ${exit.value} instead of being force-killed`); + } + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); interface ThemeIconRun { diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index dd38acbc3894e..ebd7d71b25726 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -174,6 +174,7 @@ export const make = Effect.gen(function* () { stdin: "ignore", stdout: "ignore", stderr: "ignore", + forceKillAfter: "1 second", }, ); const handle = yield* spawner.spawn(command); From bdf84df8ca24bd6ab6f4cdbadef2f8a205da4441 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Thu, 29 Oct 2026 13:56:17 -0400 Subject: [PATCH 104/144] fix(cli): auth pairing create rejects a --base-url without http(s):// (#23343) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/cli/auth.ts | 12 ++++++- apps/server/src/cli/pair.test.ts | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 0aeb7d6a3b5ab..06b949e3013ab 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -68,6 +69,15 @@ const subjectFlag = Flag.String("subject").pipe( ); const baseUrlFlag = Flag.String("base-url").pipe( + Flag.withSchema( + Schema.URLFromString.pipe( + Schema.check( + Schema.makeFilter((url) => url.protocol === "http:" || url.protocol === "https:", { + expected: "an http:// or https:// URL", + }), + ), + ), + ), Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), Flag.optional, ); @@ -99,7 +109,7 @@ const pairingCreateCommand = Command.make("create", { }); const output = formatIssuedPairingCredential(issued, { json: flags.json, - ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value.href } : {}), }); yield* Console.log(output); }), diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 5ad9bde0acc28..88fc9cec74c13 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -488,3 +488,63 @@ describe("auth scope options", () => { }), ); }); + +describe("auth pairing create --base-url", () => { + it.effect.each(["192.168.1.5:3773", "localhost:3773", "ftp://localhost:3773"])( + "rejects %s before issuing a token", + (baseUrl) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-")); + const error = yield* runCli([ + "auth", + "pairing", + "create", + "--base-dir", + baseDir, + "--base-url", + baseUrl, + ]).pipe(Effect.provide(layerCliRuntime), Effect.flip); + + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + const flagError = error.errors[0]; + if (flagError?._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(flagError?._tag)}`); + } + assert.equal(flagError.option, "base-url"); + assert.equal(flagError.value, baseUrl); + + const listOutput = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + assert.deepEqual(JSON.parse(listOutput), []); + }), + ); + + it.effect.each(["http://192.168.1.5:3773", "https://example.com", "http:localhost:3773"])( + "prints a pair URL for %s", + (baseUrl) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-")); + const output = yield* captureStdout( + runCli([ + "auth", + "pairing", + "create", + "--base-dir", + baseDir, + "--base-url", + baseUrl, + "--json", + ]), + ); + const issued = JSON.parse(output) as { + readonly credential: string; + readonly pairUrl: string; + }; + + assert.equal(issued.pairUrl, `${new URL(baseUrl).origin}/pair#token=${issued.credential}`); + }), + ); +}); From 4afdabab4479a68c7ea02d09174fe7936f084882 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:56:40 -0500 Subject: [PATCH 105/144] fix(server): signed asset and upload URLs stay out of HTTP traces (#24556) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/http.test.ts | 63 ++++++++++++++++++++++++++++++++++++ apps/server/src/http.ts | 9 ++++-- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 0319bbb55c03d..5f7df20c738c8 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -13,7 +13,10 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; +import { ASSET_ROUTE_PREFIX } from "./assets/AssetAccess.ts"; +import { ATTACHMENT_UPLOAD_ROUTE_PREFIX } from "./assets/AttachmentUpload.ts"; import { openMediaFile } from "./assets/MediaFile.ts"; import { @@ -139,6 +142,66 @@ describe("browser API CORS", () => { }); }); +describe("untraced requests", () => { + it.effect("keeps signed asset and upload URLs out of HTTP server spans", () => + Effect.gen(function* () { + const serverSpans: Array = []; + const layerTracer = Layer.succeed( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + if (options.kind === "server") serverSpans.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ); + const layerRoutes = Layer.effectDiscard( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + yield* router.add("GET", `${ASSET_ROUTE_PREFIX}/*`, HttpServerResponse.empty()); + yield* router.add( + "POST", + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, + HttpServerResponse.empty(), + ); + yield* router.add("GET", "/api/environment", HttpServerResponse.empty()); + }), + ); + const services = yield* Layer.build( + HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( + ServerHttp.withUntracedRequests, + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide(layerTracer), + ), + ); + const client = Context.get(services, HttpClient.HttpClient); + + // Each URL is a bearer credential: url.full and url.path would carry it. + for (const request of [ + HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), + HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png?v=1`), + HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature`), + HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature?v=1`), + HttpClientRequest.head(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), + ]) { + expect((yield* client.execute(request)).status).toBe(204); + } + expect(serverSpans).toEqual([]); + + expect((yield* client.execute(HttpClientRequest.get("/api/environment"))).status).toBe(204); + expect(serverSpans).toEqual(["GET"]); + + for (const path of [ + `${ASSET_ROUTE_PREFIX}-other/file`, + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}-other/token`, + ]) { + expect((yield* client.execute(HttpClientRequest.get(path))).status).toBe(404); + } + expect(serverSpans).toEqual(["GET", "GET", "GET"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + const layerFileResponse = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (staticDir: string) { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8f9737a3efc18..e792a233c6f06 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -391,8 +391,13 @@ const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_P const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { const queryIndex = request.url.indexOf("?"); const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex); - // Webhook URLs carry their secret token in the path, so they never reach a trace. - return UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`); + // Signed URLs carry bearer tokens in the path, so they never reach a trace. + return ( + UNTRACED_REQUEST_PATHS.has(path) || + path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`) || + path.startsWith(`${ASSET_ROUTE_PREFIX}/`) || + path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) + ); }); export const withUntracedRequests = Layer.provide(layerUntracedRequests); From 1cc9d413a52c05ad51df5faa8d11437ca97043c7 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:57:04 -0500 Subject: [PATCH 106/144] fix(server): clone errors no longer show a token from the URL's query (#24698) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../SourceControlRepositoryService.test.ts | 56 +++++++++++++++++++ .../SourceControlRepositoryService.ts | 16 ++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 4a9105e9f5d9e..b0770b06d4830 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -383,6 +383,62 @@ it.effect.each([ }), ); +it.effect.each([ + ["a plain token", "s3cret"], + ["a token with an apostrophe", "s3'cret"], + ["a token with a double quote", 's3"cret'], + ["a host-only token with an at sign", "abc@secret"], + ["an unquoted token with an apostrophe", "s3'cret", true], + ["an unquoted token with a double quote", 's3"cret', true], + ["an unquoted URL with an apostrophe in its path", "path-token", true], +] as const)("keeps %s in the query out of a failed clone's error text", ([, token, unquoted]) => + Effect.gen(function* () { + const remote = + token === "abc@secret" + ? "https://github.com" + : token === "path-token" + ? "https://github.com/octocat/t3code's.git" + : "https://github.com/octocat/t3code.git"; + const error = yield* Effect.flip( + cloneInTemporaryDirectory( + { + execute: (input) => + Effect.gen(function* () { + const cloneUrl = input.args.at(-2)!; + // Git treats @ in a pathless URL's query as userinfo, losing the query delimiter. + const diagnosticUrl = + cloneUrl === "https://github.com?access_token=abc@secret" + ? "https://secret" + : cloneUrl; + yield* ( + input.progress?.onStderrLine?.( + unquoted + ? `fatal: ${diagnosticUrl}/info/refs not valid: is this a git repository?` + : `fatal: unable to access '${diagnosticUrl}/': Could not resolve host: github.com`, + ) ?? Effect.void + ); + return yield* new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command exited with a non-zero status.", + exitCode: 128, + }); + }), + }, + { remoteUrl: `${remote}?access_token=${token}` }, + ), + ); + + assert.strictEqual( + error.detail, + unquoted + ? `fatal: ${remote} not valid: is this a git repository?` + : `fatal: unable to access '${remote}${token === "abc@secret" ? "/" : ""}': Could not resolve host: github.com`, + ); + }), +); + it.effect("strips embedded credentials from the remote URL it reports", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b6e7679037040..44d31a43b6978 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -110,7 +110,7 @@ function toRepositoryInfo( }; } -// Redact credentials from client clone snapshots; git still receives the original URL. +// Redact credentials from client clone snapshots; git still receives the credentials. function redactRemoteUrl(remoteUrl: string): string { try { const url = new URL(remoteUrl); @@ -128,10 +128,16 @@ function redactRemoteUrl(remoteUrl: string): string { } // Userinfo can contain @; remove through the last one before the host boundary. -const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; +const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]+@/gi; +// Git leaves quotes inside query tokens unescaped; consume through the closing quote. +const QUOTED_URL_QUERY = /(['"])([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?.*\1(?=[\s:]|$)/gi; +const URL_QUERY = /\b([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?[^\s]*/gi; function redactUrlCredentials(text: string): string { - return text.replace(URL_WITH_USERINFO, "$1"); + return text + .replace(QUOTED_URL_QUERY, "$1$2$1") + .replace(URL_QUERY, "$1") + .replace(URL_WITH_USERINFO, "$1"); } // The remote already exists after push failure; advise fixing access and pushing. @@ -340,11 +346,13 @@ export const make = Effect.gen(function* () { if (stderrTail.length > 4) stderrTail.shift(); }); }; + // Git misreads @ in a pathless URL's query as userinfo and echoes its suffix as the host. + const normalizedCloneUrl = cloneUrl.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)\?/i, "$1/?"); yield* git .execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: path.dirname(destinationPath), - args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], + args: ["clone", "--progress", "--", normalizedCloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, From c9d3f7c9f4c312e61990ea5184afd9c3056b4791 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:57:27 -0500 Subject: [PATCH 107/144] fix(claude): pass every --add-dir launch directory to Claude (#23281) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../Adapters/ClaudeAdapterV2.test.ts | 22 ++++++++++ .../Adapters/ClaudeAdapterV2.ts | 8 +++- .../src/provider/Drivers/ClaudeSkills.test.ts | 2 + .../src/provider/Drivers/ClaudeSkills.ts | 41 ++++++++++++------- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 38253cd82f1d1..fb17c34713fd8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -268,6 +268,28 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.isUndefined(options.extraArgs?.["dangerously-skip-permissions"]); }); + it("passes every --add-dir launch directory to Claude", () => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-add-dir", + resume: false, + cwd: "/workspace", + attachmentsDir: "/attachments", + settings: { + ...AUTO_COMPACT_CLAUDE_SETTINGS, + launchArgs: "--add-dir ../apps ../lib --verbose --add-dir=/shared", + }, + }); + assert.deepEqual(options.additionalDirectories, [ + "/workspace", + "/attachments", + "../apps", + "../lib", + "/shared", + ]); + assert.isUndefined(options.extraArgs?.["add-dir"]); + }); + it("gives delegated children Claude's subagent prompt cache TTL", () => { const lineage = (relationshipToParent: "fork" | "subagent" | null) => ({ lineage: { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b1273cee72061..496a0b2e77d0f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -135,7 +135,10 @@ import { planClaudeSkillDispatch, type ClaudeSkillTarget, } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; -import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; +import { + claudeLaunchArgDirectories, + discoverClaudeSkills, +} from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection, type CompiledClaudeModelSelection, @@ -1473,6 +1476,8 @@ export function makeClaudeQueryOptions(input: { }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const { + // claudeLaunchArgDirectories reads every --add-dir into additionalDirectories. + "add-dir": _launchArgAddDir, "permission-mode": launchArgPermissionMode, "dangerously-skip-permissions": launchArgSkipPermissions, "append-system-prompt": launchArgAppendSystemPrompt, @@ -1592,6 +1597,7 @@ export function makeClaudeQueryOptions(input: { const additionalDirectories = [ ...(input.cwd === null ? [] : [input.cwd]), ...(input.attachmentsDir === undefined ? [] : [input.attachmentsDir]), + ...claudeLaunchArgDirectories(input.settings?.launchArgs), ]; const withDirectories = additionalDirectories.length === 0 ? options : { ...options, additionalDirectories }; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 2897cc0b84282..860280aa91e89 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -358,6 +358,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { '--add-dir "extra skills"', '--add-dir="extra skills"', `--add-dir missing --add-dir "${extraDir}"`, + `--add-dir "${extraDir}" --add-dir missing`, + `--add-dir missing "${extraDir}"`, ]) { const settings = { homePath: configDir, launchArgs }; const skills = yield* discoverClaudeSkills(settings, workspace); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 56c2ff19bebdf..bc68e42b76a2a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { parse as parseYamlDocument } from "yaml"; @@ -427,6 +427,26 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); +// Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. +export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { + const directories: string[] = []; + let collecting = false; + for (const token of tokenizeCliArgs(launchArgs)) { + if (token === "--") break; + if (token === "--add-dir") { + collecting = true; + } else if (token.startsWith("--add-dir=")) { + directories.push(token.slice("--add-dir=".length)); + collecting = false; + } else if (token.startsWith("-")) { + collecting = false; + } else if (collecting) { + directories.push(token); + } + } + return directories; +} + /** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, @@ -442,22 +462,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* environment ?? process.env, ); - const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), - ...(additionalDirectory && (cwd || path.isAbsolute(additionalDirectory)) - ? [ - { - directory: path.join( - path.resolve(cwd ?? ".", additionalDirectory), - ".claude", - "skills", - ), - scope: "project" as const, - }, - ] - : []), + ...claudeLaunchArgDirectories(config.launchArgs) + .filter((directory) => directory && (cwd || path.isAbsolute(directory))) + .map((directory) => ({ + directory: path.join(path.resolve(cwd ?? ".", directory), ".claude", "skills"), + scope: "project" as const, + })), ...(yield* discoverPluginSkillRoots( configDirPath, cwd, From 4b7a347ca9372aea7e2cc93d8b2835cdf34f69eb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:57:51 -0500 Subject: [PATCH 108/144] fix(composer): retry expired catalogs after failed discovery (#23174) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../client-runtime/src/providerSkills.test.ts | 57 +++++++++++++++++++ packages/client-runtime/src/providerSkills.ts | 21 +++++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 7539861026165..ccc5bf6ef2807 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -199,6 +199,63 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it.each(["already expired", "expires during discovery"])( + "retries an unchanged catalog that %s after the short cooldown", + async (expiry) => { + const start = Date.parse("2026-01-01T00:05:00.000Z"); + vi.useFakeTimers({ now: expiry === "expires during discovery" ? start - 1 : start }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, responses, run, unmount } = setup( + expiry === "expires during discovery" + ? withSnapshot("2026-01-01T00:00:00.000Z", true) + : stale, + [ + () => { + if (expiry === "expires during discovery") vi.setSystemTime(start + 1); + return stale; + }, + ], + ); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(9_999); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + responses.push(() => withSnapshot("2026-01-01T00:05:10.000Z")); + await vi.advanceTimersByTimeAsync(1); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(2); + unmount(); + }, + ); + + it("keeps the TTL guard for a newly scanned catalog when the client clock is ahead", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T01:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z"), [ + () => withSnapshot("2026-01-01T00:01:00.000Z"), + ]); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("bounds retries when discovery repeatedly returns the same expired catalog", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:05:00.000Z") }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, run, unmount } = setup(stale, [() => stale, () => stale, () => stale]); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(3); + unmount(); + }); + it("does not rescan a current snapshot, and rescans once it ages past the TTL", () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z")); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index e0e15450669f8..63ded7a47dc5c 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -144,7 +144,7 @@ interface ProviderWorkspaceSnapshotRefreshState { readonly requestedAt: number | null; /** A failed scan is not retried before this time. */ readonly retryNotBefore: number | null; - /** Scans in a row that ended without a complete snapshot. */ + /** Failed scans within the current request TTL. */ readonly failedAttempts: number; readonly hadSnapshot: boolean; } @@ -183,8 +183,10 @@ function planProviderWorkspaceSnapshotRefresh( const state = { requestedAt, retryNotBefore, - // A complete snapshot ends the run of failures. - failedAttempts: hasSnapshot ? 0 : previous.failedAttempts, + failedAttempts: + lost || (requestedAt !== null && now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) + ? 0 + : previous.failedAttempts, hadSnapshot: hasSnapshot, }; const refresh = @@ -305,9 +307,18 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { { environmentId, input: { instanceId, cwd } }, { reportFailure: false }, ).then((result) => { + const refreshedProvider = + result._tag === "Success" ? findProvider(result.value.providers) : undefined; + const snapshot = + refreshedProvider && resolveProviderWorkspaceSnapshot(refreshedProvider, cwd); + // A new server timestamp proves progress even when the client clock is ahead. + // @effect-diagnostics-next-line globalDate:off + const completedAt = Date.now(); const refreshed = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot(findProvider(result.value.providers), cwd); + hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && + (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || + snapshot?.checkedAt !== + (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); if (!refreshed) retryLater(); }, retryLater); return plan.state; From 7860ee983d81d287eaf15664ffd92dc6964d92cc Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:58:14 +0000 Subject: [PATCH 109/144] fix(auth): preserve pairing codes after scope rejection (#25222) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> --- apps/server/src/auth/EnvironmentAuth.test.ts | 29 +++++++++++++++++++ apps/server/src/auth/EnvironmentAuth.ts | 15 ++++++---- apps/server/src/auth/McpOAuth.test.ts | 3 +- apps/server/src/auth/PairingGrantStore.ts | 15 +++++++--- .../src/persistence/AuthPairingLinks.ts | 17 ++++++++++- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 8558a6664c8a6..bd5d3b569958f 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -377,6 +377,35 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(layerEnvironmentAuth())), ); + it.effect( + "preserves a rejected MCP pairing code for exactly one concurrent lower-access retry", + () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairing = yield* serverAuth.issuePairingCredential({ + scopes: ["orchestration:read"], + }); + + const rejected = yield* Effect.flip( + serverAuth.consumeMcpApprovalCode(pairing.credential, "full-access"), + ); + expect(rejected).toMatchObject({ + _tag: "ServerAuthMcpApprovalCodeError", + reason: "insufficient_scope", + }); + + const retry = Effect.result( + serverAuth.consumeMcpApprovalCode(pairing.credential, "read-only"), + ); + const results = yield* Effect.all([retry, retry], { concurrency: "unbounded" }); + + expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); + expect(results.filter((result) => result._tag === "Failure")).toMatchObject([ + { failure: { _tag: "ServerAuthMcpApprovalCodeError", reason: "unknown_or_used" } }, + ]); + }).pipe(Effect.provide(layerEnvironmentAuth())), + ); + it.effect("narrows seeded desktop grants to the requested scopes", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 3fbe5a0061c0e..7d73e65925228 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -429,7 +429,7 @@ export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError; + readonly requiredScopes?: ReadonlyArray; }, ): Effect.Effect< ResolvedBootstrapGrant, @@ -920,6 +921,12 @@ export const make = Effect.gen(function* () { } return sessions.verify(credential).pipe( mapSessionVerificationErrors, + Effect.filterOrFail( + (session) => + input?.requiredScopes === undefined || + input.requiredScopes.every((scope) => session.scopes.includes(scope)), + () => new ServerAuthScopeNotGrantedError({}), + ), Effect.map( (session) => ({ @@ -1340,7 +1347,7 @@ export const make = Effect.gen(function* () { code, access, ) => - resolveBootstrapGrant(code.trim()).pipe( + resolveBootstrapGrant(code.trim(), { requiredScopes: mcpClientScopes(access) }).pipe( Effect.catchTags({ ServerAuthInvalidCredentialError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), @@ -1350,9 +1357,7 @@ export const make = Effect.gen(function* () { Effect.flatMap((grant) => grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" })) - : mcpClientScopes(access).every((scope) => grant.scopes.includes(scope)) - ? Effect.void - : Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), + : Effect.void, ), Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"), ); diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 3dc35aca981b9..29588040217ae 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -457,11 +457,10 @@ it.live( }); expect(operatorFull.status).toBe(400); - const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); const readOnlyApproval = yield* decide(handler, params, { _tag: "pairing-code", access: "read-only", - code: readOnlyCode.credential, + code: readOnly.credential, }); expect(readOnlyApproval.status).toBe(200); const readOnlyToken = yield* handler( diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 440dbe3788a45..0709f371684fb 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -226,6 +226,8 @@ export class PairingGrantStore extends Context.Service< input?: { readonly proofKeyThumbprint?: string; readonly requestedScopes?: ReadonlyArray; + /** All scopes must be granted; requestedScopes only requires an overlap for OAuth. */ + readonly requiredScopes?: ReadonlyArray; }, ) => Effect.Effect; } @@ -486,8 +488,10 @@ const make = Effect.gen(function* () { } if ( - input?.requestedScopes !== undefined && - !input.requestedScopes.some((scope) => grant.scopes.includes(scope)) + (input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => grant.scopes.includes(scope))) || + (input?.requiredScopes !== undefined && + !input.requiredScopes.every((scope) => grant.scopes.includes(scope))) ) { return [ { @@ -550,6 +554,7 @@ const make = Effect.gen(function* () { ...(input?.requestedScopes !== undefined ? { requestedScopes: input.requestedScopes } : {}), + ...(input?.requiredScopes !== undefined ? { requiredScopes: input.requiredScopes } : {}), consumedAt: now, now, }) @@ -596,8 +601,10 @@ const make = Effect.gen(function* () { } if ( - input?.requestedScopes !== undefined && - !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope)) + (input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))) || + (input?.requiredScopes !== undefined && + !input.requiredScopes.every((scope) => matching.value.scopes.includes(scope))) ) { return yield* new BootstrapCredentialScopeNotGrantedError({}); } diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index 4e2d572bd96b4..8754bdbd2edcd 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -48,6 +48,7 @@ export const ConsumeAuthPairingLinkInput = Schema.Struct({ credential: Schema.String, proofKeyThumbprint: Schema.NullOr(Schema.String), requestedScopes: Schema.optionalKey(AuthEnvironmentScopes), + requiredScopes: Schema.optionalKey(AuthEnvironmentScopes), consumedAt: Schema.DateTimeUtcFromString, now: Schema.DateTimeUtcFromString, }); @@ -161,7 +162,14 @@ export const make = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, Result: AuthPairingLinkRawDbRow, - execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) => + execute: ({ + credential, + proofKeyThumbprint, + requestedScopes, + requiredScopes, + consumedAt, + now, + }) => sql` UPDATE auth_pairing_links SET consumed_at = ${consumedAt} @@ -180,6 +188,13 @@ export const make = Effect.gen(function* () { SELECT value FROM json_each(auth_pairing_links.scopes) ) )) + AND NOT EXISTS ( + SELECT 1 + FROM json_each(${JSON.stringify(requiredScopes ?? [])}) AS required + WHERE required.value NOT IN ( + SELECT value FROM json_each(auth_pairing_links.scopes) + ) + ) RETURNING id AS "id", credential AS "credential", From 04f35381b65015f5204c2ceb11a94ba931a38fea Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 17:58:36 +0000 Subject: [PATCH 110/144] fix(mobile): discard unchanged queued edits after starting (#25232) Co-authored-by: Tristan Knight <18444302+tris203@users.noreply.github.com> --- apps/mobile/src/state/queued-run-edit.test.ts | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 361a743ac71b5..6670505ad3988 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { Atom } from "effect/reactivity"; import { ChatAttachmentId, @@ -20,7 +20,25 @@ import { type QueuedRunEdit, } from "./queued-run-edit"; import { threadComposerErrorsAtom } from "./thread-composer-error"; -import { getComposerDraftSnapshot, setComposerDraftText } from "./use-composer-drafts"; +import { + flushComposerDrafts, + getComposerDraftSnapshot, + setComposerDraftText, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; + +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + File: class { + exists = false; + create() {} + write() {} + moveSync() {} + }, + Paths: { document: { uri: "file:///documents" } }, +})); const image = (id: string): ChatAttachment => ({ type: "image", @@ -94,6 +112,64 @@ describe("resolveQueuedEditPayload", () => { }); describe("beginQueuedRunEdit", () => { + it("silently drops an untouched edit when its run starts", () => { + const threadKey = "env-exit:thread-untouched"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([]), runs); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe(""); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined(); + }); + + it("recovers text after a server attachment was removed", async () => { + await waitForComposerDraftsLoaded(); + const threadKey = "env-exit:thread-removed-recover"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([image("removed")]), runs); + removeQueuedRunEditAttachment(threadKey, "removed"); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + await flushComposerDrafts(); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("original"); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe( + "That message already started. Your edit is back in the composer.", + ); + }); + + it("recovers changed text into an empty thread draft", async () => { + await waitForComposerDraftsLoaded(); + const threadKey = "env-exit:thread-changed-recover"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([]), runs); + setComposerDraftText(queuedEditDraftKey(threadKey, runId), "revised"); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + await flushComposerDrafts(); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("revised"); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe( + "That message already started. Your edit is back in the composer.", + ); + }); + it("ends a changed edit when its run starts, without the thread's composer mounted", () => { const threadKey = "env-exit:thread-exit"; const runId = RunId.make("run-1"); From b7c7a7819a9bd144072cb8ea11b4896cf435473d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:58:59 -0500 Subject: [PATCH 111/144] fix(server): t3 project commands wait for a busy server's reply (#24090) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/cli/project.test.ts | 67 +++++++++++++++++++++++++++++ apps/server/src/cli/project.ts | 23 +++++----- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 40a7ab7113839..723f5254e7a2c 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -18,11 +18,14 @@ import { import * as NetService from "@t3tools/shared/Net"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Stream from "effect/Stream"; import { Command } from "effect/cli"; +import { FetchHttpClient } from "effect/http"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cli } from "../binCli.ts"; @@ -37,6 +40,10 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -121,6 +128,66 @@ it.effect("adds, renames, and removes projects through the V2 project CLI domain }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect.each(["snapshot", "mutation"] as const)( + "waits for a busy live server's %s response", + (slowRequest) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-live-project-cli-" }); + const baseDir = NodePath.join(root, "state"); + const workspaceRoot = NodePath.join(root, "workspace"); + yield* fs.makeDirectory(workspaceRoot); + yield* persistServerRuntimeState({ + path: NodePath.join(baseDir, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: 3_773, + }), + }); + const now = "2026-01-01T00:00:00.000Z"; + const requestReceived = Promise.withResolvers(); + const releaseResponse = Promise.withResolvers(); + let snapshotRequests = 0; + let mutations = 0; + const fetch: typeof globalThis.fetch = async (_input, init) => { + const isMutation = init?.method === "POST"; + if (isMutation) mutations++; + else snapshotRequests++; + if (slowRequest === "mutation" ? isMutation : !isMutation && snapshotRequests === 2) { + requestReceived.resolve(); + await releaseResponse.promise; + } + return Response.json( + isMutation + ? { + id: "live-project", + title: NodePath.basename(workspaceRoot), + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + } + : { projects: [], updatedAt: now }, + ); + }; + + const add = yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]).pipe( + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.forkChild, + ); + yield* Effect.promise(() => requestReceived.promise); + yield* TestClock.adjust("2 seconds"); + releaseResponse.resolve(); + yield* Fiber.join(add); + + assert.equal(snapshotRequests, 2); + assert.equal(mutations, 1); + assert.deepEqual((yield* readProjects(baseDir)).projects, []); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixture")(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-lookup-" }); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index a5c8aa532ff3e..3d02f9c921fce 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -214,7 +214,8 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( Layer.provideMerge(SqlitePersistence.layerConfig), ); -const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); +const PROJECT_CLI_LIVE_SERVER_PROBE_TIMEOUT = Duration.seconds(1); +const PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT = Duration.seconds(10); const withProjectCliSessionToken = ( environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, @@ -228,9 +229,6 @@ const withProjectCliSessionToken = ( (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), ); -const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => - effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); - const makeLiveServerClient = (origin: string) => HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin, @@ -317,17 +315,14 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }); const fetchLiveOrchestrationSnapshot = Effect.fn("project.fetchLiveOrchestrationSnapshot")( - function* (origin: string, bearerToken: string) { + function* (origin: string, bearerToken: string, _timeout?: Duration.Duration) { const client = yield* makeLiveServerClient(origin); return yield* client.projects.snapshot({ headers: { authorization: `Bearer ${bearerToken}` }, }); }, - (effect, _origin, _bearerToken) => - effect.pipe( - withProjectCliLiveServerTimeout, - Effect.mapError(projectCommandErrorFromLiveServerRequest), - ), + (effect, _origin, _bearerToken, timeout = PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT) => + effect.pipe(Effect.timeout(timeout), Effect.mapError(projectCommandErrorFromLiveServerRequest)), ); const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestrationCommand")( @@ -340,7 +335,7 @@ const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestr }, (effect, _origin, _bearerToken, _command) => effect.pipe( - withProjectCliLiveServerTimeout, + Effect.timeout(PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT), Effect.mapError(projectCommandErrorFromLiveServerRequest), ), ); @@ -361,7 +356,11 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu } const attempt = withProjectCliSessionToken(environmentAuth, (token) => - fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( + fetchLiveOrchestrationSnapshot( + runtimeState.value.origin, + token, + PROJECT_CLI_LIVE_SERVER_PROBE_TIMEOUT, + ).pipe( Effect.as({ origin: runtimeState.value.origin, }), From 9d38d68d0437da92966866a789164a135af5c050 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:59:21 -0500 Subject: [PATCH 112/144] fix(web): wait for fresh diff pages in review guides (#23225) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../guide/useGuideDiffFiles.browser.test.tsx | 70 +++++++++++++++++++ .../pullRequest/guide/useGuideDiffFiles.ts | 2 +- .../client-runtime/src/state/runtime.test.ts | 29 ++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx new file mode 100644 index 0000000000000..cdb7d4099e5c7 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx @@ -0,0 +1,70 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, ProjectId, type PullRequestDiffResult } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../../test/render"; + +const state = vi.hoisted(() => ({ + pages: new Map>>(), +})); +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { + diff: ({ input }: { input: { cursor?: string } }) => state.pages.get(input.cursor ?? null)!, + }, +})); + +import { useGuideDiffFiles } from "./useGuideDiffFiles"; + +function Probe({ wantedPaths }: { wantedPaths: string[] }) { + const diff = useGuideDiffFiles({ + environmentId: EnvironmentId.make("test"), + reference: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + headSha: "new-head", + wantedPaths, + }); + return ( + + {JSON.stringify({ + loading: diff.loading, + files: [...diff.files].map(([path, file]) => [path, file.additionLines.join("")]), + })} + + ); +} + +function page(path: string, value: string, nextCursor: string | null): PullRequestDiffResult { + return { + patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+${value}\n`, + truncated: false, + nextCursor, + }; +} + +it.each([ + { name: "contains the requested file", path: "wanted.ts", cursor: null }, + { name: "ends before a newly added file", path: "other.ts", cursor: null }, + { name: "has an obsolete next cursor", path: "other.ts", cursor: "old-next" }, +])("waits for a cached page that $name to settle", async ({ path, cursor }) => { + state.pages.clear(); + await using stack = new AsyncDisposableStack(); + const registry = stack.adopt(AtomRegistry.make(), (value) => value.dispose()); + const first = Atom.make>( + AsyncResult.success(page(path, "STALE", cursor), { waiting: true }), + ); + state.pages.set(null, first); + state.pages.set( + "old-next", + Atom.make>(AsyncResult.initial(true)), + ); + await using screen = await render( + + + , + ); + const result = () => JSON.parse(screen.container.querySelector("output")!.textContent!); + expect(result()).toEqual({ loading: true, files: [] }); + + await act(() => registry.set(first, AsyncResult.success(page("wanted.ts", "FRESH", null)))); + expect(result()).toEqual({ loading: false, files: [["wanted.ts", "FRESH"]] }); +}); diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts index c905488949cff..21765c68d18e8 100644 --- a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts @@ -51,7 +51,7 @@ export function useGuideDiffFiles({ ); const data = query.data; - if (data !== null && !current.done && current.folded !== data) { + if (data !== null && !query.isPending && !current.done && current.folded !== data) { const parsed = getRenderablePatch( data.patch, `pull-request-guide:${key}:${current.cursor ?? "first"}`, diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 963b3a7bfdeb7..519ccd025b070 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1212,6 +1212,35 @@ describe("runtime command runner", () => { }); describe("persisted environment queries", () => { + it.effect("keeps an in-memory cached page pending until revalidation settles", () => + Effect.scoped( + Effect.gen(function* () { + const refreshStarted = Latch.makeUnsafe(); + const finishRefresh = Latch.makeUnsafe(); + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.suspend(() => { + if (++executions === 1) return Effect.succeed("cached"); + refreshStarted.openUnsafe(); + return finishRefresh.await.pipe(Effect.as("fresh")); + }), + { persistence: { load: () => Effect.succeedNone, save: () => Effect.void } }, + ); + const registry = yield* mountEnvironmentQuery(harness.atom); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("cached"); + registry.refresh(harness.atom); + yield* refreshStarted.await; + expect(registry.get(harness.atom)).toMatchObject({ + _tag: "Success", + value: "cached", + waiting: true, + }); + finishRefresh.openUnsafe(); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("fresh"); + }), + ), + ); + it.effect( "restores cached data in a new registry while offline, then refreshes on reconnect", () => From 99ff58abe8507acc4a97abb89c6854c4e74fbd08 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 12:59:44 -0500 Subject: [PATCH 113/144] fix(server): Antigravity stops retrying session startup while signed out (#24939) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../Adapters/AntigravityAdapterV2.test.ts | 59 +++++++++++++++++++ .../Adapters/AntigravityAdapterV2.ts | 2 + .../ProviderTurnStartService.test.ts | 28 +++++++++ .../ProviderTurnStartService.ts | 10 +++- packages/provider-acp/src/server/adapter.ts | 2 + .../src/server/ProviderAdapter.ts | 1 + 6 files changed, 101 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 90226cc68bd2a..0f5670f1d2de0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -24,6 +24,7 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; +import * as EffectAcpErrors from "effect-acp/errors"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -32,6 +33,7 @@ import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSession import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { normalizeAntigravitySessionUpdate } from "../../provider/acp/AntigravityProtocol.ts"; +import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../../provider/antigravityAuthSupport.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { @@ -126,6 +128,63 @@ const layerSession = Layer.mergeAll( TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), ); +describe("AntigravityAdapterV2 session open", () => { + it.effect.each([ + { + name: "browser sign-in", + cause: new EffectAcpErrors.AcpTransportError({ + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + cause: undefined, + }), + requiresSignIn: true, + }, + { + name: "ACP authentication required", + cause: EffectAcpErrors.AcpRequestError.authRequired(), + requiresSignIn: true, + }, + { + name: "transport failure", + cause: new EffectAcpErrors.AcpTransportError({ + detail: "Connection closed", + cause: undefined, + }), + requiresSignIn: undefined, + }, + ] as const)("classifies $name during session open", ({ cause, requiresSignIn }) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("antigravity-sign-in-test"); + const adapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: () => Effect.fail(cause), + withProcess: (_stop, task) => task, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + defaultModel: Effect.succeed(undefined), + }); + const threadId = ThreadId.make("thread-antigravity-sign-in"); + const error = yield* adapter + .openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-sign-in"), + modelSelection: { instanceId, model: "gemini-test-low" }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }), + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterOpenSessionError"); + assert.equal( + error._tag === "ProviderAdapterOpenSessionError" && error.requiresSignIn, + requiresSignIn, + ); + }).pipe(Effect.provide(layerSession), Effect.scoped), + ); +}); + describe("AntigravityAdapterV2 client file system", () => { it.effect("serves confined reads and leaves edits to the agent's diff tools", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 4d06da900f4a8..9ccaca83b542f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -34,6 +34,7 @@ import { makeAntigravityUserInputResponse, normalizeAntigravityToolCall, } from "../../provider/acp/AntigravityProtocol.ts"; +import { isAntigravitySignInRequiredError } from "../../provider/antigravityAuthSupport.ts"; import { AcpProviderCapabilitiesV2, makeAcpAdapterV2, @@ -238,6 +239,7 @@ export function makeAntigravityAcpAdapterFlavor( extractSubagentUpdate: extractAntigravitySubagentUpdate, supportsImagePrompts: true, supportsCompaction: true, + requiresSignIn: isAntigravitySignInRequiredError, }; } diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 7a922d79c7317..beaf7961e3782 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1304,6 +1304,34 @@ effectIt.effect("leaves the run starting when a session-open failure will be ret }), ); +effectIt.effect("fails the run without a retry when the provider needs sign-in", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + openFailure: new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: ProviderDriverKind.make("antigravity"), + providerSessionId: ProviderSessionId.make("new-provider-session"), + requiresSignIn: true, + }), + }); + + yield* harness.startWithRetry; + + expect(harness.open).toHaveBeenCalledOnce(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + expect(harness.projection().turnItems).toMatchObject([ + { + type: "error", + status: "failed", + failure: { + class: "provider_error", + message: "Failed to open antigravity provider session new-provider-session.", + }, + }, + ]); + }), +); + effectIt.effect("does not terminalize a provider-session open interruption", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ text: "Continue", interruptOpen: true }); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 94ba406b106f5..fad06732fb48e 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -92,6 +92,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError Effect.Effect, EffectAcpErrors.AcpError>; readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; + readonly requiresSignIn?: (cause: unknown) => boolean; /** * Runs before attachment paths, T3 instructions and native slash-command detection, * so a rewrite can turn the user's text into a native command. @@ -8555,6 +8556,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( new ProviderAdapter.ProviderAdapterOpenSessionError({ driver, providerSessionId: input.providerSessionId, + ...(flavor.requiresSignIn?.(cause) === true ? { requiresSignIn: true } : {}), cause, }), ), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9dc7ec1f62285..e168512736c7c 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -183,6 +183,7 @@ export class ProviderAdapterOpenSessionError extends Schema.TaggedError Date: Thu, 29 Oct 2026 13:00:06 -0500 Subject: [PATCH 114/144] fix(web): create tasks on a permitted connected environment (#23244) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../ScheduledTasksSettings.browser.test.tsx | 79 ++++++++++++++++--- .../settings/ScheduledTasksSettings.tsx | 24 ++++-- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e42242f01093e..124a27c7b11f4 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -1,3 +1,5 @@ +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { DEFAULT_UNIFIED_SETTINGS, EnvironmentId, @@ -10,7 +12,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { render } from "../../../test/render"; +import { act, render } from "../../../test/render"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; const environmentId = EnvironmentId.make("laptop"); @@ -38,7 +40,23 @@ const task: ScheduledTask = { runCount: 0, }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true })); +const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false })); +const secondaryId = EnvironmentId.make("secondary"); +const permissions = new Map>(); + +function setup() { + state.connected = true; + state.tasksLoaded = true; + state.secondary = false; + permissions.clear(); + permissions.set(environmentId, Atom.make(true)); + permissions.set(secondaryId, Atom.make(true)); + permissions.set("", Atom.make(false)); +} + +function secondaryEnvironment() { + return { ...environment(), environmentId: secondaryId, label: "Secondary" }; +} function environment() { return { @@ -54,8 +72,12 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { const entry = environment(); return { scope: { kind: "all", label: "All", members: [], environmentIds: [environmentId] }, - environments: [entry], - connectedEnvironments: state.connected ? [entry] : [], + environments: state.secondary ? [entry, secondaryEnvironment()] : [entry], + connectedEnvironments: state.connected + ? state.secondary + ? [entry, secondaryEnvironment()] + : [entry] + : [], environment: entry, target: null, targets: [], @@ -68,15 +90,14 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { }); vi.mock(import("../../state/server"), async (original) => { const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const allowed = Atom.make(true); + return { ...actual, serverEnvironment: { ...actual.serverEnvironment, upsertScheduledTask: { ...actual.serverEnvironment.upsertScheduledTask, - permissionAtom: () => allowed, + permissionAtom: (id: string | null) => permissions.get(id ?? "")!, }, }, }; @@ -93,7 +114,8 @@ vi.mock(import("../../state/query"), async (original) => ({ })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironment: (() => environment()) as never, + useEnvironment: ((id: EnvironmentId) => + id === secondaryId ? secondaryEnvironment() : environment()) as never, })); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), @@ -105,9 +127,44 @@ vi.mock(import("../../hooks/useSettings"), async (original) => ({ })); describe("ScheduledTasksSettings", () => { + it("creates on a permitted secondary and reacts to permission changes", async () => { + setup(); + state.secondary = true; + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + registry.set(permissions.get(environmentId)!, false); + await using screen = await render( + + + , + ); + const create = screen.getByRole("button", { name: "New task" }); + await expect.element(create).toBeEnabled(); + await act(() => registry.set(permissions.get(secondaryId)!, false)); + await expect.element(create).toBeDisabled(); + await act(() => registry.set(permissions.get(secondaryId)!, true)); + await expect.element(create).toBeEnabled(); + await create.click(); + await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Secondary"); + }); + + it("keeps the permitted representative as the creation target", async () => { + setup(); + state.secondary = true; + await using screen = await render(); + await screen.getByRole("button", { name: "New task" }).click(); + await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Laptop"); + }); + + it("disables creation without a connected target", async () => { + setup(); + state.connected = false; + await using screen = await render(); + await expect.element(screen.getByRole("button", { name: "New task" })).toBeDisabled(); + }); + it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => { - state.connected = true; - state.tasksLoaded = true; + setup(); await using screen = await render(); const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" }); await expect.element(name).toHaveValue("Review issues"); @@ -124,7 +181,7 @@ describe("ScheduledTasksSettings", () => { }); it("keeps an editor the user opened when the linked task loads later", async () => { - state.connected = true; + setup(); state.tasksLoaded = false; await using screen = await render(); await userEvent.click(page.getByRole("button", { name: "New task" })); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 1f64fe501af1d..e027438a6abdb 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -396,9 +396,18 @@ export function ScheduledTasksSettings(target: { replace: true, }); }; - const canCreate = useAtomValue( - serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null), + const createPermissions = useAtomValue( + scheduledTaskCreatePermissionsAtom( + environmentIdsKey(connectedEnvironments.map((entry) => entry.environmentId)), + ), ); + const permittedEnvironments = connectedEnvironments.filter( + (_entry, index) => createPermissions[index], + ); + const creationEnvironment = + permittedEnvironments.find( + (entry) => entry.environmentId === defaultEnvironment?.environmentId, + ) ?? permittedEnvironments[0]; return ( - defaultEnvironment && - setEditor({ environmentId: defaultEnvironment.environmentId, task: null }) + creationEnvironment && + setEditor({ environmentId: creationEnvironment.environmentId, task: null }) } > @@ -464,6 +473,11 @@ export function ScheduledTasksSettings(target: { ); } +const scheduledTaskCreatePermissionsAtom = environmentAtomList( + "web-scheduled-task-create-permissions", + serverEnvironment.upsertScheduledTask.permissionAtom, +); + const scheduledTasksListAtom = environmentAtomList("web-scheduled-tasks-list", (environmentId) => serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), ); From fa2a2dc7749eded7352c04f72c7c48167e6f2b78 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:00:29 -0500 Subject: [PATCH 115/144] fix(mobile): recognize this thread's main checkout (#23764) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../git/GitBranchesSheet.occupancy.test.tsx | 100 ++++++++++++++++++ .../features/threads/git/GitBranchesSheet.tsx | 4 +- apps/mobile/vite.config.ts | 1 + 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx new file mode 100644 index 0000000000000..7667b0c793fc4 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -0,0 +1,100 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const checkout = vi.hoisted(() => ({ + projectRoot: "", + worktreePath: null as string | null, + branches: [] as Array<{ name: string; worktreePath: string | null }>, +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "android" }, + View: ({ children }: { children: ReactNode }) =>
{children}
, + ScrollView: ({ children }: { children: ReactNode }) =>
{children}
, + Pressable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => ( + + ), + useWindowDimensions: () => ({ height: 800 }), +})); +vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock("../../../components/ui/AndroidScreenHeader", () => ({ AndroidSheetHeader: () => null })); +vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); +vi.mock("../../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../../components/ui/AppText", () => ({ + AppText: ({ children }: { children: ReactNode }) => {children}, + AppTextInput: () => null, +})); +vi.mock("./gitSheetComponents", () => ({ SheetActionButton: () => null })); +vi.mock("../../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: { environmentId: "environment-1", worktreePath: checkout.worktreePath }, + selectedThreadProject: { workspaceRoot: checkout.projectRoot }, + }), +})); +vi.mock("../../../state/use-thread-detail", () => ({ + useSelectedThreadWorktreePath: () => checkout.worktreePath, +})); +vi.mock("../../../state/use-selected-thread-git-state", () => ({ + useSelectedThreadGitState: () => ({ + selectedThreadBranches: checkout.branches, + selectedThreadBranchesLoading: false, + gitOperationLabel: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-actions", () => ({ + useSelectedThreadGitActions: () => ({ canChangeThreadBranch: true }), +})); +vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); + +import { GitBranchesSheet } from "./GitBranchesSheet"; + +it("recognizes both the main checkout and a linked worktree as this thread's checkout", () => { + const directory = "/repo"; + const linked = "/repo-linked"; + checkout.projectRoot = directory; + checkout.branches = [ + { name: "feature/available", worktreePath: null }, + { name: "feature/linked", worktreePath: linked }, + { name: "main", worktreePath: directory }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const rows = () => { + act(() => { + root.render( + , + ); + }); + return [...container.querySelectorAll("button")].map((button) => ({ + name: button.querySelectorAll("span")[0]?.textContent, + subtitle: button.querySelectorAll("span")[1]?.textContent, + disabled: button.disabled, + })); + }; + try { + checkout.worktreePath = null; + expect(rows()).toEqual([ + { name: "feature/available", subtitle: "Local branch", disabled: false }, + { name: "feature/linked", subtitle: "Checked out in another worktree", disabled: true }, + { name: "main", subtitle: "Checked out in this thread", disabled: false }, + ]); + + checkout.worktreePath = linked; + expect(rows()).toEqual([ + { name: "feature/available", subtitle: "Local branch", disabled: false }, + { name: "feature/linked", subtitle: "Checked out in this thread", disabled: false }, + { name: "main", subtitle: "Checked out in another worktree", disabled: true }, + ]); + } finally { + act(() => root.unmount()); + } +}); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 4075a49e9774a..dcaf96818bfe0 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -28,7 +28,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); - const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const { canChangeThreadBranch } = gitActions; @@ -43,7 +43,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; - const currentWorktreePath = selectedThreadWorktreePath; + const currentWorktreePath = selectedThreadCwd; const availableBranches = gitState.selectedThreadBranches; const branchesLoading = gitState.selectedThreadBranchesLoading; const busy = gitState.gitOperationLabel !== null; diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6a92a025778d9..9ddb84efbc832 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/threads/git/GitBranchesSheet.occupancy.test.tsx", "src/features/files/useFileTreeEntries.test.tsx", "src/features/projects/AddProjectScreen.test.ts", "src/lib/useMediaVideoPlayback.test.tsx", From 06ded0b60049cf213c62bf34b0b2ad85efd8d1f4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:00:53 -0500 Subject: [PATCH 116/144] fix(web): keep usage hubs that share a host (#23767) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../AddUsageLimitSourceDialog.logic.test.ts | 61 +++++++++++++++++++ .../AddUsageLimitSourceDialog.logic.ts | 24 ++++++++ .../settings/AddUsageLimitSourceDialog.tsx | 19 ++---- .../settings/UsageProviderSettings.tsx | 1 + 4 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts create mode 100644 apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts new file mode 100644 index 0000000000000..33192bbaab71f --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts @@ -0,0 +1,61 @@ +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; + +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; + +describe("adding usage limit sources", () => { + it.each([ + ["http://proxy.test:8318/hub-a", "http://proxy.test:8318/hub-b"], + ["localhost:8318", "localhost:9318"], + ])("retains saved configurations for %s and %s", (firstUrl, secondUrl) => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + const sources = [ + [firstUrl, "First hub", "first-key"], + [secondUrl, "Second hub", "second-key"], + [`${secondUrl}/extra`, "Third hub", "third-key"], + ] as const; + for (const [url, label, managementKey] of sources) { + const id = sourceIdFromUrl(url, settings.usageLimitSources); + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [id]: { kind: "cliproxy", url, label, managementKey, enabled: true }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual( + sources.map(([url, label, managementKey]) => ({ + kind: "cliproxy", + url, + label, + managementKey, + enabled: true, + })), + ); + }); + + it("updates a saved hub when its URL is added again", () => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + for (const managementKey of ["old-key", "new-key"]) { + const url = "http://proxy.test:8318/hub-a"; + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [sourceIdFromUrl(url, settings.usageLimitSources)]: { + kind: "cliproxy", + url, + managementKey, + enabled: true, + }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual([ + { + kind: "cliproxy", + url: "http://proxy.test:8318/hub-a", + managementKey: "new-key", + enabled: true, + }, + ]); + }); +}); diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts new file mode 100644 index 0000000000000..67ccabdefefc9 --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts @@ -0,0 +1,24 @@ +import { type UnifiedSettings, UsageLimitSourceId } from "@t3tools/contracts"; + +export function sourceIdFromUrl( + url: string, + sources: UnifiedSettings["usageLimitSources"], +): UsageLimitSourceId { + const saved = Object.entries(sources).find(([, source]) => source.url === url); + if (saved) return UsageLimitSourceId.make(saved[0]); + let host = url; + try { + host = new URL(url).host; + } catch {} + const slug = host + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + // Settings merge by ID, so adding a different hub must not replace an existing entry. + const base = `cliproxy-${slug || "hub"}`; + let id = base; + for (let suffix = 2; id in sources; suffix += 1) { + id = `${base}-${suffix}`; + } + return UsageLimitSourceId.make(id); +} diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index cc150016d73ce..e3abd8b951067 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,4 +1,4 @@ -import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; +import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -13,32 +13,23 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; -function sourceIdFromUrl(url: string): UsageLimitSourceId { - let host = url; - try { - host = new URL(url).host; - } catch {} - const slug = host - .toLowerCase() - .replace(/[^a-z0-9.-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); -} - /** Send the management key once to server secret storage; settings retain only a redaction marker. */ export function AddUsageLimitSourceDialog({ open, onOpenChange, environmentId, environmentLabel, + sources, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: EnvironmentId; readonly environmentLabel: string; + readonly sources: UnifiedSettings["usageLimitSources"]; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); @@ -55,7 +46,7 @@ export function AddUsageLimitSourceDialog({ const save = () => { if (!canSave) return; - const id = sourceIdFromUrl(trimmedUrl); + const id = sourceIdFromUrl(trimmedUrl, sources); updateSettings({ usageLimitSources: { [id]: { diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index bab7ddd9c52a6..f238850ae04d0 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -121,6 +121,7 @@ export function UsageProviderSettings({ onOpenChange={setAdding} environmentId={environmentId} environmentLabel={environmentLabel} + sources={sources} /> ) : null} From f8646e00c1528ef2aaa161a5c246119a6e5f7780 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:01:16 +0000 Subject: [PATCH 117/144] fix(github): read text and mixed-case pull request templates (#25025) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../gitHubChangeRequestTemplate.test.ts | 38 ++++++++++ .../src/server/gitHubChangeRequestTemplate.ts | 72 ++++++++++++++++--- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts index 58b094063ecba..29bf922020d0d 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts @@ -227,3 +227,41 @@ it.effect("bounds template reads and marks truncated content", () => }), ), ); + +it.effect.each([ + ".github/pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.TXT", + "docs/PuLl_ReQuEsT_TeMpLaTe.Md", + ".github/PULL_REQUEST_TEMPLATE/template.TxT", +])("reads committed text and mixed-case template $0 from a subdirectory", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* runGit(cwd, ["config", "grep.patternType", "fixed"]); + yield* runGit(cwd, ["config", "color.grep", "always"]); + yield* writeTemplate(cwd, relativePath, "committed template"); + yield* writeTemplate(cwd, "nested/README.md", "project"); + for (let index = 0; index < 600; index += 1) { + yield* writeTemplate(cwd, `docs/${index}-${"a".repeat(180)}.md`, "guide"); + } + yield* commitTemplates(cwd); + yield* writeTemplate(cwd, relativePath, "uncommitted template"); + + assert.strictEqual( + Option.getOrUndefined(yield* detectTemplate(path.join(cwd, "nested"))), + "committed template", + ); + }), + ), +); + +it.effect("does not guess between Markdown and text directory templates", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "markdown"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.TXT", "text"); + yield* commitTemplates(cwd); + assert.isTrue(Option.isNone(yield* detectTemplate(cwd))); + }), + ), +); diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts index c6af80389d1f7..9edbccdd504dc 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts @@ -11,10 +11,16 @@ const TRUNCATION_MARKER = "[truncated]"; const TEMPLATE_PATHS = [ ".github/pull_request_template.md", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.txt", + ".github/PULL_REQUEST_TEMPLATE.txt", "pull_request_template.md", "PULL_REQUEST_TEMPLATE.md", + "pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.txt", "docs/pull_request_template.md", "docs/PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.txt", + "docs/PULL_REQUEST_TEMPLATE.txt", ] as const; const TEMPLATE_DIRECTORIES = [ @@ -23,8 +29,6 @@ const TEMPLATE_DIRECTORIES = [ "docs/PULL_REQUEST_TEMPLATE", ] as const; -const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; - type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"]; interface TemplateTreeEntry { @@ -105,7 +109,7 @@ const readTemplateDirectory = Effect.fn("gitHubChangeRequestTemplate.readTemplat return false; } const relativePath = entry.path.slice(prefix.length); - return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); + return !relativePath.includes("/") && /\.(?:md|txt)$/i.test(relativePath); }); const templates: string[] = []; @@ -132,10 +136,50 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* ) { return yield* Effect.gen(function* () { // Read committed regular blobs; worktree paths can become symlinks between validation and open. + // ls-tree does not support case-insensitive pathspecs; grep discovers only matching names. + const paths = yield* executeGit({ + operation: "gitHubChangeRequestTemplate.findTemplatePaths", + cwd, + args: [ + "grep", + "-G", + "--no-color", + "-l", + "-z", + "--full-name", + "--no-recurse-submodules", + "-e", + "^", + treeish, + "--", + ...TEMPLATE_PATHS.map((path) => `:(top,icase,literal)${path}`), + ], + maxOutputBytes: TREE_LIST_MAX_BYTES, + }).pipe( + Effect.map((result) => + result.stdoutTruncated + ? [] + : result.stdout + .split("\0") + .filter((path) => path.startsWith(`${treeish}:`)) + .map((path) => path.slice(treeish.length + 1)), + ), + // grep exits with status 1 when no nonempty templates match. + Effect.orElseSucceed(() => []), + ); const result = yield* executeGit({ operation: "gitHubChangeRequestTemplate.listTemplates", cwd, - args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], + args: [ + "ls-tree", + "-r", + "-z", + "--full-tree", + treeish, + "--", + ...new Set([...TEMPLATE_PATHS, ...paths]), + ...TEMPLATE_DIRECTORIES, + ], maxOutputBytes: TREE_LIST_MAX_BYTES, appendTruncationMarker: true, }); @@ -144,12 +188,20 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* } const entries = parseTemplateTreeEntries(result.stdout); - const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); - for (const templatePath of TEMPLATE_PATHS) { - const entry = entriesByPath.get(templatePath); - if (!entry) { - continue; - } + const candidates = [".github/", "", "docs/"].flatMap((location) => { + const paths = TEMPLATE_PATHS.filter( + (path) => path.slice(0, path.lastIndexOf("/") + 1) === location, + ); + return [ + ...paths.flatMap((path) => entries.filter((entry) => entry.path === path)), + ...entries.filter( + (entry) => + !paths.some((path) => path === entry.path) && + paths.some((path) => path.toLowerCase() === entry.path.toLowerCase()), + ), + ]; + }); + for (const entry of candidates) { const template = yield* readTemplateBlob({ cwd, executeGit, entry }); if (Option.isSome(template)) { return template; From de2b1d33582edee2604567dc572fde4c3b906450 Mon Sep 17 00:00:00 2001 From: Stevan Borus Date: Thu, 29 Oct 2026 19:01:27 +0100 Subject: [PATCH 118/144] fix(web): keep HTML prototype selection rings visible (#23745) Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/provider/agentContextBudget.json | 2 +- .../BrowserDocumentFrame.browser.test.tsx | 107 ++++++++++++++++++ packages/shared/src/htmlRender.ts | 3 +- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index d5496ea8cfc57..1711e148d4ca4 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 45907 + "mcpToolListJsonBytes": 46007 } diff --git a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx index 9785a6764b2b6..3915ecce9a4ca 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx @@ -1,3 +1,4 @@ +import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender"; import { describe, expect, it, vi } from "vite-plus/test"; import "~/index.css"; @@ -34,3 +35,109 @@ describe("HtmlRenderDocument", () => { expect(onContentHeight).toHaveBeenCalledTimes(1); }); }); + +it("keeps the first and last selected card rings inside the fitted renderer frame", async () => { + const html = injectHtmlRenderBootstrap(`
+ + + +
`); + const src = URL.createObjectURL(new Blob([html], { type: "text/html" })); + using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + const container = document.createElement("div"); + container.style.width = "728px"; + const reports = new Map< + string, + { + selected: boolean; + ring: number; + left: number; + right: number; + top: number; + bottom: number; + pageHeight: number; + viewportHeight: number; + viewportWidth: number; + scrollWidth: number; + } + >(); + const messages = new AbortController(); + using _messages = { [Symbol.dispose]: () => messages.abort() }; + window.addEventListener( + "message", + (event) => { + const frame = container.querySelector("iframe"); + if (event.source === frame?.contentWindow && event.data?.type === "selected-card-bounds") { + reports.set(event.data.id, event.data); + } + }, + { signal: messages.signal }, + ); + const contentHeight = vi.fn((height: number) => { + container.style.setProperty("--content-height", `${height}px`); + }); + await using screen = await render( + <> + + + , + { container }, + ); + + await vi.waitFor(() => expect(contentHeight).toHaveBeenCalled()); + const frame = screen.getByTitle("Edge cards").element() as HTMLIFrameElement; + await vi.waitFor(() => { + expect(frame.getBoundingClientRect().height).toBe(contentHeight.mock.lastCall?.[0]); + }); + + for (const id of ["first", "last"]) { + frame.contentWindow?.postMessage({ type: "select-card", id }, "*"); + await vi.waitFor(() => expect(reports.get(id)?.selected).toBe(true)); + const bounds = reports.get(id)!; + expect(bounds.ring).toBe(5); + expect(bounds.viewportWidth).toBe(728); + expect(bounds.pageHeight).toBe(contentHeight.mock.lastCall?.[0]); + expect(bounds.viewportHeight).toBe(bounds.pageHeight); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.top).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth); + expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewportHeight); + expect(bounds.scrollWidth).toBeLessThanOrEqual(bounds.viewportWidth); + } +}); diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 572441ffe5366..12af0f39336b2 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -232,6 +232,7 @@ export const HTML_RENDER_THEME_GUIDE = [ export const HTML_RENDER_LAYOUT_GUIDE = [ `Frame width is reply width (${HTML_RENDER_COLUMN_WIDTH}px desktop, about 360px phones), on the thread background.`, "Leave html, body, and the outer element with no background color, no outer card, border, banner title, or side padding. This wins over any rule that sets a page background.", + "T3 adds an overridable 8px html gutter for selection rings and shadows. Use fluid widths within it.", "A box that needs its own background gets 16px padding and var(--radius) corners. Give charts fixed pixel heights.", "Let content set the height. Do not use vh units or height:100% on html or body. Both are rejected or loop.", ].join(" "); @@ -353,7 +354,7 @@ export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { } const BASE_CSS = - "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + + "html{box-sizing:border-box;padding:8px;background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}" + // Focus outlines paint inside the element, so the frame edge never clips them. ":where(:focus-visible){outline:2px solid var(--ring)}:focus-visible{outline-width:2px!important;outline-offset:-2px!important}" + From 12bd9d6a618ad0f2eadf34d31f6c428a12a8d150 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:01:49 +0000 Subject: [PATCH 119/144] fix(mobile): list pull request repositories once per environment (#25219) Co-authored-by: Tristan Knight <18444302+tris203@users.noreply.github.com> --- .../pull-requests/PullRequestsScreen.tsx | 13 +++-- .../pull-requests/pullRequestList.test.ts | 49 +++++++++++++++++++ .../features/pull-requests/pullRequestList.ts | 29 +++++++++++ .../pullRequestProjectFilter.logic.test.ts | 2 +- .../pullRequest/usePullRequestListView.ts | 2 +- packages/client-runtime/package.json | 4 ++ .../src/pullRequestProjectFilter.ts | 13 ++--- 7 files changed, 100 insertions(+), 12 deletions(-) rename apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts => packages/client-runtime/src/pullRequestProjectFilter.ts (82%) diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index b5fb14b198ab4..6c675219dc943 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -44,6 +44,7 @@ import { scopedPullRequestListKey as pullRequestKey, type EnvironmentPullRequestListEntry, withoutScratchProjectScope, + pullRequestProjectChoices, } from "./pullRequestList"; import { useDebouncedValue } from "../../state/queries"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -221,10 +222,14 @@ export function PullRequestsScreen( } const labelColors = knownFacets.environmentId === environmentId ? knownFacets.labelColors : facets.labelColors; - const projects = allProjects.filter( - (project) => - !project.isScratch && (environmentId === null || project.environmentId === environmentId), - ); + const projects = pullRequestProjectChoices(allProjects, { + environmentId, + controls, + environmentLabels: new Map( + environments.map((item) => [item.environmentId, item.environmentLabel]), + ), + servesPullRequests: (id) => configs.get(id)?.environment.capabilities.pullRequests === true, + }); const filters = usePullRequestListFilters({ environments: environments.map((item) => ({ id: String(item.environmentId), diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index f7b1a7a1bf832..b6f47b6533ee2 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, + ProjectId, SourceControlProviderKind, type PullRequestListEntry, type PullRequestListResult, @@ -26,6 +27,7 @@ import { withListedPullRequests, withShownPullRequestStats, withoutScratchProjectScope, + pullRequestProjectChoices, } from "./pullRequestList"; function entry(number: number, patch: Partial = {}): PullRequestListEntry { @@ -402,6 +404,53 @@ describe("pullRequestFilterChoices", () => { }); }); +describe("pullRequestProjectChoices", () => { + const laptop = EnvironmentId.make("env-laptop"); + const desktop = EnvironmentId.make("env-desktop"); + const project = (id: string, environmentId: EnvironmentId) => ({ + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `/work/${id}`, + repositoryIdentity: { canonicalKey: "github.com/pingdotgg/t3code" }, + }); + const projects = [project("main", laptop), project("worktree", laptop), project("main", desktop)]; + const choices = ( + servesPullRequests: (environmentId: EnvironmentId) => boolean, + scope: { projectId?: string; projectEnvironmentId?: string } = {}, + ) => + pullRequestProjectChoices(projects, { + environmentId: null, + controls: { ...DEFAULT_PR_LIST_CONTROLS, ...scope }, + environmentLabels: new Map([ + [laptop, "laptop"], + [desktop, "desktop"], + ]), + servesPullRequests, + }).map(({ id, environmentId, title }) => ({ id, environmentId, title })); + + it("lists each repository once per environment that serves pull requests", () => { + expect(choices(() => true)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code ยท desktop" }, + { id: "main", environmentId: laptop, title: "t3code ยท laptop" }, + ]); + expect(choices((environmentId) => environmentId === desktop)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code" }, + ]); + }); + + it("keeps the saved scope when its environment stops serving or has not loaded", () => { + const scope = { projectId: "worktree", projectEnvironmentId: laptop }; + expect(choices((environmentId) => environmentId === desktop, scope)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code ยท desktop" }, + { id: "worktree", environmentId: laptop, title: "t3code ยท laptop" }, + ]); + expect(choices(() => false, scope)).toEqual([ + { id: "worktree", environmentId: laptop, title: "t3code" }, + ]); + }); +}); + describe("shown PR diff stats", () => { const cups = EnvironmentId.make("cups"); const stat = (number: number, additions: number, deletions: number) => ({ diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index fcb41ac8ec861..674d3b0e24fe3 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -1,3 +1,4 @@ +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query"; import { ProjectId, @@ -34,6 +35,34 @@ export function withoutScratchProjectScope( : controls; } +type FilterProject = Parameters[0][number]; + +export function pullRequestProjectChoices< + Project extends FilterProject & { readonly isScratch?: true }, +>( + projects: readonly Project[], + input: { + readonly environmentId: string | null; + readonly controls: PullRequestListControls; + readonly environmentLabels: ReadonlyMap; + readonly servesPullRequests: (environmentId: EnvironmentId) => boolean; + }, +) { + const isSelected = (project: Project) => + project.id === input.controls.projectId && + project.environmentId === input.controls.projectEnvironmentId; + return pullRequestFilterProjects( + projects.filter( + (project) => + !project.isScratch && + (input.environmentId === null || project.environmentId === input.environmentId) && + (input.servesPullRequests(project.environmentId) || isSelected(project)), + ), + input.environmentLabels, + projects.find(isSelected), + ); +} + export function pullRequestListKey( item: Pick, ) { diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts index ec3319f010843..59d7e0c83ad12 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { findScopedProject } from "./pullRequestList.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; const cups = EnvironmentId.make("env-cups"); const nucbox = EnvironmentId.make("env-nucbox"); diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index c4e917fa7134e..fe22fe5af2838 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -36,7 +36,7 @@ import { import type { PullRequestExpectedHost } from "./PullRequestListFilters"; import type { PullRequestListPreferences, PullRequestListSort } from "./pullRequestListPreferences"; import { assignProjectsToEnvironments } from "./pullRequestProjectAssignment.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; import { pullRequestListEntryId } from "./pullRequestListGroups.logic"; export interface PullRequestsSearch extends PullRequestListPreferences { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 5808641a5606a..e1f81638ea3d4 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -415,6 +415,10 @@ "types": "./src/threadReference.ts", "default": "./src/threadReference.ts" }, + "./pull-request-project-filter": { + "types": "./src/pullRequestProjectFilter.ts", + "default": "./src/pullRequestProjectFilter.ts" + }, "./thread-pull-request-compatibility": { "types": "./src/threadPullRequestCompatibility.ts", "default": "./src/threadPullRequestCompatibility.ts" diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/packages/client-runtime/src/pullRequestProjectFilter.ts similarity index 82% rename from apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts rename to packages/client-runtime/src/pullRequestProjectFilter.ts index 040f3537446cf..03e47dcafab16 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts +++ b/packages/client-runtime/src/pullRequestProjectFilter.ts @@ -1,8 +1,9 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; - -interface FilterProject extends AssignableProject { +interface FilterProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly repositoryIdentity?: { readonly canonicalKey?: string | undefined } | null | undefined; readonly title: string; readonly workspaceRoot: string; } @@ -25,7 +26,7 @@ function distinguishTitles( export function pullRequestFilterProjects( projects: ReadonlyArray, environmentLabels: ReadonlyMap, - selectedProject?: Pick, + selectedProject?: Pick, ) { const byRepository = new Map(); for (const project of projects) { @@ -45,7 +46,7 @@ export function pullRequestFilterProjects( ); const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); - return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + return distinguishTitles(byEnvironment, (project) => project.id).sort((left, right) => left.title.localeCompare(right.title), ); } From 4e274aed25fdb94d71320c4565a1c3c35ca17093 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu Date: Thu, 29 Oct 2026 11:02:12 -0700 Subject: [PATCH 120/144] fix(shared): Open in Zed prefers zeditor over the ZFS zed on Linux (#23416) Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: maria-rcks --- .../src/process/externalLauncher.test.ts | 103 ++++++++++++++++++ packages/shared/src/editor.ts | 14 ++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1e816f31b77d5..265c4a2fc36bd 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -10,11 +10,14 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { EDITORS } from "@t3tools/contracts"; +import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; @@ -335,6 +338,106 @@ for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12 ); } +for (const { layout, pathNames, fallbackNames, expected } of [ + { layout: "PATH", pathNames: ["zed", "zeditor"], fallbackNames: [], expected: "zeditor" }, + { + layout: "mixed locations", + pathNames: ["zed"], + fallbackNames: ["zeditor"], + expected: "fallback", + }, + { + layout: "fallback directory", + pathNames: [], + fallbackNames: ["zed", "zeditor"], + expected: "fallback", + }, +]) { + it.effect.skipIf(windowsHost)(`prefers the Zed editor on Linux with ${layout}`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-linux-" }); + const binDir = path.join(home, "bin"); + const fallbackDir = path.join(home, ".local/bin"); + for (const [dir, names] of [ + [binDir, pathNames], + [fallbackDir, fallbackNames], + ] as const) { + yield* fs.makeDirectory(dir, { recursive: true }); + for (const name of names) { + yield* fs.writeFileString(path.join(dir, name), "#!/bin/sh\n"); + yield* fs.chmod(path.join(dir, name), 0o755); + } + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + assert.include(yield* launcher.resolveAvailableEditors(), "zed"); + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { HOME: home, PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.equal( + spawned.command, + expected === "fallback" ? path.join(fallbackDir, "zeditor") : expected, + ); + assert.deepEqual(spawned.args, ["/workspace"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} + +it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-fallback-" }); + const localBin = path.join(home, ".local/bin"); + const systemBin = path.join(home, "system-bin"); + for (const [dir, name] of [ + [localBin, "zed"], + [systemBin, "zeditor"], + ] as const) { + yield* fs.makeDirectory(dir, { recursive: true }); + const executable = path.join(dir, name); + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + } + const editor = EDITORS.find((editor) => editor.id === "zed")!; + const resolve = resolveEditorCommand(editor, { HOME: home, PATH: "" }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(Path.Path, { + ...path, + join: (...parts) => + path.join( + ...parts.map((part) => + ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, + ), + ), + }), + ); + assert.deepEqual( + yield* resolve, + Option.some({ command: path.join(systemBin, "zeditor"), baseArgs: [] }), + ); + yield* fs.remove(path.join(systemBin, "zeditor")); + assert.deepEqual( + yield* resolve, + Option.some({ command: path.join(localBin, "zed"), baseArgs: [] }), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.each([ { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" }, { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" }, diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index dd9f11b0751e4..57bc67afdcfa6 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -34,7 +34,8 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun const platform = yield* HostProcess.Platform; // Prefer Zed's macOS bundle because OpenZFS also installs a zed executable. const preferBundle = platform === "darwin" && editor.id === "zed"; - if (!preferBundle) { + const preferZeditor = platform === "linux" && editor.id === "zed"; + if (!preferBundle && !preferZeditor) { for (const command of editor.commands) { if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs }); } @@ -129,8 +130,15 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local/share") : undefined); if (dataHome) dirs.push(path.join(dataHome, "JetBrains/Toolbox/scripts")); } - for (const dir of dirs) { - for (const name of editor.commands) candidates.push(path.join(dir, name)); + if (preferZeditor) { + // Linux packages use zeditor because OpenZFS owns the zed command. + for (const name of ["zeditor", "zed"]) { + candidates.push(name, ...dirs.map((dir) => path.join(dir, name))); + } + } else { + for (const dir of dirs) { + for (const name of editor.commands) candidates.push(path.join(dir, name)); + } } } From ef515949d0c1914a29a279ada1a45002f94601ed Mon Sep 17 00:00:00 2001 From: Koushik_xd <122906171+koushikxd@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:32:34 +0530 Subject: [PATCH 121/144] fix(server): native Codex subagents no longer show the parent's reasoning effort (#24467) Co-authored-by: maria-rcks --- .../Adapters/CodexAdapterV2.test.ts | 106 +++++++++++++++++- .../Adapters/CodexAdapterV2.ts | 12 +- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 69fca8c0825b8..19eb2a1e23913 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -16,6 +16,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, type OrchestrationV2TurnItem, ProjectId, type ProviderApprovalDecision, @@ -566,6 +567,7 @@ function codexReplayPreamble(input: { readonly nativeTurnId: string; readonly prompt: string; readonly sentPrompt?: string; + readonly effort?: string; }): Array { return [ { @@ -664,7 +666,14 @@ function codexReplayPreamble(input: { approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess" }, summary: "detailed", - collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + ...(input.effort === undefined ? {} : { effort: input.effort }), + collaborationMode: { + mode: "default", + settings: { + model: "gpt-5.4", + ...(input.effort === undefined ? {} : { reasoning_effort: input.effort }), + }, + }, }, }, }, @@ -6211,6 +6220,101 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each(["activity", "spawn", "activity then spawn"] as const)( + "creates a child from %s without the parent's effort", + (source) => + Effect.scoped( + Effect.gen(function* () { + const created = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const activity = { + type: "subAgentActivity", + id: "call-codex-resume-spawn", + kind: "started", + agentThreadId: RESUME_CHILD_THREAD, + agentPath: "/root/resume_agent", + }; + const spawn = { + type: "collabAgentToolCall", + id: "call-codex-resume-spawn", + tool: "spawnAgent", + status: "completed", + senderThreadId: RESUME_NATIVE_THREAD, + receiverThreadIds: [RESUME_CHILD_THREAD], + model: "gpt-5.4", + reasoningEffort: "high", + agentsStates: { [RESUME_CHILD_THREAD]: { status: "completed", message: "finished" } }, + }; + const items = + source === "activity" ? [activity] : source === "spawn" ? [spawn] : [activity, spawn]; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: RESUME_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: RESUME_NATIVE_THREAD, + nativeTurnId: RESUME_NATIVE_TURN, + prompt: RESUME_PROMPT, + effort: "low", + }), + ...items.map((item) => ({ + type: "emit_inbound" as const, + frame: { + method: "item/completed", + params: { + item, + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + })), + ], + }), + (event) => + event.type === "app_thread.created" + ? Deferred.succeed(created, event.appThread) + : event.type === "subagent.updated" && event.subagent.status === "completed" + ? Deferred.succeed(completed, event.subagent) + : Effect.void, + ); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-child-effort"), + text: RESUME_PROMPT, + }), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "reasoningEffort", value: "low" }], + }, + }); + const child = yield* Deferred.await(created); + assert.equal( + child.modelSelection.options?.find(({ id }) => id === "reasoningEffort")?.value, + source === "spawn" ? "high" : undefined, + ); + if (source !== "activity") { + const task = yield* Deferred.await(completed); + assert.equal( + task.modelSelection?.options?.find(({ id }) => id === "reasoningEffort")?.value, + "high", + ); + assert.lengthOf( + harness.events.filter((event) => event.type === "app_thread.created"), + 1, + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("preserves a subagent result across a trailing empty final and resume", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 58cd4a0c0c17b..51796da166eda 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2717,9 +2717,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly emitInitialPrompt: boolean; }) { const registeredSubagents = yield* Ref.get(subagentThreads); - if (registeredSubagents.has(input.nativeThreadId)) { - return; - } if (input.model && !subagentSelections.has(input.nativeThreadId)) { yield* updateSubagentSelection( input.nativeThreadId, @@ -2727,6 +2724,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( input.reasoningEffort, ); } + if (registeredSubagents.has(input.nativeThreadId)) { + return; + } const now = yield* DateTime.now; const subagentNodeId = idAllocator.derive.nodeFromProviderItem({ @@ -2803,9 +2803,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( parentNodeId: subagentNodeId, activeProviderThreadId: providerThread.id, providerInstanceId: input.context.input.modelSelection.instanceId, - modelSelection: { + // A child's effort can differ from the parent's. Until Codex reports it, show none. + modelSelection: task.modelSelection ?? { ...input.context.input.modelSelection, model: task.model ?? input.context.input.modelSelection.model, + options: (input.context.input.modelSelection.options ?? []).filter( + ({ id }) => id !== "reasoningEffort", + ), }, title: subagentThreadTitle({ parentTitle: input.context.projectionAppThread.title, From 3bd64cf2c1c72c3c09d8258653a8e1e2d30b5116 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:02:57 -0700 Subject: [PATCH 122/144] fix(server): mark unread works after an automatic compaction (#25286) Co-authored-by: maria-rcks --- .../src/orchestration-v2/Orchestrator.ts | 10 +- .../src/orchestration-v2/runtimeLayer.test.ts | 108 ++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d0edd5dacf8f6..94de805b46522 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3229,8 +3229,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } let markUnreadVisitedAt: DateTime.Utc | null = null; if (command.type === "thread.mark-unread") { - const projection = yield* loadProjectionForCommand(command, ["runs"]); - const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null; + const shell = yield* projectionStore + .getThreadShell(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const latestRunCompletedAt = shell?.latestRunCompletedAt ?? null; if (latestRunCompletedAt === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 65c0c040e0daf..428202d17b651 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6278,6 +6278,114 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { assert.instanceOf(invalidVisit, Orchestrator.OrchestratorDispatchError); }), ); + + it.effect("marks unread before the user's last turn when an automatic compaction followed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-mark-unread-compacted"); + const turnCompletedAt = DateTime.makeUnsafe("2026-07-24T10:00:00.000Z"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Compacted thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const runs = [ + { text: "Continue", at: turnCompletedAt }, + { + text: "/compact", + idleCompaction: "beforeCacheExpires" as const, + at: DateTime.makeUnsafe("2026-07-24T11:00:00.000Z"), + }, + ]; + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make(`${threadId}:seed`), + events: runs.flatMap(({ at, ...message }, index) => { + const runId = RunId.make(`${threadId}:run-${index}`); + const messageId = MessageId.make(`${threadId}:message-${index}`); + return [ + { + id: EventId.make(`${messageId}:event`), + type: "message.updated" as const, + threadId, + occurredAt: at, + payload: { + ...message, + id: messageId, + threadId, + runId, + nodeId: null, + role: "user" as const, + attachments: [], + streaming: false, + createdBy: "user" as const, + creationSource: "web" as const, + createdAt: at, + updatedAt: at, + }, + }, + { + id: EventId.make(`${runId}:event`), + type: "run.created" as const, + threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: index + 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed" as const, + queuePosition: null, + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }, + ]; + }), + }); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make(`${threadId}:mark-unread`), + threadId, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.thread.lastVisitedAt, + DateTime.subtract(turnCompletedAt, { milliseconds: 1 }), + ); + const shell = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(shell!.latestRunCompletedAt, turnCompletedAt); + assert.deepEqual(shell!.lastVisitedAt, projection.thread.lastVisitedAt); + + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make(`${threadId}:visit`), + threadId, + visitedAt: DateTime.formatIso(turnCompletedAt), + }); + const revisited = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(revisited!.lastVisitedAt, turnCompletedAt); + }), + ); }); it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("thread opens", (it) => { From a1621a81af727fe40bdb2a351860b2704827d31c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:03:19 +0000 Subject: [PATCH 123/144] fix(server): show MCP App forms from failed tool results (#25225) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/mobile/src/lib/threadActivity.test.ts | 18 +++++ apps/mobile/src/lib/threadActivity.ts | 5 +- .../Adapters/CodexAdapterV2.test.ts | 67 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 10 ++- apps/web/src/session-logic.test.ts | 17 +++++ apps/web/src/session-logic.ts | 5 +- 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f2ced2d689794..96626a5caefab 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -38,6 +38,24 @@ const threadId = ThreadId.make("thread-1"); const sourceThreadId = ThreadId.make("thread-source"); const runId = RunId.make("run-1"); +it("shows a captured MCP app for a failed deferred write", () => { + const mcpApp = { + attachmentId: "deferred-app-html", + server: "github", + tool: "create_pull_request", + resourceUri: "ui://github-mcp-server/pr-create", + }; + const item: OrchestrationV2TurnItem = { + ...base("deferred-app", "2026-08-29T00:00:00.000Z", 1), + type: "dynamic_tool", + toolName: "github.create_pull_request", + status: "failed", + input: {}, + output: { t3McpApp: mcpApp, result: { content: [], isError: true } }, + }; + expect(buildThreadFeed([projected(item, 0)])).toMatchObject([{ type: "mcp-app", app: mcpApp }]); +}); + it("keeps historical plan detail accessible from its paged turn item", () => { const item = { ...base("historical-plan", "2026-08-29T00:00:00.000Z", 1), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 4cc5a842c21f9..bd7af44b2fa07 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2102,10 +2102,7 @@ export function buildThreadFeed( entryRows.push(index); continue; } - const app = - item.type === "dynamic_tool" && item.status === "completed" - ? mcpAppFromToolItem(item) - : undefined; + const app = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; if (app) { const entry: RawThreadFeedEntry = { type: "mcp-app", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 19eb2a1e23913..c20994423f3e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5138,6 +5138,73 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const MCP_APP_DEFERRED_RESULT = { + content: [{ type: "text", text: "An interactive form is being shown to the user." }], + structuredContent: { status: "awaiting_user_submission" }, + }; + const mcpAppDeferredTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: mcpAppTranscript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === "item/completed/app-tool" + ? { + ...entry, + frame: { + method: "item/completed", + params: { + item: { + ...mcpAppToolItem("inProgress"), + status: "failed", + result: MCP_APP_DEFERRED_RESULT, + }, + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, + }, + } + : entry, + ), + }); + + it.effect("captures the app of a tool call that defers to its form", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppDeferredTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "failed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, + ); + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app-deferred"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + assert.equal( + readMcpAppReference(output[MCP_APP_OUTPUT_KEY])?.resourceUri, + MCP_APP_RESOURCE, + ); + assert.deepEqual(output.result, { ...MCP_APP_DEFERRED_RESULT, isError: true }); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 51796da166eda..51a686e270389 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -3744,7 +3744,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const reference = - item.status === "completed" && item.result != null && item.error == null + item.result != null && item.error == null ? yield* restore( getNativeThreadId(context.providerThread).pipe( Effect.flatMap((threadId) => @@ -3798,7 +3798,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? artifacts.turnItem : { ...artifacts.turnItem, - output: { [MCP_APP_OUTPUT_KEY]: reference, result: item.result }, + output: { + [MCP_APP_OUTPUT_KEY]: reference, + result: + item.status === "failed" + ? { ...item.result, isError: true } + : item.result, + }, }; yield* emitProviderEvent({ type: "node.updated", diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 9c4e38c30e510..df3eb85c3db38 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1307,6 +1307,23 @@ describe("HTML renders in the timeline", () => { expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]); }); + it("hosts an MCP app whose call failed, as a form-deferring write does", () => { + const mcpApp = { + attachmentId: "render-thread-app-html", + server: "github", + tool: "create_pull_request", + resourceUri: "ui://github-mcp-server/pr-create", + }; + const call = renderCall("failed", { + t3McpApp: mcpApp, + result: { content: [], isError: true }, + }); + const entries = turn( + call.type === "dynamic_tool" ? { ...call, toolName: "github.create_pull_request" } : call, + ); + expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({ mcpApp }); + }); + it("keeps a render visible when its superseded attempt folds", () => { const attempt: OrchestrationV2RunAttempt = { id: RunAttemptId.make("attempt-superseded"), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7b79dea6f7401..6cf2761d3d6d3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -711,10 +711,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( continue; } - const mcpApp = - item.type === "dynamic_tool" && item.status === "completed" - ? mcpAppFromToolItem(item) - : undefined; + const mcpApp = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; if (mcpApp !== undefined) { entries.push({ id: item.id, From ad5ba437bd0b467879915efa9a14411b71a54fe7 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:03:42 +0000 Subject: [PATCH 124/144] fix(server): restore Linux session display for editor launches (#25228) Co-authored-by: Inaya Yousfi <193243337+inayayousfi@users.noreply.github.com> --- .../src/process/externalLauncher.test.ts | 73 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 54 +++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 265c4a2fc36bd..3262c555bcf8c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -140,6 +140,79 @@ it.effect.skipIf(windowsHost)( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// Windows drive letters cannot resolve the POSIX editor stub. +it.effect.skipIf(windowsHost)( + "a Linux editor launch without a display takes the desktop session's display from the systemd user manager", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const codiumPath = path.join(binDir, "codium"); + yield* fileSystem.writeFileString(codiumPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(codiumPath, 0o755); + + const launchEnv = (sessionManager: MockSpawnResult) => + Effect.gen(function* () { + const spawned: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "vscodium", cwd: "/workspace" }); + yield* launcher.launchBrowser("https://example.com"); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir }, + onSpawn: (command) => spawned.push(command), + spawnResult: (command) => + command.command === "busctl" ? sessionManager : undefined, + }), + ), + Effect.provideService(HostProcess.Environment, { PATH: binDir }), + ); + // Only editors borrow the session display; a browser must not open on that screen. + assert.deepEqual(spawned.find((command) => command.command === "xdg-open")?.options.env, { + PATH: binDir, + }); + return spawned.find((command) => command.command === "codium")?.options.env; + }); + + assert.deepEqual( + yield* launchEnv({ + stdout: JSON.stringify({ + type: "as", + data: [ + "DISPLAY=:0", + "HOME=/home/user", + "WAYLAND_DISPLAY=wayland-1", + "XAUTHORITY=/home/user/auth files/cookie", + ], + }), + }), + { + PATH: binDir, + DISPLAY: ":0", + WAYLAND_DISPLAY: "wayland-1", + // Raw, not the $'...' quoting `systemctl show-environment` would print. + XAUTHORITY: "/home/user/auth files/cookie", + }, + ); + assert.deepEqual(yield* launchEnv({ stdout: "invalid JSON" }), { PATH: binDir }); + assert.deepEqual(yield* launchEnv({ stdout: '{"data":[42]}' }), { PATH: binDir }); + assert.deepEqual( + yield* launchEnv({ + spawnError: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + { PATH: binDir }, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index b8c0911a520d2..6a2eb145162e2 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -31,6 +31,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -672,6 +673,56 @@ const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* ( yield* launchEditorProcess(launch); }); +const GRAPHICAL_SESSION_ENV_KEYS: ReadonlySet = new Set([ + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", +]); + +// The user manager's environment over D-Bus. `systemctl show-environment` would +// shell-quote values with spaces as $'...', while this JSON carries them raw. +const decodeManagerEnvironment = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ data: Schema.Array(Schema.String) })), +); + +// Boot services miss the desktop environment imported later into the systemd user manager. +const readGraphicalSessionEnv = Effect.fn("externalLauncher.readGraphicalSessionEnv")( + function* () { + const hostEnv = yield* HostProcess.Environment; + if ((yield* HostProcess.Platform) !== "linux" || hasGraphicalLinuxSession(hostEnv)) { + return {}; + } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner.string( + ChildProcess.make( + "busctl", + [ + "--user", + "--json=short", + "get-property", + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "Environment", + ], + { stdin: "ignore", stderr: "ignore" }, + ), + ); + const { data } = yield* decodeManagerEnvironment(output); + return Object.fromEntries( + data.flatMap((line) => { + const separator = line.indexOf("="); + const key = line.slice(0, separator); + return separator > 0 && GRAPHICAL_SESSION_ENV_KEYS.has(key) + ? [[key, line.slice(separator + 1)]] + : []; + }), + ); + }, + Effect.timeout("2 seconds"), + Effect.orElseSucceed((): NodeJS.ProcessEnv => ({})), +); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -681,7 +732,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment; const command = ChildProcess.make(launch.command, launch.args, { ...launch.options, - env, + env: { ...env, ...launch.options.env }, extendEnv: false, }); @@ -735,6 +786,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu options: { detached: launch.detached ?? true, shell: spawnCommand.shell, + env: yield* readGraphicalSessionEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", From 9e60e945d27a27f6505d0718122473454ce4afae Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:04:04 +0000 Subject: [PATCH 125/144] fix(usage): keep host identity across container recreation (#25220) Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/usage/UsageService.test.ts | 89 ++++++++++++++++++++++ apps/server/src/usage/UsageService.ts | 35 ++++++++- docs/user/usage.md | 4 + 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index b77d3437bef78..bee7a8a480bc1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -142,6 +142,7 @@ const layerService = (input: { ) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; + readonly hostname?: string; }) => layerCursorUsageAccounts( CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), @@ -157,6 +158,7 @@ const layerService = (input: { ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), + Layer.provideMerge(Layer.succeed(HostProcess.Hostname, input.hostname ?? "usage-test-host")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -465,6 +467,93 @@ describe("Codex usage history", () => { }); describe("UsageService", () => { + it.live("keeps the first host identity across recreation and explicit override removal", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + for (const [hostname, override, expected] of [ + ["container-a", undefined, "container-a"], + ["container-b", undefined, "container-a"], + ["container-c", "physical-host", "physical-host"], + ["container-d", undefined, "container-a"], + ] as const) { + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-host-id", + home, + settings, + hostname, + environment: override === undefined ? {} : { T3CODE_HOST_ID: override }, + }), + ), + ); + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.fingerprint.hostId, expected); + } + }).pipe(Effect.scoped), + ); + + it.live("concurrent initialization keeps one host identity across service recreation", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const prefix = "usage-host-id-race"; + const hostIdPath = NodePath.join(home, prefix, "usage-host-id"); + const bothRead = yield* Deferred.make(); + let reads = 0; + const summaries = yield* Effect.forEach( + ["container-a", "container-b"], + (hostname) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let firstRead = true; + const service = yield* UsageService.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path, encoding) => + Effect.gen(function* () { + if (path !== hostIdPath || !firstRead) { + return yield* fileSystem.readFileString(path, encoding); + } + firstRead = false; + // Both instances observe the missing file before either can create it. + const result = yield* Effect.exit(fileSystem.readFileString(path, encoding)); + reads += 1; + if (reads === 2) yield* Deferred.succeed(bothRead, undefined); + yield* Deferred.await(bothRead); + return yield* result; + }), + }), + ); + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix, home, settings, hostname })), + ), + { concurrency: "unbounded" }, + ); + const persisted = yield* Effect.promise(() => NodeFSP.readFile(hostIdPath, "utf8")); + const hostId = persisted.trim(); + assert.include(["container-a", "container-b"], hostId); + const restarted = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix, home, settings, hostname: "container-c" })), + ); + for (const summary of [...summaries, restarted]) { + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.fingerprint.hostId, hostId); + } + }).pipe(Effect.scoped), + ); + it.live( "repairs initial Codex prices after unchanged-file retries, persists evidence and corrections, and honors later native tiers", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6150ab1d3cc35..c1f619eaae177 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import { type ProviderInstanceConfig, ProviderInstanceId, @@ -155,6 +153,7 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcess.Environment; + const hostname = yield* HostProcess.Hostname; const readerContext = yield* Effect.context(); const fileCache: ScanCache = new Map(); @@ -177,6 +176,37 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); + const hostIdPath = path.join(config.baseDir, "usage-host-id"); + const explicitHostId = hostEnvironment["T3CODE_HOST_ID"]?.trim(); + const hostId = + explicitHostId || + (yield* fileSystem.readFileString(hostIdPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => + Effect.scoped( + Effect.gen(function* () { + yield* fileSystem.makeDirectory(config.baseDir, { recursive: true }); + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: config.baseDir, + prefix: "usage-host-id.", + }); + const tempFile = path.join(tempDirectory, "identity"); + yield* fileSystem.writeFileString(tempFile, `${hostname}\n`); + // Publish complete contents without replacing another initializer's identity. + yield* fileSystem.link(tempFile, hostIdPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); + return yield* fileSystem.readFileString(hostIdPath); + }), + ), + ), + Effect.map((value) => value.trim() || hostname), + Effect.orElseSucceed(() => hostname), + )); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesLastFailedAtMs: number | null = null; @@ -664,7 +694,6 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureScanCacheLoaded; - const hostId = NodeOS.hostname(); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ diff --git a/docs/user/usage.md b/docs/user/usage.md index 155175351c550..af1fc02352f83 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -43,6 +43,10 @@ Use absolute paths or `~/` paths in the account's environment settings; relative depend on each project's working directory and cannot be reliably discovered by Usage. Accounts sharing a history directory count once. +The server saves its first hostname in `/usage-host-id`, so a recreated container keeps +its usage identity when T3 home is persisted. Set `T3CODE_HOST_ID` to choose the identity +explicitly. + When your app and server support different providers, usage totals may cover only the providers your app understands. Update the app to include newly supported providers. From d796063f78a96c8816196fac3fa5660bebdd99a9 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:04:25 +0000 Subject: [PATCH 126/144] fix(opencode): authenticate with the resolved server password (#25230) Co-authored-by: Alex Southwell <4596216+saphid@users.noreply.github.com> Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/server/adapter.test.ts | 126 ++++++++++++++++++ .../provider-opencode/src/server/adapter.ts | 7 +- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 41da6bd0ff435..52f7590e63f42 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - Exercise HTTP authentication with the real SDK. +import * as NodeHttp from "node:http"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; @@ -50,6 +55,7 @@ import { import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeOpenCodeSettings = Schema.decodeEffect(OpenCodeSettings); const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ serverUrl: "http://test.invalid", }); @@ -2671,3 +2677,123 @@ it.effect.each([false, true])( Effect.scoped, ), ); + +it.live.each([true, false])("opens an authenticated OpenCode session (external=%s)", (external) => + Effect.gen(function* () { + const password = external ? "settings-password" : "environment-password"; + const requests: string[] = []; + let eventConnected!: () => void; + const eventRequest = new Promise((resolve) => { + eventConnected = resolve; + }); + const server = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise>((resolve) => { + const server = NodeHttp.createServer((request, response) => { + if ( + request.headers.authorization !== + `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` + ) { + response.writeHead(401).end("Unauthorized"); + return; + } + const url = new URL(request.url!, "http://localhost"); + requests.push(url.pathname); + if (url.pathname === "/event") { + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write(": connected\n\n"); + eventConnected(); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify( + url.pathname.endsWith("/children") + ? [] + : url.pathname === "/session" + ? { id: "native-auth", time: { created: 1, updated: 1 } } + : { healthy: true, version: "1.14.19" }, + ), + ); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server.address(); + assert.isNotNull(address); + if (!address || typeof address === "string") return; + const url = `http://127.0.0.1:${address.port}`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let managedStarts = 0; + const runtimeLayer = OpenCodeRuntime.layer.pipe( + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + assert.isTrue(ChildProcess.isStandardCommand(command)); + if (!ChildProcess.isStandardCommand(command)) return Effect.die("Unexpected pipeline"); + assert.include(command.args, "serve"); + assert.equal(command.options.env?.OPENCODE_SERVER_PASSWORD, password); + managedStarts += 1; + return spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "-e", + `console.log("server listening on " + process.argv[1]); setInterval(() => {}, 1000)`, + url, + ], + command.options, + ), + ); + }), + ), + ), + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provideMerge(NodeServices.layer), + ); + const instanceId = ProviderInstanceId.make("opencode-auth"); + const adapter = yield* makeOpenCodeAdapterV2({ + instanceId, + settings: yield* decodeOpenCodeSettings({ + serverUrl: external ? url : "", + ...(external ? { serverPassword: password } : {}), + }), + environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, + }).pipe(Effect.provide(runtimeLayer)); + const session = yield* adapter.openSession({ + threadId: ThreadId.make("thread-opencode-auth"), + providerSessionId: ProviderSessionId.make("session-opencode-auth"), + modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + yield* session.ensureThread({ + threadId: ThreadId.make("thread-opencode-auth"), + modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + assert.include(requests, "/session"); + yield* Effect.promise(() => eventRequest); + assert.include(requests, "/event"); + assert.include(requests, "/global/health"); + assert.equal(managedStarts, external ? 0 : 1); + }).pipe( + Effect.scoped, + Effect.provide([ + NodeServices.layer, + TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), + IdAllocator.layer, + McpProviderSessions.layer, + ]), + ), +); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 2a851188d076e..7762c3b64503d 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -936,13 +936,16 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function binaryPath: options.settings.binaryPath, directory: cwd, serverUrl: options.settings.serverUrl, + ...(options.settings.serverPassword + ? { serverPassword: options.settings.serverPassword } + : {}), environment: options.environment, }); const client = runtime.createOpenCodeSdkClient({ baseUrl: connection.url, directory: cwd, - ...(connection.external && options.settings.serverPassword - ? { serverPassword: options.settings.serverPassword } + ...(connection.serverPassword !== undefined + ? { serverPassword: connection.serverPassword } : {}), }); From f06dd4bbf5ad19226344279effcaa5928ce452f0 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:04:47 +0000 Subject: [PATCH 127/144] feat(web): allow resizing the file explorer (#25737) Co-authored-by: Rishabh Bothra <37180068+07rjain@users.noreply.github.com> --- .../src/components/files/FileExplorerPane.tsx | 90 +++++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 12 +-- .../preview/RightPanelResizeHandle.tsx | 13 ++- apps/web/src/hooks/useResizableWidth.ts | 19 ++-- 4 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/files/FileExplorerPane.tsx diff --git a/apps/web/src/components/files/FileExplorerPane.tsx b/apps/web/src/components/files/FileExplorerPane.tsx new file mode 100644 index 0000000000000..c13a814f63051 --- /dev/null +++ b/apps/web/src/components/files/FileExplorerPane.tsx @@ -0,0 +1,90 @@ +import { type CSSProperties, type ReactNode, useState } from "react"; + +import { RightPanelResizeHandle } from "~/components/preview/RightPanelResizeHandle"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "~/hooks/useResizableWidth"; +import { observeResize } from "~/lib/observeResize"; +import { cn } from "~/lib/utils"; + +const MIN_WIDTH = 160; +const MAX_WIDTH = 560; +const FILE_CONTENT_MIN_WIDTH = 256; + +export function FileExplorerPane({ + resizable, + children, +}: { + resizable: boolean; + children: ReactNode; +}) { + const [maxWidth, setMaxWidth] = useState(MAX_WIDTH); + const { width, setWidth, handlers } = useResizableWidth({ + storageKey: "t3code.fileExplorerWidth", + defaultWidth: 256, + minWidth: MIN_WIDTH, + maxWidth, + edge: "left", + }); + + return ( + + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 03e682758e321..e9b257e3e312c 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -84,6 +84,7 @@ import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; import { PreviewImage } from "./PreviewImage"; import FileBrowserPanel from "./FileBrowserPanel"; +import { FileExplorerPane } from "./FileExplorerPane"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; @@ -1513,14 +1514,7 @@ export default function FilePreviewPanel({ ) : null}
{showExplorer ? ( - + ) : null} diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx index dd644ccf0c822..851daaebe5eab 100644 --- a/apps/web/src/components/preview/RightPanelResizeHandle.tsx +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -1,16 +1,23 @@ +import type { ComponentProps } from "react"; import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; -interface Props { +interface Props extends Pick< + ComponentProps<"div">, + "tabIndex" | "onKeyDown" | "aria-valuenow" | "aria-valuemin" | "aria-valuemax" +> { handlers: ResizableWidthHandlers; className?: string; + label?: string; } -export function RightPanelResizeHandle({ handlers, className }: Props) { +export function RightPanelResizeHandle({ handlers, className, label, ...separatorProps }: Props) { return (
); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 16bb19829135b..50fdba38a9fc5 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -29,6 +29,7 @@ export interface ResizableWidthHandlers { /** Persist on drag end to avoid a storage write on every animation frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; + readonly setWidth: (width: number) => void; readonly handlers: ResizableWidthHandlers; } { const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; @@ -55,6 +56,16 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const clampedWidth = clamp(widthState.width); + const commitWidth = (nextStorageKey: string, nextWidth: number) => { + // Resizing remains usable for this session when browser storage is unavailable. + setWidthState({ storageKey: nextStorageKey, width: nextWidth }); + try { + setLocalStorageItem(nextStorageKey, nextWidth, WidthSchema); + } catch (error) { + console.error("Could not persist panel width.", error); + } + }; + const drag = useResizeDrag((event) => { const host = event.currentTarget.parentElement; // Disable drag transitions through transition-property; the host renders transition-duration. @@ -72,12 +83,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return nextWidth; }, finish(finalWidth) { - setWidthState({ storageKey: startStorageKey, width: finalWidth }); - try { - setLocalStorageItem(startStorageKey, finalWidth, WidthSchema); - } catch (error) { - console.error("Could not persist panel width.", error); - } + commitWidth(startStorageKey, finalWidth); }, cleanup(committed) { host?.style.removeProperty("transition-property"); @@ -94,6 +100,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return { width: clampedWidth, + setWidth: (value) => commitWidth(storageKey, clamp(value)), handlers: { ref: drag.ref, onPointerDown: drag.onPointerDown, From f4147ddf7948c30163714975dc8b9de671434f3c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:05:09 +0000 Subject: [PATCH 128/144] fix(server): update shared Codex installs from their owning home (#25235) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/provider/Drivers/CodexDriver.test.ts | 101 ++++++++++++++++++ .../src/provider/Drivers/CodexDriver.ts | 27 +++-- .../src/server/maintenanceResolver.ts | 9 +- 3 files changed, 129 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 90e37cc66712b..0aacca303e6a6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -377,6 +377,107 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ); + it.effect.skipIf(windowsHost)( + "runs the standalone updater against the home that holds a shared install", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + // One standalone install, linked onto PATH and used by two account homes. + // `ฤฐ` gets longer when lowercased, so the home must not be sliced from a + // lowercased copy of the path. + const installHome = NodePath.join(tempDir, "codex-ฤฐnstall"); + const realBinaryPath = NodePath.join( + installHome, + "packages", + "standalone", + "releases", + "0.162.1-x86_64-unknown-linux-musl", + "bin", + "codex", + ); + const binaryPath = NodePath.join(tempDir, "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + yield* fs.symlink(realBinaryPath, binaryPath); + + const updates = yield* Effect.forEach(["codex-p", "codex-vm"], (account) => + Effect.gen(function* () { + const homePath = NodePath.join(tempDir, `home-${account}`); + yield* fs.makeDirectory(homePath, { recursive: true }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(account), + displayName: account, + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath }, + }); + return (yield* instance.snapshot.resolveMaintenance()).update; + }), + ); + + for (const update of updates) { + expect(update).toMatchObject({ + command: `${binaryPath} update`, + args: ["update"], + lockKey: "codex-native", + env: { CODEX_HOME: installHome }, + }); + } + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + + it.effect.skipIf(windowsHost)("derives the standalone update home from either command path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-update-home-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const linkedInstallHome = NodePath.join(tempDir, "install"); + const fixtures = [ + { binary: "codex", realPath: "/packages/standalone/bin/codex", home: "/" }, + { binary: "codex", realPath: "C:\\Packages\\Standalone\\bin\\codex.exe", home: "C:\\" }, + // The standalone tree itself is a symlink, so only the resolved path names its home. + { + binary: NodePath.join("install", "packages", "standalone", "bin", "codex"), + realPath: "/disk/codex/releases/0.163.0/bin/codex", + home: linkedInstallHome, + }, + ]; + + for (const fixture of fixtures) { + const binaryPath = NodePath.join(tempDir, fixture.binary); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-update-home"), + displayName: "Codex update home", + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: tempDir }, + }).pipe( + // Virtualize only the executable target: these trees do not belong to this test. + Effect.provideService(FileSystem.FileSystem, { + ...fs, + realPath: (path) => + path === binaryPath ? Effect.succeed(fixture.realPath) : fs.realPath(path), + }), + ); + expect((yield* instance.snapshot.resolveMaintenance()).update?.env).toEqual({ + CODEX_HOME: fixture.home, + }); + } + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), + ); + for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2d078cf32594f..bbd910cf5d4a9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,7 +37,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -60,20 +59,36 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -function isCodexStandaloneCommandPath(commandPath: string): boolean { - return normalizeCommandPath(commandPath).includes("/packages/standalone/"); +const CODEX_STANDALONE_HOME_PATTERN = /^(.*)\/packages\/standalone\//is; + +function codexStandaloneHome(commandPath: string): string | null { + // Slash replacement preserves indices; Unicode lowercasing can change the path length. + const match = CODEX_STANDALONE_HOME_PATTERN.exec(commandPath.replaceAll("\\", "/")); + if (!match) { + return null; + } + const home = match[1]!; + // Keep root separators: `C:` is drive-relative, and an empty home is not `/`. + const isRoot = home === "" || /^[a-z]:$/i.test(home); + return commandPath.slice(0, home.length + (isRoot ? 1 : 0)); } -/** `codex update` owns the shared home's standalone tree; auth-overlay homes do not carry it. */ +// Standalone updates locate their install through CODEX_HOME. Account and auth-overlay +// homes can share an executable, so only the updater uses the executable's owning home. function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", nativeUpdate: { args: ["update"], - isCommandPath: isCodexStandaloneCommandPath, + isCommandPath: (commandPath) => codexStandaloneHome(commandPath) !== null, requiresCommandPath: true, - env: { CODEX_HOME: sharedHomePath }, + env: (context) => ({ + CODEX_HOME: + codexStandaloneHome(context.realCommandPath) ?? + codexStandaloneHome(context.resolvedCommandPath) ?? + sharedHomePath, + }), }, }); } diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 2e3aad166c58e..8a8e11e2ec0d7 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -87,7 +87,9 @@ export interface PackageManagedProviderMaintenanceDefinition { readonly isCommandPath?: (commandPath: string) => boolean; /** The updater cannot detect installs outside `isCommandPath`, so leave those manual-only. */ readonly requiresCommandPath?: boolean; - readonly env?: NodeJS.ProcessEnv; + readonly env?: + | NodeJS.ProcessEnv + | ((context: ProviderMaintenanceResolutionContext) => NodeJS.ProcessEnv); } | null; } @@ -446,6 +448,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( } const nativeUpdate = definition.nativeUpdate; + const nativeEnv = nativeUpdate?.env; const native = nativeUpdate ? makeProviderMaintenanceCapabilities({ provider: definition.provider, @@ -454,7 +457,9 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateArgs: nativeUpdate.args, updateLockKey: `${definition.provider}-native`, platform: context.platform, - ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), + ...(nativeEnv + ? { env: typeof nativeEnv === "function" ? nativeEnv(context) : nativeEnv } + : {}), }) : manual; // A native-looking launcher can still delegate to a mise-owned install. From 8bcc9911f11e5dd9666e1a1b625766e0ccd2cb49 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:05:45 -0500 Subject: [PATCH 129/144] fix(cli): keep published runtimes in place during installation (#24092) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- scripts/build-npm-launcher.test.ts | 112 +++++++++++++++++++++++++++++ scripts/npm-launcher/t3.mjs | 29 +++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/scripts/build-npm-launcher.test.ts b/scripts/build-npm-launcher.test.ts index 749e27bd5454a..4c7edd4763359 100644 --- a/scripts/build-npm-launcher.test.ts +++ b/scripts/build-npm-launcher.test.ts @@ -195,6 +195,118 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { VERSION, ]); + // A peer's executable and sidecar stay available after every later rename or removal, + // even when it publishes after our missing-marker read. + const staleHome = path.join(root, "stale-home"); + const staleDir = path.join(staleHome, "runtime/versions", VERSION); + const peerDir = path.join(root, "peer-runtime"); + yield* fs.makeDirectory(staleDir, { recursive: true }); + yield* fs.writeFileString(path.join(staleDir, "t3"), ""); + yield* fs.makeDirectory(peerDir); + yield* fs.writeFileString(path.join(peerDir, "t3"), '#!/bin/sh\necho "peer $*"\nexit 7\n'); + yield* fs.chmod(path.join(peerDir, "t3"), 0o755); + yield* fs.writeFileString(path.join(peerDir, ".install-complete"), `${VERSION}\n`); + yield* fs.writeFileString(path.join(peerDir, "sidecar"), "peer sidecar"); + const peerChecks = path.join(root, "peer-checks.txt"); + // The peer publishes after the launcher's rename fails and it reads the marker. + const peerHook = path.join(root, "peer-publishes.mjs"); + yield* fs.writeFileString( + peerHook, + `import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +const dir = ${JSON.stringify(staleDir)}; +const { readFileSync, renameSync, rmSync } = fs; +let checking = false; +let published = false; +const checkPeer = () => { + const child = spawnSync(dir + "/t3", ["--version"], { encoding: "utf8" }); + let sidecar; + try { + sidecar = readFileSync(dir + "/sidecar", "utf8"); + } catch (error) { + sidecar = error.code; + } + fs.appendFileSync(${JSON.stringify(peerChecks)}, + [child.status, child.error?.code ?? child.stdout.trim(), sidecar].join("|") + "\\n"); +}; +fs.renameSync = (from, to) => { + try { + return renameSync(from, to); + } catch (error) { + checking ||= to === dir; + throw error; + } finally { + if (published) checkPeer(); + } +}; +fs.rmSync = (...args) => { + try { + return rmSync(...args); + } finally { + if (published) checkPeer(); + } +}; +fs.readFileSync = (file, ...rest) => { + try { + return readFileSync(file, ...rest); + } finally { + if (checking && file === dir + "/.install-complete") { + checking = false; + rmSync(dir, { recursive: true }); + renameSync(${JSON.stringify(peerDir)}, dir); + published = true; + checkPeer(); + } + } +}; +`, + ); + const recovered = yield* run( + process.execPath, + ["--import", peerHook, launcher, "--version"], + { cwd: root, env: { ...env, T3CODE_HOME: staleHome } }, + ); + const checks = (yield* fs.readFileString(peerChecks)).trim().split("\n"); + assert.isAtLeast(checks.length, 2); + assert.deepStrictEqual([...new Set(checks)], ["7|peer --version|peer sidecar"]); + assert.equal(recovered.stdout.trim(), "peer --version", recovered.stderr); + assert.equal(recovered.exitCode, 7); + assert.deepStrictEqual(yield* fs.readDirectory(path.join(staleHome, "runtime/versions")), [ + VERSION, + ]); + + // While another launch holds the lock, a stale folder is left alone and the error says + // how to clear a lock left by a crash. + const staleOnlyHome = path.join(root, "stale-only-home"); + const staleOnlyDir = path.join(staleOnlyHome, "runtime/versions", VERSION); + yield* fs.makeDirectory(staleOnlyDir, { recursive: true }); + yield* fs.writeFileString(path.join(staleOnlyDir, "t3"), "incomplete"); + yield* fs.makeDirectory(`${staleOnlyDir}.lock`); + const locked = yield* run(process.execPath, [launcher, "--version"], { + cwd: root, + env: { ...env, T3CODE_HOME: staleOnlyHome }, + }); + assert.equal(locked.exitCode, 1); + assert.include(locked.stderr, `delete ${staleOnlyDir}.lock`); + assert.equal(yield* fs.readFileString(path.join(staleOnlyDir, "t3")), "incomplete"); + + // Without a peer, the stale folder is replaced by our own download. + yield* fs.remove(`${staleOnlyDir}.lock`, { recursive: true }); + const replaced = yield* run(process.execPath, [launcher, "--version"], { + cwd: root, + env: { ...env, T3CODE_HOME: staleOnlyHome }, + }); + assert.equal( + replaced.stdout.trim(), + `stub --version launcher=${launcher}`, + replaced.stderr, + ); + assert.equal(replaced.exitCode, 7); + assert.deepStrictEqual( + yield* fs.readDirectory(path.join(staleOnlyHome, "runtime/versions")), + [VERSION], + ); + release.corruptChecksum(); const corrupt = yield* run(process.execPath, [launcher, "--version"], { cwd: root, diff --git a/scripts/npm-launcher/t3.mjs b/scripts/npm-launcher/t3.mjs index 4973cf10a423a..f8828210862e3 100644 --- a/scripts/npm-launcher/t3.mjs +++ b/scripts/npm-launcher/t3.mjs @@ -109,11 +109,36 @@ const isComplete = (dir) => { function publish(staging, dir) { try { fs.renameSync(staging, dir); + return; } catch (error) { if (isComplete(dir)) return; if (!fs.existsSync(dir)) throw error; - fs.rmSync(dir, { recursive: true, force: true }); - fs.renameSync(staging, dir); + } + const lock = `${dir}.lock`; + try { + fs.mkdirSync(lock); + } catch (error) { + if (error.code !== "EEXIST") throw error; + throw new Error( + `another t3 is installing ${version}. If none is running, delete ${lock} and try again.`, + { cause: error }, + ); + } + const stale = `${staging}.stale`; + try { + // A peer may have published between our marker check and taking the lock. + if (isComplete(dir)) return; + // Move it aside rather than deleting in place: once the folder is empty, a + // peer's first rename can land in it. + fs.renameSync(dir, stale); + try { + fs.renameSync(staging, dir); + } catch (error) { + if (!isComplete(dir)) throw error; + } + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + fs.rmSync(stale, { recursive: true, force: true }); } } From e836ab1275cdb17494e708ad34447071b472b82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Jiron=20=C2=B7=20MarMarLabs?= Date: Thu, 29 Oct 2026 13:06:08 -0500 Subject: [PATCH 130/144] fix(web): right panel launcher letters work on non-Latin keyboard layouts (#21508) Co-authored-by: maria-rcks --- .../src/components/RightPanelTabs.test.tsx | 69 +++++++++++++++++++ apps/web/src/components/RightPanelTabs.tsx | 9 +-- .../components/chat/composerFocusRedirect.ts | 12 ++-- .../chat/useChatShortcuts.browser.test.tsx | 40 ++++++++++- apps/web/src/lib/surfaceShortcutKey.ts | 14 ++++ 5 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/surfaceShortcutKey.ts diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index fa9cbf0141c3e..88793cce44850 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -26,8 +26,62 @@ describe("surface shortcuts", () => { const actions = [ { shortcut: "B", available: true, label: "Browser" }, { shortcut: "D", available: false, label: "Diff" }, + { shortcut: "T", available: true, label: "Terminal" }, + { shortcut: "M", available: true, label: "Device" }, ] as const; + it("falls back to the physical key when the layout types a non-Latin letter or mark", () => { + // Ukrainian layout: the B key types "ะธ". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0438", { code: "KeyB" }))).toBe( + actions[0], + ); + // Greek layout: the B key types "ฮฒ". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u03b2", { code: "KeyB" }))).toBe( + actions[0], + ); + // Thai layout: the B key types the vowel sign "\u0e34", a combining mark. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0e34", { code: "KeyB" }))).toBe( + actions[0], + ); + // Arabic (101) layout: the B key types lam alef "\u0644\u0627", two code points. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0644\u0627", { code: "KeyB" })), + ).toBe(actions[0]); + // Ukrainian layout: the D key types "\u0432", but the Diff surface is unavailable. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0432", { code: "KeyD" })), + ).toBeNull(); + }); + + it("follows the typed letter on Latin layouts that move it", () => { + // Dvorak: the key at QWERTY B types "x", and "b" sits on QWERTY N. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("x", { code: "KeyB" }))).toBeNull(); + // The same key with Shift or Caps Lock types "X". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("X", { code: "KeyB" }))).toBeNull(); + expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyN" }))).toBe( + actions[0], + ); + }); + + it("keeps accented Latin letters typed on a letter key", () => { + // Bรฉpo: the T key types "รจ". + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u00e8", { code: "KeyT" })), + ).toBeNull(); + }); + + it("keeps punctuation typed on a letter key", () => { + // French AZERTY: the M key types ",". + expect(surfaceShortcutActionForKey(actions, shortcutEvent(",", { code: "KeyM" }))).toBeNull(); + }); + + it("keeps a dead key on a letter key", () => { + // A dead key starts an accented letter on a Latin layout. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("Dead", { code: "KeyT" })), + ).toBeNull(); + }); + it("leaves modified, composing, and already-handled key events alone", () => { expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull(); expect( @@ -37,6 +91,21 @@ describe("surface shortcuts", () => { surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })), ).toBeNull(); }); + + it("leaves AltGr text alone unless it is a plain letter", () => { + const getModifierState = (key: string) => key === "AltGraph"; + // German layout: AltGr+M types "\u00b5" and sets only AltGraph on Linux and Windows. + expect( + surfaceShortcutActionForKey( + actions, + shortcutEvent("\u00b5", { code: "KeyM", getModifierState }), + ), + ).toBeNull(); + // Finnish layout: AltGr+B types "b", which still matches, as it does in keybindings.ts. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyB", getModifierState })), + ).toBe(actions[0]); + }); }); describe("surface shortcut typing contexts", () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index dd62a684e92e4..d74e03ea842ab 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -92,6 +92,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; export interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -238,17 +239,17 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { type SurfaceShortcutEvent = Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" ->; +> & + Partial>; export function surfaceShortcutActionForKey< const Action extends { available: boolean; shortcut: string }, >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { if (event.defaultPrevented || event.isComposing) return null; if (event.metaKey || event.ctrlKey || event.altKey) return null; + const key = surfaceShortcutKey(event); return ( - actions.find( - (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), - ) ?? null + actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null ); } diff --git a/apps/web/src/components/chat/composerFocusRedirect.ts b/apps/web/src/components/chat/composerFocusRedirect.ts index 9f791f944ea3e..f7730b367925e 100644 --- a/apps/web/src/components/chat/composerFocusRedirect.ts +++ b/apps/web/src/components/chat/composerFocusRedirect.ts @@ -1,3 +1,5 @@ +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; + export const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", "textarea", @@ -48,11 +50,13 @@ const SURFACE_LAUNCHER_KEYS_ATTRIBUTE = "data-surface-launcher-keys"; // The right-panel launcher registers its own window capture listener when it // mounts. When the panel opens after page load that listener runs after ours, // so we must leave its letters alone or the composer takes them first. -function isSurfaceLauncherKey(key: string): boolean { +function isSurfaceLauncherKey(event: KeyboardEvent): boolean { const launcherKeys = document .querySelector(`[${SURFACE_LAUNCHER_KEYS_ATTRIBUTE}]`) - ?.getAttribute(SURFACE_LAUNCHER_KEYS_ATTRIBUTE); - return launcherKeys?.toLowerCase().includes(key.toLowerCase()) ?? false; + ?.getAttribute(SURFACE_LAUNCHER_KEYS_ATTRIBUTE) + ?.toLowerCase(); + const key = surfaceShortcutKey(event); + return key !== null && (launcherKeys?.includes(key) ?? false); } export function shouldRedirectInputToComposer(event: Event): boolean { @@ -70,7 +74,7 @@ export function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; if (!shouldRedirectInputToComposer(event)) return false; - if (isSurfaceLauncherKey(event.key)) return false; + if (isSurfaceLauncherKey(event)) return false; return true; } diff --git a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx index b601de299dac5..64f56007ceaaa 100644 --- a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx +++ b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { expect, it } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; import { render } from "../../../test/render"; import type { ChatComposerHandle } from "./ChatComposer"; import { useChatShortcuts } from "./useChatShortcuts"; @@ -25,7 +26,8 @@ function Launcher(props: { keys: string; received: string[] }) { useEffect(() => { const claim = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (!props.keys.toLowerCase().split("").includes(event.key.toLowerCase())) return; + const key = surfaceShortcutKey(event); + if (key === null || !props.keys.toLowerCase().includes(key)) return; event.preventDefault(); props.received.push(event.key); }; @@ -94,3 +96,39 @@ it("still types other letters into the composer while the launcher is open", asy expect(composerKeys).toEqual(["a"]); expect(received).toEqual([]); }); + +it("leaves a non-Latin launcher letter to the launcher by its physical key", async () => { + const composerKeys: string[] = []; + const received: string[] = []; + await using screen = await render(, options); + await screen.rerender(); + + // Playwright cannot press a key its US layout lacks, so send what a Ukrainian T key sends. + const event = new KeyboardEvent("keydown", { + key: "\u0435", + code: "KeyT", + bubbles: true, + cancelable: true, + }); + (document.activeElement ?? document.body).dispatchEvent(event); + + expect(composerKeys).toEqual([]); + expect(received).toEqual(["\u0435"]); +}); + +it.each([ + { key: "b", code: "KeyN", composer: [], launcher: ["b"] }, + { key: "x", code: "KeyB", composer: ["x"], launcher: [] }, + { key: "รจ", code: "KeyT", composer: ["รจ"], launcher: [] }, +])("routes $key on $code by the typed letter", async ({ key, code, composer, launcher }) => { + const composerKeys: string[] = []; + const received: string[] = []; + await using screen = await render(, options); + await screen.rerender(); + + const event = new KeyboardEvent("keydown", { key, code, bubbles: true, cancelable: true }); + (document.activeElement ?? document.body).dispatchEvent(event); + + expect(composerKeys).toEqual(composer); + expect(received).toEqual(launcher); +}); diff --git a/apps/web/src/lib/surfaceShortcutKey.ts b/apps/web/src/lib/surfaceShortcutKey.ts new file mode 100644 index 0000000000000..98c4d6660da11 --- /dev/null +++ b/apps/web/src/lib/surfaceShortcutKey.ts @@ -0,0 +1,14 @@ +type SurfaceKeyEvent = Pick & + Partial>; + +export function surfaceShortcutKey(event: SurfaceKeyEvent): string | null { + // AltGr text must stay text even when the browser omits Ctrl and Alt. + if (event.getModifierState?.("AltGraph") && !/^[a-z0-9]$/i.test(event.key)) return null; + const typed = event.key.toLowerCase(); + // Preserve Latin layouts, punctuation and dead keys; non-Latin letters/marks use the physical key. + const physical = + /^[\p{L}\p{M}]+$/u.test(typed) && !/\p{Script=Latin}/u.test(typed) + ? event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase() + : undefined; + return physical ?? typed; +} From 5b4f2aa1532730d4dd51d2cb48a57ff74a0f54eb Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Thu, 29 Oct 2026 14:06:30 -0400 Subject: [PATCH 131/144] fix(server): report a deleted or replaced ownership lock (#21886) Co-authored-by: maria-rcks --- apps/server/src/serverOwnership.ts | 54 +++++++++++-- apps/server/src/serverRuntimeState.test.ts | 91 ++++++++++++++++++++++ 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 1959735425515..6d89f45cb792e 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -44,7 +44,7 @@ export class ServerAlreadyRunningError extends Schema.TaggedError new ServerOwnershipError({ stateDir, cause }))); - yield* Effect.acquireRelease( + const lockPath = NodePath.join(stateDir, "server-owner.sqlite"); + const lock = yield* Effect.acquireRelease( Effect.try({ try: () => { NodeFS.mkdirSync(stateDir, { recursive: true }); - const db = new NodeSqlite.DatabaseSync(NodePath.join(stateDir, "server-owner.sqlite")); + let before = NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); + if (before === undefined) { + NodeFS.closeSync(NodeFS.openSync(lockPath, "a", 0o644)); + before = NodeFS.statSync(lockPath, { bigint: true }); + } + const db = new NodeSqlite.DatabaseSync(lockPath); try { db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;"); + const identity = NodeFS.statSync(lockPath, { bigint: true }); + return { + db, + identity, + replacedDuringAcquire: before.dev !== identity.dev || before.ino !== identity.ino, + }; } catch (cause) { db.close(); throw cause; } - return db; }, catch: (cause) => isSqliteBusy(cause) ? new ServerAlreadyRunningError({ stateDir }) : new ServerOwnershipError({ stateDir, cause }), }), - (db) => + ({ db }) => readPersistedServerRuntimeState(statePath).pipe( Effect.flatMap((state) => Option.isSome(state) && state.value.ownerId === ownerId @@ -233,6 +244,39 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi return yield* new OlderBootServiceError({ baseDir: olderServiceHome }); } + yield* Effect.gen(function* () { + let lost = lock.replacedDuringAcquire; + let statWarningLogged = false; + while (!lost) { + yield* Effect.sleep("5 seconds"); + // Closing another descriptor for this inode releases SQLite's POSIX locks. Stat by path only. + const current = yield* Effect.sync(() => { + try { + return NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); + } catch (cause) { + return cause instanceof Error && "code" in cause ? String(cause.code) : "unknown"; + } + }); + if (typeof current === "string") { + if (!statWarningLogged) { + yield* Effect.logWarning( + `Cannot check server ownership lock at ${lockPath} (${current}).`, + ); + statWarningLogged = true; + } + continue; + } + statWarningLogged = false; + lost = + current === undefined || + current.dev !== lock.identity.dev || + current.ino !== lock.identity.ino; + } + yield* Effect.logError( + `Server ownership lock at ${lockPath} was deleted or replaced. Another server may now share this T3 home. Stop any other server using this home, then restart this server. Do not delete server-owner.sqlite while a server is running.`, + ); + }).pipe(Effect.forkScoped); + return { publish: (state: PersistedServerRuntimeState) => persistServerRuntimeState({ path: statePath, state: { ...state, ownerId } }), diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index abafbc7e24f62..b75ab72594add 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -1,13 +1,20 @@ +// @effect-diagnostics nodeBuiltinImport:off - Injects a real file replacement during the native SQLite transaction. +import * as NodeFS from "node:fs"; +import * as NodeSqlite from "node:sqlite"; + import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Runtime from "effect/Runtime"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts"; @@ -179,6 +186,7 @@ describe("server ownership", () => { Effect.flip, ); assert.equal(refused._tag, "ServerAlreadyRunningError"); + assert.include(refused.message, "do not delete it while a server is running"); assert.equal(yield* fs.readFileString(statePath), before); }), ); @@ -187,6 +195,89 @@ describe("server ownership", () => { }).pipe(Effect.provide(layerOwnership)), ); + describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("lock file checks", () => { + it.effect.each([ + "unchanged", + "unlink", + "replace", + "during acquisition", + "first acquisition", + // Root bypasses directory permissions, so chmod cannot force EACCES. + ...(process.getuid?.() === 0 ? [] : (["stat error"] as const)), + ] as const)("monitors ownership after %s", (operation) => { + const messages: unknown[] = []; + const warnings: unknown[] = []; + const logger = Logger.make(({ message, logLevel }) => + (logLevel === "Warn" ? warnings : messages).push(message), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-lost-" }); + const statePath = path.join(root, "server-runtime.json"); + const lockPath = path.join(root, "server-owner.sqlite"); + const replacement = path.join(root, "replacement.sqlite"); + if (operation === "during acquisition" || operation === "first acquisition") { + if (operation === "during acquisition") yield* fs.writeFileString(lockPath, ""); + yield* fs.writeFileString(replacement, ""); + const exec = NodeSqlite.DatabaseSync.prototype.exec; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(NodeSqlite.DatabaseSync.prototype, "exec").mockImplementationOnce(function ( + this: NodeSqlite.DatabaseSync, + sql: string, + ) { + exec.call(this, sql); + NodeFS.renameSync(replacement, lockPath); + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + } + yield* ServerOwnership.acquireServerOwnership(statePath); + if (operation === "unlink") { + yield* fs.remove(lockPath); + } else if (operation === "replace") { + yield* fs.writeFileString(replacement, ""); + yield* fs.rename(replacement, lockPath); + } + if (operation === "stat error") { + yield* fs.chmod(root, 0); + yield* TestClock.adjust("15 seconds").pipe( + Effect.ensuring(fs.chmod(root, 0o700).pipe(Effect.orDie)), + ); + assert.deepEqual(warnings, [ + [`Cannot check server ownership lock at ${lockPath} (EACCES).`], + ]); + yield* TestClock.adjust("5 seconds"); + assert.deepEqual(messages, []); + yield* fs.remove(lockPath); + } + yield* TestClock.adjust("5 seconds"); + const expected = + operation === "unchanged" + ? [] + : [ + [ + `Server ownership lock at ${lockPath} was deleted or replaced. Another server may now share this T3 home. Stop any other server using this home, then restart this server. Do not delete server-owner.sqlite while a server is running.`, + ], + ]; + assert.deepEqual(messages, expected); + if (operation !== "unchanged") { + yield* fs.remove(`${lockPath}-journal`, { force: true }); + yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)); + } + yield* TestClock.adjust("15 seconds"); + assert.deepEqual(messages, expected); + assert.lengthOf(warnings, operation === "stat error" ? 1 : 0); + }).pipe( + Effect.provide( + Layer.merge(layerOwnership, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + }); + it.effect("refuses beside a live pre-lock server and ignores a stale record", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; From cb5fac907a21576e1e4b4da24aa70e0e2ea2e8d9 Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:06:53 +0300 Subject: [PATCH 132/144] fix(server): mark unread no longer brings back a dismissed wake (#24080) Co-authored-by: maria-rcks --- .../Orchestrator.control-reads.test.ts | 114 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 15 ++- .../src/state/threadVisits.test.ts | 18 +++ .../client-runtime/src/state/threadVisits.ts | 3 +- 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index c36dfc365af60..61515a053ac36 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -260,6 +260,120 @@ it.effect("marks a never-visited thread's first completion unread", () => }).pipe(Effect.provide(layerTest)), ); +const completeAndSnoozeThread = Effect.fn("test.completeAndSnoozeThread")(function* ( + name: string, + snoozeMinutes: number, +) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make(`thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${name}`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: "Snoozed completion", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`dispatch-${name}`), + threadId, + messageId: MessageId.make(`dispatch-${name}`), + text: "Run in the background", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`interrupt-${name}`), + threadId, + runId: (yield* projections.getThreadProjection(threadId)).runs[0]!.id, + }); + const completedAt = (yield* projections.getThreadProjection(threadId)).runs.at(-1)?.completedAt; + assert.ok(completedAt); + yield* TestClock.adjust("1 second"); + const snoozedUntil = DateTime.add(yield* DateTime.now, { minutes: snoozeMinutes }); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`snooze-${name}`), + threadId, + snoozedUntil: DateTime.formatIso(snoozedUntil), + }); + return { threadId, completedAt, snoozedUntil }; +}); + +it.effect("mark unread retires an expired snooze so a dismissed wake stays dismissed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt, snoozedUntil } = yield* completeAndSnoozeThread( + "mark-unread-expired-snooze", + 1, + ); + yield* TestClock.adjust("2 minutes"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("dismiss-woke"), + threadId, + visitedAt: DateTime.formatIso(snoozedUntil), + }); + const dismissed = yield* projections.getThread(threadId); + assert.deepEqual(dismissed.lastVisitedAt, snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-expired-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...dismissed, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + snoozedUntil: null, + snoozedAt: null, + lastSnoozeWakeAt: snoozedUntil, + }); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("mark unread leaves a future snooze untouched", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt } = yield* completeAndSnoozeThread( + "mark-unread-future-snooze", + 60, + ); + yield* TestClock.adjust("1 minute"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("visit-snoozed"), + threadId, + visitedAt: DateTime.formatIso(yield* DateTime.now), + }); + const visited = yield* projections.getThread(threadId); + assert.isNotNull(visited.snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-future-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...visited, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "dispatches metadata, queue resume and request controls without hydrating unrelated history", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 94de805b46522..76bcc00bbcb4e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3360,8 +3360,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: thread.updatedAt, }; } - case "thread.mark-unread": - return { ...thread, lastVisitedAt: markUnreadVisitedAt }; + case "thread.mark-unread": { + // Retire the wake before rewinding the visit, preserving its settlement timer. + const snoozeExpired = + thread.snoozedUntil != null && + DateTime.toEpochMillis(thread.snoozedUntil) <= DateTime.toEpochMillis(now); + return { + ...thread, + lastVisitedAt: markUnreadVisitedAt, + ...(snoozeExpired + ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: thread.snoozedUntil } + : {}), + }; + } case "thread.metadata.update": { const previousRecovery = thread.limitRecovery?.runId === command.limitRecovery?.runId && diff --git a/packages/client-runtime/src/state/threadVisits.test.ts b/packages/client-runtime/src/state/threadVisits.test.ts index a99417534ea7a..adcac7139e82a 100644 --- a/packages/client-runtime/src/state/threadVisits.test.ts +++ b/packages/client-runtime/src/state/threadVisits.test.ts @@ -111,6 +111,24 @@ it("keeps a mark-unread on the open thread until new activity lands", () => { expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); }); +it("keeps mark unread sticky after an expired wake is retired", () => { + using t = setup("thread-unread-retired-wake", { + updatedAt: at(10), + lastVisitedAt: at(0), + latestRun: { completedAt: at(9) }, + wokeAt: at(15), + }); + t.mount(); + expect(t.visits).toEqual([{ threadId: t.ref.threadId, visitedAt: at(15), opened: true }]); + t.update({ lastVisitedAt: at(15) }); + t.update({ lastVisitedAt: at(8), wokeAt: null }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toHaveLength(1); + + t.update({ updatedAt: at(30) }); + expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); +}); + it("waits for the shell and records local visits for servers without tracking", () => { using t = setup("thread-local", null); t.mount(); diff --git a/packages/client-runtime/src/state/threadVisits.ts b/packages/client-runtime/src/state/threadVisits.ts index 08d05909a2dad..c08f47ed01436 100644 --- a/packages/client-runtime/src/state/threadVisits.ts +++ b/packages/client-runtime/src/state/threadVisits.ts @@ -115,7 +115,8 @@ export function createThreadVisitTracker(options: ThreadVisitTrackerOptions) { } const lastVisitedAtMs = shell.lastVisitedAt ? Date.parse(shell.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) return; - if (lastDispatchedUpdatedAt === visitedAt) return; + if (lastDispatchedUpdatedAt !== null && Date.parse(lastDispatchedUpdatedAt) >= updatedAtMs) + return; const completedAtMs = shell.latestRun?.completedAt ? Date.parse(shell.latestRun.completedAt) From fa4f3a2925822201e6fe4f11db45c8649383e420 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 29 Oct 2026 18:07:16 +0000 Subject: [PATCH 133/144] fix(mobile): stop refreshes in hidden PR Guide panes (#25486) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: maria-rcks --- .../pull-requests/PullRequestRouteScreen.tsx | 7 +- .../guide/PullRequestGuideScreen.tsx | 10 +- .../guide/useReviewGuide.test.tsx | 130 ++++++++++++++++-- .../pull-requests/guide/useReviewGuide.ts | 4 +- 4 files changed, 137 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 446794800eadb..bd3f04d9d6b4d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -794,7 +794,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps
) : null} - {tab === "guide" ? : null} + {tab === "guide" ? ( + + ) : null} {tab === "activity" ? ( diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx index 764cd5f881ac9..e797a12a57217 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx @@ -3,18 +3,21 @@ import { type NavigationProp, type ParamListBase, } from "@react-navigation/native"; -import { act } from "react"; +import { AsyncResult } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ + enabled: true, + generate: vi.fn(), query: { - data: null as { run: { _tag: "Running" | "Completed" } } | null, + data: null as { run: { _tag: "Running" | "Completed" | "Failed" } | null } | null, isPending: false, refresh: vi.fn<() => void>(), }, otherQuery: { - data: null as { run: { _tag: "Running" | "Completed" } } | null, + data: null as { run: { _tag: "Running" | "Completed" | "Failed" } | null } | null, isPending: false, refresh: vi.fn<() => void>(), }, @@ -33,7 +36,8 @@ vi.mock("@react-navigation/native", async () => { return { NavigationContext, useFocusEffect }; }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => (atom === "settings" ? { reviewGuidesEnabled: true } : true), + useAtomValue: (atom: unknown) => + atom === "settings" ? { reviewGuidesEnabled: mocks.enabled } : true, })); vi.mock("../../../state/server", () => ({ serverEnvironment: { settingsValueAtom: () => "settings" }, @@ -48,7 +52,7 @@ vi.mock("../../../state/pull-requests", () => ({ vi.mock("../../../state/query", () => ({ useEnvironmentQuery: (number: number) => ({ ...(number === 1 ? mocks.query : mocks.otherQuery) }), })); -vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.generate })); vi.mock("../usePullRequestConnected", () => ({ usePullRequestConnected: () => true })); vi.mock("../usePullRequestDiff", () => ({ pullRequestTarget: (params: unknown) => params, @@ -78,6 +82,8 @@ function createNavigation() { function setup() { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.enabled = true; + mocks.generate = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); mocks.query = { data: null, isPending: false, refresh: vi.fn() }; mocks.otherQuery = { data: null, isPending: false, refresh: vi.fn() }; const root = createRoot(document.createElement("div")); @@ -88,20 +94,35 @@ function setup() { repository: "repo", number: 1, }; - function Probe({ number, environmentId }: { number: number; environmentId: string }) { - useReviewGuide({ ...params, number, environmentId }); + let current: ReturnType; + function Probe({ + number, + environmentId, + visible, + }: { + number: number; + environmentId: string; + visible?: boolean; + }) { + const state = useReviewGuide({ ...params, number, environmentId }, visible); + useLayoutEffect(() => { + current = state; + }); return null; } return { - render(number = 1, environmentId = params.environmentId) { + render(number = 1, environmentId = params.environmentId, visible?: boolean) { act(() => root.render( - + , ), ); }, + get current() { + return current; + }, focus: navigation.focus, [Symbol.dispose]() { act(() => root.unmount()); @@ -188,3 +209,94 @@ it("retires the old environment's polling on target change and clears polling on expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); + +it("suspends only manual refresh while hidden, including focus and target changes", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + { + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + mocks.otherQuery.data = { run: { _tag: "Running" } }; + screen.render(); + const cached = screen.current.data; + screen.render(1, "environment", false); + act(() => vi.advanceTimersByTime(60_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(screen.current.data).toBe(cached); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + screen.render(1, "environment", false); + screen.focus(false); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + + screen.render(2, "other-environment", false); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.otherQuery.refresh).not.toHaveBeenCalled(); + screen.focus(false); + screen.render(2, "other-environment", true); + expect(mocks.otherQuery.refresh).not.toHaveBeenCalled(); + screen.focus(true); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + } + expect(vi.getTimerCount()).toBe(0); +}); + +it("keeps disabled, completed and failed guides idle and refreshes once on reveal", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + mocks.enabled = false; + screen.render(); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).not.toHaveBeenCalled(); + mocks.enabled = true; + mocks.query.data = { run: null }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.render(1, "environment", false); + mocks.query.data = { run: { _tag: "Failed" } }; + screen.render(1, "environment", false); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); + +it("leaves generation running when hidden and accepts its completion without a refresh", async () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + const generated = Promise.withResolvers>>(); + mocks.generate.mockReturnValue(generated.promise); + screen.render(); + let writing: Promise; + act(() => { + writing = screen.current.write(); + }); + expect(screen.current.running).toBe(true); + screen.render(1, "environment", false); + const beforeHidden = mocks.query.refresh.mock.calls.length; + act(() => vi.advanceTimersByTime(60_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden); + expect(mocks.generate).toHaveBeenCalledTimes(1); + expect(screen.current.running).toBe(true); + await act(async () => { + generated.resolve(AsyncResult.success(undefined)); + await writing; + }); + expect(screen.current.running).toBe(false); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden + 1); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts index 5dad083e76108..bbdb6caee00c3 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts @@ -17,7 +17,7 @@ export function useReviewGuidesEnabled(params: PullRequestParams) { return settings?.reviewGuidesEnabled === true; } -export function useReviewGuide(params: PullRequestParams) { +export function useReviewGuide(params: PullRequestParams, visible = true) { const target = pullRequestTarget(params); const enabled = useReviewGuidesEnabled(params); const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); @@ -35,7 +35,7 @@ export function useReviewGuide(params: PullRequestParams) { const running = starting || query.data?.run?._tag === "Running"; const refresh = query.refresh; useFocusEffect(() => { - if (!enabled) return; + if (!enabled || !visible) return; refresh(); if (!running) return; const timer = setInterval(refresh, 5_000); From b6fbd181cae49d38927360913851121cc35ec4ff Mon Sep 17 00:00:00 2001 From: Gourav Kumar Dhiman Date: Thu, 29 Oct 2026 23:54:19 +0530 Subject: [PATCH 134/144] fix(desktop): installing the t3 command no longer reports its own launcher as another t3 (#22375) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../desktop/src/app/DesktopCliCommand.test.ts | 36 +++++++++++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 30 ++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 7141ebde70537..8566613dd6069 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -136,6 +136,14 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* command.uninstall; expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + + const launcher = path.join(home, ".t3", "bin", "t3"); + yield* fs.writeFileString(launcher, "npm's t3\n"); + process.env.PATH = path.dirname(launcher); + expect(yield* command.state).toMatchObject({ onPath: false, shadowedBy: launcher }); + expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); + yield* command.uninstall; + expect(yield* fs.readFileString(launcher)).toBe("npm's t3\n"); }).pipe(Effect.scoped), ); @@ -217,6 +225,34 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); + it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const launcher = path.join(home, ".t3", "bin", "t3"); + process.env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":"); + + const command = yield* commandIn({ home }); + const installed = yield* command.install; + + expect(yield* fs.exists(launcher)).toBe(true); + expect(installed).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + expect(installed.shadowedBy).toBeUndefined(); + expect(yield* command.state).toEqual(installed); + + const removed = yield* command.uninstall; + expect(removed.installedPath).toBeNull(); + expect(removed.onPath).toBe(true); + expect(yield* command.state).toEqual(removed); + expect(yield* fs.exists(installed.installedPath!)).toBe(false); + expect(yield* fs.exists(launcher)).toBe(true); + }).pipe(Effect.scoped), + ); + it.effect("leaves the Windows PATH alone when it cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 8a89a366814ed..f53895a60a570 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -92,6 +92,19 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); + // PATH can reach the regular launcher directly; removal still requires isOurLink. + const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")( + function* (candidate: string) { + if (yield* isOurLink(candidate)) return true; + const [resolved, ours] = yield* Effect.all([fs.realPath(candidate), fs.realPath(launcher)]); + if (resolved !== ours) return false; + const info = yield* fs.stat(candidate); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + return (yield* fs.readFileString(candidate)).includes(DesktopCliShim.MARKER); + }, + Effect.orElseSucceed(() => false), + ); + const powershell = (env: Record) => Effect.scoped( Effect.gen(function* () { @@ -141,7 +154,7 @@ export const make = Effect.gen(function* () { const foreignFirstOnPath = Effect.gen(function* () { if (windows) return Option.none(); const first = yield* firstOnPath; - if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none(); + if (Option.isNone(first) || (yield* runsOurLauncher(first.value))) return Option.none(); return first; }); @@ -168,12 +181,17 @@ export const make = Effect.gen(function* () { const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; - if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; - } const first = yield* firstOnPath; - const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + const onPath = windows + ? Option.isSome(installed) + : Option.isSome(first) && (yield* runsOurLauncher(first.value)); + return { + supported: true, + installedPath: Option.getOrNull(installed), + onPath, + launcherPath, + ...shadow, + }; }).pipe( Effect.orElseSucceed(() => ({ supported: false, From 1fe8ddd222d127815064d400d1667c88f60cd71a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 18:28:30 +0000 Subject: [PATCH 135/144] test(mobile): load the branch occupancy test without native sources (#27063) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/git/GitBranchesSheet.occupancy.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx index 7667b0c793fc4..e611b95d3f683 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -30,6 +30,8 @@ vi.mock("../../../components/ui/AppText", () => ({ AppTextInput: () => null, })); vi.mock("./gitSheetComponents", () => ({ SheetActionButton: () => null })); +vi.mock("../../../state/entities", () => ({})); +vi.mock("../../../state/threads", () => ({})); vi.mock("../../../state/use-thread-selection", () => ({ useThreadSelection: () => ({ selectedThread: { environmentId: "environment-1", worktreePath: checkout.worktreePath }, From 08a463e29976617134de01e9341f898bc881d0eb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 29 Oct 2026 13:34:43 -0500 Subject: [PATCH 136/144] fix(settings): project edits keep overrides the client does not know (#24472) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/serverSettings.test.ts | 25 +++++++++++++++ apps/server/src/serverSettings.ts | 11 ++++--- packages/contracts/src/settings.test.ts | 22 +++++++++++++ packages/contracts/src/settings.ts | 27 ++++++++++++---- packages/shared/src/serverSettings.test.ts | 37 ++++++++++++++++++++++ packages/shared/src/serverSettings.ts | 16 ++++++++-- 6 files changed, 125 insertions(+), 13 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 29dea381e759a..f34b653c8cbc8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -212,6 +212,31 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("keeps an override key a newer server saved when a project setting is edited", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("project-a"); + yield* fs.writeFileString( + config.settingsPath, + `{ "projectSettingsOverrides": { "project-a": { "defaultAutoPull": true, "addedByNewerServer": 3 } } }`, + ); + const loaded = yield* service.getSettings; + + yield* service.updateSettings({ + projectSettingsOverrides: { + [projectId]: { ...loaded.projectSettingsOverrides[projectId], defaultAutoPull: false }, + }, + }); + + const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(persisted.projectSettingsOverrides, { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 3 }, + }); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("moves an idle compaction mode saved in the legacy switch to its own field", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index e386b95cf506c..6f6a1db5a292a 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -3,6 +3,7 @@ import { ModelSelection, ProjectId, ProjectScript, + keepUnknownOverrideKeys, ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceMutation, @@ -292,10 +293,12 @@ const ServerSettingsJson = fromLenientJson( ), projectSettingsOverrides: Schema.Record( ProjectId, - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), + }), + ), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), }), ); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 6088cf7a2fa2a..9d765ccee92c6 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -25,6 +25,7 @@ const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("storage cleanup settings", () => { @@ -372,6 +373,27 @@ it("validates remote device hosts and rejects ambiguous host ids", () => { expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); }); +describe("ServerSettings project overrides across versions", () => { + it("sends back an override key from a newer server when an older client edits the entry", () => { + const projectId = ProjectId.make("project-a"); + // As an older client reads a newer server's settings, with one key it does not know. + const entry = decodeServerSettings({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: { mode: "kept" } }, + }, + }).projectSettingsOverrides[projectId]; + + const sent = encodeServerSettingsPatch({ + projectSettingsOverrides: { [projectId]: { ...entry, defaultAutoPull: false } }, + }); + + expect(decodeServerSettingsPatch(sent).projectSettingsOverrides?.[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: { mode: "kept" }, + }); + }); +}); + describe("resolveProjectProviderInstanceEnabled", () => { const projectId = "project-a" as ProjectId; const instanceId = ProviderInstanceId.make("codex_work"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c7d61aeb5ee21..179e58b7a0b56 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1137,6 +1137,16 @@ export const ProjectSettingsOverrides = Schema.Struct({ } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; +// Whole-entry writers must round-trip unknown keys; keep the type closed to catch misspellings. +export const keepUnknownOverrideKeys = >( + entry: Entry, +): Schema.Codec< + Entry["Type"], + Entry["Encoded"], + Entry["DecodingServices"], + Entry["EncodingServices"] +> => Schema.StructWithRest(entry, [Schema.Record(Schema.String, Schema.Unknown)]); + /** True if null stores an override; otherwise null removes it. */ export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean { return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key); @@ -1263,9 +1273,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), - projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - ), + projectSettingsOverrides: Schema.Record( + ProjectId, + keepUnknownOverrideKeys(ProjectSettingsOverrides), + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), /** Fold once so restarts cannot undo later UI resets. */ projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1626,10 +1637,12 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record( ProjectId, Schema.NullOr( - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + }), + ), ), ), ), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index bd315271c78e1..5a02f302826de 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -6,10 +6,13 @@ import { ProviderInstanceId, SecretProviderId, SecretProviderKind, + ServerSettings, + ServerSettingsPatch, UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; @@ -24,6 +27,9 @@ import { resolveSourceControlWriterModelSelection, } from "./serverSettings.ts"; +const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); +const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); + const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { @@ -499,6 +505,37 @@ describe("serverSettings helpers", () => { }); }); + // regression: #18334 a newer client must still see this server drop a key it does not know + it("drops an override key this server does not know from a patch and keeps one it stored", () => { + const projectId = ProjectId.make("project-a"); + const current = decodeServerSettings({ + projectSettingsFolded: true, + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: 3 }, + }, + }); + const patch = decodeServerSettingsPatch({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 4, addedByNewerClient: "x" }, + }, + }); + + const updated = applyServerSettingsPatch(current, patch); + expect(updated.projectSettingsOverrides[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: 3, + }); + const reset = applyServerSettingsPatch(updated, { + projectSettingsOverrides: { [projectId]: {} }, + }); + expect(reset.projectSettingsOverrides[projectId]).toEqual({ addedByNewerServer: 3 }); + expect( + applyServerSettingsPatch(reset, { + projectSettingsOverrides: { [projectId]: null }, + }).projectSettingsOverrides[projectId], + ).toBeUndefined(); + }); + it("clears a project's providerInstances override when a patch entry sends an empty map", () => { const instanceId = ProviderInstanceId.make("codex_work"); const projectId = ProjectId.make("project-a"); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 11eecc6fe3df8..e936c9f83acba 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -14,7 +14,7 @@ import { type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, - type ProjectSettingsOverrides, + ProjectSettingsOverrides, ProviderDriverKind, ProviderInstanceId, type ServerProvider, @@ -226,6 +226,16 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); + +function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { + return Object.fromEntries( + Object.entries(entry ?? {}).filter( + ([key]) => knownProjectOverrideKeys.has(key) === known, + ), + ); +} + // Older clients omit providerInstances during whole-entry replacement; preserve it to avoid wiping overrides. function mergeProjectSettingsOverridesPatch( current: Readonly>, @@ -239,7 +249,9 @@ function mergeProjectSettingsOverridesPatch( } const storedProviderInstances = current[projectId]?.providerInstances; next.set(projectId, { - ...entry, + // Preserve downgraded storage, but reject new unknown writes so clients can detect unsupported settings. + ...pickOverrideKeys(current[projectId], false), + ...pickOverrideKeys(entry, true), ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined ? {} : { providerInstances: storedProviderInstances }), From 4092c4ac567b791c84c20a15403f2b7d5051d42d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 12:49:29 -0700 Subject: [PATCH 137/144] fix(client): retry the permissions check after a reconnect (#27008) Co-authored-by: Claude Opus 5.5 --- .../settings/EnvironmentRoutesSection.tsx | 15 +- .../SettingsEnvironmentDetailRouteScreen.tsx | 14 +- .../settings/SessionPermissions.tsx | 11 +- .../client-runtime/src/state/session.test.ts | 133 ++++++++++++++++++ packages/client-runtime/src/state/session.ts | 57 +++++++- packages/shared/src/authScopeOptions.ts | 16 +++ 6 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 packages/client-runtime/src/state/session.test.ts diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 012d00737d1df..842965dbf1575 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -12,7 +12,10 @@ import { isLearned, } from "@t3tools/client-runtime/connection"; import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; -import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; +import { + AUTH_SCOPE_OPTIONS, + sessionPermissionsUnavailableMessage, +} from "@t3tools/shared/authScopeOptions"; import { AsyncResult } from "effect/reactivity"; import * as Option from "effect/Option"; import { useState } from "react"; @@ -163,8 +166,14 @@ export function EnvironmentRoutesSection({ ) : ( - Permissions not checked. Connect to this environment to view this sessionโ€™s - permissions. + {sessionPermissionsUnavailableMessage({ + connected: activeRouteId !== null, + failed: sessionResult._tag === "Failure", + rejected: + sessionResult._tag !== "Failure" && + !sessionResult.waiting && + session?.authenticated === false, + })} )}
diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index cd9d8e03c9c1f..c581cbc688142 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -8,6 +8,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { sessionPermissionsUnavailableMessage } from "@t3tools/shared/authScopeOptions"; import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import { AsyncResult } from "effect/reactivity"; @@ -208,11 +209,14 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm ) : !allowed ? ( - {AsyncResult.isFailure(sessionResult) - ? "Could not verify your permissions. Reconnect to try again." - : session === null - ? "Checking permissionsโ€ฆ" - : "This connection does not have permission to manage the environment."} + {AsyncResult.isFailure(sessionResult) || session === null || !session.authenticated + ? sessionPermissionsUnavailableMessage({ + connected, + failed: AsyncResult.isFailure(sessionResult), + rejected: + !AsyncResult.isFailure(sessionResult) && session?.authenticated === false, + }) + : "This connection does not have permission to manage the environment."} ) : null} {error ? ( diff --git a/apps/web/src/components/settings/SessionPermissions.tsx b/apps/web/src/components/settings/SessionPermissions.tsx index 1cbff3d750cf3..30f61ddd80371 100644 --- a/apps/web/src/components/settings/SessionPermissions.tsx +++ b/apps/web/src/components/settings/SessionPermissions.tsx @@ -1,5 +1,8 @@ import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; -import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; +import { + AUTH_SCOPE_OPTIONS, + sessionPermissionsUnavailableMessage, +} from "@t3tools/shared/authScopeOptions"; import { useEnvironmentSessionState } from "~/state/session"; export function SessionPermissions({ @@ -32,7 +35,11 @@ export function SessionPermissions({ ) : (

- Permissions not checked. Connect to this environment to view this sessionโ€™s permissions. + {sessionPermissionsUnavailableMessage({ + connected, + failed: session.hasError, + rejected: !session.hasError && !session.isPending && session.data?.authenticated === false, + })}

); } diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts new file mode 100644 index 0000000000000..740ab2229bdb4 --- /dev/null +++ b/packages/client-runtime/src/state/session.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcHttp from "../rpc/http.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createEnvironmentSessionAtoms } from "./session.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://environment.example.test", + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: { _tag: "Bearer", token: "bearer-token" }, + target: TARGET, +}; +const SESSION = { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "t3_session", + }, + scopes: ["orchestration:read"], +} satisfies AuthSessionState; + +const rpcSession = (): RpcSession => ({ closed: Effect.never }) as unknown as RpcSession; + +const makeHarness = Effect.fn("TestSessionState.makeHarness")(function* ( + reply: (requestNumber: number) => Promise, +) { + let requestCount = 0; + const transport = yield* SubscriptionRef.make(Option.some(rpcSession())); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: transport, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + RpcHttp.layerRemoteHttpClient(async () => reply(++requestCount)), + // The test's clock, so the retry wait moves with `TestClock.adjust`. + Layer.succeed(Clock.Clock, yield* Clock.Clock), + ), + ); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const results = yield* Queue.unbounded>(); + const atom = createEnvironmentSessionAtoms(runtime).sessionStateAtom(TARGET.environmentId); + const unsubscribe = registry.subscribe(atom, (result) => Queue.offerUnsafe(results, result), { + immediate: true, + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const nextSettled = Effect.fn("TestSessionState.nextSettled")(function* () { + for (;;) { + const result = yield* Queue.take(results); + if (!result.waiting && result._tag !== "Initial") return result; + } + }); + return { transport, nextSettled }; +}); + +const unreachable = () => Promise.reject(new TypeError("Network request failed")); + +describe("environment session state", () => { + it.effect("fetches permissions again when the connection is re-established", () => + Effect.gen(function* () { + const harness = yield* makeHarness((requestNumber) => + requestNumber === 1 ? unreachable() : Promise.resolve(Response.json(SESSION)), + ); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession())); + + expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION }); + }), + ); + + it.effect("retries a failed permission check with backoff that restarts per connection", () => + Effect.gen(function* () { + const harness = yield* makeHarness((requestNumber) => + requestNumber <= 3 ? unreachable() : Promise.resolve(Response.json(SESSION)), + ); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* TestClock.adjust("2 seconds"); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession())); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* TestClock.adjust("2 seconds"); + expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION }); + }), + ); +}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index efdf0b37c0a09..90afa29f9d05d 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -14,6 +14,7 @@ import type { PreparedConnection } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./environmentStreams.ts"; @@ -39,6 +40,8 @@ function initialConfigOption( const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; const MAX_SESSION_STATE_TIMEOUT_MS = 60_000; +const SESSION_STATE_RETRY_BASE_MS = 2_000; +const SESSION_STATE_RETRY_MAX_MS = 60_000; export const fetchEnvironmentSessionState = Effect.fn( "clientRuntime.state.fetchEnvironmentSessionState", @@ -136,13 +139,38 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); - const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => - runtime + const transportSessionAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.session)), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => { + let consecutiveFailures = 0; + let backoffConnection: RpcSession | null = null; + return runtime .atom((get) => { const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); - if (prepared === null) { + // Each established connection fetches anew, and a failure retries while it stays up, + // so a fetch that failed mid-reconnect cannot outlive the socket that recovered. + const connection = AsyncResult.getOrElse(get(transportSessionAtom(environmentId)), () => + Option.none(), + ); + if (prepared === null || Option.isNone(connection)) { return Effect.never; } + if (connection.value !== backoffConnection) { + backoffConnection = connection.value; + consecutiveFailures = 0; + } return Effect.gen(function* () { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); const remoteAuthorization = yield* Effect.serviceOption( @@ -155,14 +183,31 @@ function makeEnvironmentSessionAtoms( signer, remoteAuthorization, }).pipe(Effect.provideService(HttpClient.HttpClient, client.value)); - }); + }).pipe( + Effect.tap(() => + Effect.sync(() => { + consecutiveFailures = 0; + }), + ), + Effect.tapError(() => { + const delayMs = Math.min( + SESSION_STATE_RETRY_BASE_MS * 2 ** consecutiveFailures, + SESSION_STATE_RETRY_MAX_MS, + ); + consecutiveFailures += 1; + return Effect.sleep(delayMs).pipe( + Effect.andThen(Effect.sync(() => get.refreshSelf())), + Effect.forkScoped, + ); + }), + ); }) .pipe( Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`environment-session-state:${environmentId}`), - ), - ); + ); + }); const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make( diff --git a/packages/shared/src/authScopeOptions.ts b/packages/shared/src/authScopeOptions.ts index d443e50326d28..9a2261a09f3e9 100644 --- a/packages/shared/src/authScopeOptions.ts +++ b/packages/shared/src/authScopeOptions.ts @@ -104,3 +104,19 @@ export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{ description: "Change managed tunnel connectivity.", }, ]; + +/** Shown instead of the granted scopes while they are unknown, so every surface says the same thing. */ +export function sessionPermissionsUnavailableMessage(input: { + readonly connected: boolean; + /** The check errored; the client retries it while connected. */ + readonly failed: boolean; + /** The environment answered that this session is not signed in; retrying will not change that. */ + readonly rejected: boolean; +}): string { + if (!input.connected) return "Connect to this environment to view this sessionโ€™s permissions."; + if (input.rejected) + return "This environment did not accept your session. Reconnect to try again."; + return input.failed + ? "Could not verify your permissions. Retrying automatically." + : "Checking permissionsโ€ฆ"; +} From da04ebc5087477a82deae9ce19c03539a627359f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:06:51 -0700 Subject: [PATCH 138/144] fix(mobile): tab bar spans the screen on iOS before 26 (#27089) Co-authored-by: Claude Opus 5.5 --- .../workspace/WorkspaceNativeTabs.ios.tsx | 16 ++++++++++------ .../features/workspace/WorkspaceToolbar.ios.tsx | 10 +++++----- .../workspace/useSystemWorkspaceTabBar.ts | 13 +++++++++++++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx index 929660587d731..e93175677fc02 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx @@ -18,7 +18,11 @@ import { NativeWorkspaceTabActivityContext, WorkspaceSlotOptionsContext, } from "../../native/v5-workspace-context"; -import { useCompactWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; +import { + useCompactWorkspaceTabs, + useFloatingWorkspaceTabs, + useSystemWorkspaceTabBar, +} from "./useSystemWorkspaceTabBar"; import { useControlTheme } from "../../components/ui/useControlTheme"; import { useDeviceType } from "../../native/native-layout-metrics"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; @@ -80,7 +84,7 @@ export function WorkspaceNativeTabs(props: { const deviceType = useDeviceType(); const compactTabs = useCompactWorkspaceTabs(); const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); - const compactSidebarTabs = deviceType === "phone" || !layout.usesSplitView; + const floatingTabs = useFloatingWorkspaceTabs(); const { width } = useWindowDimensions(); const { colors } = useControlTheme(); const selectedTab = workspace.state.selectedTab; @@ -158,17 +162,17 @@ export function WorkspaceNativeTabs(props: { bottom: 0, // UIKit can shrink the sidebar to 280pt independently of its preferred width. width: - deviceType === "pad" + deviceType === "pad" && (floatingTabs || layout.usesSplitView) ? Math.min(280, layout.listPaneWidth ?? width, width) : (layout.listPaneWidth ?? width), height: 128, }} > (null); // The Settings list takes the selected tab's place, so search follows it. const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab; @@ -189,9 +189,9 @@ export function WorkspaceToolbar(props: { {composeButton} {selectedTab !== "threads" || isDuoDevice(deviceType) || - deviceType === "pad" || + (deviceType === "pad" && layout.usesSplitView) || sidebarTabs === "picker" || - compactTabs ? null : ( + floatingTabs ? null : ( Date: Thu, 29 Oct 2026 13:17:45 -0700 Subject: [PATCH 139/144] perf(server): thread recovery reads are 10x faster (#27099) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ProjectionStore.test.ts | 15 ++++++++++++++- apps/server/src/persistence/Migrations.ts | 2 ++ .../Migrations/073_ActiveTurnItemIndexes.ts | 19 +++++++++++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 ++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 17b8bb818864a..8d46b51031506 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -6640,10 +6640,23 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: releasedAt, }, }); + const { statements, tracer } = traceSqlStatements(); assert.deepEqual( - (yield* store.getRuntimeRecoveryProjection(threadId)).runs.map((run) => run.id), + (yield* store + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.withTracer(tracer))).runs.map((run) => run.id), [runId, newerRunId], ); + // Live-item filters must hit the active partial indexes, not walk the thread's history. + const sql = yield* SqlClient.SqlClient; + for (const statement of statements.filter((query) => query.includes("turn_items"))) { + const plan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statement}`, + )).map((row) => row.detail); + for (const detail of plan) { + assert.notMatch(detail, /turn_items_(thread_ordinal|thread_run)_idx \(thread_id=\?\)$/); + } + } }), ); }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cc395406eb502..8143a86b15034 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -75,6 +75,7 @@ import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; +import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -150,6 +151,7 @@ const migrationEntries = [ [70, "PeerLinks", Migration0070], [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], + [73, "ActiveTurnItemIndexes", Migration0073], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts new file mode 100644 index 0000000000000..d9759bca6d82a --- /dev/null +++ b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Recovery and provider-thread reads filter live items by this exact predicate; without + // these, each read walked every historical item in the thread. + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_ordinal_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) + WHERE status IN ('pending', 'running', 'waiting') + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_run_idx + ON orchestration_v2_projection_turn_items(thread_id, run_id) + WHERE status IN ('pending', 'running', 'waiting') + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 91e40578091a7..c08062c09d1ad 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -52,6 +52,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -147,6 +148,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 3898c3298617e8a12177dbc8cd1ad681c96e3fac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:17:50 -0700 Subject: [PATCH 140/144] perf(server): source control discovery no longer stalls up to 10s on Bitbucket (#27102) Co-authored-by: Claude Opus 5.5 --- .../SourceControlProviderRegistry.test.ts | 32 +++++++++- .../src/server/BitbucketApi.test.ts | 3 +- .../src/server/BitbucketApi.ts | 31 ++++++---- .../src/server/discovery.ts | 60 ++++++++++--------- 4 files changed, 85 insertions(+), 41 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 249bc07b3ad3f..05b6a1a8d0e42 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -118,7 +118,12 @@ function makeRegistry(input: { kind: provider.kind, label: provider.kind, installHint: "unused", - probeAuth: Effect.die("unused"), + probeAuth: Effect.succeed({ + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), }, })), ).pipe( @@ -440,3 +445,28 @@ it.effect("asks again once Settings change", () => assert.strictEqual((yield* resolveUnknown(registry, "c")).provider.kind, "github"); }), ); + +it.effect("answers discovery without waiting on a hosting API that stalls", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + discovery: [ + claimingCli("gitlab"), + { + type: "api", + kind: kind("bitbucket"), + label: "Bitbucket", + installHint: "unused", + probeAuth: Effect.never, + }, + ], + }); + const discovering = yield* registry.discover.pipe(Effect.forkChild); + yield* TestClock.adjust("3 seconds"); + const items = yield* Fiber.join(discovering); + const bitbucket = items.find((item) => item.kind === "bitbucket"); + assert.strictEqual(bitbucket?.status, "available"); + assert.strictEqual(bitbucket?.auth.status, "unknown"); + assert.strictEqual(items.find((item) => item.kind === "gitlab")?.status, "available"); + }), +); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index 26891c3548974..c0464276abf24 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -513,7 +513,7 @@ it.effect("never puts a saved token that is unsafe for an HTTP header on the wir }); it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { - const { layer } = makeLayer({ + const { execute, layer } = makeLayer({ response: () => new Response(null, { status: 401 }), env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, }); @@ -523,6 +523,7 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + assert.strictEqual(execute.mock.calls.length, 0); yield* settings.update({ sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } }, diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 72077040aa870..863b206d95578 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -986,18 +986,25 @@ export const make = Effect.gen(function* () { return BitbucketApi.of({ request, - probeAuth: executeJson( - "probeAuth", - HttpClientRequest.get(apiUrl("/user")), - BitbucketUserSchema, - ).pipe( - Effect.map((user) => ({ - status: "authenticated" as const, - account: nonEmpty(user.username ?? user.display_name ?? user.account_id), - host: Option.some("bitbucket.org"), - detail: Option.none(), - })), - Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), + // Bitbucket answers anonymous /user with 401, so without a credential there is nothing to ask. + probeAuth: currentCredential.pipe( + Effect.flatMap((credential) => + credential === null + ? Effect.succeed(authFromCredential(null)) + : executeJson( + "probeAuth", + HttpClientRequest.get(apiUrl("/user")), + BitbucketUserSchema, + ).pipe( + Effect.map((user) => ({ + status: "authenticated" as const, + account: nonEmpty(user.username ?? user.display_name ?? user.account_id), + host: Option.some("bitbucket.org"), + detail: Option.none(), + })), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), + ), + ), ), listPullRequests: Effect.fn("BitbucketApi.listPullRequests")(function (input) { return resolveRepository(input).pipe( diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts index 66889ddc4f2ab..2f018bfa10ca3 100644 --- a/packages/source-control-core/src/server/discovery.ts +++ b/packages/source-control-core/src/server/discovery.ts @@ -75,6 +75,8 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { }; const DEFAULT_PROBE_TIMEOUT_MS = 5_000; +/** An API host that stalls must not hold the whole discovery answer behind it. */ +const API_PROBE_TIMEOUT = "3 seconds"; function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; @@ -236,6 +238,12 @@ export function probeSourceControlProvider(input: { if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd); if (input.spec.type === "api") { return input.spec.probeAuth.pipe( + Effect.timeoutOption(API_PROBE_TIMEOUT), + Effect.map( + Option.getOrElse(() => + unknownAuth("The hosting service did not answer in time. Refresh to try again."), + ), + ), Effect.map( (auth) => ({ @@ -253,20 +261,15 @@ export function probeSourceControlProvider(input: { const spec = input.spec; - return probeCli({ - spec, - process: input.process, - cwd: input.cwd, - }).pipe( - Effect.flatMap((item) => { - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("Hosting integration command was not found on the server PATH."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return input.process + // The auth check does not depend on the version output, so both spawn at once. + return Effect.all( + [ + probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }), + input.process .run({ operation: "source-control.discovery.auth", command: spec.executable, @@ -278,21 +281,24 @@ export function probeSourceControlProvider(input: { appendTruncationMarker: true, }) .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), + Effect.map(spec.parseAuth), Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), + Effect.succeed(unknownAuth(Option.getOrUndefined(detailFromCause(cause)))), ), - ); - }), + ), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map( + ([item, auth]) => + ({ + ...item, + auth: + item.status === "available" + ? auth + : unknownAuth("Hosting integration command was not found on the server PATH."), + }) satisfies SourceControlProviderDiscoveryItem, + ), ); } From 7f39a860d9775e1c5e7cf9c0655d719d966f64f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:17:55 -0700 Subject: [PATCH 141/144] perf: huge checkpoints ship up to 75x smaller to clients (#27110) Co-authored-by: Claude Opus 5.5 --- .../mobile/src/features/review/reviewModel.ts | 7 ++- .../src/orchestration-v2/ThreadStream.ts | 10 ++-- .../orchestration-v2/WireProjection.test.ts | 43 ++++++++++++++ .../src/orchestration-v2/WireProjection.ts | 58 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 10 ++-- .../src/components/chat/ChangedFilesTree.tsx | 34 +++++++---- .../components/chat/TimelineAssistantRows.tsx | 2 + apps/web/src/lib/turnDiffTree.ts | 14 ----- .../src/state/threadCheckpoints.ts | 17 ++++++ packages/contracts/src/orchestrationV2.ts | 10 ++++ 10 files changed, 167 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index bc0e23b336582..1b2131c73e0c1 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,6 +1,9 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; -import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { + checkpointFileTotals, + type ThreadCheckpointSummary, +} from "@t3tools/client-runtime/state/thread-checkpoints"; import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; @@ -107,7 +110,7 @@ function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { } function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { - const fileCount = checkpoint.files.length; + const fileCount = checkpointFileTotals(checkpoint).count; if (checkpoint.status !== "ready") { return `Diff ${checkpoint.status}`; } diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 3c4baab80ef07..80ee5c32d6152 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -10,7 +10,10 @@ import { buildBoundedThreadProjection, type BoundedProjectionResult, } from "./threadHistoryPaging.ts"; -import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { + capProjectionCheckpointFilesForWire, + projectThreadProjectionForWire, +} from "./WireProjection.ts"; export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; @@ -44,10 +47,9 @@ export function boundedSnapshotResponseFields(input: { readonly compactIds: boolean; }) { const { bounded } = input; + const capped = capProjectionCheckpointFilesForWire(bounded.projection); // Dedupe keeps shared items one object, which the compaction's identity check needs. - const projection = input.dedupeBodies - ? dedupeSnapshotBodies(bounded.projection) - : bounded.projection; + const projection = input.dedupeBodies ? dedupeSnapshotBodies(capped) : capped; const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(projection) : null; return { projection: compactProjection ?? projection, diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index d449aeb933dbf..0d63d832f0ca9 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -1,4 +1,7 @@ import { + CheckpointId, + CheckpointRef, + CheckpointScopeId, MessageId, ContextHandoffId, ProviderThreadId, @@ -381,6 +384,46 @@ describe("live body deduper", () => { ]); }); + it("caps a large checkpoint's files for the wire and still dedupes its item", () => { + const files = Array.from({ length: 250 }, (_, index) => ({ + path: `vendor/file-${index}.ts`, + kind: "modified", + additions: 2, + deletions: 1, + })); + const checkpointId = CheckpointId.make("checkpoint-large"); + const scopeId = CheckpointScopeId.make("scope-large"); + const captured = event(1, { + type: "checkpoint.captured", + payload: { + id: checkpointId, + threadId: base.threadId, + scopeId, + runId: null, + nodeId: NodeId.make("node-large"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: null, + ref: CheckpointRef.make("refs/t3/checkpoints/large"), + status: "ready", + files, + capturedAt: at, + }, + }); + const item = event(2, { + type: "turn-item.updated", + payload: { ...base, type: "checkpoint", checkpointId, scopeId, files }, + }); + const dedupe = makeLiveBodyDeduper(); + const [record, card] = [captured, item].map((streamed) => + dedupe({ ...streamed, event: projectDomainEventForWire(streamed.event) }), + ); + + const filesTotal = { count: 250, additions: 500, deletions: 250 }; + expect(payloadOf(record!)).toMatchObject({ files: files.slice(0, 200), filesTotal }); + expect(payloadOf(card!)).toMatchObject({ files: [], filesOmitted: true, filesTotal }); + }); + it("ships a reply whole when its message arrives after it", () => { const first = reply(1, "a"); expect([first, message(2, "a")].map(makeLiveBodyDeduper())[0]).toBe(first); diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 6b66f1b91f204..2181b7feec5fa 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -21,6 +21,8 @@ import { toolOutputIndicatesFailure, } from "@t3tools/shared/toolOutput"; +// A checkpoint over vendored or generated trees can list 15k+ files; the card needs a prefix. +const MAX_WIRE_CHECKPOINT_FILES = 200; const MAX_DETAIL_STRING_BYTES = 32_768; const MAX_DYNAMIC_VALUE_BYTES = 16_384; const MAX_ON_DEMAND_BYTES = 256 * 1024; @@ -96,8 +98,59 @@ function summarizeDynamicValue(value: unknown): unknown { }; } +type CheckpointFiles = Pick; + +// Same input, same output object: snapshot compaction relies on shared item identity. +const cappedCheckpointFiles = new WeakMap(); + +function capCheckpointFiles(value: T): T { + if (value.files.length <= MAX_WIRE_CHECKPOINT_FILES || value.filesTotal !== undefined) { + return value; + } + const cached = cappedCheckpointFiles.get(value); + if (cached !== undefined) return cached as T; + let additions = 0; + let deletions = 0; + for (const file of value.files) { + additions += file.additions; + deletions += file.deletions; + } + const capped = { + ...value, + files: value.files.slice(0, MAX_WIRE_CHECKPOINT_FILES), + filesTotal: { count: value.files.length, additions, deletions }, + }; + cappedCheckpointFiles.set(value, capped); + return capped; +} + +/** Idempotent; bounded snapshots that skip `projectThreadProjectionForWire` still need it. */ +export function capProjectionCheckpointFilesForWire( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const checkpoints = projection.checkpoints.map(capCheckpointFiles); + const turnItems = projection.turnItems.map(capCheckpointItemFiles); + const visibleTurnItems = projection.visibleTurnItems.map((row) => { + const item = capCheckpointItemFiles(row.item); + return item === row.item ? row : { ...row, item }; + }); + const unchanged = (next: ReadonlyArray, previous: ReadonlyArray) => + next.every((value, index) => value === previous[index]); + return unchanged(checkpoints, projection.checkpoints) && + unchanged(turnItems, projection.turnItems) && + unchanged(visibleTurnItems, projection.visibleTurnItems) + ? projection + : { ...projection, checkpoints, turnItems, visibleTurnItems }; +} + +function capCheckpointItemFiles(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + return item.type === "checkpoint" ? capCheckpointFiles(item) : item; +} + export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { switch (item.type) { + case "checkpoint": + return capCheckpointFiles(item); case "handoff": { const { summary: _summary, ...projected } = item; return projected; @@ -241,6 +294,7 @@ export function projectThreadProjectionForWire( }; return { ...projection, + checkpoints: projection.checkpoints.map(capCheckpointFiles), contextHandoffs: projection.contextHandoffs.map(projectContextHandoffForWire), turnItems: projection.turnItems.map(project), visibleTurnItems: projection.visibleTurnItems.map((row) => ({ @@ -257,7 +311,9 @@ export function projectDomainEventForWire( ? { ...event, payload: projectTurnItemForWire(event.payload) } : event.type === "context-handoff.updated" ? { ...event, payload: projectContextHandoffForWire(event.payload) } - : event; + : event.type === "checkpoint.captured" + ? { ...event, payload: capCheckpointFiles(event.payload) } + : event; } const LIVE_DEDUPE_MEMORY = 32; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c7c0f77bc97fb..bef8f7ab405f1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,6 +3,7 @@ import { deriveLatestThreadRun, deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -218,7 +219,7 @@ export function shouldOpenProactiveTurnDiff(input: { } export function resolveProactiveTurnDiffAction(input: { - checkpoint: Pick | undefined; + checkpoint: Pick | undefined; isGitRepo: boolean | undefined; }): "defer" | "ignore" | "open" { if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; @@ -230,11 +231,8 @@ export function resolveProactiveTurnDiffAction(input: { ) { return "ignore"; } - const changedLines = input.checkpoint.files.reduce( - (total, file) => total + file.additions + file.deletions, - 0, - ); - return input.checkpoint.files.length >= 3 || changedLines >= 50 ? "open" : "ignore"; + const totals = checkpointFileTotals(input.checkpoint); + return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } /** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 0b569cbc0c563..868484f37b474 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,11 +1,7 @@ -import { type RunId } from "@t3tools/contracts"; +import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts"; import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; -import { - buildTurnDiffTree, - summarizeTurnDiffStats, - type TurnDiffTreeNode, -} from "../../lib/turnDiffTree"; +import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { ChevronRightIcon, FileDiffIcon } from "lucide-react"; import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide"; import { cn } from "~/lib/utils"; @@ -30,7 +26,9 @@ type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => vo export const ChangedFilesCard = memo(function ChangedFilesCard(props: { runId: RunId; + /** May be a prefix of the changed files; `totals` covers all of them. */ files: ReadonlyArray; + totals: OrchestrationV2CheckpointFileTotals; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; onToggleAllDirectories: () => void; @@ -40,13 +38,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const { runId, files, + totals, allDirectoriesExpanded, resolvedTheme, onToggleAllDirectories, onOpenTurnDiff, onFileContextMenu, } = props; - const summaryStat = summarizeTurnDiffStats(files); + const hiddenFileCount = totals.count - files.length; const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); return ( @@ -60,12 +59,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { >
- {files.length} changed file{files.length === 1 ? "" : "s"} + {totals.count.toLocaleString()} changed file{totals.count === 1 ? "" : "s"} - {hasNonZeroStat(summaryStat) && ( + {hasNonZeroStat(totals) && ( @@ -126,6 +125,19 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} onFileContextMenu={onFileContextMenu} /> + {hiddenFileCount > 0 && ( +
+ +
+ )}
); }); diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 89b52ae5c5cba..228eb6f79b9c3 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -9,6 +9,7 @@ import { Button } from "../ui/button"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; import { McpAppFrame } from "./McpAppFrame"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; @@ -347,6 +348,7 @@ function AssistantChangedFilesSectionInner({ diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index 810a04755e895..e841e18ce24d2 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -98,20 +98,6 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { return [...subdirectories, ...files]; } -export function summarizeTurnDiffStats(files: ReadonlyArray): TurnDiffStat { - return files.reduce( - (acc, file) => { - const stat = readStat(file); - if (!stat) return acc; - return { - additions: acc.additions + stat.additions, - deletions: acc.deletions + stat.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); -} - export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { const root: MutableDirectoryNode = { name: "", diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 4501007e20053..ee2fe45d9c756 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -3,6 +3,7 @@ import type { CheckpointRef, CheckpointScopeId, MessageId, + OrchestrationV2CheckpointFileTotals, OrchestrationV2ThreadProjection, RunId, } from "@t3tools/contracts"; @@ -15,12 +16,14 @@ export interface ThreadCheckpointSummary { readonly checkpointTurnCount: number; readonly checkpointRef: CheckpointRef; readonly status: "ready" | "missing" | "error" | "stale"; + /** A prefix of the changed files when `filesTotal` is set; see `checkpointFileTotals`. */ readonly files: ReadonlyArray<{ readonly path: string; readonly kind: string; readonly additions: number; readonly deletions: number; }>; + readonly filesTotal?: OrchestrationV2CheckpointFileTotals; readonly assistantMessageId: MessageId | null; readonly completedAt: string; } @@ -43,9 +46,23 @@ export function deriveThreadCheckpointSummaries( checkpointRef: checkpoint.ref, status: checkpoint.status, files: checkpoint.files, + ...(checkpoint.filesTotal === undefined ? {} : { filesTotal: checkpoint.filesTotal }), assistantMessageId, completedAt: DateTime.formatIso(checkpoint.capturedAt), }, ]; }); } + +export function checkpointFileTotals( + checkpoint: Pick, +): OrchestrationV2CheckpointFileTotals { + if (checkpoint.filesTotal !== undefined) return checkpoint.filesTotal; + let additions = 0; + let deletions = 0; + for (const file of checkpoint.files) { + additions += file.additions; + deletions += file.deletions; + } + return { count: checkpoint.files.length, additions, deletions }; +} diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 747d05e7ce4eb..026e74831233d 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1349,6 +1349,14 @@ export const OrchestrationV2CheckpointFileSummary = Schema.Struct({ }); export type OrchestrationV2CheckpointFileSummary = typeof OrchestrationV2CheckpointFileSummary.Type; +/** Sent only when the wire carries a prefix of `files`; the totals cover the full list. */ +export const OrchestrationV2CheckpointFileTotals = Schema.Struct({ + count: NonNegativeInt, + additions: NonNegativeInt, + deletions: NonNegativeInt, +}); +export type OrchestrationV2CheckpointFileTotals = typeof OrchestrationV2CheckpointFileTotals.Type; + export const OrchestrationV2Checkpoint = Schema.Struct({ id: CheckpointId, threadId: ThreadId, @@ -1361,6 +1369,7 @@ export const OrchestrationV2Checkpoint = Schema.Struct({ ref: CheckpointRef, status: Schema.Literals(["ready", "missing", "error", "stale"]), files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), capturedAt: Schema.DateTimeUtc, }); export type OrchestrationV2Checkpoint = typeof OrchestrationV2Checkpoint.Type; @@ -1659,6 +1668,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ checkpointId: CheckpointId, scopeId: CheckpointScopeId, files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), /** Same as the files of checkpoint `checkpointId`. */ filesOmitted: DedupedBodyMarker, }), From 8bf4822f5eb5b24f3129960ce771daacc5c01a44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:18:37 -0700 Subject: [PATCH 142/144] perf(server): sidebar shell loads 3x faster and startup thread counts 80x faster (#27106) Co-authored-by: Claude Opus 5.5 --- .../transferBudgetV2.integration.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 12 + .../orchestration-v2/ProjectionStore.test.ts | 34 +++ .../src/orchestration-v2/ProjectionStore.ts | 110 +++++++ .../ProviderTurnControlService.test.ts | 2 + .../src/orchestration-v2/ShellStream.ts | 287 +++++++++++++----- .../orchestration-v2/ThreadLaunchService.ts | 7 +- .../ThreadManagementService.ts | 13 +- apps/server/src/orchestration-v2/http.ts | 31 +- apps/server/src/project/ProjectService.ts | 6 +- .../src/relay/AgentAwarenessRelay.test.ts | 2 + apps/server/src/relay/AgentAwarenessRelay.ts | 13 +- .../src/serverRuntimeStartup.autoPull.test.ts | 8 +- apps/server/src/serverRuntimeStartup.test.ts | 9 +- apps/server/src/serverRuntimeStartup.ts | 9 +- apps/server/src/ws.test.ts | 15 +- apps/server/src/ws.ts | 31 +- 17 files changed, 436 insertions(+), 154 deletions(-) diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 22db0e643db9b..68a622c541fe9 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -117,6 +117,7 @@ const layerManagement = Layer.unwrap( .pipe(Effect.orDie), getThreadShell: (id) => projections.getThreadShell(id).pipe(Effect.orDie), getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie), + listActiveShellIndex: () => projections.listActiveShellIndex().pipe(Effect.orDie), listActiveSubagentThreadIds: () => projections.listActiveSubagentThreadIds().pipe(Effect.orDie), readShellSnapshot: (options) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 76bcc00bbcb4e..c464a8743f3c2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -141,6 +141,7 @@ import { type ProjectionRecords, type ProjectionCheckpointContext, type OlderSettledThreadsListing, + type ActiveShellIndexEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -392,6 +393,11 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly countThreads: () => Effect.Effect; + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -13434,6 +13440,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError(shellProjectionError)); }, ), + countThreads: Effect.fn("OrchestratorV2.countThreads")(function () { + return projectionStore.countThreads().pipe(Effect.mapError(shellProjectionError)); + }), + listActiveShellIndex: Effect.fn("OrchestratorV2.listActiveShellIndex")(function () { + return projectionStore.listActiveShellIndex().pipe(Effect.mapError(shellProjectionError)); + }), listOlderSettledThreads: Effect.fn("OrchestratorV2.listOlderSettledThreads")(function (query) { return projectionStore .listOlderSettledThreads(query) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 8d46b51031506..1dc7c912e7765 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -26,6 +26,7 @@ import { ThreadId, TurnItemId, type ThreadPullRequestLink, + isLiveSubagentShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -76,6 +77,33 @@ const traceSqlStatements = () => { return { statements, tracer }; }; +/** The shell index flags every live shell and matches its shells on the fields subscriber trims read. */ +const assertShellIndexCoversShells = Effect.fnUntraced(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const shells = (yield* store.getShellSnapshot({ location: "active" })).threads; + const index = yield* store.listActiveShellIndex(); + const iso = (value: DateTime.Utc | null | undefined) => + value == null ? null : DateTime.formatIso(value); + const trimmed = ( + row: Pick< + ProjectionStore.ActiveShellIndexEntry, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" + >, + ) => ({ + id: row.id, + projectId: row.projectId, + lineage: row.lineage, + settledOverride: row.settledOverride, + settledAt: iso(row.settledAt), + pinnedAt: iso(row.pinnedAt), + snoozedUntil: iso(row.snoozedUntil), + }); + assert.deepEqual(index.map(trimmed), shells.map(trimmed)); + shells.forEach((shell, position) => { + if (isLiveSubagentShell(shell)) assert.isTrue(index[position]?.mayBeLive, shell.id); + }); +}); + const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* ( suffix: string, ) { @@ -272,6 +300,7 @@ it.effect("SQL shells flag only an active turn blocked solely on T3 thread waits assert.equal(shell?.status, "running"); assert.equal(shell?.blockedOnThreadWait ?? false, expected); } + yield* assertShellIndexCoversShells(); }); const wait = { ...itemBase, @@ -531,6 +560,7 @@ it.effect("SQL shells use the latest subagent owner for duplicate provider-threa const listed = (yield* store.getShellSnapshot()).threads.find( (thread) => thread.id === targetThreadId, ); + yield* assertShellIndexCoversShells(); for (const shell of [yield* store.getThreadShell(targetThreadId), listed]) { assert.equal(shell?.providerInstanceId, otherInstanceId); assert.equal(shell?.activeRunId, null); @@ -1336,6 +1366,7 @@ it.effect("SQL list older settled threads below a cursor", () => const next = yield* list({ settledAt: DateTime.formatIso(at(4)), threadId: id("b") }, 10); assert.deepEqual(next.threadIds, [id("c"), id("d"), id("snoozed")]); assert.deepEqual(yield* list(before, 25, []), { threadIds: [], counts: [] }); + yield* assertShellIndexCoversShells(); const scoped = yield* list(before, 0, [ProjectId.make("project:b")]); assert.deepEqual(scoped, { threadIds: [], @@ -3798,6 +3829,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( (row) => row.id === threadId, )!; + yield* assertShellIndexCoversShells(); for (const questionShell of [ sqlShell, ProjectionStore.threadShellFromProjection(projection), @@ -4860,6 +4892,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET status = 'running', payload_json = json_set(payload_json, '$.status', 'running') WHERE run_id = ${runId} `; + yield* assertShellIndexCoversShells(); const secretOnlyShell = (yield* projectionStore.getShellSnapshot()).threads.find( (thread) => thread.id === threadId, ); @@ -6466,6 +6499,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (candidate) => candidate.id === threadId, ); assert.deepEqual(listed?.pendingBackgroundTasks, shell?.pendingBackgroundTasks); + yield* assertShellIndexCoversShells(); return shell && projectThreadAwarenessV2({ environmentId, project, thread: shell })?.phase; }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 287a30a9d904e..6bb04041c75b6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -393,6 +393,15 @@ export interface ShellSnapshotOptions { readonly threadIds?: ReadonlyArray; } +/** What a shell subscriber's trims read from an active thread, without building its shell. */ +export interface ActiveShellIndexEntry extends Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" +> { + /** False only when no run, request, work item, or watch can make the shell live. */ + readonly mayBeLive: boolean; +} + export interface OlderSettledThreadsQuery { readonly before: OrchestrationV2OlderSettledCursor; readonly projectIds?: ReadonlyArray | undefined; @@ -450,6 +459,13 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Threads that are not deleted, archived ones included. */ + readonly countThreads: () => Effect.Effect; + /** Every active thread, in shell order. */ + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, @@ -6614,6 +6630,98 @@ export const layer: Layer.Layer = Effect.mapError(shellSnapshotReadError), ); + const countThreads = Effect.fn("ProjectionStore.countThreads")(function* () { + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL + `; + return rows[0]?.count ?? 0; + }, Effect.mapError(shellSnapshotReadError)); + + const listActiveShellIndex = Effect.fn("ProjectionStore.listActiveShellIndex")(function* () { + const rows = yield* sql<{ + readonly thread_id: string; + readonly project_id: string; + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly settled_override: string | null; + readonly settled_at: string | null; + readonly pinned_at: string | null; + readonly snoozed_until: string | null; + readonly may_be_live: number; + }>` + SELECT + t.thread_id, + t.project_id, + json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, + json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, + json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.settledOverride') AS settled_override, + json_extract(t.payload_json, '$.settledAt') AS settled_at, + json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, + json_extract(t.payload_json, '$.snoozedUntil') AS snoozed_until, + -- A superset of isLiveSubagentShell: the sources of its run status, pending request, + -- and pending background work. + ( + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs r + WHERE r.thread_id = t.thread_id + AND r.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runtime_requests request + WHERE request.thread_id = t.thread_id AND request.status = 'pending' + ) + -- Same predicates as the shell's pending item and link reads, for their partial indexes. + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items item + WHERE item.thread_id = t.thread_id + AND item.type IN ('command_execution', 'dynamic_tool', 'subagent') + AND item.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items link_request + WHERE link_request.thread_id = t.thread_id + AND link_request.type = 'link_request' AND link_request.status = 'waiting' + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_provider_threads provider_thread + WHERE provider_thread.thread_id = t.thread_id + AND json_array_length(provider_thread.payload_json, '$.pendingBackgroundTasks') > 0 + ) + OR EXISTS ( + SELECT 1 FROM json_each(t.payload_json, '$.pullRequests') link + WHERE json_type(link.value, '$.watch') IS NOT NULL + ) + ) AS may_be_live + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.archivedAt') IS NULL + ORDER BY t.updated_at ASC, t.thread_id ASC + `; + return rows.map((row): ActiveShellIndexEntry => ({ + id: ThreadId.make(row.thread_id), + projectId: ProjectId.make(row.project_id), + lineage: { + parentThreadId: + row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), + relationshipToParent: + row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" + ? row.relationship_to_parent + : null, + rootThreadId: ThreadId.make(row.root_thread_id), + }, + settledOverride: + row.settled_override === "settled" || row.settled_override === "active" + ? row.settled_override + : null, + settledAt: row.settled_at === null ? null : DateTime.makeUnsafe(row.settled_at), + pinnedAt: row.pinned_at === null ? null : DateTime.makeUnsafe(row.pinned_at), + snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), + mayBeLive: row.may_be_live === 1, + })); + }, Effect.mapError(shellSnapshotReadError)); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( shareId: GuestShareId, ) { @@ -6768,6 +6876,8 @@ export const layer: Layer.Layer = apply, getShellSnapshot: readOnReader(getShellSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), + countThreads: readOnReader(countThreads), + listActiveShellIndex: readOnReader(listActiveShellIndex), readShellSnapshot: readOnReader(readShellSnapshot), listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index a39f6b7f036a6..1d79e87eb9b91 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -213,6 +213,8 @@ it.effect( getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), + countThreads: () => Effect.die("unused countThreads"), + listActiveShellIndex: () => Effect.die("unused listActiveShellIndex"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 8a075e13c528b..67d07d6684c08 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -31,6 +31,7 @@ import type * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; import { bufferLatestLiveStream } from "./LiveStreamBudget.ts"; +import type { ActiveShellIndexEntry } from "./ProjectionStore.ts"; type ShellDelta = Extract; @@ -56,7 +57,7 @@ export const bufferShellLiveStream = ( limits, ); -export function buildActiveShellSnapshot(input: { +function buildActiveShellSnapshot(input: { readonly projects: ReadonlyArray; readonly threads: OrchestrationV2ThreadShellSnapshot; readonly snapshotSequence: number; @@ -70,26 +71,119 @@ export function buildActiveShellSnapshot(input: { }; } +type ShellTrimRow = Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" +>; + /** - * Idle subagent children have no sidebar row, so clients that opt in read them from their thread - * family instead. A child stays while it is live or the parent of another child, so the request - * and working walks over shell rows never meet a gap in a nested chain. + * What a subscriber's trims read across every active row. Computed from full shells or from the + * store's index, so a snapshot read only for the rows it sends trims the same way. */ -export function omitIdleSubagentShells( - threads: ReadonlyArray, -): ReadonlyArray { - const parentIds = new Set(); - for (const thread of threads) { - if (thread.lineage.relationshipToParent === "subagent" && thread.lineage.parentThreadId) { - parentIds.add(thread.lineage.parentThreadId); +interface ShellTrimFacts { + readonly subagentParentIds: ReadonlySet; + readonly subagentThreadIds: Set; + /** Undefined when no thread is settled, so none is older. */ + readonly olderSettledBeforeMs: number | undefined; + readonly olderSettledCounts: ReadonlyArray; +} + +const NEWEST_SETTLED_KEPT = 25; + +function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { + const subagentParentIds = new Set(); + const subagentThreadIds = new Set(); + const settledTimes: Array = []; + for (const row of rows) { + if (row.lineage.relationshipToParent === "subagent") { + subagentThreadIds.add(row.id); + if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); + } else if (row.settledOverride === "settled" && row.settledAt !== null) { + settledTimes.push(DateTime.toEpochMillis(row.settledAt)); + } + } + settledTimes.sort((left, right) => right - left); + // With fewer than 25, the oldest one is the cut, so nothing is older. + const olderSettledBeforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1); + const counts = new Map(); + if (olderSettledBeforeMs !== undefined) { + for (const row of rows) { + if (isOlderSettledThreadShell(row, olderSettledBeforeMs)) { + counts.set(row.projectId, (counts.get(row.projectId) ?? 0) + 1); + } } } + return { + subagentParentIds, + subagentThreadIds, + olderSettledBeforeMs, + olderSettledCounts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), + }; +} + +function subagentAncestorIds(threads: ReadonlyArray>) { + const ancestorIds = new Set(); + for (const thread of threads) { + if (thread.lineage.relationshipToParent !== "subagent") continue; + if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); + ancestorIds.add(thread.lineage.rootThreadId); + } + return ancestorIds; +} + +function omitIdleSubagentShellsWith( + threads: ReadonlyArray, + facts: Pick, +): ReadonlyArray { return threads.filter( (thread) => thread.lineage.relationshipToParent !== "subagent" || isLiveSubagentShell(thread) || - parentIds.has(thread.id), + facts.subagentParentIds.has(thread.id), + ); +} + +function omitOlderSettledShellsWith( + input: { + readonly threads: ReadonlyArray; + readonly snapshotSequence: number; + readonly now: DateTime.Utc; + }, + facts: Pick, +): { + readonly threads: ReadonlyArray; + readonly olderSettled: OrchestrationV2OlderSettledThreads; +} { + const nowMs = DateTime.toEpochMillis(input.now); + const beforeMs = facts.olderSettledBeforeMs ?? nowMs; + const ancestorIds = subagentAncestorIds(input.threads); + const threads = input.threads.filter( + (thread) => + !isOlderSettledThreadShell(thread, beforeMs) || + thread.pinnedAt != null || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || + isLiveSubagentShell(thread) || + ancestorIds.has(thread.id), ); + return { + threads, + olderSettled: { + before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), + asOfSequence: input.snapshotSequence, + counts: facts.olderSettledCounts, + }, + }; +} + +/** + * Idle subagent children have no sidebar row, so clients that opt in read them from their thread + * family instead. A child stays while it is live or the parent of another child, so the request + * and working walks over shell rows never meet a gap in a nested chain. + */ +export function omitIdleSubagentShells( + threads: ReadonlyArray, +): ReadonlyArray { + return omitIdleSubagentShellsWith(threads, shellTrimFacts(threads)); } /** @@ -103,18 +197,13 @@ export function activeShellRowsForSubscriber( readonly threads: ReadonlyArray; readonly subagentThreadIds: Set; } { + const facts = shellTrimFacts(threads); return { - threads: omitIdleSubagents ? omitIdleSubagentShells(threads) : threads, - subagentThreadIds: new Set( - threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ), + threads: omitIdleSubagents ? omitIdleSubagentShellsWith(threads, facts) : threads, + subagentThreadIds: facts.subagentThreadIds, }; } -const NEWEST_SETTLED_KEPT = 25; - /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while @@ -128,77 +217,107 @@ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; readonly olderSettled: OrchestrationV2OlderSettledThreads; } { - const nowMs = DateTime.toEpochMillis(input.now); - const settledTimes = input.threads - .flatMap((thread) => - thread.lineage.relationshipToParent !== "subagent" && - thread.settledOverride === "settled" && - thread.settledAt !== null - ? [DateTime.toEpochMillis(thread.settledAt)] + return omitOlderSettledShellsWith(input, shellTrimFacts(input.threads)); +} + +interface ShellSubscriberOptions { + readonly omitIdleSubagents: boolean; + readonly omitOlderSettled: boolean; +} + +function trimShellSnapshot( + base: OrchestrationV2ShellSnapshot, + facts: ShellTrimFacts, + options: ShellSubscriberOptions & { readonly now: DateTime.Utc }, +): OrchestrationV2ShellSnapshot { + const threads = options.omitIdleSubagents + ? omitIdleSubagentShellsWith(base.threads, facts) + : base.threads; + if (!options.omitOlderSettled) return { ...base, threads }; + const older = omitOlderSettledShellsWith( + { threads, snapshotSequence: base.snapshotSequence, now: options.now }, + facts, + ); + return { ...base, threads: older.threads, olderSettled: older.olderSettled }; +} + +/** + * The threads whose shells a trimmed snapshot can contain: a superset of what the trims keep, so + * trimming the shells read for these ids gives the snapshot trimming every shell would. + */ +function shellSubscriberThreadIds( + index: ReadonlyArray, + options: ShellSubscriberOptions, +): ReadonlyArray { + const facts = shellTrimFacts(index); + const subagents = index.filter( + (entry) => + entry.lineage.relationshipToParent === "subagent" && + (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), + ); + const ancestorIds = subagentAncestorIds(subagents); + const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); + const beforeMs = facts.olderSettledBeforeMs; + return index.flatMap((entry) => + entry.lineage.relationshipToParent === "subagent" + ? keptSubagentIds.has(entry.id) + ? [entry.id] + : [] + : !options.omitOlderSettled || + beforeMs === undefined || + !isOlderSettledThreadShell(entry, beforeMs) || + entry.pinnedAt != null || + entry.snoozedUntil != null || + entry.mayBeLive || + ancestorIds.has(entry.id) + ? [entry.id] : [], - ) - .sort((left, right) => right - left); - // With fewer than 25, the oldest one is the cut, so nothing is older. - const beforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1) ?? nowMs; - const ancestorIds = new Set(); - for (const thread of input.threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; - if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); - ancestorIds.add(thread.lineage.rootThreadId); - } - const counts = new Map(); - const threads = input.threads.filter((thread) => { - if (!isOlderSettledThreadShell(thread, beforeMs)) return true; - counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1); - return ( - thread.pinnedAt != null || - (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || - isLiveSubagentShell(thread) || - ancestorIds.has(thread.id) - ); - }); - return { - threads, - olderSettled: { - before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), - asOfSequence: input.snapshotSequence, - counts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), - }, - }; + ); } /** - * The snapshot a shell subscriber gets for its opt-ins, and the subagent ids whose transcript - * events its stream drops (from every row, so an omitted child that wakes keeps that filter). + * A subscriber's active shell snapshot, read like `loadShellSnapshotParts`. With an opt-in, the + * store's index decides which shells to read, so threads the trims drop are never built. */ -export function shellSnapshotForSubscriber( - base: OrchestrationV2ShellSnapshot, - options: { - readonly omitIdleSubagents: boolean; - readonly omitOlderSettled: boolean; - readonly now: DateTime.Utc; - }, -): { - readonly snapshot: OrchestrationV2ShellSnapshot; - readonly subagentThreadIds: Set; -} { - const rows = activeShellRowsForSubscriber(base.threads, options.omitIdleSubagents); - if (!options.omitOlderSettled) { +export const loadSubscriberShellSnapshot = Effect.fn("ShellStream.loadSubscriberShellSnapshot")( + function* (input: { + readonly sql: SqlClient.SqlClient; + readonly listShellIndex: Effect.Effect, E1>; + readonly readThreads: ( + threadIds: ReadonlyArray | undefined, + ) => Effect.Effect, E2>; + readonly listProjects: Effect.Effect, E4>; + readonly latestSequence: Effect.Effect; + readonly options: ShellSubscriberOptions; + }) { + const trims = input.options.omitIdleSubagents || input.options.omitOlderSettled; + const read = yield* input.sql + .withTransaction( + Effect.gen(function* () { + const index = trims ? yield* input.listShellIndex : null; + return { + index, + decodeThreads: yield* input.readThreads( + index === null ? undefined : shellSubscriberThreadIds(index, input.options), + ), + projects: yield* input.listProjects, + snapshotSequence: yield* input.latestSequence, + }; + }), + ) + .pipe(NodeSqliteClient.readOnly); + const base = buildActiveShellSnapshot({ + projects: read.projects, + threads: yield* read.decodeThreads, + snapshotSequence: read.snapshotSequence, + }); + const facts = shellTrimFacts(read.index ?? base.threads); return { - snapshot: { ...base, threads: rows.threads }, - subagentThreadIds: rows.subagentThreadIds, + snapshot: trimShellSnapshot(base, facts, { ...input.options, now: yield* DateTime.now }), + subagentThreadIds: facts.subagentThreadIds, }; - } - const older = omitOlderSettledShells({ - threads: rows.threads, - snapshotSequence: base.snapshotSequence, - now: options.now, - }); - return { - snapshot: { ...base, threads: older.threads, olderSettled: older.olderSettled }, - subagentThreadIds: rows.subagentThreadIds, - }; -} + }, +); /** * One transaction covers the reads so they agree; the threads are decoded after it commits, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7a45a54a312f4..5c51b8c48b859 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -514,12 +514,9 @@ const make = Effect.gen(function* () { // Update parents first so children inherit names; reread each pass for newly updated descendants. const visited = new Set([threadId]); while (true) { - const snapshot = yield* threads.getShellSnapshot(); + const snapshot = yield* threads.getShellSnapshot({ worktreePath: worktreeCwd }); const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => - !visited.has(thread.id) && - thread.worktreePath === worktreeCwd && - thread.branch === oldBranch, + (thread) => !visited.has(thread.id) && thread.branch === oldBranch, ); if (sharing.length === 0) break; for (const thread of sharing) { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index cfbbd4a68a51d..7b79b21e88153 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -394,10 +394,15 @@ export interface ThreadManagementServiceShape { ProjectionRecords, ThreadManagementProjectionLoadError | ThreadManagementThreadNotFoundError >; - readonly getShellSnapshot: (options?: { - readonly location?: "active" | "archive"; - }) => Effect.Effect; + readonly getShellSnapshot: ( + options?: Pick< + ShellSnapshotOptions, + "location" | "projectId" | "unsettledOnly" | "worktreePath" | "threadIds" + >, + ) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; + readonly countThreads: Orchestrator.OrchestratorV2["Service"]["countThreads"]; + readonly listActiveShellIndex: Orchestrator.OrchestratorV2["Service"]["listActiveShellIndex"]; readonly listOlderSettledThreads: Orchestrator.OrchestratorV2["Service"]["listOlderSettledThreads"]; readonly readShellSnapshot: ( options?: Pick, @@ -1333,6 +1338,8 @@ const make = Effect.gen(function* () { getProjectThreadRecords, getShellSnapshot: orchestrator.getShellSnapshot, listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, + countThreads: orchestrator.countThreads, + listActiveShellIndex: orchestrator.listActiveShellIndex, listOlderSettledThreads: orchestrator.listOlderSettledThreads, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 61ff54d0c794f..c2c67eb04d9dc 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -3,7 +3,6 @@ import { EnvironmentHttpApi, type OrchestrationProjectShell, } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; import * as Predicate from "effect/Predicate"; @@ -32,11 +31,7 @@ import { } from "./threadHistoryPaging.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import { - buildActiveShellSnapshot, - loadShellSnapshotParts, - shellSnapshotForSubscriber, -} from "./ShellStream.ts"; +import { loadSubscriberShellSnapshot } from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -84,19 +79,19 @@ export const layer = HttpApiBuilder.group( readonly omitIdleSubagents: boolean; readonly omitOlderSettled: boolean; }) { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projectStore.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const { snapshot } = shellSnapshotForSubscriber(base, { - ...options, - now: yield* DateTime.now, + const { snapshot } = yield* loadSubscriberShellSnapshot({ + sql, + listShellIndex: threadManagement.listActiveShellIndex(), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ + location: "active", + ...(threadIds === undefined ? {} : { threadIds }), + }), + listProjects: projectStore.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + options, }); - const projects = yield* enrichProjectShells(base.projects); + const projects = yield* enrichProjectShells(snapshot.projects); return { ...snapshot, projects }; }, ); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 18004510baa45..3ba57db6d7e5a 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -457,15 +457,13 @@ export const make = Effect.gen(function* () { ) { const { projectId } = input; const snapshot = yield* threadProjections - .getShellSnapshot() + .getShellSnapshot({ projectId }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), ), ); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === projectId, - ); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads]; if (projectThreads.length > 0 && input.force !== true) { return yield* new ProjectNotEmptyError({ projectId }); } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 7f0e86bd6cf49..48c4a5c240ea9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -190,6 +190,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + countThreads: unused, + listActiveShellIndex: () => Effect.succeed([]), listOlderSettledThreads: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index cbe7b35ef409e..54742a0af7d66 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -726,10 +726,15 @@ export const make = Effect.gen(function* () { return "unlinked" as const; } const environmentId = yield* serverEnvironment.getEnvironmentId; - const [projectSnapshot, shellSnapshot] = yield* Effect.all([ - projects.snapshot, - threads.getShellSnapshot(), - ]); + const projectSnapshot = yield* projects.snapshot; + // Subagents never publish awareness, and they are most threads. + const topLevelThreadIds = (yield* threads.listActiveShellIndex()).flatMap((thread) => + thread.lineage.relationshipToParent === "subagent" ? [] : [thread.id], + ); + const shellSnapshot = yield* threads.getShellSnapshot({ + location: "active", + threadIds: topLevelThreadIds, + }); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, startedAt, diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 59f6b67f04509..549aae79e8be5 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -141,13 +141,7 @@ it.effect("parks automatic pull until activation without delaying command readin }), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), + countThreads: () => Effect.succeed(0), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({}), Layer.mock(ServiceLauncherClient.ServiceLauncherClient)({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 274b149c66efc..bef61bbca56fd 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -176,13 +176,8 @@ it.effect.each([ }), }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - threads: [], - archivedThreads: [], - schemaVersion: 1, - snapshotSequence: 0, - }), + countThreads: () => Effect.succeed(0), + listActiveShellIndex: () => Effect.succeed([]), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({ launch: (input) => { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index d8d62dc029c24..b812ad07fa6d6 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -153,11 +153,11 @@ const recordStartupHeartbeat = Effect.gen(function* () { const { threadCount, projectCount } = yield* Effect.all({ projects: projects.snapshot, - threads: threads.getShellSnapshot(), + threadCount: threads.countThreads(), }).pipe( - Effect.map(({ projects: projectSnapshot, threads: shellSnapshot }) => ({ + Effect.map(({ projects: projectSnapshot, threadCount }) => ({ projectCount: projectSnapshot.projects.length, - threadCount: shellSnapshot.threads.length + shellSnapshot.archivedThreads.length, + threadCount, })), Effect.catch((cause) => Effect.logWarning("failed to gather V2 startup counts for telemetry", { @@ -279,8 +279,7 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { title: path.basename(serverConfig.cwd) || "project", workspaceRoot: serverConfig.cwd, }); - const shell = yield* threads.getShellSnapshot(); - const existingThread = shell.threads.find( + const existingThread = (yield* threads.listActiveShellIndex()).find( (thread) => thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index bce9450778736..2c6adce82f390 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -412,12 +412,23 @@ it.effect("shell snapshots omit idle children and older settled threads only on Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), Effect.map(([item]) => (item?.kind === "snapshot" ? item.snapshot : null)), Effect.provideService(ThreadManagement.ThreadManagementService, { - readShellSnapshot: () => + listActiveShellIndex: () => + Effect.succeed( + threads.map((thread) => ({ + ...thread, + pinnedAt: null, + snoozedUntil: null, + mayBeLive: thread.activityRunStatus !== null, + })), + ), + readShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => Effect.succeed( Effect.succeed({ schemaVersion: 1, snapshotSequence: 0, - threads, + threads: threads.filter( + (thread) => options?.threadIds?.includes(thread.id) ?? true, + ), archivedThreads: [], }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 86c7a4db56af4..a7ef5dedf24fb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,7 +124,6 @@ import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { - buildActiveShellSnapshot, bufferShellLiveStream, coalesceShellApplicationEvents, composeShellStreamWithEnrichment, @@ -134,7 +133,7 @@ import { shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, - shellSnapshotForSubscriber, + loadSubscriberShellSnapshot, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -1010,21 +1009,23 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); const loadSnapshot = Effect.fn("ws.orchestrationV2.loadShellSnapshot")(function* () { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projects.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const forSubscriber = shellSnapshotForSubscriber(base, { - omitIdleSubagents: input.omitIdleSubagents === true, - omitOlderSettled: input.omitOlderSettled === true, - now: yield* DateTime.now, + const forSubscriber = yield* loadSubscriberShellSnapshot({ + sql, + listShellIndex: threadManagement.listActiveShellIndex(), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ + location: "active", + ...(threadIds === undefined ? {} : { threadIds }), + }), + listProjects: projects.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + options: { + omitIdleSubagents: input.omitIdleSubagents === true, + omitOlderSettled: input.omitOlderSettled === true, + }, }); subagentThreadIds = forSubscriber.subagentThreadIds; - const enriched = yield* enrichProjectShells(base.projects); + const enriched = yield* enrichProjectShells(forSubscriber.snapshot.projects); return { snapshot: { ...forSubscriber.snapshot, From 22c37a8da4567d59b0759396dd5412b28da9ab4a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 13:23:56 -0700 Subject: [PATCH 143/144] perf(server): preparing a run is 15x faster and uses 200x less memory (#27105) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ThreadLaunchService.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 5c51b8c48b859..b68eef4b2a40c 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -1063,12 +1063,14 @@ const make = Effect.gen(function* () { "ThreadLaunchService.prepareRun", )(function* (input) { const scheduled = yield* Effect.gen(function* () { - const projection = yield* threads.getThreadProjection(input.threadId); - const run = projection.runs.find((candidate) => candidate.id === input.runId); + const { thread, runs } = yield* threads.getThreadRecords(input.threadId, ["runs"], { + runIds: [input.runId], + }); + const run = runs.find((candidate) => candidate.id === input.runId); const workspacePreparation = run?.workspacePreparation; if (run?.status !== "preparing" || workspacePreparation === undefined) return; if (!(yield* reservePreparation(input.commandId))) return; - yield* scheduleRunPreparation(input, projection, run, workspacePreparation).pipe( + yield* scheduleRunPreparation(input, thread, run, workspacePreparation).pipe( Effect.onError(() => releasePreparation(input.commandId)), ); }).pipe(Effect.exit); @@ -1090,30 +1092,33 @@ const make = Effect.gen(function* () { return dispatched; }); - const scheduleRunPreparation = ( + const scheduleRunPreparation = Effect.fn("ThreadLaunchService.scheduleRunPreparation")(function* ( input: ThreadLaunchPreparationInput, - projection: OrchestrationV2ThreadProjection, + thread: OrchestrationV2ThreadProjection["thread"], run: OrchestrationV2ThreadProjection["runs"][number], workspacePreparation: ThreadLaunchWorkspaceStrategy, - ) => { - const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + ) { + const { messages } = yield* threads.getThreadRecords(input.threadId, ["messages"], { + messageIds: [run.userMessageId], + }); + const message = messages.find((candidate) => candidate.id === run.userMessageId); const reuse = workspacePreparation.type === "worktree" && - projection.thread.worktreePath !== null && - projection.thread.branch !== null + thread.worktreePath !== null && + thread.branch !== null ? { strategy: { type: "existing_worktree" as const, - worktreePath: projection.thread.worktreePath, - branch: projection.thread.branch, + worktreePath: thread.worktreePath, + branch: thread.branch, }, reusedWorktree: { baseRef: workspacePreparation.baseRef }, } : null; - return schedulePreparation( + return yield* schedulePreparation( { commandId: input.commandId, - projectId: projection.thread.projectId, + projectId: thread.projectId, workspaceStrategy: reuse?.strategy ?? workspacePreparation, ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }), ...(message === undefined @@ -1129,7 +1134,7 @@ const make = Effect.gen(function* () { input.threadId, run.id, ); - }; + }); return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); }); From a00720802c9a3e8c14a8cea4e3ba1b25b4f71ae3 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 29 Oct 2026 17:04:18 -0400 Subject: [PATCH 144/144] chore: move the cloud box host id rationale to where it is read Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.ts | 3 ++- apps/server/src/usage/boxUsage.ts | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c1f81335c1d4b..a62792783d437 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -180,7 +180,8 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); const hostIdPath = path.join(config.baseDir, "usage-host-id"); - // A cloud box is named by the lease its host gave it, ahead of any operator override. + // Cloud boxes are cloned from one template and share a hostname and seeded home, so the + // provisioner's lease id names each one, ahead of any operator override. const explicitHostId = hostEnvironment["T3CODE_USAGE_HOST_ID"]?.trim() || hostEnvironment["T3CODE_HOST_ID"]?.trim(); const hostId = diff --git a/apps/server/src/usage/boxUsage.ts b/apps/server/src/usage/boxUsage.ts index f263b7eeb89a8..a4450c3fc7708 100644 --- a/apps/server/src/usage/boxUsage.ts +++ b/apps/server/src/usage/boxUsage.ts @@ -42,10 +42,6 @@ export const BOX_USAGE_RETENTION_DAYS = 90; const isoAt = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); -/** - * The host id usage sources carry. Cloud boxes are cloned from one template - * and share a hostname, so the provisioner names each one. - */ /** The order `UsageAggregator` sorts a summary's buckets in. */ const compareUsageBuckets = (a: UsageBucket, b: UsageBucket) => a.day.localeCompare(b.day) ||