From f193a6863c494fdfa36a1ef3132e42a7d7b42926 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 12:48:00 -0700 Subject: [PATCH 01/17] fix(server): bypass owned caches on explicit provider refresh (#13109) --- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/provider/Drivers/CursorDriver.ts | 5 +- .../provider/Layers/CursorProvider.test.ts | 6 +- .../src/provider/Layers/CursorProvider.ts | 7 +- .../server/src/provider/ModelManifest.test.ts | 79 +++++++++++++ apps/server/src/provider/ModelManifest.ts | 10 +- apps/server/src/provider/ProviderDriver.ts | 2 + apps/server/src/server.test.ts | 104 +++++++++++++++++- apps/server/src/ws.ts | 26 +++++ packages/contracts/src/rpc.ts | 3 +- 10 files changed, 233 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b87a89dcf528..48f96b7fe433 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -261,6 +261,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), snapshotForCwd, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 70af46ff6867..59e2138c302f 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -134,11 +134,11 @@ export const CursorDriver: ProviderDriver = { const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus( effectiveConfig, processEnv, - discoverModels, + modelDiscovery.discover, ).pipe( Effect.flatMap((snapshot) => effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" @@ -217,6 +217,7 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: modelDiscovery.invalidate, snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 19cfd22c6964..47826fcc8704 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -742,7 +742,7 @@ describe("discoverCursorModelsViaAcp", () => { apiEndpoint: "", customModels: [], }; - const discover = yield* makeCursorModelDiscovery(settings, { + const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }); @@ -755,6 +755,10 @@ describe("discoverCursorModelsViaAcp", () => { yield* fileSystem.writeFileString(requestLogPath, ""); expect(yield* discover(about)).toEqual(first); expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* invalidate; + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); yield* discover({ ...about, version: "2026.08.12" }); expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); yield* fileSystem.writeFileString(requestLogPath, ""); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 6a8b93c88e90..dfaa8ffe338e 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -678,8 +678,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, }, ); - return (about: Pick) => - Cache.get(cache, JSON.stringify([about.version, about.auth])); + return { + discover: (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])), + invalidate: Cache.invalidateAll(cache), + }; }); function getCursorFallbackModels( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 0a2ca28feb73..1f00cb56040a 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -342,6 +342,84 @@ const serviceLayers = (input: { ); describe("ModelManifest service", () => { + it.live("explicit refresh bypasses fresh memory and disk caches", () => { + let fetchCount = 0; + const updated: ModelManifestData = { + ...REMOTE_MANIFEST, + currentModels: { codex: ["gpt-reloaded"] }, + }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* rebooted.current, updated); + assert.deepStrictEqual(yield* (yield* make).current, updated); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-refresh-test", + response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), + }), + ), + ); + }); + + it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* (yield* make).current, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 3); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-retry-test", + response: () => + fetchCount++ === 1 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + + it.live("explicit refresh bypasses the retry delay after an initial failure", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-initial-retry-test", + response: () => + fetchCount++ === 0 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* make; @@ -457,6 +535,7 @@ describe("ModelManifest service", () => { ), ); assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, BUNDLED_MODEL_MANIFEST); assert.strictEqual(fetchCount, 0); }).pipe( Effect.scoped, diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index f5ad15dd8586..7f2305097b78 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -315,6 +315,8 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; /** Manifest after a TTL-gated remote refresh; never fails. */ readonly refresh: Effect.Effect; + /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */ + readonly forceRefresh: Effect.Effect; /** Forks `refresh` into the service's own scope. Drivers call this from * provider checks: the fetch is process-shared state, so it must survive * the teardown of whichever instance happened to trigger it. */ @@ -326,6 +328,7 @@ export class ModelManifest extends Context.Service< const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, }; @@ -369,7 +372,7 @@ export const make = Effect.gen(function* () { }), ); - const refresh = Effect.fn("ModelManifest.refresh")(function* () { + const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; // A timestamp in the future means the wall clock moved backwards (the @@ -377,8 +380,8 @@ export const make = Effect.gen(function* () { // it as expired: the refetch rewrites both timestamps and self-heals. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; - if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; - if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; // The same switch that gates provider CLI update checks. It stops network // fetches only: a manifest already cached on disk from an earlier fetch @@ -413,6 +416,7 @@ export const make = Effect.gen(function* () { return ModelManifest.of({ current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), refresh: guardedRefresh, + forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)), refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..059a2508e40f 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -74,6 +74,8 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; + /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ + readonly invalidateCaches?: Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then * re-probe so the snapshot reflects the cleared windows. Account-level, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5d46c866a165..362cc7e3128a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -132,6 +132,7 @@ import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationE import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -142,7 +143,10 @@ import { import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + ProviderVersionCache, +} from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; @@ -516,6 +520,7 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + modelManifest?: Partial; usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; @@ -790,6 +795,10 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mergeAll( + Layer.mock(ModelManifest.ModelManifest)({ + forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + ...options?.layers?.modelManifest, + }), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), @@ -6337,6 +6346,99 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const mode of ["all", "targeted", "background"] as const) { + it.effect(`provider refresh invalidates T3 caches before probing (${mode})`, () => { + const driver = ProviderDriverKind.make("codex"); + const instanceIds = [ProviderInstanceId.make("codex"), ProviderInstanceId.make("codex_work")]; + const packageNames = ["@example/personal", "@example/work"]; + const versionCache = new Map( + packageNames.map((name) => [ + name, + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "1.0.0", + }, + ]), + ); + const invalidated: string[] = []; + const freshMaintenance: string[] = []; + let manifestRefreshed = false; + let probed = false; + const instances = instanceIds.map( + (instanceId, index) => + ({ + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + invalidateCaches: Effect.sync(() => { + invalidated.push(instanceId); + }), + snapshot: { + resolveMaintenance: (options) => + Effect.sync(() => { + assert.isTrue(options?.fresh); + freshMaintenance.push(instanceId); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: packageNames[index]!, + }); + }), + getSnapshot: Effect.never, + refresh: Effect.never, + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }) satisfies ProviderInstance, + ); + const expected = + mode === "background" ? [] : mode === "targeted" ? [instanceIds[1]!] : instanceIds; + const probe = Effect.sync(() => { + probed = true; + assert.equal(manifestRefreshed, mode !== "background"); + assert.deepEqual(invalidated.toSorted(), expected.toSorted()); + assert.deepEqual(freshMaintenance.toSorted(), expected.toSorted()); + for (let index = 0; index < instanceIds.length; index++) { + assert.equal( + versionCache.has(packageNames[index]!), + !expected.includes(instanceIds[index]!), + ); + } + return []; + }); + return Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + modelManifest: { + forceRefresh: Effect.sync(() => { + manifestRefreshed = true; + return ModelManifest.BUNDLED_MODEL_MANIFEST; + }), + }, + providerInstanceRegistry: { listInstances: Effect.succeed(instances) }, + providerRegistry: { refresh: () => probe, refreshInstance: () => probe }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshProviders]({ + ...(mode === "targeted" ? { instanceId: instanceIds[1]! } : {}), + ...(mode !== "background" ? { refreshModels: true } : {}), + }), + ), + ); + assert.isTrue(probed); + }).pipe( + Effect.provideService(ProviderVersionCache, versionCache), + Effect.provide(NodeHttpServer.layerTest), + ); + }); + } + it.effect("serves config on reconnect without starting provider probes", () => Effect.gen(function* () { const refresh = vi.fn(() => Effect.never); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 652b1ddf3f52..87ed56ddd46d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -109,6 +109,8 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -561,6 +563,8 @@ const makeWsRpcLayer = ( yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const modelManifest = yield* ModelManifest.ModelManifest; + const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache; const providerService = yield* ProviderService.ProviderService; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; @@ -2361,6 +2365,28 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.serverRefreshProviders, Effect.gen(function* () { + // Only explicit catalog refreshes bypass T3's caches. Workspace + // discovery and background status checks retain their timers. + if (input.refreshModels) { + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) + providerVersionCache.delete(maintenance.packageName); + }), + { concurrency: "unbounded", discard: true }, + ); + } // An untargeted refresh is "re-read everything's status", which // includes quota from configured usage-limit sources. Awaited, // not forked: the RPC scope closes on return and would diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41af6d9f1e9e..5a4210522073 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -476,7 +476,8 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, */ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), - /** Explicit user request. Background status refreshes must not open agent sessions. */ + /** Explicit user request: bypass T3-owned caches and rediscover models. + * Background status refreshes must not open agent sessions. */ refreshModels: Schema.optional(Schema.Boolean), }), success: ServerProviderUpdatedPayload, From d7819c18813fa03b033cc1c9472c9acc0ffc0618 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 13:02:14 -0700 Subject: [PATCH 02/17] chore(devices): bump agent-device to 0.21.12 (#13124) --- apps/server/src/device/DeviceToolchain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 81cdb79217fe..e8e7d5cae46a 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -28,7 +28,7 @@ import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; export const DEVICE_HUB_VERSION = "0.10.1"; const AGENT_DEVICE_PACKAGE = "agent-device"; -export const AGENT_DEVICE_VERSION = "0.21.7"; +export const AGENT_DEVICE_VERSION = "0.21.12"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); From bed0b4cdad41408bb09b445a0a8f8ea0e5b62d1b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:35:06 -0700 Subject: [PATCH 03/17] chore(mobile): drop dead nitro-markdown tgz override and @expo/metro-runtime (#13148) --- .../deps/react-native-nitro-markdown-0.5.0.tgz | Bin 180007 -> 0 bytes apps/mobile/package.json | 4 ---- pnpm-lock.yaml | 4 +--- scripts/release-smoke.ts | 1 - 4 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz diff --git a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz deleted file mode 100644 index 8fafc3896813974532ce67ef0969b487045aa8d3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180007 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%0DoW8ullU@$ zL|!u|h=WPDMV1ZAojp8wbp(j$@nzEH$fEd%7o1&uz}_EBqZtD@mk$On_FwM4J3Ldg z2Z#GF^*>JzUcJ`;-os!Fk*lVXgVcJkEycS9NTD`Ts;WAT{J{Ri(^2oHs9&c@vJKG^ zkb9kFi}kH-D35wX;PvjPEt>@E%U2`WK^GD7yb*obUZ^i&0^y!)Xl%a1=5$+|C>=i? z{=+AF(|jQWtm3x@hXZ+;W&o~DMszrdvPfnesjM}kWvwM?HjO5Oi%B}X6Q%%YPX4?& zh4bK4vv_`Zy!R6xVh97lM2^YZ?sn0#<)A~@ADQHa6bEN7YUEZbKygFe(C`6Glx*;# zuj(0E%=0Fh(D;-=qpFYL?+$Mmk}Qq&W<*h1T2OcRn}%H1FaoD=uuX01ShA$DnCND$ z@&#wflkBqG)AC4J=u@B}=0T{&ukbQNmR{H62S;4`k3~~38j^IFP6oqto)lr$Iez(a zh0Nj^9i-!NZlq44WNVXO+vH=LBq469y?T(~g@+iLC_v3a38`>Za=5_8BLK)6-@tJM z-68;e77YuU0gyu~QVZb*!aNEZAj^#x5yyu7F;<*eA6sT+RDpSs(J93;k9I?V*AgdW zk`AK@;b!3rI*jJRt+`@&RY-mX={FaUy*US;#%P{lNkkwiI8nGf^fFP2uo8zrV^&2p zy1s%Yarb@@uEZL@gF65}VouZPym{eSA)yYMqW( zd*_{As}VB(dk!sj1}b@kg5~|k)z$O1PT2g5HPg~(4m4!x6ohimuo-9qK6hjH(@!)3 z_aE-Id6rRuvxR#kiW8YwT%;)((`%m8Je9M}jwfT1jBNeej&>d3b^K?8XGPu90TTAr z(g>0~}l2uv6p5wgH^oJdi8&79Pf%teNzu|F-$E({LkT@lI(mx6Lalt>k0 z)#NJw==;f38#cGzh`1Se(FRM`L;NRO}V~ zS5t^DJfSZF6Uut1nlYwT|G9L2O(RNhw%w^Qkuww=mMs?9UBADm^};n?JO^=BX}ef6 z;Hp{g_l}Rwb`OsBMO9Y;PBw|OwwNe zy#B;}@pPYAX!mzth>om(UjHK;3RvMzzF0(;q<2CQi z(OD`$UoRSGAnNoR-}*Y|DMnY^hXoA<6uqk3mJcRrno~A&e{`SX;`*Orks#|giC~}D zPlPIDJjKDFrgU*rpaQ3%07dyn7P)QwoTqaz+u{_!r~_Kqy;-14fJ)+k(N^AuZNx(i zTW^(|?j>KhEZr9ja~Y+~Pe!M+G>ftuh^vAcl8o(1UZfcvp`sQiBu}RT%*e#KHSUPB zg2NzUed(%ep*o=9%<;zC$6~-P`Gpm1;X5o{TrcXCw&wayuUdPt+_TBJjIisfpm`;Nx6zNp4jGGTD*cNCt!W4liZ z>4gzn--GJQ-^4oTX4wVq$i%<`_Eu!zU8%dNpur(PojI-n0cB5n+e4-ASl*L@bhh0$ z1H}wwH`iOY?T&wn@NN&�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 + ) : ( + No providers available + ) + } + /> + ); + const workspaceRow = ( + updateSettings({ defaultThreadEnvMode: null })} + /> + ) : null + } + control={ + + } + /> + ); + return ( - {category === "general" ? ( + {category === "project" ? ( <> - setModel(null)} /> - ) : null - } - control={ - selection && activeEntry ? ( -
- { - if (representative) - void navigate({ - to: "/settings/providers", - search: { environmentId: representative.environmentId, instanceId }, - }); - }} - onInstanceModelChange={(instanceId, model) => - setModel(createModelSelection(instanceId, model)) - } - /> - {!mixedModel ? ( - {}} - modelOptions={selection.options ?? []} - allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} - triggerVariant="outline" - triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} - onModelOptionsChange={(options) => - setModel( - createModelSelection(selection.instanceId, selection.model, options), - ) - } - /> - ) : null} -
- ) : ( - No providers available - ) - } - /> + {modelRow} + {workspaceRow} + + ) : category === "general" ? ( + <> + {modelRow} } /> - updateSettings({ defaultThreadEnvMode: null })} - /> - ) : null - } - control={ - - } - /> + {workspaceRow} + + + + Can't find a setting? Keep this project picked above and hop to any other settings page. + + + {hasMultipleCheckouts ? checkoutChoices : null} diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index 995b11191179..397662c5f700 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -3,6 +3,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; +import { SettingsPageContainer } from "./settingsLayout"; /** Project identity and checkout management for the selected project. */ export function ProjectsSettings() { @@ -22,7 +23,9 @@ export function ProjectsSettings() { checkoutKey={value.checkout ?? null} /> ) : scope.kind === "unavailable" ? ( -

{scope.message}

+ +

{scope.message}

+
) : ( Choose a project to manage its name, icon, checkouts and actions. diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 90d84d1c1efa..2d5d5d751719 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -58,6 +58,7 @@ vi.mock("./settingsLayout", async (importOriginal) => { }; }); +vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); vi.mock("react/compiler-runtime", async () => { const { reactHookHarness } = await import("../../test/reactHookHarness"); return { c: reactHookHarness.useMemoCache }; diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx index 29555c337821..c4e019d6fcf7 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -1,37 +1,9 @@ -import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { LayersIcon } from "lucide-react"; -import type { ReactNode } from "react"; - -import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; -import type { EnvironmentPresentation } from "../../state/environments"; -import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { ProjectFavicon } from "../ProjectFavicon"; -import { InlineButton } from "../ui/button"; -import { - Menu, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuRadioItemIndicator, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../WorkspaceBreadcrumb"; import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; -import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; -import { - ALL_ENVIRONMENTS_VALUE, - ALL_PROJECTS_VALUE, - environmentAxisValue, - projectAxisValue, - selectEnvironmentAxis, - selectProjectAxis, - settingsScopeEnvironmentLabel, -} from "./settingsScopeAxis"; const SETTINGS_BREADCRUMB_LABELS: Readonly> = { ...SETTINGS_SECTION_LABELS, @@ -44,27 +16,11 @@ function settingsBreadcrumbLabel(pathname: string): string | null { return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null; } -export interface SettingsScopeBreadcrumbProps { - readonly value: SettingsScopeSearch; - readonly groups: readonly SidebarProjectSnapshot[]; - readonly environments: readonly EnvironmentPresentation[]; - readonly onChange: (next: SettingsScopeSearch) => void; -} - /** - * `Settings / Section / Environment / Project`. The last two crumbs are the - * targets a change applies to and read like the usage page's filter: muted at - * "all", foreground once narrowed. A project is the same project on every - * environment, so the environment crumb alone decides where a project - * override is written. + * `Settings / Section`. The scope a change applies to lives at the top of the + * page content, see `SettingsScopeSentence`. */ -export function SettingsBreadcrumb({ - pathname, - scope, -}: { - pathname: string; - scope?: SettingsScopeBreadcrumbProps | undefined; -}) { +export function SettingsBreadcrumb({ pathname }: { pathname: string }) { const sectionLabel = settingsBreadcrumbLabel(pathname); return ( @@ -78,154 +34,6 @@ export function SettingsBreadcrumb({ {sectionLabel ?? "Settings"} - {scope ? ( - <> - - - - - - - - - - ) : null} ); } - -function ScopeMenu({ - ariaLabel, - icon, - label, - narrowed, - children, -}: { - ariaLabel: string; - icon: ReactNode; - label: string; - narrowed: boolean; - children: ReactNode; -}) { - return ( - - } - className="min-w-0 max-w-56 gap-1.5" - > - {icon} - {label} - - {children} - - ); -} - -function EnvironmentScopeMenu({ - value, - groups, - environments, - onChange, -}: SettingsScopeBreadcrumbProps) { - const resolved = resolveSettingsScope(value, groups, environments); - const environmentValue = environmentAxisValue( - value, - resolved.kind === "checkout" ? resolved.environmentId : null, - ); - const selected = environments.find( - (environment) => environment.environmentId === environmentValue, - ); - return ( - - ) : null - } - label={ - selected - ? settingsScopeEnvironmentLabel(selected, environments) - : environmentValue !== ALL_ENVIRONMENTS_VALUE - ? "Unavailable environment" - : "All environments" - } - > - { - if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); - }} - > - - - - All environments - - - - - {environments.map((environment) => ( - - - - - {settingsScopeEnvironmentLabel(environment, environments)} - - {environment.connection.phase === "connected" ? null : ( - Offline - )} - - - - ))} - - - ); -} - -function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeBreadcrumbProps) { - const selected = groups.find((group) => group.projectKey === value.project); - return ( - : null} - label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")} - > - { - if (typeof next === "string") onChange(selectProjectAxis(value, next)); - }} - > - - - All projects - - - - - {groups.map((group) => ( - - - - {group.displayName} - - - - ))} - - - ); -} diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx new file mode 100644 index 000000000000..5341b72e73e5 --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -0,0 +1,207 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { useLocation } from "@tanstack/react-router"; +import { ChevronDownIcon, LayersIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { InlineButton } from "../ui/button"; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { useOptionalSettingsScope } from "./SettingsScopeContext"; +import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { + ALL_ENVIRONMENTS_VALUE, + ALL_PROJECTS_VALUE, + environmentAxisValue, + projectAxisValue, + selectEnvironmentAxis, + selectProjectAxis, + settingsScopeEnvironmentLabel, +} from "./settingsScopeAxis"; + +/** Pages whose every row is saved on this client; they have no scope to pick. */ +export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet = new Set([ + "/settings/appearance", + "/settings/snap-shot", + "/settings/connections", +]); + +interface SettingsScopeMenuProps { + readonly value: SettingsScopeSearch; + readonly groups: readonly SidebarProjectSnapshot[]; + readonly environments: readonly EnvironmentPresentation[]; + readonly onChange: (next: SettingsScopeSearch) => void; +} + +/** + * "Applying settings for across " at the top of a settings + * page. The two pickers are the targets a change is written to. A project is + * the same project on every environment, so the environment alone decides + * where a project override is written. + */ +export function SettingsScopeSentence() { + const scope = useOptionalSettingsScope(); + const pathname = useLocation({ select: (location) => location.pathname }); + const { environments } = useEnvironments(); + if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null; + const props: SettingsScopeMenuProps = { + value: scope.search, + groups: scope.groups, + environments, + onChange: scope.selectScope, + }; + return ( +

+ {/* Each connective stays with its picker so a wrap never strands "on". */} + + Applying settings for + + + + + {/* A legacy checkout link names one environment without `machine`. */} + {scope.search.machine || scope.scope.kind === "checkout" ? "on" : "across"} + + + +

+ ); +} + +function ScopeMenu({ + ariaLabel, + icon, + label, + children, +}: { + ariaLabel: string; + icon: ReactNode; + label: string; + children: ReactNode; +}) { + return ( + + } + className="min-w-0 max-w-72" + > + {icon} + {label} + + + {children} + + ); +} + +function EnvironmentScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) { + const resolved = resolveSettingsScope(value, groups, environments); + const environmentValue = environmentAxisValue( + value, + resolved.kind === "checkout" ? resolved.environmentId : null, + ); + const selected = environments.find( + (environment) => environment.environmentId === environmentValue, + ); + return ( + + ) : null + } + label={ + selected + ? settingsScopeEnvironmentLabel(selected, environments) + : environmentValue !== ALL_ENVIRONMENTS_VALUE + ? "Unavailable environment" + : "All environments" + } + > + { + if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); + }} + > + + + + All environments + + + + + {environments.map((environment) => ( + + + + + {settingsScopeEnvironmentLabel(environment, environments)} + + {environment.connection.phase === "connected" ? null : ( + Offline + )} + + + + ))} + + + ); +} + +function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) { + const selected = groups.find((group) => group.projectKey === value.project); + return ( + : null} + label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")} + > + { + if (typeof next === "string") onChange(selectProjectAxis(value, next)); + }} + > + + + All projects + + + + + {groups.map((group) => ( + + + + {group.displayName} + + + + ))} + + + ); +} diff --git a/apps/web/src/components/settings/SnapShotSettings.test.tsx b/apps/web/src/components/settings/SnapShotSettings.test.tsx index 584c224493dc..b8f5bed9979f 100644 --- a/apps/web/src/components/settings/SnapShotSettings.test.tsx +++ b/apps/web/src/components/settings/SnapShotSettings.test.tsx @@ -27,6 +27,7 @@ vi.mock("react/compiler-runtime", async () => { vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} })); vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null })); +vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null })); const bridge = vi.hoisted(() => ({ getSnapShotState: vi.fn<() => Promise>(), setSnapShotShortcutSuppressed: vi.fn(), diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index c204fb9d6b65..7f2a56f33122 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -23,6 +23,7 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useOptionalSettingsScope } from "./SettingsScopeContext"; +import { SettingsScopeSentence } from "./SettingsScopeSentence"; import { isProjectScopedSettingKey, listProjectOverrides, @@ -545,6 +546,7 @@ export function SettingsPageContainer({ data-settings-page-scroll > + {children} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index ef373e49f140..f2775a82ffd6 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -106,6 +106,9 @@ const inlineButtonVariants = cva( default: "text-foreground", muted: "text-muted-foreground hover:text-foreground", destructive: "text-destructive/80 hover:text-destructive", + /** Opens a menu from inside a sentence; the dotted underline marks it as a choice. */ + picker: + "gap-1.5 text-foreground underline decoration-foreground/30 decoration-dotted decoration-from-font underline-offset-4 hover:decoration-foreground hover:decoration-solid data-popup-open:decoration-foreground data-popup-open:decoration-solid", }, }, }, diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index e95895926d4d..5fc6d63f20f6 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -19,9 +19,10 @@ import { SettingsScopeProvider, useSettingsScope, } from "../components/settings/SettingsScopeContext"; -import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups"; import { useEnvironments } from "../state/environments"; import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice"; +import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence"; +import { SettingsPageContainer } from "../components/settings/settingsLayout"; import { retainSettingsScope, validateSettingsRouteSearch, @@ -47,13 +48,6 @@ function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) ); } -/** Pages whose every row is saved on this client; the scope selects are hidden there. */ -const DEVICE_ONLY_PATHS = new Set([ - "/settings/appearance", - "/settings/snap-shot", - "/settings/connections", -]); - function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) { const { scope, connectedEnvironments } = useSettingsScope(); const { environments } = useEnvironments(); @@ -99,16 +93,23 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child } // Device-local pages ignore the scope entirely; the project page follows // remembered members while a grouping change replaces its URL key. - if (DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") { + if (SETTINGS_DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") { return children; } + // Keep the scope sentence on screen so the selection can be changed back. if (scope.kind === "unavailable") - return

{scope.message}

; + return ( + +

{scope.message}

+
+ ); if (scope.kind === "environment" && connectedEnvironments.length === 0) { return ( -

- Reconnect {scope.label} to change its settings. -

+ +

+ Reconnect {scope.label} to change its settings. +

+
); } return children; @@ -118,11 +119,8 @@ function SettingsContentLayout() { const location = useLocation(); const navigate = useNavigate(); const canGoBack = useCanGoBack(); - const { search, selectScope } = useSettingsScope(); - const groups = useSettingsProjectGroups(); - const { environments } = useEnvironments(); + const { search } = useSettingsScope(); const [restoreSignal, setRestoreSignal] = useState(0); - const showScope = !DEVICE_ONLY_PATHS.has(location.pathname); const navigateBackWithinApp = useCallback(() => { if (canGoBack) { window.history.back(); @@ -157,14 +155,7 @@ function SettingsContentLayout() {
- + {location.pathname === "/settings/general" ? (
Date: Tue, 22 Sep 2026 19:00:38 -0500 Subject: [PATCH 05/17] refactor(web): move settings scope pickers into breadcrumbs (#13165) --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 43adf7907dbf..dd35a506d78c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4475,7 +4475,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { if (tone === "thinking") { return { iconName: "brain", - className: "text-foreground", + className: "text-icon-muted", }; } if (tone === "info") { From 7e65b226e7a74ecd13bf8b4738ea6bdd096cb1ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:01:42 -0700 Subject: [PATCH 06/17] feat(auth): share provider sign-in flows and credential bindings (#12983) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../Layers/ProviderAdapterRegistry.test.ts | 98 ++++ .../Layers/ProviderAdapterRegistry.ts | 76 ++- .../Layers/ProviderAuthService.test.ts | 319 ++++++++++- .../provider/Layers/ProviderAuthService.ts | 163 +++++- .../src/provider/ProviderAuthFlow.test.ts | 520 ++++++++++++++++++ apps/server/src/provider/ProviderAuthFlow.ts | 463 ++++++++++++++++ .../provider/ProviderCredentialStore.test.ts | 49 ++ .../src/provider/ProviderCredentialStore.ts | 21 + .../provider/Services/ProviderAuthService.ts | 28 +- apps/server/src/ws.ts | 9 + packages/client-runtime/src/state/server.ts | 4 + packages/contracts/src/providerSetup.test.ts | 53 ++ packages/contracts/src/providerSetup.ts | 96 +++- packages/contracts/src/rpc.ts | 12 +- 15 files changed, 1870 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/provider/ProviderAuthFlow.test.ts create mode 100644 apps/server/src/provider/ProviderAuthFlow.ts create mode 100644 apps/server/src/provider/ProviderCredentialStore.test.ts create mode 100644 apps/server/src/provider/ProviderCredentialStore.ts create mode 100644 packages/contracts/src/providerSetup.test.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 68a167083762..8ab1520a6f34 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -37,6 +37,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index bb8ff7835c8f..6a24b49dc930 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -1,15 +1,21 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, + ThreadId, type ServerProvider, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; +import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; + import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; import type * as CodexAdapter from "../Services/CodexAdapter.ts"; import type * as CursorAdapter from "../Services/CursorAdapter.ts"; @@ -185,3 +191,95 @@ it.layer(layer)("ProviderAdapterRegistryLive", (it) => { ]); })); }); + +it.effect("blocks shared credential session startup and preserves guarded adapter identity", () => + Effect.gen(function* () { + const target = fakeInstances[0]!; + const peer = fakeInstances[1]!; + const auth = yield* ProviderAuthFlow.make({ + instanceId: target.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([ + { id: "browser", name: "Browser", description: null, type: "agent" }, + ]), + authenticate: () => Effect.never, + logout: Effect.void, + }); + const peerAuth = yield* ProviderAuthFlow.make({ + instanceId: peer.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([]), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const session = { + threadId: ThreadId.make("new-session"), + provider: peer.driverKind, + providerInstanceId: peer.instanceId, + status: "ready" as const, + runtimeMode: "approval-required" as const, + createdAt: "2026-09-21T00:00:00.000Z", + updatedAt: "2026-09-21T00:00:00.000Z", + }; + const start = vi.fn(() => Effect.succeed(session)); + const instances = [ + { ...target, auth }, + { ...peer, auth: peerAuth, adapter: { ...peer.adapter, startSession: start } }, + ]; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry.pipe( + Effect.provide( + ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive.pipe( + Layer.provide( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + listInstances: Effect.succeed(instances), + }), + ), + ), + ), + ); + const guarded = yield* registry.getByInstance(peer.instanceId); + assert.strictEqual(yield* registry.getByInstance(peer.instanceId), guarded); + const flow = yield* auth.start("owner"); + const error = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterValidationError"); + assert.strictEqual(start.mock.calls.length, 0); + yield* auth.cancel("owner", flow.flowId!); + assert.deepStrictEqual( + yield* guarded.startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }), + session, + ); + assert.strictEqual(start.mock.calls.length, 1); + const entered = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + start.mockImplementation(() => + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe(Effect.ensuring(Deferred.succeed(stopped, undefined))), + ); + const startup = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + // Signing out through another instance must drain its peer's startup too. + yield* auth.logout(Effect.void); + yield* Deferred.await(stopped); + assert.strictEqual(Exit.isFailure(yield* Fiber.await(startup)), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 9e8e3c5d1f90..5d70ee0d3d75 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,19 +15,89 @@ * * @module ProviderAdapterRegistryLive */ -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId, ProviderSetupError, type ProviderSession } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; -import { ProviderUnsupportedError } from "../Errors.ts"; +import { + ProviderUnsupportedError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const isSetupError = Schema.is(ProviderSetupError); + const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { const registry = yield* ProviderInstanceRegistry; + // Stable identity keeps ProviderService's event subscriptions attached once. + const guarded = new WeakMap>(); + const guard = (instance: ProviderInstance) => { + const auth = instance.auth; + if (!auth || (!auth.withAccess && !auth.isChangingCredentials && !auth.credentialBinding)) + return instance.adapter; + const cached = guarded.get(instance); + if (cached) return cached; + const adapter: ProviderAdapterShape = { + ...instance.adapter, + startSession: (input) => { + const start = Effect.gen(function* () { + const binding = auth.credentialBinding; + const related = binding + ? (yield* registry.listInstances).filter( + (peer) => + peer.auth?.credentialBinding?.key === binding.key && + peer.auth.credentialBinding.owner === binding.owner, + ) + : [instance]; + for (const peer of related) { + if (peer.auth?.isChangingCredentials && (yield* peer.auth.isChangingCredentials)) { + return yield* new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + } + } + let admitted: Effect.Effect< + ProviderSession, + ProviderAdapterError | ProviderSetupError, + Scope.Scope + > = instance.adapter.startSession(input); + // Every shared owner holds the startup scope. A credential change + // interrupts admitted startup before it can escape the session drain. + for (const peer of related) { + if (peer.auth?.withAccess) admitted = peer.auth.withAccess(admitted); + } + return yield* Effect.scoped(admitted); + }); + // Adapters own established session lifetimes. This scope guards startup; + // ProviderAuthService drains routed sessions before changing credentials. + return start.pipe( + Effect.mapError((cause) => + isSetupError(cause) + ? new ProviderAdapterValidationError({ + provider: instance.driverKind, + operation: "startSession", + issue: cause.detail, + }) + : cause, + ), + ); + }, + }; + guarded.set(instance, adapter); + return adapter; + }; const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => registry.getInstance(instanceId).pipe( @@ -38,7 +108,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun provider: instanceId, }), ) - : Effect.succeed(instance.adapter), + : Effect.succeed(guard(instance)), ), ); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.test.ts b/apps/server/src/provider/Layers/ProviderAuthService.test.ts index fb961b710486..8430ad3e1319 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.test.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.test.ts @@ -112,6 +112,14 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* directoryError?: ProviderSessionDirectoryPersistenceError; stopError?: ProviderServiceError; logoutError?: ProviderSetupError; + sharedCredentials?: boolean; + sharedBusy?: boolean; + sharedBusyEffect?: Effect.Effect; + responds?: boolean; + beforeLogout?: Effect.Effect; + beforeStop?: Effect.Effect; + beforeListSessions?: Effect.Effect; + onLookup?: Effect.Effect; } = {}, ) { const actions: string[] = []; @@ -138,6 +146,18 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }); const auth: ProviderAuthController = { + ...(input.sharedCredentials + ? { credentialBinding: { owner: "provider" as const, key: "shared" } } + : {}), + ...(input.responds + ? { + respond: Effect.fn(function* (ownerSessionId, request) { + yield* checkOwner(ownerSessionId, request.flowId, "respond"); + actions.push(`respond:${request.response.type}`); + return state; + }), + } + : {}), start: Effect.fn(function* (ownerSessionId, stopSessions) { gateClosed = true; actions.push("close-gate"); @@ -169,6 +189,7 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* actions.push("close-gate"); yield* stopSessions; if (input.logoutError) return yield* input.logoutError; + yield* input.beforeLogout ?? Effect.void; actions.push("native-logout"); state = idle; flowOwner = undefined; @@ -181,13 +202,34 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* const instances = [ makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), + ...(input.sharedCredentials + ? [ + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...auth, + isChangingCredentials: + input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), + invalidate: Effect.sync(() => { + actions.push("invalidate-shared"); + }), + }, + }), + ] + : []), ]; const service = yield* makeProviderAuthService.pipe( Effect.provide( Layer.mergeAll( Layer.mock(ProviderInstanceRegistry)({ getInstance: (id) => - Effect.succeed(instances.find((instance) => instance.instanceId === id)), + Effect.gen(function* () { + const found = instances.find((instance) => instance.instanceId === id); + yield* input.onLookup ?? Effect.void; + return found; + }), + listInstances: Effect.succeed(instances), subscribeChanges: PubSub.subscribe(registryChanges), }), Layer.mock(ProviderSessionDirectory)({ @@ -202,26 +244,44 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }), Layer.mock(ProviderService)({ listSessions: () => - Effect.sync(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push("list-sessions"); + yield* input.beforeListSessions ?? Effect.void; return [...sessions.values()]; }), stopSession: ({ threadId }) => - Effect.suspend(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push(`stop:${threadId}`); - if (input.stopError) return Effect.fail(input.stopError); + yield* input.beforeStop ?? Effect.void; + if (input.stopError) return yield* input.stopError; sessions.delete(threadId); const binding = bindings.get(threadId); if (binding) bindings.set(threadId, { ...binding, status: "stopped" }); - return Effect.void; }), }), ), ), ); - return { service, actions, sessions, bindings }; + return { + service, + actions, + sessions, + bindings, + auth, + addInstance: (instance: ProviderInstance) => instances.push(instance), + replaceInstance: (replacement: ProviderInstance) => { + const index = instances.findIndex( + (instance) => instance.instanceId === replacement.instanceId, + ); + assert.isAtLeast(index, 0); + instances[index] = replacement; + }, + replaceAuth: (next: ProviderAuthController) => { + instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); + }, + }; }); const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( @@ -305,6 +365,56 @@ const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* }); describe("ProviderAuthService", () => { + it.effect( + "stops sessions sharing credentials and invalidates their processes before logout", + () => + Effect.gen(function* () { + const { service, actions, sessions } = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target"), + makeSession("shared", otherInstanceId), + makeSession("unrelated", unsupportedInstanceId), + ], + }); + yield* service.logout({ instanceId }); + assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); + assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); + assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); + }), + ); + it.effect("rejects overlapping changes to a shared sign-in", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ + sharedCredentials: true, + sharedBusy: true, + }); + for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { + const error = yield* task.pipe(Effect.flip); + assert.include(error.detail, "shared sign-in"); + } + assert.deepStrictEqual(actions, []); + }), + ); + it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ responds: true }); + yield* service.start({ instanceId }, owner); + const request = { + instanceId, + flowId, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); + assert.strictEqual(rejected.operation, "respond"); + yield* service.respond(request, owner); + assert.strictEqual(actions.at(-1), "respond:browser"); + const unsupported = yield* makeHarness(); + const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); + assert.include(error.detail, "does not accept"); + }), + ); it.effect("stops routed sessions before sign-in, including for a disabled instance", () => Effect.gen(function* () { const { service, actions, sessions } = yield* makeHarness({ @@ -606,3 +716,200 @@ describe("ProviderAuthService", () => { }), ); }); + +it.effect("queued logout prompts resolve the current controller after provider replacement", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const lookup = yield* Deferred.make(); + let observeLookup = false; + const harness = yield* makeHarness({ + beforeLogout: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + onLookup: Effect.suspend(() => + observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, + ), + }); + const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + observeLookup = true; + const queued = yield* harness.service + .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) + .pipe(Effect.forkChild); + yield* Deferred.await(lookup); + let replacementLoggedOut = false; + harness.replaceAuth({ + ...harness.auth, + logout: (stopSessions) => + stopSessions.pipe( + Effect.andThen( + Effect.sync(() => { + replacementLoggedOut = true; + return idleAuthState; + }), + ), + ), + }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + assert.isTrue(yield* Fiber.join(queued)); + assert.isTrue(replacementLoggedOut); + assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); + }).pipe(Effect.scoped), +); + +it.effect.each(["start", "logout", "prompt"] as const)( + "%s uses the same credential controller for shared exclusion and mutation during replacement", + (action) => + Effect.gen(function* () { + const checked = yield* Deferred.make(); + const continueCheck = yield* Deferred.make(); + const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); + const harness = yield* makeHarness({ + sharedCredentials: true, + sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( + Effect.andThen(Deferred.await(continueCheck)), + Effect.as(false), + ), + sessions: [ + makeSession("old-shared", otherInstanceId), + makeSession("replacement-shared", replacementPeerId), + ], + }); + harness.addInstance( + makeInstance({ + instanceId: replacementPeerId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + isChangingCredentials: Effect.succeed(true), + invalidate: Effect.die( + "The unrelated replacement credential binding must stay intact.", + ), + }, + }), + ); + const operation = + action === "start" + ? harness.service.start({ instanceId }, owner) + : action === "logout" + ? harness.service.logout({ instanceId }) + : harness.service.tryHandlePromptCommand({ + instanceId, + text: "/logout", + hasAttachments: false, + }); + const running = yield* operation.pipe(Effect.forkChild); + yield* Deferred.await(checked); + let replacementMutations = 0; + harness.replaceAuth({ + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + start: () => + Effect.sync(() => { + replacementMutations++; + return waitingAuthState; + }), + logout: () => + Effect.sync(() => { + replacementMutations++; + return idleAuthState; + }), + }); + yield* Deferred.succeed(continueCheck, undefined); + yield* Fiber.join(running); + assert.equal(replacementMutations, 0); + assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); + assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); + assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); + const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); + assert.include(blocked.detail, "shared sign-in"); + assert.equal(replacementMutations, 0); + }).pipe(Effect.scoped), +); + +it.effect.each([ + { owner: "provider" as const, key: "different-binding" }, + { owner: "t3" as const, key: "shared" }, +])( + "does not invalidate a peer that switches credential binding during session draining %#", + (binding) => + Effect.gen(function* () { + const draining = yield* Deferred.make(); + const continueDrain = yield* Deferred.make(); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [makeSession("shared-draining", otherInstanceId)], + beforeStop: Deferred.succeed(draining, undefined).pipe( + Effect.andThen(Deferred.await(continueDrain)), + ), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(draining); + let replacementInvalidated = false; + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: binding, + invalidate: Effect.sync(() => { + replacementInvalidated = true; + }), + }, + }), + ); + yield* Deferred.succeed(continueDrain, undefined); + yield* Fiber.join(logout); + assert.isFalse(replacementInvalidated); + assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); + +it.effect.each(["selection", "drain"] as const)( + "preserves replacement peer sessions when credentials change during session %s", + (phase) => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const block = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target-draining"), + makeSession("peer-replacement", otherInstanceId), + ], + bindings: [ + makeBinding("target-draining", "running"), + makeBinding("peer-persisted", "running", otherInstanceId), + ], + ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-credentials" }, + invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), + }, + }), + ); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(logout); + assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); + assert.notInclude(harness.actions, "stop:peer-replacement"); + assert.notInclude(harness.actions, "stop:peer-persisted"); + assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts index 3b69c5082528..cec2a5878880 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -2,8 +2,9 @@ import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; -import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "../Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -12,6 +13,7 @@ export const makeProviderAuthService = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry; const providers = yield* ProviderService; const directory = yield* ProviderSessionDirectory; + const credentialChanges = yield* Semaphore.make(1); const getController = Effect.fn("ProviderAuthService.getController")(function* ( instanceId: ProviderInstanceId, @@ -30,9 +32,25 @@ export const makeProviderAuthService = Effect.gen(function* () { return instance.auth; }); + // Native sessions may still belong to the previous provider after the + // selected model changes. Read session bindings, not the selected model, + // when invalidating credentials for sign-in or sign-out. const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( instanceId: ProviderInstanceId, + binding: ProviderAuthService.ProviderAuthController["credentialBinding"], ) { + const affectedIds = new Set([ + instanceId, + ...(binding === undefined + ? [] + : (yield* registry.listInstances) + .filter( + (instance) => + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ) + .map((instance) => instance.instanceId)), + ]); const bindings = yield* directory.listBindings().pipe( Effect.mapError( () => @@ -44,39 +62,111 @@ export const makeProviderAuthService = Effect.gen(function* () { ), ); const sessions = yield* providers.listSessions(); - const threadIds = new Set( - bindings - .filter( - (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped", - ) - .map((binding) => binding.threadId), + const sessionsToStop = new Map( + bindings.flatMap((session) => + session.providerInstanceId !== undefined && + affectedIds.has(session.providerInstanceId) && + session.status !== "stopped" + ? [[session.threadId, session.providerInstanceId] as const] + : [], + ), ); for (const session of sessions) { - if (session.providerInstanceId === instanceId) { - threadIds.add(session.threadId); + if (session.providerInstanceId !== undefined && affectedIds.has(session.providerInstanceId)) { + sessionsToStop.set(session.threadId, session.providerInstanceId); } } yield* Effect.forEach( - threadIds, - (threadId) => - providers.stopSession({ threadId }).pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation: "stopSessions", - detail: "Could not stop all sessions for this provider. Try again.", - }), - ), - ), + sessionsToStop, + ([threadId, sessionInstanceId]) => + Effect.gen(function* () { + if (sessionInstanceId !== instanceId) { + const current = yield* registry.getInstance(sessionInstanceId); + if ( + !binding || + current?.auth?.credentialBinding?.key !== binding.key || + current.auth.credentialBinding.owner !== binding.owner + ) + return; + } + yield* providers.stopSession({ threadId }).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Could not stop all sessions for this provider. Try again.", + }), + ), + ); + }), { discard: true }, ); + if (binding) { + yield* Effect.forEach( + (yield* registry.listInstances).filter( + (instance) => + instance.instanceId !== instanceId && + affectedIds.has(instance.instanceId) && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ), + (instance) => instance.auth?.invalidate ?? Effect.void, + { discard: true }, + ); + } }); - return ProviderAuthService.of({ + const checkSharedBinding = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + operation: "start" | "logout", + auth: ProviderAuthService.ProviderAuthController, + ) { + const binding = auth.credentialBinding; + if (!binding) return; + const instances = yield* registry.listInstances; + for (const instance of instances) { + if ( + instance.instanceId !== instanceId && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner && + instance.auth.isChangingCredentials && + (yield* instance.auth.isChangingCredentials) + ) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: + "Another provider instance is changing this shared sign-in. Finish or cancel it first.", + }); + } + } + }); + + return ProviderAuthService.ProviderAuthService.of({ start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { - const auth = yield* getController(input.instanceId, "start"); - return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "start"); + yield* checkSharedBinding(input.instanceId, "start", auth); + return yield* auth.start( + ownerSessionId, + stopSessions(input.instanceId, auth.credentialBinding), + input.methodId, + ); + }), + ); + }), + respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "respond"); + if (!auth.respond) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "respond", + detail: "This provider does not accept this sign-in interaction.", + }); + } + return yield* auth.respond(ownerSessionId, input); }), complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { const auth = yield* getController(input.instanceId, "complete"); @@ -87,8 +177,13 @@ export const makeProviderAuthService = Effect.gen(function* () { return yield* auth.cancel(ownerSessionId, input.flowId); }), logout: Effect.fn("ProviderAuthService.logout")(function* (input) { - const auth = yield* getController(input.instanceId, "logout"); - return yield* auth.logout(stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + yield* checkSharedBinding(input.instanceId, "logout", auth); + return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + }), + ); }), subscribe: (input, ownerSessionId) => Effect.gen(function* () { @@ -110,11 +205,21 @@ export const makeProviderAuthService = Effect.gen(function* () { if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { return false; } - yield* instance.auth.logout(stopSessions(input.instanceId)); - return true; + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; + yield* checkSharedBinding(input.instanceId, "logout", auth); + yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + return true; + }), + ); }, ), }); }); -export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService); +export const ProviderAuthServiceLive = Layer.effect( + ProviderAuthService.ProviderAuthService, + makeProviderAuthService, +); diff --git a/apps/server/src/provider/ProviderAuthFlow.test.ts b/apps/server/src/provider/ProviderAuthFlow.test.ts new file mode 100644 index 000000000000..d12b6967c82e --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.test.ts @@ -0,0 +1,520 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ProviderInstanceId, + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthResponse, + type ProviderAuthState, +} from "@t3tools/contracts"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; + +const instanceId = ProviderInstanceId.make("auth-flow-test"); +const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; + +it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () => + Effect.gen(function* () { + const discovered = yield* Deferred.make>(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Deferred.await(discovered), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const pending = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isUndefined(pending.methods); + yield* Deferred.succeed(discovered, []); + const ready = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.methods !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepEqual(ready.methods, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeHarness = Effect.gen(function* () { + const approved = yield* Deferred.make(); + const verified = yield* Deferred.make(); + const started = yield* Deferred.make(); + let attempts = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + attempts++; + yield* context.setInteraction( + { + type: "browser", + id: "consent", + url: "https://example.com/login", + requiresConsent: true, + }, + (response) => + response.type === "browser" && response.action === "accept" + ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(approved); + yield* context.verifying; + yield* Deferred.await(verified); + }), + logout: Effect.void, + }); + const phase = (phase: ProviderAuthState["phase"], owner = "owner") => + controller.subscribe(owner).pipe( + Stream.filter((state) => state.phase === phase), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + return { controller, phase, started, verified, attempts: () => attempts }; +}); + +it.effect("requires owner consent and provider verification before success", () => + Effect.gen(function* () { + const { controller, phase, started, verified, attempts } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + const response = { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const other = yield* phase("waiting", "other"); + assert.isNull(other.interaction); + assert.isNull(other.flowId); + assert.isTrue( + (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure", + ); + assert.isTrue( + (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe( + Effect.result, + ))._tag === "Failure", + ); + yield* controller.start("owner"); + assert.strictEqual(attempts(), 1); + yield* controller.respond!("owner", response); + yield* phase("verifying"); + yield* Deferred.succeed(verified, undefined); + assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancellation rejects late consent and permits another login", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* controller.cancel("owner", state.flowId!); + assert.strictEqual((yield* phase("cancelled")).phase, "cancelled"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + const next = yield* controller.start("other"); + assert.notStrictEqual(next.flowId, state.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("expires pending login and rejects its response", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* TestClock.adjust(300_001); + assert.include((yield* phase("failed")).message ?? "", "expired"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closes admitted provider processes and blocks new ones while login is pending", () => + Effect.gen(function* () { + const { controller, started } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.start("owner"); + yield* Deferred.await(started); + assert.isTrue(closed); + assert.isTrue( + (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => + Effect.gen(function* () { + const { controller } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.invalidate!; + assert.isTrue(closed); + assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + interaction: { type: "terminal", id: "terminal", output: "Log in" }, + response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } }, + }, + { + interaction: { + type: "credentials", + id: "credentials", + fields: [{ name: "token", label: "Token", secret: true }], + }, + response: { type: "credentials", values: { token: "private-token" } }, + }, +] satisfies ReadonlyArray<{ + interaction: ProviderAuthInteraction; + response: ProviderAuthResponse; +}>)( + "keeps $interaction.type responses private and waits for adapter verification", + ({ interaction, response }) => + Effect.gen(function* () { + const received = yield* Deferred.make(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "binding" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction(interaction, (response) => + Deferred.succeed(received, response).pipe(Effect.asVoid), + ); + yield* Deferred.await(received); + yield* context.verifying; + return yield* Effect.never; + }), + logout: Effect.void, + }); + const state = yield* controller.start("owner"); + const pending = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepStrictEqual(pending.interaction, interaction); + const hidden = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(hidden.interaction); + yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: interaction.id, + response, + }); + assert.deepStrictEqual(yield* Deferred.await(received), response); + const verifying = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "verifying"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.isNull(verifying.interaction); + assert.notInclude(verifying.message ?? "", "private-token"); + yield* controller.cancel("owner", state.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shows a device code only to its owner without treating it as authenticated", () => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "device" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + context + .setInteraction({ + type: "deviceCode", + id: "code", + url: "https://example.com/device", + userCode: "ABCD-EFGH", + }) + .pipe(Effect.andThen(Effect.never)), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + const waiting = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(waiting.interaction?.type, "deviceCode"); + assert.strictEqual(waiting.authorizationUrl, "https://example.com/device"); + const other = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(other.interaction); + assert.isNull(other.authorizationUrl); + yield* controller.cancel("owner", start.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + failure: Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "authenticate", + detail: "This account cannot sign in. Choose another account.", + }), + ), + message: "This account cannot sign in. Choose another account.", + }, + { + failure: Effect.die(new Error("private-token in native diagnostics")), + message: "Sign-in failed. Start again.", + }, +])("publishes only safe authentication failure text %#", ({ failure, message }) => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "failure" }, + methods: Effect.succeed([method]), + authenticate: () => failure, + logout: Effect.void, + }); + yield* controller.start("owner"); + const failed = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(failed.message, message); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeBlockingResponseHarness = Effect.gen(function* () { + const entered = yield* Deferred.make(); + const cleanupStarted = yield* Deferred.make(); + const cleanupReleased = yield* Deferred.make(); + const cleanupFinished = yield* Deferred.make(); + const authenticationFinished = yield* Deferred.make(); + let responses = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "blocked-response" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction( + { + type: "browser", + id: "blocked", + url: "https://example.com/login", + requiresConsent: true, + }, + () => + Effect.gen(function* () { + responses++; + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* Deferred.succeed(cleanupStarted, undefined); + yield* Deferred.await(cleanupReleased); + yield* Deferred.succeed(cleanupFinished, undefined); + }), + ), + ), + ); + yield* Deferred.await(authenticationFinished); + }), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + ); + const input = { + instanceId, + flowId: start.flowId!, + interactionId: "blocked", + response: { type: "browser" as const, action: "accept" as const }, + }; + const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(entered); + return { + controller, + input, + response, + cleanupStarted, + cleanupReleased, + cleanupFinished, + authenticationFinished, + responses: () => responses, + }; +}); + +it.effect.each(["cancel", "logout"] as const)( + "%s interrupts and awaits a blocked adapter response before admitting another sign-in", + (action) => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input)); + assert.include(duplicate.detail, "already in progress"); + assert.equal(harness.responses(), 1); + const stopping = yield* ( + action === "cancel" + ? harness.controller.cancel("owner", harness.input.flowId) + : harness.controller.logout(Effect.void) + ).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + assert.isUndefined(stopping.pollUnsafe()); + const premature = yield* Effect.flip(harness.controller.start("other")); + assert.include(premature.detail, "in progress"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(stopping); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + assert.isFalse(yield* harness.controller.isChangingCredentials!); + assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("timeout interrupts and drains a blocked response before publishing failure", () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* TestClock.adjust(300_001); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + const duringCleanup = yield* harness.controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.notEqual(duringCleanup.phase, "failed"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const failed = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.include(failed.message ?? "", "expired"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "successful authentication drains a still-running response before admitting provider access", + () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* Deferred.succeed(harness.authenticationFinished, undefined); + yield* Deferred.await(harness.cleanupStarted); + const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void)); + assert.include(denied.detail, "changing"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const succeeded = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "succeeded"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(succeeded.phase, "succeeded"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + yield* harness.controller.withAccess!(Effect.void); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closing the controller scope interrupts and drains its adapter response", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const harness = yield* makeBlockingResponseHarness.pipe( + Effect.provideService(Scope.Scope, scope), + ); + const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isUndefined(closing.pollUnsafe()); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(closing); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("a failed method refresh keeps the last discovered methods", () => + Effect.gen(function* () { + let fail = false; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.suspend(() => + fail + ? Effect.fail( + new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), + ) + : Effect.succeed([method]), + ), + authenticate: () => Effect.void, + logout: Effect.void, + }); + yield* controller.refreshMethods!; + fail = true; + yield* controller.refreshMethods!; + const state = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.deepEqual(state.methods, [method]); + assert.strictEqual(state.message, "interrupted"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope)); + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* Scope.close(scope, Exit.void); + assert.isFalse(closed); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ProviderAuthFlow.ts b/apps/server/src/provider/ProviderAuthFlow.ts new file mode 100644 index 000000000000..f01ce92e3fb5 --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.ts @@ -0,0 +1,463 @@ +import { + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthMethod, + type ProviderAuthResponse, + type ProviderAuthState, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; + +export interface ProviderAuthFlowContext { + readonly flowId: string; + readonly setInteraction: ( + interaction: ProviderAuthInteraction, + respond?: (response: ProviderAuthResponse) => Effect.Effect, + ) => Effect.Effect; + readonly verifying: Effect.Effect; +} + +const isSetupError = Schema.is(ProviderSetupError); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.findErrorOption(cause); + return Option.isSome(error) && isSetupError(error.value) + ? error.value.detail + : "Sign-in failed. Start again."; +}; + +interface Flow { + readonly id: string; + readonly owner: string; + readonly expiresAt: number; + fiber?: Fiber.Fiber; + responseFiber?: Fiber.Fiber; + respond: + | ((response: ProviderAuthResponse) => Effect.Effect) + | undefined; +} + +/** Adapters do login and credential handling; this owns client consent and flow lifetime. */ +export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly credentialBinding: NonNullable< + ProviderAuthService.ProviderAuthController["credentialBinding"] + >; + readonly methods: Effect.Effect, ProviderSetupError>; + readonly defaultMethodId?: string; + /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ + readonly authenticate: ( + methodId: string, + context: ProviderAuthFlowContext, + ) => Effect.Effect; + readonly logout: Effect.Effect; + readonly timeoutMs?: number; +}) { + const scope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + const timeoutMs = options.timeoutMs ?? 300_000; + const empty: ProviderAuthState = { + instanceId: options.instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + interaction: null, + credentialOwner: options.credentialBinding.owner, + }; + const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty }); + let active: Flow | undefined; + let operation: "idle" | "auth" | "stopping" | "closed" = "idle"; + const sessions = new Set(); + const stopOwnedSessions = Effect.suspend(() => + Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { + discard: true, + concurrency: "unbounded", + }), + ); + const publish = (flow: Flow, patch: Partial) => + Effect.suspend(() => + active === flow + ? SubscriptionRef.update(snapshot, (current) => ({ + owner: flow.owner, + state: { ...current.state, ...patch }, + })) + : Effect.void, + ); + + const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) { + if ( + operation !== "auth" || + !active || + active.owner !== owner || + active.id !== id || + (yield* Clock.currentTimeMillis) >= active.expiresAt + ) { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in is no longer active in this client.", + }); + } + return active; + }); + + // Method discovery does not start a sign-in flow. + const refreshMethods = options.methods.pipe( + Effect.flatMap((methods) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods }, + })), + ), + // Keep the last known methods when a refresh fails or is interrupted. + Effect.catch((error) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods: current.state.methods ?? [], message: error.detail }, + })), + ), + ); + yield* refreshMethods.pipe(Effect.forkIn(scope)); + + const controller: ProviderAuthService.ProviderAuthController = { + credentialBinding: options.credentialBinding, + refreshMethods, + invalidate: lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") return; + yield* stopOwnedSessions; + yield* SubscriptionRef.set(snapshot, { + owner: null, + state: { + ...empty, + methods: snapshot.value.state.methods ?? [], + message: "This provider's shared sign-in changed.", + }, + }); + }), + ), + isChangingCredentials: Effect.sync(() => operation !== "idle"), + withAccess: (task) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const parent = yield* Scope.Scope; + const child = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + const child = yield* Scope.make(); + sessions.add(child); + yield* Scope.addFinalizer( + child, + Effect.sync(() => { + sessions.delete(child); + }), + ); + yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); + return child; + }), + ); + const fiber = yield* restore(task).pipe( + Effect.provideService(Scope.Scope, child), + Effect.forkIn(child), + ); + return yield* restore(Fiber.await(fiber)).pipe( + Effect.flatMap((result) => result), + Effect.onExit((result) => + Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, + ), + ); + }), + ), + start: (owner, stopSessions = Effect.void, selectedMethodId) => + lock.withPermit( + Effect.gen(function* () { + if (operation === "auth" && active?.owner === owner) return snapshot.value.state; + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Provider setup is already in progress.", + }); + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Could not start sign-in. Try again.", + }), + ), + ); + const flow: Flow = { + id, + owner, + expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs, + respond: undefined, + }; + active = flow; + operation = "auth"; + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: "starting", + flowId: id, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)), + message: "Starting sign-in.", + }; + yield* SubscriptionRef.set(snapshot, { owner, state }); + flow.fiber = yield* Effect.gen(function* () { + const methods = yield* options.methods; + yield* publish(flow, { methods }); + const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id; + if (!methodId || !methods.some((method) => method.id === methodId)) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "The provider did not advertise this sign-in method.", + }); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + yield* options.authenticate(methodId, { + flowId: id, + setInteraction: (interaction, respond) => + Effect.gen(function* () { + if (active !== flow) return; + flow.respond = respond; + yield* publish(flow, { + phase: "waiting", + interaction, + authorizationUrl: + interaction.type === "browser" || interaction.type === "deviceCode" + ? interaction.url + : null, + message: "Complete sign-in to continue.", + }); + }), + verifying: Effect.gen(function* () { + flow.respond = undefined; + yield* publish(flow, { + phase: "verifying", + interaction: null, + authorizationUrl: null, + message: "Checking provider sign-in.", + }); + }), + }); + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Sign-in expired. Start again.", + }), + ), + }), + Effect.exit, + Effect.flatMap((result) => + Effect.gen(function* () { + const finishing = yield* lock.withPermit( + Effect.sync(() => { + if (active !== flow) return false; + operation = "stopping"; + return true; + }), + ); + if (!finishing) return; + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + yield* lock.withPermit( + Effect.gen(function* () { + if (active !== flow) return; + yield* publish(flow, { + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? "Sign-in complete." + : failureMessage(result.cause), + }); + active = undefined; + operation = "idle"; + }), + ); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), + ); + return state; + }).pipe(Effect.uninterruptible), + ), + respond: (owner, input) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const { flow, fiber, ready } = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, input.flowId); + const interaction = snapshot.value.state.interaction; + if ( + !interaction || + interaction.id !== input.interactionId || + interaction.type !== input.response.type || + !flow.respond + ) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in interaction is no longer available.", + }); + if (flow.responseFiber) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "A sign-in response is already in progress.", + }); + const ready = yield* Deferred.make(); + const callback = flow.respond; + const fiber = yield* Deferred.await(ready).pipe( + Effect.andThen(Effect.suspend(() => callback(input.response))), + Effect.interruptible, + Effect.forkIn(scope), + ); + flow.responseFiber = fiber; + return { flow, fiber, ready }; + }), + ); + yield* Deferred.succeed(ready, undefined); + yield* restore(Fiber.join(fiber)).pipe( + Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)), + Effect.ensuring( + lock.withPermit( + Effect.sync(() => { + if (flow.responseFiber === fiber) delete flow.responseFiber; + }), + ), + ), + ); + return snapshot.value.state; + }), + ), + complete: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "complete", + detail: "This provider does not accept a pasted redirect URL.", + }), + ), + cancel: (owner, id) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, id); + yield* publish(flow, { + phase: "cancelled", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: "Sign-in cancelled.", + }); + active = undefined; + operation = "stopping"; + return flow; + }), + ); + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + operation = "idle"; + return snapshot.value.state; + }).pipe(Effect.uninterruptible), + logout: (stopSessions) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle" && operation !== "auth") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: "Provider setup is already stopping.", + }); + operation = "stopping"; + const flow = active; + active = undefined; + return flow; + }), + ); + const result = yield* Effect.gen(function* () { + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + yield* options.logout; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) ? "Signed out." : "Could not sign out. Try again.", + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }).pipe(Effect.uninterruptible), + subscribe: (owner) => + SubscriptionRef.changes(snapshot).pipe( + Stream.map((current) => + current.owner === null || current.owner === owner + ? current.state + : { + ...current.state, + flowId: null, + authorizationUrl: null, + interaction: null, + expiresAt: null, + message: active + ? "Sign-in is in progress in another client." + : current.state.message, + }, + ), + ), + }; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + operation = "closed"; + const flow = active; + active = undefined; + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + // Settings edits rebuild instances; admitted sessions (including those of + // peers sharing this binding) end with their own scopes, not this one. + }), + ); + return controller; +}); diff --git a/apps/server/src/provider/ProviderCredentialStore.test.ts b/apps/server/src/provider/ProviderCredentialStore.test.ts new file mode 100644 index 000000000000..0a5ad906e126 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; + +it.effect("isolates provider bindings and preserves opaque credentials", () => + Effect.gen(function* () { + const data = new Map(); + const secretStore = ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(data.get(name))), + set: (name, value) => + Effect.sync(() => { + data.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + data.delete(name); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + const a = yield* ProviderCredentialStore.make("cursor", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const b = yield* ProviderCredentialStore.make("cursor", "work").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const c = yield* ProviderCredentialStore.make("other", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const bytes = Uint8Array.from([0, 255, 128, 1]); + yield* a.set(bytes); + assert.deepStrictEqual(Option.getOrThrow(yield* a.get), bytes); + assert.isTrue(Option.isNone(yield* b.get)); + assert.isTrue(Option.isNone(yield* c.get)); + assert.isFalse(a.binding.key.includes("/")); + const long = yield* ProviderCredentialStore.make("a".repeat(64), "b".repeat(64)).pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.isBelow(long.binding.key.length, 255); + const delimiter = yield* ProviderCredentialStore.make("cur", "sor../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.notStrictEqual(delimiter.binding.key, a.binding.key); + yield* a.remove; + assert.isTrue(Option.isNone(yield* a.get)); + }), +); diff --git a/apps/server/src/provider/ProviderCredentialStore.ts b/apps/server/src/provider/ProviderCredentialStore.ts new file mode 100644 index 000000000000..259b441654c9 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.ts @@ -0,0 +1,21 @@ +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +/** A provider binding stores opaque bytes; only its adapter decodes or refreshes them. */ +export const make = Effect.fn("ProviderCredentialStore.make")(function* ( + driver: string, + bindingId: string, +) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + // Hash the tuple so arbitrary bindings cannot escape or exceed a filename. + const key = `provider-auth-${NodeCrypto.createHash("sha256") + .update(`${driver.length}:${driver}${bindingId}`) + .digest("hex")}`; + return { + binding: { owner: "t3" as const, key }, + get: secrets.get(key), + set: (credentials: Uint8Array) => secrets.set(key, credentials), + remove: secrets.remove(key), + }; +}); diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts index ea89edf78af5..93fd39fb3e3f 100644 --- a/apps/server/src/provider/Services/ProviderAuthService.ts +++ b/apps/server/src/provider/Services/ProviderAuthService.ts @@ -1,12 +1,28 @@ -import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; +import type { + ProviderAuthRespondInput, + ProviderAuthStartInput, + ProviderAuthState, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; +import type * as Scope from "effect/Scope"; export interface ProviderAuthController { + /** Equal keys mean these instances share credentials on this environment. */ + readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; + readonly isChangingCredentials?: Effect.Effect; + readonly invalidate?: Effect.Effect; + readonly refreshMethods?: Effect.Effect; + readonly withAccess?: ( + task: Effect.Effect, + ) => Effect.Effect; readonly start: ( ownerSessionId: string, stopSessions?: Effect.Effect, + methodId?: string, ) => Effect.Effect; readonly complete: ( ownerSessionId: string, @@ -16,6 +32,10 @@ export interface ProviderAuthController { ownerSessionId: string, flowId: string, ) => Effect.Effect; + readonly respond?: ( + ownerSessionId: string, + input: ProviderAuthRespondInput, + ) => Effect.Effect; /** The controller closes process admission before it stops routed sessions. */ readonly logout: ( stopSessions: Effect.Effect, @@ -30,13 +50,17 @@ interface ProviderAuthTarget { export interface ProviderAuthServiceShape { readonly start: ( - input: ProviderAuthTarget, + input: ProviderAuthStartInput, ownerSessionId: string, ) => Effect.Effect; readonly complete: ( input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, ownerSessionId: string, ) => Effect.Effect; + readonly respond: ( + input: ProviderAuthRespondInput, + ownerSessionId: string, + ) => Effect.Effect; readonly cancel: ( input: ProviderAuthTarget & { readonly flowId: string }, ownerSessionId: string, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 87ed56ddd46d..17500460d9ba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2497,6 +2497,15 @@ const makeWsRpcLayer = ( providerAuth.start(input, currentSessionId), { "rpc.aggregate": "provider" }, ), + [WS_METHODS.providerAuthRespond]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthRespond, + providerAuth.respond(input, currentSessionId), + { + "rpc.aggregate": "provider", + instanceId: input.instanceId, + }, + ), [WS_METHODS.providerAuthComplete]: (input) => observeRpcEffect( WS_METHODS.providerAuthComplete, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 18127076ff19..a7476ec17157 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -978,6 +978,10 @@ export function createServerEnvironmentAtoms( key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), }, }), + respondProviderAuth: createEnvironmentRpcCommand(runtime, { + label: "environment-data:provider:auth-respond", + tag: WS_METHODS.providerAuthRespond, + }), completeProviderAuth: createEnvironmentRpcCommand(runtime, { label: "environment-data:provider:auth-complete", tag: WS_METHODS.providerAuthComplete, diff --git a/packages/contracts/src/providerSetup.test.ts b/packages/contracts/src/providerSetup.test.ts new file mode 100644 index 000000000000..13fb3fca1f11 --- /dev/null +++ b/packages/contracts/src/providerSetup.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { ProviderAuthResponse, ProviderAuthState } from "./providerSetup.ts"; + +const decodeResponse = Schema.decodeUnknownSync(ProviderAuthResponse); +const decodeState = Schema.decodeUnknownSync(ProviderAuthState); + +describe("provider credential responses", () => { + it("accepts the advertised field limit and rejects oversized or invalid fields", () => { + const values = Object.fromEntries( + Array.from({ length: 16 }, (_, i) => [`field_${i}`, "value"]), + ); + expect(decodeResponse({ type: "credentials", values })).toEqual({ + type: "credentials", + values, + }); + expect(() => + decodeResponse({ type: "credentials", values: { ...values, extra: "value" } }), + ).toThrow(); + expect(() => decodeResponse({ type: "credentials", values: { "": "value" } })).toThrow(); + expect(() => + decodeResponse({ type: "credentials", values: { token: "x".repeat(16_385) } }), + ).toThrow(); + }); +}); + +describe("provider auth state", () => { + it("drops auth variants from newer servers instead of rejecting the state", () => { + const method = { id: "browser", name: "Browser", description: null, type: "agent" }; + expect( + decodeState({ + instanceId: "cursor", + phase: "waiting", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [method, { ...method, id: "passkey", type: "passkey" }], + interaction: { type: "passkey", id: "passkey" }, + credentialOwner: "keychain", + }), + ).toEqual({ + instanceId: "cursor", + phase: "waiting", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [method], + }); + }); +}); diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts index db6ce1f79aec..04261e934c89 100644 --- a/packages/contracts/src/providerSetup.ts +++ b/packages/contracts/src/providerSetup.ts @@ -1,6 +1,11 @@ import * as Schema from "effect/Schema"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + ForwardCompatibleArray, + ForwardCompatibleOptional, + IsoDateTime, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; export const ProviderSetupInput = Schema.Struct({ @@ -10,6 +15,88 @@ export type ProviderSetupInput = typeof ProviderSetupInput.Type; const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export const ProviderAuthMethod = Schema.Struct({ + id: SetupOperationId, + name: TrimmedNonEmptyString, + description: Schema.NullOr(Schema.String), + type: Schema.Literals(["agent", "terminal", "credentials"]), +}); +export type ProviderAuthMethod = typeof ProviderAuthMethod.Type; + +// These describe client interactions, not OAuth grant types. The provider +// adapter remains responsible for credentials, callbacks, and refresh. +export const ProviderAuthInteraction = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("browser"), + id: SetupOperationId, + url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), + requiresConsent: Schema.Boolean, + acceptsCallback: Schema.optionalKey(Schema.Boolean), + }), + Schema.Struct({ + type: Schema.Literal("deviceCode"), + id: SetupOperationId, + url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), + userCode: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), + Schema.Struct({ + type: Schema.Literal("terminal"), + id: SetupOperationId, + output: Schema.String.check(Schema.isMaxLength(16_384)), + outputOffset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }), + Schema.Struct({ + type: Schema.Literal("credentials"), + id: SetupOperationId, + fields: Schema.Array( + Schema.Struct({ + name: SetupOperationId, + label: TrimmedNonEmptyString, + secret: Schema.Boolean, + }), + ).check(Schema.isMaxLength(16)), + }), +]); +export type ProviderAuthInteraction = typeof ProviderAuthInteraction.Type; + +export const ProviderAuthResponse = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("browser"), + action: Schema.Literals(["accept", "decline"]), + }), + Schema.Struct({ + type: Schema.Literal("terminal"), + data: Schema.String.check(Schema.isMaxLength(4_096)), + size: Schema.optionalKey( + Schema.Struct({ + cols: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 500 })), + rows: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 })), + }), + ), + }), + Schema.Struct({ + type: Schema.Literal("credentials"), + values: Schema.Record(SetupOperationId, Schema.String.check(Schema.isMaxLength(16_384))).check( + Schema.isMaxProperties(16), + ), + }), +]); +export type ProviderAuthResponse = typeof ProviderAuthResponse.Type; + +export const ProviderAuthStartInput = Schema.Struct({ + instanceId: ProviderInstanceId, + methodId: Schema.optionalKey(SetupOperationId), +}); +export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type; + +export const ProviderAuthRespondInput = Schema.Struct({ + instanceId: ProviderInstanceId, + flowId: SetupOperationId, + interactionId: SetupOperationId, + response: ProviderAuthResponse, +}); +export type ProviderAuthRespondInput = typeof ProviderAuthRespondInput.Type; + export const ProviderAuthState = Schema.Struct({ instanceId: ProviderInstanceId, phase: Schema.Literals([ @@ -25,6 +112,13 @@ export const ProviderAuthState = Schema.Struct({ authorizationUrl: Schema.NullOr(Schema.String), expiresAt: Schema.NullOr(IsoDateTime), message: Schema.NullOr(Schema.String), + // Newer servers may add method types, interactions, or owners; older + // clients drop what they cannot decode instead of rejecting the state. + methods: Schema.optionalKey( + ForwardCompatibleArray(ProviderAuthMethod).check(Schema.isMaxLength(32)), + ), + interaction: ForwardCompatibleOptional(Schema.NullOr(ProviderAuthInteraction)), + credentialOwner: ForwardCompatibleOptional(Schema.Literals(["provider", "t3"])), }); export type ProviderAuthState = typeof ProviderAuthState.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a4210522073..dbc143048a72 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -6,6 +6,8 @@ import { ProviderAuthCancelInput, ProviderAuthCompleteInput, ProviderAuthState, + ProviderAuthStartInput, + ProviderAuthRespondInput, ProviderInstallCancelInput, ProviderInstallState, ProviderSetupError, @@ -299,6 +301,7 @@ export const WS_METHODS = { providerAuthStart: "provider.auth.start", providerConsumeResetCredit: "provider.consumeResetCredit", providerAuthComplete: "provider.auth.complete", + providerAuthRespond: "provider.auth.respond", providerAuthCancel: "provider.auth.cancel", providerAuthLogout: "provider.auth.logout", providerAuthSubscribe: "provider.auth.subscribe", @@ -499,7 +502,13 @@ const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeReset }); const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { - payload: ProviderSetupInput, + payload: ProviderAuthStartInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); + +const WsProviderAuthRespondRpc = Rpc.make(WS_METHODS.providerAuthRespond, { + payload: ProviderAuthRespondInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); @@ -1390,6 +1399,7 @@ export const WsRpcGroup = RpcGroup.make( WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, WsProviderAuthCompleteRpc, + WsProviderAuthRespondRpc, WsProviderAuthCancelRpc, WsProviderAuthLogoutRpc, WsProviderAuthSubscribeRpc, From ec28eefa0d017e2b0cb65ba4269cee1adddb0e98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:11:48 -0700 Subject: [PATCH 07/17] refactor(mobile): git sheets use uniwind platform variants instead of className ternaries (#13161) --- .../features/threads/git/GitBranchesSheet.tsx | 87 +++------------ .../features/threads/git/GitCommitSheet.tsx | 104 ++++-------------- .../features/threads/git/GitConfirmSheet.tsx | 32 ++---- .../features/threads/git/GitOverviewSheet.tsx | 26 +---- .../threads/git/gitSheetComponents.tsx | 30 +---- 5 files changed, 57 insertions(+), 222 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 7ca5085965f5..d16b72d13d81 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -63,7 +63,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { return ( {Platform.OS === "android" ? ( @@ -83,31 +83,19 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - - + + New branch - - + + New worktree {Platform.OS === "android" ? ( @@ -157,7 +133,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBaseBranch} placeholder="main" accessibilityLabel="Worktree base branch" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> {Platform.OS === "android" ? ( New branch @@ -167,7 +143,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBranchName} placeholder="feature/mobile-thread" accessibilityLabel="Worktree branch name" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> - + Existing branches {branchesLoading ? ( - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -236,16 +196,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - + {branch.name} {subtitle} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 7f70a39d9372..19d77a327def 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -72,7 +72,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { return ( {Platform.OS === "android" ? ( @@ -92,35 +92,21 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - - + + Branch - + {gitStatus.data?.refName ?? "(detached HEAD)"} @@ -131,20 +117,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - + - + Files @@ -154,39 +130,19 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {!allSelected && isEditingFiles ? ( setExcludedFiles(new Set())} > - + Reset ) : null} setIsEditingFiles((current) => !current)} > - + {isEditingFiles ? "Done" : "Edit"} @@ -226,15 +182,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { - - + + Commit message - - + + void runCommitAction(true)} /> - + {Platform.OS === "android" ? ( @@ -130,49 +130,31 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { - + {Platform.OS !== "android" ? ( Confirm ) : null} - + {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} {sheetMenuItems.map(({ item, disabledReason }, index) => ( @@ -320,11 +316,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { {linkedPrChains.map((chain) => ( {chain.layers.length > 1 ? ( @@ -483,13 +475,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} {isInspector ? ( - + {Platform.OS === "android" ? ( {refreshMenu} ) : ( diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index daf494cb79cc..c358466e22d2 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -71,13 +71,7 @@ export function SheetActionButton(props: { export function MetaCard(props: { readonly label: string; readonly value: string }) { return ( - + {props.label} @@ -97,21 +91,11 @@ export function SheetListRow(props: { }) { return ( - + - + {props.title} {props.subtitle ? ( From 34096519121033ccf72cd43ea5350f9482df188a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:20:02 -0700 Subject: [PATCH 08/17] chore(mobile): name the two project favicon caches by their job (#13160) --- apps/mobile/src/components/ProjectFavicon.tsx | 2 +- apps/mobile/src/connection/environment-cache-store.ts | 9 ++++++--- ...he.test.ts => projectFaviconDatabaseCache.test.ts} | 2 +- ...FaviconCache.ts => projectFaviconDatabaseCache.ts} | 11 ++++++++--- .../projectFaviconRequests.test.ts} | 4 ++-- .../projectFaviconRequests.ts} | 8 ++++++++ apps/mobile/src/state/assets.ts | 4 ++-- apps/mobile/src/state/client-cache-state.ts | 6 +++--- 8 files changed, 31 insertions(+), 15 deletions(-) rename apps/mobile/src/lib/{projectFaviconCache.test.ts => projectFaviconDatabaseCache.test.ts} (97%) rename apps/mobile/src/lib/{projectFaviconCache.ts => projectFaviconDatabaseCache.ts} (90%) rename apps/mobile/src/{components/projectFaviconCache.test.ts => lib/projectFaviconRequests.test.ts} (98%) rename apps/mobile/src/{components/projectFaviconCache.ts => lib/projectFaviconRequests.ts} (87%) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f20..e1d883a01026 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -18,7 +18,7 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "../lib/projectFaviconRequests"; const EMPTY_FAVICON_URL = Atom.make(null); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index c4a7cbdcce06..44a1d3114532 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,7 +15,10 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; +import { + attachProjectFaviconDatabase, + projectFaviconDatabaseCache, +} from "../lib/projectFaviconDatabaseCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -130,7 +133,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -241,7 +244,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe( Effect.andThen(database.clearEnvironmentCache(environmentId)), Effect.mapError(mapDatabaseError("clear-environment")), ), diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts similarity index 97% rename from apps/mobile/src/lib/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts index adde56fbf0b1..1e7344a684c3 100644 --- a/apps/mobile/src/lib/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts @@ -27,7 +27,7 @@ vi.mock("expo-file-system", () => ({ }, })); -import { downscaleProjectFavicon } from "./projectFaviconCache"; +import { downscaleProjectFavicon } from "./projectFaviconDatabaseCache"; const png = "iVBORw0KGgoAAAAA"; const image = { url: "https://remote/icon.png" }; diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts similarity index 90% rename from apps/mobile/src/lib/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.ts index 26a6d848d11d..f24cb2b394f7 100644 --- a/apps/mobile/src/lib/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts @@ -1,5 +1,5 @@ import { - createProjectFaviconCache, + createProjectFaviconCache as createSharedProjectFaviconCache, createProjectFaviconImageLoader, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, @@ -78,8 +78,13 @@ export async function downscaleProjectFavicon( throw new Error("Project icon thumbnail exceeds the cache limit."); } -/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ -export const projectFaviconCache = createProjectFaviconCache({ +/** + * The database-backed project favicon cache. Named apart from the shared + * `createProjectFaviconCache` factory it calls and from the in-flight request + * registry in `projectFaviconRequests`. Rows live in `client_cache` so + * Settings → Client storage counts and clears them. + */ +export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({ storage: { list: () => runDatabase((database) => diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconRequests.test.ts similarity index 98% rename from apps/mobile/src/components/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconRequests.test.ts index d0582a8b5f5b..540a45473926 100644 --- a/apps/mobile/src/components/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.test.ts @@ -6,9 +6,9 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "./projectFaviconRequests"; -describe("project favicon cache", () => { +describe("project favicon requests", () => { it("ignores callbacks from a superseded URL", () => { const cacheKey = "environment-1:/workspace:v1-favicon.svg"; const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconRequests.ts similarity index 87% rename from apps/mobile/src/components/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconRequests.ts index da77d7613f2d..202142d175e1 100644 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.ts @@ -1,3 +1,11 @@ +/** + * Request bookkeeping behind `ProjectFavicon`: tracks the favicon URLs with + * mounted views per cache key, keeps one of them current, and remembers which + * keys have already loaded so superseded or already-shown loads settle without + * churn. This is not request coalescing — each mount still issues its own + * load — and it is not the cache; persistence lives in + * `projectFaviconDatabaseCache`. + */ export interface ProjectFaviconRequest { readonly cacheKey: string; readonly faviconUrl: string; diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 15cbd1d9a89f..b2fb759c5680 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -16,7 +16,7 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; @@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ - imageCache: projectFaviconCache, + imageCache: projectFaviconDatabaseCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, }); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index c210c54f2fdd..0060e6ab0789 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => Effect.promise(() => scope.type === "all" - ? projectFaviconCache.clearAll() - : projectFaviconCache.clearEnvironment(scope.environmentId), + ? projectFaviconDatabaseCache.clearAll() + : projectFaviconDatabaseCache.clearEnvironment(scope.environmentId), ).pipe( Effect.andThen(MobileDatabase), Effect.flatMap((database) => From 6dcde218ad6c07af4dde2a69517c9316adaf732f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:31:13 -0700 Subject: [PATCH 09/17] revert(mobile): git sheets back to Platform.OS ternaries (un-guarded uniwind variants broke both platforms) (#13169) --- .../features/threads/git/GitBranchesSheet.tsx | 87 ++++++++++++--- .../features/threads/git/GitCommitSheet.tsx | 104 ++++++++++++++---- .../features/threads/git/GitConfirmSheet.tsx | 32 ++++-- .../features/threads/git/GitOverviewSheet.tsx | 26 ++++- .../threads/git/gitSheetComponents.tsx | 30 ++++- 5 files changed, 222 insertions(+), 57 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index d16b72d13d81..7ca5085965f5 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -63,7 +63,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { return ( {Platform.OS === "android" ? ( @@ -83,19 +83,31 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - - + + New branch - - + + New worktree {Platform.OS === "android" ? ( @@ -133,7 +157,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBaseBranch} placeholder="main" accessibilityLabel="Worktree base branch" - className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" + className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} /> {Platform.OS === "android" ? ( New branch @@ -143,7 +167,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBranchName} placeholder="feature/mobile-thread" accessibilityLabel="Worktree branch name" - className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" + className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} /> - + Existing branches {branchesLoading ? ( - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -196,10 +236,16 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - + {branch.name} {subtitle} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 19d77a327def..7f70a39d9372 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -72,7 +72,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { return ( {Platform.OS === "android" ? ( @@ -92,21 +92,35 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - - + + Branch - + {gitStatus.data?.refName ?? "(detached HEAD)"} @@ -117,10 +131,20 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - + - + Files @@ -130,19 +154,39 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {!allSelected && isEditingFiles ? ( setExcludedFiles(new Set())} > - + Reset ) : null} setIsEditingFiles((current) => !current)} > - + {isEditingFiles ? "Done" : "Edit"} @@ -182,10 +226,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { - - + + Commit message - - + + void runCommitAction(true)} /> - + {Platform.OS === "android" ? ( @@ -130,31 +130,49 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { - + {Platform.OS !== "android" ? ( Confirm ) : null} - + {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} {sheetMenuItems.map(({ item, disabledReason }, index) => ( @@ -316,7 +320,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { {linkedPrChains.map((chain) => ( {chain.layers.length > 1 ? ( @@ -475,7 +483,13 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} {isInspector ? ( - + {Platform.OS === "android" ? ( {refreshMenu} ) : ( diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index c358466e22d2..daf494cb79cc 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -71,7 +71,13 @@ export function SheetActionButton(props: { export function MetaCard(props: { readonly label: string; readonly value: string }) { return ( - + {props.label} @@ -91,11 +97,21 @@ export function SheetListRow(props: { }) { return ( - + - + {props.title} {props.subtitle ? ( From 5d493d67cedc3401af42e77d6a238bc49204a09b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:32:50 -0700 Subject: [PATCH 10/17] docs(mobile): document the two mobile routes that intentionally skip deep links (#13164) --- apps/mobile/src/Stack.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 3de335cfd562..91845bb9d0ac 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -284,6 +284,15 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Diagnostics", }, }), + // Deliberately the one settings screen with no `linking:` path. Its params + // are a tap-time snapshot, not a stable resource address: `now` is the + // wall-clock of the tap, `environmentIds` is the usage screen's local + // filter selection, and the window id/kind identify freshly aggregated + // pools. React Navigation round-trips non-path params through the URL as + // query strings, so a path here would bake in a permanently stale + // timestamp and filter. The deep linkable surface is the list at + // `settings/usage`, which rebuilds this state and pushes the detail from a + // tapped account segment. SettingsUsageAccount: createNativeStackScreen({ screen: UsageLimitAccountScreen, options: { title: "Account" }, @@ -649,6 +658,14 @@ const RootStackConfig = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, options: SOLID_HEADER_OPTIONS, }), + // Deliberately the one root route with no `linking:` path. The route + // carries zero params: its content is a session object (staged model, + // provider groups, and live update callbacks) that the active + // ThreadComposer presents into ExistingThreadSettingsRouteProvider before + // pushing this screen — state no URL can reconstruct. Reached without a + // presented session the screen navigates straight back, so a path would + // only produce a flash-and-dismiss link. Deep links to a thread land on + // `threads/:environmentId/:threadId`, where this sheet is one tap away. ThreadSettingsSheet: createNativeStackScreen({ screen: ExistingThreadSettingsRouteScreen, options: { From 5821b778fd29e9c5b6945f4e7150f92d19a0a3da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:35:52 -0700 Subject: [PATCH 11/17] refactor(mobile): break module cycles with focused extractions (#13151) --- .../src/components/ConfirmDialog.types.ts | 18 ++ .../src/components/ConfirmDialogHost.tsx | 20 +- .../mobile/src/components/FilePreview.ios.tsx | 2 +- apps/mobile/src/components/FilePreview.tsx | 2 +- .../src/components/FilePreviewModal.tsx | 21 +- .../src/components/FilePreviewModal.types.ts | 21 ++ .../src/components/MaterialConfirmDialog.tsx | 2 +- .../MaterialSegmentedButtons.android.tsx | 2 +- .../MaterialSegmentedControl.android.tsx | 2 +- .../components/MaterialSegmentedControl.tsx | 2 +- .../src/components/MaterialSwitch.android.tsx | 2 +- .../src/components/MaterialSwitch.types.ts | 12 + .../src/components/MediaImagePreview.tsx | 2 +- .../src/components/SegmentedControl.tsx | 16 +- .../src/components/SegmentedControl.types.ts | 14 + apps/mobile/src/components/ThemedSwitch.tsx | 14 +- apps/mobile/src/dependency-graph.test.ts | 288 ++++++++++++++++++ .../features/review/reviewDiffRendering.tsx | 2 +- .../review/reviewHighlightedToken.types.ts | 6 + .../src/features/review/reviewWordDiffs.ts | 2 +- .../features/review/shikiReviewHighlighter.ts | 9 +- .../threads/new-task-flow-provider.tsx | 2 +- .../threads/use-legacy-plan-mode-enabled.ts | 2 +- apps/mobile/src/lib/attachmentUpload.test.ts | 2 +- apps/mobile/src/lib/attachmentUpload.ts | 2 +- .../lib/composerAttachmentPreviewRetention.ts | 28 ++ .../src/lib/localAttachmentPreview.test.ts | 2 +- apps/mobile/src/lib/localAttachmentPreview.ts | 2 +- .../src/state/composer-attachment-uploads.ts | 2 +- .../legacy-plan-mode.test.ts | 0 .../threads => state}/legacy-plan-mode.ts | 0 apps/mobile/src/state/thread-outbox-model.ts | 2 +- .../src/state/use-composer-drafts.test.ts | 2 +- apps/mobile/src/state/use-composer-drafts.ts | 21 +- .../src/state/use-thread-composer-state.ts | 2 +- 35 files changed, 431 insertions(+), 97 deletions(-) create mode 100644 apps/mobile/src/components/ConfirmDialog.types.ts create mode 100644 apps/mobile/src/components/FilePreviewModal.types.ts create mode 100644 apps/mobile/src/components/MaterialSwitch.types.ts create mode 100644 apps/mobile/src/components/SegmentedControl.types.ts create mode 100644 apps/mobile/src/dependency-graph.test.ts create mode 100644 apps/mobile/src/features/review/reviewHighlightedToken.types.ts create mode 100644 apps/mobile/src/lib/composerAttachmentPreviewRetention.ts rename apps/mobile/src/{features/threads => state}/legacy-plan-mode.test.ts (100%) rename apps/mobile/src/{features/threads => state}/legacy-plan-mode.ts (100%) diff --git a/apps/mobile/src/components/ConfirmDialog.types.ts b/apps/mobile/src/components/ConfirmDialog.types.ts new file mode 100644 index 000000000000..7a32787cc32d --- /dev/null +++ b/apps/mobile/src/components/ConfirmDialog.types.ts @@ -0,0 +1,18 @@ +export type ConfirmDialogRequest = { + readonly title: string; + readonly message?: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; + readonly onCancel?: () => void; +}; + +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index aa1653055b59..67e9d465c573 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; +import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; -export type ConfirmDialogRequest = { - readonly title: string; - readonly message?: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly destructive?: boolean; - readonly onConfirm: () => void; - readonly onCancel?: () => void; -}; - -export type TextInputDialogRequest = { - readonly title: string; - readonly initialValue: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly onConfirm: (value: string) => void; - readonly onCancel?: () => void; -}; +export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index e2bae6101b60..4f06319a6846 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; const NativeControls = requireNativeModule<{ presentFile( diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index 337699e8a61e..85286dbda891 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaImagePreview } from "./MediaImagePreview"; import { AppText as Text } from "./AppText"; diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 77dd5600bc15..0692109cacde 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,30 +1,13 @@ import { useIsFocused } from "@react-navigation/native"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import type { FileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { MediaActionsSource } from "../lib/mediaActions"; import { useRefreshAssetUrl } from "../state/assets"; import { FilePreview } from "./FilePreview"; +import type { FilePreviewSource } from "./FilePreviewModal.types"; -export interface ResolvedFilePreviewSource { - readonly kind: "image" | "pdf" | "document"; - readonly mimeType?: string; - readonly uri: string; - readonly name?: string; - readonly sourceIdentifier?: string; - readonly srcFragment?: string; - readonly actionsSource?: MediaActionsSource; -} - -export type FilePreviewSource = Omit & - ( - | { readonly uri: string } - | { readonly attachment: FileBackedComposerAttachment } - | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } - ); +export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; function ResolvedFilePreview(props: { readonly source: FilePreviewSource; diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/FilePreviewModal.types.ts new file mode 100644 index 000000000000..6d125125c80f --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.types.ts @@ -0,0 +1,21 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; + +import type { FileBackedComposerAttachment } from "../lib/composerImages"; +import type { MediaActionsSource } from "../lib/mediaActions"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf" | "document"; + readonly mimeType?: string; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: FileBackedComposerAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx index c21ad042c39c..7154c7d716d8 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; +import type { ConfirmDialogRequest } from "./ConfirmDialog.types"; export interface MaterialConfirmDialogProps { readonly request: Pick< diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx index ceea91b24447..e941fc52eef1 100644 --- a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ export function MaterialSegmentedButtons( diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index 16ec879821e1..76734e14d763 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx index eca51d2d3f26..0935da6cd66c 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -1,4 +1,4 @@ -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( _props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index 2bc9d11130d7..f08fa78690f7 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -1,6 +1,6 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; import { View } from "react-native"; -import type { ThemedSwitchProps } from "./ThemedSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; diff --git a/apps/mobile/src/components/MaterialSwitch.types.ts b/apps/mobile/src/components/MaterialSwitch.types.ts new file mode 100644 index 000000000000..657a2139a69e --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.types.ts @@ -0,0 +1,12 @@ +import type { SwitchProps } from "react-native"; + +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 03e317c296af..09bebcb28b66 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useMediaActions } from "../lib/mediaActions"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaActionsMenu } from "./MediaActionsMenu"; import { MediaSourceCaption } from "./MediaSourceCaption"; diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index e1e61cda26df..8e4d6c3130bc 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -3,21 +3,9 @@ import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-r import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; -export interface SegmentedControlProps { - readonly options: readonly { - readonly value: Value; - readonly label: string; - readonly accessibilityLabel?: string; - }[]; - readonly selected: Value; - readonly onSelect: (value: Value) => void; - /** Compact sizing applies to the non-Material control. */ - readonly size?: "default" | "compact"; - /** "tab" for the view switcher; filters stay plain buttons. */ - readonly role?: "tab" | "button"; - readonly className?: string; -} +export type { SegmentedControlProps } from "./SegmentedControl.types"; export function SegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/SegmentedControl.types.ts b/apps/mobile/src/components/SegmentedControl.types.ts new file mode 100644 index 000000000000..921dcdb1cbde --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.types.ts @@ -0,0 +1,14 @@ +export interface SegmentedControlProps { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** Compact sizing applies to the non-Material control. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +} diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index f0cf8701e50c..d88ace54cb49 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,17 +1,9 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Switch } from "react-native"; import { MaterialSwitch } from "./MaterialSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; -export type ThemedSwitchProps = Pick< - SwitchProps, - | "accessibilityHint" - | "accessibilityLabel" - | "disabled" - | "onValueChange" - | "style" - | "testID" - | "value" ->; +export type { ThemedSwitchProps } from "./MaterialSwitch.types"; export function ThemedSwitch(props: ThemedSwitchProps) { if (Platform.OS === "android") { diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts new file mode 100644 index 000000000000..f4c953925bcc --- /dev/null +++ b/apps/mobile/src/dependency-graph.test.ts @@ -0,0 +1,288 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * Dependency-graph guards for the mobile source tree (audit #13). + * + * 1. No circular imports, checked once per platform the way Metro resolves + * modules (`..*` before `.native.*` before the + * generic file). A cycle can hide behind platform resolution: a `.tsx` + * base importing a component whose `.android.tsx` variant type-imports + * back into the base is acyclic on iOS but circular on Android. + * Dynamic `import("...")` calls are excluded from this rule on purpose: + * they are the deliberate async escape hatch (e.g. composer-draft cleanup + * reaching `lib/attachmentUpload`), and Metro resolves them after both + * modules have initialized, so they cannot create an initialization cycle. + * + * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, + * `native`, and `components` still reach upward into `features` at known + * sites (the app composition root `lib/runtime.ts` legitimately wires + * feature layers). Ceilings count unique `from -> to` module pairs across + * all platforms, including dynamic imports, and may only shrink: when you + * remove one of these imports, lower the constant in the same PR. + * + * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up + * the `@t3tools/mobile` package test task). + */ + +const SOURCE_ROOT = __dirname; + +/** Metro candidate order per platform (platform, then native, then generic). */ +const PLATFORM_EXTENSION_ORDER = { + android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], + ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], +} as const; + +type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; + +const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray; + +const isGraphFile = (filePath: string): boolean => + /\.tsx?$/.test(filePath) && + !filePath.includes(".test.") && + !filePath.includes("test-support") && + !filePath.endsWith(".d.ts"); + +/** Files Metro would not even bundle for the other platform. */ +function isRelevantForPlatform(filePath: string, platform: Platform): boolean { + const name = NodePath.basename(filePath); + if (platform === "android") { + return !name.includes(".ios."); + } + return !name.includes(".android."); +} + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of NodeFS.readdirSync(dir)) { + const filePath = NodePath.join(dir, entry); + if (NodeFS.statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, out); + } else if (isGraphFile(filePath)) { + out.push(filePath); + } + } + return out; +} + +function resolveRelative( + fromFile: string, + specifier: string, + extensionOrder: ReadonlyArray, +): string | null { + if (!specifier.startsWith(".")) { + return null; + } + const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); + for (const ext of extensionOrder) { + for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) { + try { + if (NodeFS.statSync(candidate).isFile()) { + const resolved = NodePath.resolve(candidate); + return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + } + } catch { + // Candidate does not exist; try the next one. + } + } + } + return null; +} + +interface ParsedImport { + readonly specifier: string; + readonly isDynamic: boolean; +} + +function parseImports(source: string): ParsedImport[] { + const parsed: ParsedImport[] = []; + const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g; + const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; + const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + for (const match of source.matchAll(staticRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(bareRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(dynamicRe)) { + parsed.push({ specifier: match[1]!, isDynamic: true }); + } + return parsed; +} + +type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; + +function layerOf(relativePath: string): Layer { + const top = relativePath.split(NodePath.sep)[0]!; + if (top === "features") return "features"; + return top === "state" || top === "lib" || top === "components" || top === "native" + ? (top as Layer) + : "other"; +} + +interface PlatformGraph { + readonly platform: Platform; + readonly files: ReadonlyArray; + /** Static (type or value) import edges keyed by source file. */ + readonly staticEdges: ReadonlyMap>; + /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */ + readonly crossLayerEdges: ReadonlySet; +} + +function buildPlatformGraph(platform: Platform): PlatformGraph { + const extensionOrder = PLATFORM_EXTENSION_ORDER[platform]; + const files = collectSourceFiles(SOURCE_ROOT) + .filter((file) => isRelevantForPlatform(file, platform)) + .sort(); + const staticEdges = new Map(); + const crossLayerEdges = new Set(); + for (const file of files) { + const targets = new Set(); + for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { + const resolved = resolveRelative(file, specifier, extensionOrder); + if (resolved === null || resolved === file) { + continue; + } + const from = NodePath.relative(SOURCE_ROOT, file); + const to = NodePath.relative(SOURCE_ROOT, resolved); + const fromLayer = layerOf(from); + const toLayer = layerOf(to); + const upward = + (fromLayer === "state" || + fromLayer === "lib" || + fromLayer === "components" || + fromLayer === "native") && + (toLayer === "features" || (fromLayer === "lib" && toLayer === "state")); + if (upward) { + crossLayerEdges.add(`${from} -> ${to}`); + } + if (!isDynamic) { + targets.add(resolved); + } + } + staticEdges.set(file, [...targets]); + } + return { platform, files, staticEdges, crossLayerEdges }; +} + +/** Tarjan strongly-connected components, iterative to bound stack depth. */ +function findCycles(graph: PlatformGraph): ReadonlyArray> { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + let nextIndex = 0; + + const enter = (node: string): void => { + index.set(node, nextIndex); + low.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + }; + + for (const root of graph.files) { + if (index.has(root)) continue; + const work: Array<[string, number]> = [[root, 0]]; + enter(root); + while (work.length > 0) { + const frame = work[work.length - 1]!; + const neighbors = graph.staticEdges.get(frame[0]) ?? []; + let advanced = false; + for (let i = frame[1]; i < neighbors.length; i += 1) { + const child = neighbors[i]!; + if (!graph.staticEdges.has(child)) continue; + if (!index.has(child)) { + work[work.length - 1] = [frame[0], i + 1]; + work.push([child, 0]); + enter(child); + advanced = true; + break; + } else if (onStack.has(child)) { + low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!)); + } + } + if (advanced) continue; + work.pop(); + const parent = work[work.length - 1]; + if (parent) { + low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!)); + } + if (low.get(frame[0]) === index.get(frame[0])) { + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== frame[0]); + if (component.length > 1) { + cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); + } + } + } + } + return cycles; +} + +const graphs = PLATFORMS.map(buildPlatformGraph); + +/** Unique upward edge pairs across every platform, sorted for stable diffs. */ +function upwardEdges(): string[] { + const union = new Set(); + for (const graph of graphs) { + for (const edge of graph.crossLayerEdges) { + union.add(edge); + } + } + return [...union].sort(); +} + +describe("mobile dependency graph", () => { + it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { + const graph = graphs.find((candidate) => candidate.platform === platform)!; + // The graph must see real files; a resolution regression here would make + // both rules vacuously pass. + expect(graph.files.length).toBeGreaterThan(500); + expect(findCycles(graph)).toEqual([]); + }); + + it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { + const edges = upwardEdges(); + const edgesFor = (from: Layer, to: Layer): string[] => + edges.filter((edge) => { + const [source, target] = edge.split(" -> "); + return layerOf(source!) === from && layerOf(target!) === to; + }); + + const ceilings: ReadonlyArray = [ + // state -> features: thread ordering reaching the thread-list model, + // the incoming-share store, the connection controller hook, the + // terminal launch context, and the pending message feed. + // (legacy-plan-mode was pure model logic and moved into state/.) + ["state", "features", 6, "state must not add imports from features"], + // lib -> features: lib/runtime.ts is the app composition root and + // legitimately wires cloud/observability features; the appearance + // helpers and terminal preferences still need untangling. + ["lib", "features", 7, "lib must not add imports from features"], + // components -> features: mostly the appearance preferences provider + // and the layout toolbar bridges. + ["components", "features", 33, "components must not add imports from features"], + // native -> features: native glue reading appearance/keyboard/review features. + ["native", "features", 8, "native must not add imports from features"], + // lib -> state: attachment/session plumbing that predates the cycle + // cleanup; each remaining edge needs a real owner-side seam. + ["lib", "state", 11, "lib must not add imports from state"], + ]; + + for (const [from, to, ceiling, message] of ceilings) { + const layerEdges = edgesFor(from, to); + expect( + layerEdges.length, + `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, + ).toBeLessThanOrEqual(ceiling); + } + }); +}); diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index d00cf2be4daf..818f5ab8af47 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import type { ReviewRenderableLineRow } from "./reviewModel"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; export const REVIEW_MONO_FONT_FAMILY = Platform.select({ ios: "ui-monospace", diff --git a/apps/mobile/src/features/review/reviewHighlightedToken.types.ts b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts new file mode 100644 index 000000000000..58418a32cc7c --- /dev/null +++ b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts @@ -0,0 +1,6 @@ +export interface ReviewHighlightedToken { + content: string; + readonly color: string | null; + readonly fontStyle: number | null; + readonly diffHighlight?: boolean; +} diff --git a/apps/mobile/src/features/review/reviewWordDiffs.ts b/apps/mobile/src/features/review/reviewWordDiffs.ts index 34ac9bc7a746..8dd258a296cc 100644 --- a/apps/mobile/src/features/review/reviewWordDiffs.ts +++ b/apps/mobile/src/features/review/reviewWordDiffs.ts @@ -1,6 +1,6 @@ import { diffWordsWithSpace } from "diff"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; interface ReviewDiffOperation { readonly value: string; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 3050f1f67ee8..9b69c8d552ea 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr } } -export interface ReviewHighlightedToken { - content: string; - readonly color: string | null; - readonly fontStyle: number | null; - readonly diffHighlight?: boolean; -} +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; + +export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", 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 f2801255b3bc..4d069bca9d05 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -89,7 +89,7 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode, resolveProviderInteractionMode, -} from "./legacy-plan-mode"; +} from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 61c4fb65cdc9..684049155a9e 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; +import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; /** * Mobile preferences are device-local, matching the desktop client setting. diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 2c6b27864432..df68a2de3019 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ })); // The real read lease and cleanup are covered by the composer ownership suite. -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: () => () => {}, })); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 10f4fdb7c6c7..1bce3e26774f 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; import { isComposerImageAttachment, isFileBackedComposerAttachment, diff --git a/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts new file mode 100644 index 000000000000..ca9044f32dc6 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts @@ -0,0 +1,28 @@ +import type { FileBackedComposerAttachment } from "./composerImages"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; + +/** + * Preview retention for saved composer attachment copies. + * + * The durable owners of an attachment file (composer drafts, queued outbox + * messages) live in state, so this module cannot reach the ownership-cleanup + * sweep directly. Composer draft state registers the owner-side cleanup hook + * at module load; until then a release has nothing to retry, because no draft + * store has loaded yet and there is nothing to clean. + */ +type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void; + +let onAttachmentUnused: UnusedAttachmentHandler | null = null; + +export function registerComposerAttachmentUnusedHandler(handler: UnusedAttachmentHandler): void { + onAttachmentUnused = handler; +} + +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: FileBackedComposerAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + onAttachmentUnused?.(attachment); + }); +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 3693caa41185..602d5aada56b 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ exists: vi.fn(), })); -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: mocks.retain, })); vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 0d90f7210bab..171fc26c0566 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import type { FileBackedComposerAttachment } from "./composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; /** Retains the draft original for preview and gives each outgoing share its own lease. */ export async function loadLocalAttachmentPreview( diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 352367c09a15..dec31f9ce039 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; import { isFileBackedComposerAttachment } from "../lib/composerImages"; import { composerAttachmentUploadKey, @@ -20,7 +21,6 @@ import { composerDraftsAtom, ensureComposerDraftsLoaded, flushComposerDrafts, - retainComposerAttachmentFileForPreview, setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.test.ts rename to apps/mobile/src/state/legacy-plan-mode.test.ts diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.ts rename to apps/mobile/src/state/legacy-plan-mode.ts diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3d2231fb3ac3..c53dc34e7df4 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 9f5dad4d5600..fa6b2f303f70 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -179,7 +179,6 @@ import { removeComposerDraftsForEnvironment, replaceComposerDraftAttachments, resetComposerDraftsLoadState, - retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, @@ -194,6 +193,7 @@ import { undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; const DRAFT: ComposerDraft = { text: "hello", diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..de886d54fe91 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, - retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; +import { + registerComposerAttachmentUnusedHandler, + retainComposerAttachmentFileForPreview, +} from "../lib/composerAttachmentPreviewRetention"; import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } -/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ -export function retainComposerAttachmentFileForPreview( - attachment: FileBackedComposerAttachment, -): () => void { - return retainComposerAttachmentFile(attachment.fileUri, () => { - scheduleUnusedComposerAttachmentCleanup([attachment]); - }); -} +/** + * Owner-side cleanup hook for the shared preview-retention helper: releasing + * the last preview/upload lease retries the unused-file sweep. Registered here + * because this module owns the draft and outbox references the sweep reads. + */ +registerComposerAttachmentUnusedHandler((attachment) => { + scheduleUnusedComposerAttachmentCleanup([attachment]); +}); function schedulePersistComposerState(): void { if (persistTimer !== null) { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..051fe543ea2b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { convertPastedImagesToAttachments, createPastedTextComposerAttachment, From f22331240ed1a97fae560e314bffb75fe39db8ca Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 22 Sep 2026 19:40:40 -0500 Subject: [PATCH 12/17] fix(server): generate PR diffs from branch changes (#13170) --- apps/server/src/git/GitManager.test.ts | 17 ++++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 10 ++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6837d849d779..96d91ec170e9 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4071,7 +4071,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => + it.effect("generates PR content from branch changes when the remote base advances", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -4103,7 +4103,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n"); + yield* runGit(peerDir, ["add", "later-main.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Later main commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["fetch", "origin"]); + let generatedCommitSummary = ""; + let generatedDiffSummary = ""; + let generatedDiffPatch = ""; const { manager } = yield* makeManager({ ghScenario: { prListSequence: ["[]", "[]"], @@ -4111,6 +4119,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { textGeneration: { generatePrContent: (input) => { generatedCommitSummary = input.commitSummary; + generatedDiffSummary = input.diffSummary; + generatedDiffPatch = input.diffPatch; return Effect.succeed({ title: "Feature PR", body: "Feature body" }); }, }, @@ -4124,6 +4134,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("created"); expect(generatedCommitSummary).toContain("Feature commit"); expect(generatedCommitSummary).not.toContain("Remote base commit"); + expect(generatedCommitSummary).not.toContain("Later main commit"); + expect(generatedDiffSummary).toContain("feature.txt"); + expect(generatedDiffSummary).not.toContain("later-main.txt"); + expect(generatedDiffPatch).toContain("feature.txt"); + expect(generatedDiffPatch).not.toContain("later-main.txt"); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9f91264ee623..5fbae919c258 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2285,13 +2285,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; + const commitRange = `${baseRef}..HEAD`; + // PR diffs start at the common ancestor when the base branch has advanced. + const diffRange = `${baseRef}...HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", range], + ["log", "--oneline", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2300,7 +2302,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", range], + ["diff", "--stat", diffRange], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2309,7 +2311,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", range], + ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, From 68607c5a99147710b92228e0642f6c0a642e32a6 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 22 Sep 2026 19:41:43 -0500 Subject: [PATCH 13/17] fix(web): preserve nested scroll behavior in chat timeline (#13167) --- .../web/src/components/ChatView.logic.test.ts | 131 +--------------- apps/web/src/components/ChatView.logic.ts | 16 -- apps/web/src/components/ChatView.tsx | 20 ++- apps/web/src/components/chat/ChatComposer.tsx | 9 +- .../chat/timelineScrollTarget.test.ts | 142 ++++++++++++++++++ .../components/chat/timelineScrollTarget.ts | 31 ++++ 6 files changed, 194 insertions(+), 155 deletions(-) create mode 100644 apps/web/src/components/chat/timelineScrollTarget.test.ts create mode 100644 apps/web/src/components/chat/timelineScrollTarget.ts diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a2897ce3ddb0..a2708de8bda3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,7 +12,7 @@ import { TurnId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/unstable/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -86,7 +86,6 @@ import { shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, - toolGroupConsumesUpwardNavigation, waitForRevertedMessage, prepareRevertedMessageAttachments, } from "./ChatView.logic"; @@ -446,134 +445,6 @@ describe("proactive panels", () => { }); }); -describe("toolGroupConsumesUpwardNavigation", () => { - class ScrollElement extends EventTarget { - scrollTop = 0; - scrollHeight = 100; - clientHeight = 100; - overflowY = "visible"; - - constructor( - readonly parentElement: ScrollElement | null = null, - readonly isToolGroup = false, - ) { - super(); - } - - closest(selector: string): ScrollElement | null { - if (selector !== "[data-tool-group-scroll]") return null; - return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); - } - } - - beforeEach(() => { - vi.stubGlobal("Element", ScrollElement); - vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ - overflowY: element.overflowY, - })); - }); - afterEach(() => vi.unstubAllGlobals()); - - it("releases upward navigation when an overflowing group is at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each([ - { overflowY: "auto", scrollTop: 1 }, - { overflowY: "auto", scrollTop: 0.25 }, - { overflowY: "scroll", scrollTop: 80 }, - ])("consumes upward navigation within a scrolled group: %j", (scroll) => { - const group = Object.assign(new ScrollElement(null, true), { - scrollHeight: 300, - ...scroll, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); - }); - - it.each([100, 300])( - "consumes scrolling in a nested result with a group content height of %i", - (scrollHeight) => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 0.25, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); - }, - ); - - it("releases upward navigation when the group and nested result are both at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "scroll", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }); - - it("ignores targets outside a tool group and non-element targets", () => { - const outside = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); - expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); - expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); - }); - - it("does not consume scrolling from an ancestor beyond the tool group", () => { - const timeline = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - const group = new ScrollElement(timeline, true); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each(["hidden", "clip", "visible"])( - "ignores a non-scrollable child with overflow-y %s", - (overflowY) => { - const group = new ScrollElement(null, true); - const result = Object.assign(new ScrollElement(group), { - overflowY, - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }, - ); - - it("does not consume programmatic scrolling on an overflow-hidden group", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "hidden", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); - }); -}); - const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 928d99c549a6..528024de4393 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -239,22 +239,6 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: { }); } -export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { - const elementTarget = target instanceof Element ? target : null; - const group = elementTarget?.closest("[data-tool-group-scroll]"); - if (!group) return false; - - // A nested result or the group itself can consume an upward scroll. - for (let element = elementTarget; element; element = element.parentElement) { - if (element.scrollTop > 0) { - const overflowY = getComputedStyle(element).overflowY; - if (overflowY === "auto" || overflowY === "scroll") return true; - } - if (element === group) break; - } - return false; -} - export { findRecordedWorktreeSetup, resolveVisibleWorktreeSetup, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b97411e7a44b..3ce6566c041d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -365,6 +365,7 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; +import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -474,7 +475,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - toolGroupConsumesUpwardNavigation, waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; @@ -5466,6 +5466,8 @@ export default function ChatView(props: ChatViewProps) { // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) + return; if (event.deltaY > 0) { timelineScrollIntentRef.current = "toward-end"; if (isAtEndRef.current) { @@ -5474,11 +5476,7 @@ export default function ChatView(props: ChatViewProps) { } else if (event.deltaY < 0) { timelineScrollIntentRef.current = "away-from-end"; } - if ( - event.deltaY < 0 && - contentScrollsUp() && - !toolGroupConsumesUpwardNavigation(event.target) - ) { + if (event.deltaY < 0 && contentScrollsUp()) { handleManualNavigation(); } }; @@ -5528,12 +5526,20 @@ export default function ChatView(props: ChatViewProps) { ) { return; } + if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) + return; + const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; + if ( + scrollNode.contains(event.target) && + !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) + ) + return; switch (event.key) { case "PageUp": case "Home": case "ArrowUp": timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { + if (contentScrollsUp()) { handleManualNavigation(); composerRef.current?.collapseForTimelineScrollKey(event.key); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e258c995e573..7955e1e02b25 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -310,6 +310,7 @@ import { import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { pendingDraftWork } from "./pendingDraftWork"; +import { isTimelineScrollTarget } from "./timelineScrollTarget"; import { createComposerScrollGestureState, recordComposerScrollGestureEvent, @@ -4894,8 +4895,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const scrollNode = getTimelineScrollableNode(); if (!scrollNode) return; - const targetsTimeline = scrollNode.contains(event.target); - if (!targetsTimeline && !composerScrollGestureRef.current.collapseSuppressed) return; + const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); + if ( + !scrollNode.contains(event.target) && + !composerScrollGestureRef.current.collapseSuppressed + ) + return; if (composerScrollCollapseTimeoutRef.current !== null) { window.clearTimeout(composerScrollCollapseTimeoutRef.current); diff --git a/apps/web/src/components/chat/timelineScrollTarget.test.ts b/apps/web/src/components/chat/timelineScrollTarget.test.ts new file mode 100644 index 000000000000..051f7785f5a3 --- /dev/null +++ b/apps/web/src/components/chat/timelineScrollTarget.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createComposerScrollGestureState, + recordComposerScrollGestureEvent, +} from "./composerScrollGesture"; +import { isTimelineScrollTarget } from "./timelineScrollTarget"; + +class ScrollElement extends EventTarget { + scrollTop = 0; + scrollHeight = 100; + clientHeight = 100; + overflowY = "visible"; + overscrollBehaviorY = "auto"; + + constructor(readonly parentElement: ScrollElement | null = null) { + super(); + } + + contains(target: ScrollElement): boolean { + return ( + target === this || (target.parentElement !== null && this.contains(target.parentElement)) + ); + } +} + +function targetsTimeline(target: EventTarget | null, timeline: ScrollElement, deltaY: number) { + return isTimelineScrollTarget(target, timeline as unknown as HTMLElement, deltaY); +} + +function setup() { + const timeline = Object.assign(new ScrollElement(), { + overflowY: "auto", + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const group = Object.assign(new ScrollElement(timeline), { + overflowY: "auto", + scrollHeight: 300, + scrollTop: 80, + }); + return { timeline, group, content: new ScrollElement(group) }; +} + +beforeEach(() => { + vi.stubGlobal("Element", ScrollElement); + vi.stubGlobal("getComputedStyle", (element: ScrollElement) => element); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("timeline scroll targets", () => { + it.each([-30, 30])("keeps a nested tool group's scroll out of the timeline: %i", (deltaY) => { + const { timeline, group, content } = setup(); + expect(targetsTimeline(content, timeline, deltaY)).toBe(false); + expect(targetsTimeline(group, timeline, deltaY)).toBe(false); + }); + + it.each([ + { scrollTop: 0, deltaY: -30 }, + { scrollTop: 200, deltaY: 30 }, + ])("allows chaining only past the matching edge: %j", ({ scrollTop, deltaY }) => { + const { timeline, group, content } = setup(); + group.scrollTop = scrollTop; + expect(targetsTimeline(content, timeline, deltaY)).toBe(true); + expect(targetsTimeline(content, timeline, -deltaY)).toBe(false); + }); + + it.each(["contain", "none"])("respects overscroll-y %s at either edge", (overscrollBehaviorY) => { + const { timeline, group, content } = setup(); + group.overscrollBehaviorY = overscrollBehaviorY; + group.scrollTop = 0; + expect(targetsTimeline(content, timeline, -30)).toBe(false); + group.scrollTop = 200; + expect(targetsTimeline(content, timeline, 30)).toBe(false); + group.scrollTop = 0; + group.scrollHeight = group.clientHeight; + expect(targetsTimeline(content, timeline, 30)).toBe(false); + }); + + it("checks nested results even when the tool group cannot scroll", () => { + const { timeline, group } = setup(); + group.scrollTop = 0; + group.scrollHeight = group.clientHeight; + const result = Object.assign(new ScrollElement(group), { + overflowY: "scroll", + scrollHeight: 300, + scrollTop: 0.25, + }); + expect(targetsTimeline(new ScrollElement(result), timeline, -30)).toBe(false); + result.scrollTop = 0; + expect(targetsTimeline(result, timeline, -30)).toBe(true); + }); + + it("checks an outer group when an inner result reaches its edge", () => { + const { timeline, group } = setup(); + const result = Object.assign(new ScrollElement(group), { overflowY: "auto" }); + expect(targetsTimeline(result, timeline, -30)).toBe(false); + group.scrollTop = 0; + expect(targetsTimeline(result, timeline, -30)).toBe(true); + }); + + it.each(["visible", "hidden", "clip"])("ignores overflow-y %s", (overflowY) => { + const { timeline, group, content } = setup(); + group.overflowY = overflowY; + expect(targetsTimeline(content, timeline, -30)).toBe(true); + }); + + it("allows ordinary message content and the outer viewport", () => { + const { timeline } = setup(); + expect(targetsTimeline(new ScrollElement(timeline), timeline, -30)).toBe(true); + expect(targetsTimeline(timeline, timeline, 30)).toBe(true); + }); + + it("rejects outside targets, non-elements, and horizontal-only scrolling", () => { + const { timeline, content } = setup(); + expect(targetsTimeline(new ScrollElement(), timeline, -30)).toBe(false); + expect(targetsTimeline(new EventTarget(), timeline, -30)).toBe(false); + expect(targetsTimeline(null, timeline, -30)).toBe(false); + expect(targetsTimeline(content, timeline, 0)).toBe(false); + }); + + it("does not accumulate nested scrolling toward composer collapse", () => { + const { timeline, group, content } = setup(); + const state = createComposerScrollGestureState(); + const record = (target: ScrollElement, now: number, deltaPx: number) => + recordComposerScrollGestureEvent(state, { + now, + deltaPx, + collapseThresholdPx: 24, + collapseEligible: targetsTimeline(target, timeline, -deltaPx), + canScrollInGestureDirection: timeline.scrollTop > 0, + scrollsTowardLogicalEnd: false, + }); + + expect(record(timeline, 0, 20)).toBe(false); + expect(record(content, 20, 30)).toBe(false); + group.scrollTop = 0; + expect(record(content, 40, 10)).toBe(false); + expect(record(content, 60, 14)).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/timelineScrollTarget.ts b/apps/web/src/components/chat/timelineScrollTarget.ts new file mode 100644 index 000000000000..da87e9a7036d --- /dev/null +++ b/apps/web/src/components/chat/timelineScrollTarget.ts @@ -0,0 +1,31 @@ +// A gesture inside the timeline may belong to a nested tool result or code +// block. Only treat it as timeline navigation if it can chain to the outer list. +export function isTimelineScrollTarget( + target: EventTarget | null, + timeline: HTMLElement, + deltaY: number, +): boolean { + if (!(target instanceof Element) || !timeline.contains(target) || deltaY === 0) return false; + + for ( + let element: Element | null = target; + element && element !== timeline; + element = element.parentElement + ) { + const style = getComputedStyle(element); + if (style.overflowY !== "auto" && style.overflowY !== "scroll") continue; + + const canScroll = + deltaY < 0 + ? element.scrollTop > 0 + : element.scrollTop < element.scrollHeight - element.clientHeight; + if ( + canScroll || + style.overscrollBehaviorY === "contain" || + style.overscrollBehaviorY === "none" + ) { + return false; + } + } + return true; +} From b954af60c402ff0698f9f2d9a214695e817f07fe Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:45:50 -0700 Subject: [PATCH 14/17] test(web): cover usage model ordering without static markup (#13104) Co-authored-by: Claude Opus 5.5 --- .../src/components/usage/UsagePage.test.tsx | 231 ------------------ apps/web/src/components/usage/UsagePage.tsx | 5 +- .../components/usage/usageBreakdown.test.ts | 31 +++ .../src/components/usage/usageBreakdown.ts | 7 + packages/shared/src/usageMerge.test.ts | 23 ++ 5 files changed, 63 insertions(+), 234 deletions(-) delete mode 100644 apps/web/src/components/usage/UsagePage.test.tsx create mode 100644 apps/web/src/components/usage/usageBreakdown.test.ts create mode 100644 apps/web/src/components/usage/usageBreakdown.ts diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx deleted file mode 100644 index 4a00317c7e81..000000000000 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { mergeUsage } from "@t3tools/shared/usageMerge"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - useUsage: vi.fn(), - metric: "cost" as "cost" | "tokens" | "limits", - breakdown: "time" as "model" | "time", -})); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useState: vi.fn((initial: unknown) => [ - initial === readUsagePagePreferences - ? { metric: testState.metric, windowDays: 30 } - : typeof initial === "function" - ? { - days: 1, - window: { - sinceDay: "2026-08-10", - untilDay: "2026-08-11", - timeZone: "UTC", - resolution: "hour", - sinceTime: "2026-08-10T12:37:00.000Z", - untilTime: "2026-08-11T12:37:00.000Z", - }, - } - : initial === "cost" - ? testState.metric - : initial === "model" - ? testState.breakdown - : initial, - vi.fn(), - ]), - }; -}); - -vi.mock("../../env", () => ({ isElectron: false })); -vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); -vi.mock("../ui/button", () => ({ Button: "button", InlineButton: "button" })); -vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); -vi.mock("../ui/select", () => ({ - Select: "div", - SelectItem: "div", - SelectPopup: "div", - SelectTrigger: "div", - SelectValue: "div", -})); -vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); -vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); -vi.mock("../WorkspaceBreadcrumb", () => ({ - WorkspaceBreadcrumb: "div", - WorkspaceBreadcrumbItem: "div", - WorkspaceBreadcrumbSeparator: "span", -})); -vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); -vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); -vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); -vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); -vi.mock("./usageProviders", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - PROVIDER_PRESENTATION: { - codex: { color: "white", label: "Codex", mark: "span" }, - claude: { color: "orange", label: "Claude Code", mark: "span" }, - }, - }; -}); - -import { UsagePage } from "./UsagePage"; -import { readUsagePagePreferences } from "./usagePagePreferences"; - -const providerTotals = (codex: number, claude: number) => - new Map([ - ["codex", { costUsd: codex, totalTokens: codex * 1_000 }], - ["claude", { costUsd: claude, totalTokens: claude * 1_000 }], - ] as const); - -const modelTotals = Object.freeze([ - { - model: "expensive-model", - provider: "claude" as const, - costUsd: 10, - totalTokens: 100, - records: 1, - unpricedRecords: 0, - costShare: 10 / 16, - }, - { - model: "token-heavy-model", - provider: "codex" as const, - costUsd: 5, - totalTokens: 1_000, - records: 1, - unpricedRecords: 0, - costShare: 5 / 16, - }, - { - model: "token-heavy-cheaper-model", - provider: "codex" as const, - costUsd: 1, - totalTokens: 1_000, - records: 1, - unpricedRecords: 0, - costShare: 1 / 16, - }, - { - model: "unpriced-model", - provider: "codex" as const, - costUsd: 0, - totalTokens: 500, - records: 2, - unpricedRecords: 2, - costShare: 0, - }, -]); - -const environments = [ - { - environmentId: EnvironmentId.make("test-environment"), - label: "Test environment", - isPending: false, - error: null, - summary: { - contractVersion: USAGE_CONTRACT_VERSION, - readAt: "2026-08-11T12:37:00.000Z", - sinceDay: UsageDay.make("2026-08-10"), - untilDay: UsageDay.make("2026-08-11"), - timeZone: "UTC", - buckets: [], - sources: [], - pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 }, - scanDurationMs: 1, - }, - }, -]; - -beforeEach(() => { - testState.metric = "cost"; - testState.breakdown = "time"; - testState.useUsage.mockReturnValue({ - merged: { - ...mergeUsage([], USAGE_CONTRACT_VERSION), - models: modelTotals, - hourly: [ - { - day: "2026-08-10", - hourStart: "2026-08-10T13:37:00.000Z", - costUsd: 13, - totalTokens: 13_000, - byProvider: providerTotals(7, 6), - }, - { - day: "2026-08-11", - hourStart: "2026-08-11T11:37:00.000Z", - costUsd: 11, - totalTokens: 11_000, - byProvider: providerTotals(6, 5), - }, - ], - }, - environments, - selectedEnvironments: environments, - isPending: false, - isPartial: false, - refresh: vi.fn(), - }); -}); - -describe("UsagePage hourly breakdown", () => { - it("keeps recent activity visible first without empty hourly rows", () => { - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body.match(/ { - testState.metric = "tokens"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body).toMatch(/\$11\.00.*\$13\.00/); - }); -}); - -describe("UsagePage model breakdown", () => { - it("sorts models by cost when the cost metric is selected", () => { - testState.breakdown = "model"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); - }); - - it("flags a model with no known rates instead of showing it as free", () => { - testState.breakdown = "model"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - const unpricedRow = body.split(" row.includes("unpriced-model")) ?? ""; - - expect(unpricedRow).toContain("Unpriced"); - expect(unpricedRow).not.toContain("$0.00"); - }); - - it("sorts models by token usage when the token metric is selected", () => { - testState.metric = "tokens"; - testState.breakdown = "model"; - - const markup = renderToStaticMarkup(); - const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; - - expect(body).toMatch(/token-heavy-model.*token-heavy-cheaper-model.*expensive-model/); - expect(modelTotals.map((model) => model.model)).toEqual([ - "expensive-model", - "token-heavy-model", - "token-heavy-cheaper-model", - "unpriced-model", - ]); - }); -}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index c9177d17275c..87c72f0bc8a4 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -63,6 +63,7 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { sortModelsByTokens } from "./usageBreakdown"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; import { readUsagePagePreferences, @@ -141,9 +142,7 @@ export function UsagePage() { const breakdownModels = useMemo( () => breakdown === "model" && metric === "tokens" - ? merged.models.toSorted( - (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, - ) + ? sortModelsByTokens(merged.models) : merged.models, [breakdown, merged.models, metric], ); diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts new file mode 100644 index 000000000000..db84528ce361 --- /dev/null +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -0,0 +1,31 @@ +import type { ModelTotals } from "@t3tools/shared/usageMerge"; +import { describe, expect, it } from "vite-plus/test"; + +import { sortModelsByTokens } from "./usageBreakdown"; + +const model = (name: string, totalTokens: number, costUsd: number): ModelTotals => ({ + model: name, + provider: "codex", + costUsd, + totalTokens, + records: 1, + unpricedRecords: 0, + costShare: 0, +}); + +describe("sortModelsByTokens", () => { + it("sorts by tokens, breaks ties by cost, and leaves the input alone", () => { + const models = [ + model("lower-cost", 100, 1), + model("more-tokens", 200, 2), + model("higher-cost", 100, 3), + ]; + + expect(sortModelsByTokens(models).map((item) => item.model)).toEqual([ + "more-tokens", + "higher-cost", + "lower-cost", + ]); + expect(models.map((item) => item.model)).toEqual(["lower-cost", "more-tokens", "higher-cost"]); + }); +}); diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts new file mode 100644 index 000000000000..8245b593e9a9 --- /dev/null +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -0,0 +1,7 @@ +import type { ModelTotals } from "@t3tools/shared/usageMerge"; + +export function sortModelsByTokens(models: readonly ModelTotals[]) { + return models.toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ); +} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 142d6922f0c4..668d8bcc2723 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -273,6 +273,29 @@ describe("mergeUsage", () => { ]); }); + it("orders models by cost descending", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ provider: "claude", model: "lower-cost", costUsd: 4 }), + bucket({ provider: "codex", model: "higher-cost", costUsd: 9 }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.models.map((model) => model.model)).toEqual(["higher-cost", "lower-cost"]); + }); + it("keeps two machines apart when hostname and home path collide", () => { // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make // one machine's usage vanish. Filesystem identity separates them. From e4422eec752e8cce46af2865d8fbd3d5b7468a2a Mon Sep 17 00:00:00 2001 From: Cody Ray Hoeft Date: Tue, 22 Sep 2026 17:46:08 -0700 Subject: [PATCH 15/17] fix(desktop): find linuxbrew node for the WSL backend (#7827) Co-authored-by: Claude Fable 5 --- packages/ssh/src/tunnel.test.ts | 1 + packages/ssh/src/tunnel.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 980107d19a82..a3c049323bce 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -227,6 +227,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "remote_node_satisfies_engine()"); assert.include(script, "function satisfiesSemverRange"); assert.include(script, "satisfiesSemverRange(rawVersion, range)"); + assert.include(script, 'prepend_path_if_dir "/home/linuxbrew/.linuxbrew/bin"'); assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 135cb7decae9..0473ffeceae2 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -360,6 +360,7 @@ ensure_remote_node_path() { prepend_path_if_dir "$HOME/.local/bin" prepend_path_if_dir "$HOME/bin" prepend_path_if_dir "/opt/homebrew/bin" + prepend_path_if_dir "/home/linuxbrew/.linuxbrew/bin" prepend_path_if_dir "/usr/local/bin" prepend_path_if_dir "/usr/bin" prepend_path_if_dir "/bin" From ca864a25b14813e390cbcaeb334b97cd61717ce3 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:48:16 +0300 Subject: [PATCH 16/17] chore(models): use GPT-6 Luna for text generation (#13115) --- packages/contracts/src/model.ts | 2 +- packages/contracts/src/settings.test.ts | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index ee69b5ad2aa9..31e5f9d63305 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -161,7 +161,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ "gpt-5.6-sol", "gpt-5.6-terra", ]; -export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; +export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-6-luna"; /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 692edfb18a65..16b9418ab7cc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -690,14 +690,6 @@ describe("ClientSettings pull request merge methods", () => { }); describe("ServerSettings.providerInstances (slice-2 invariant)", () => { - it("defaults text generation to Luna at low reasoning effort", () => { - expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.6-luna", - options: [{ id: "reasoningEffort", value: "low" }], - }); - }); - it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); }); From a493946bb42ab16e1d18285dac3e9e4603651330 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:52:15 -0700 Subject: [PATCH 17/17] fix(mobile): keep ordinary offline outbox failures out of console.warn (#13144) Co-authored-by: Apex by Callstack --- .../src/features/cloud/cloudDebugLog.ts | 22 ++-- .../src/features/terminal/terminalDebugLog.ts | 27 ++-- apps/mobile/src/lib/debugLog.ts | 64 +++++++++ .../src/state/use-thread-outbox-drain.ts | 123 +++++++++++++++--- 4 files changed, 189 insertions(+), 47 deletions(-) create mode 100644 apps/mobile/src/lib/debugLog.ts diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts index 840a3db55680..7e57b39b7bb8 100644 --- a/apps/mobile/src/features/cloud/cloudDebugLog.ts +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -1,18 +1,14 @@ +import { createDebugLogger } from "../../lib/debugLog"; + +const logger = createDebugLogger("cloud", { + enabledInDev: true, + legacyGlobalFlag: "__T3_CLOUD_DEBUG__", +}); + export function isCloudDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) - ); + return logger.isEnabled(); } export function cloudDebugLog(event: string, data?: Record): void { - if (!isCloudDebugEnabled()) { - return; - } - if (data) { - console.log(`[t3-cloud] ${event}`, data); - } else { - console.log(`[t3-cloud] ${event}`); - } + logger.log(event, data); } diff --git a/apps/mobile/src/features/terminal/terminalDebugLog.ts b/apps/mobile/src/features/terminal/terminalDebugLog.ts index eb11419b330c..31c8497915c3 100644 --- a/apps/mobile/src/features/terminal/terminalDebugLog.ts +++ b/apps/mobile/src/features/terminal/terminalDebugLog.ts @@ -1,24 +1,21 @@ +import { createDebugLogger } from "../../lib/debugLog"; + /** * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. * - * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS - * debugger / Metro console to trace release/TestFlight builds. + * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__` + * (or the shared `globalThis.__T3_DEBUG__` filter) in a JS debugger / Metro + * console to trace release/TestFlight builds. */ +const logger = createDebugLogger("terminal", { + enabledInDev: true, + legacyGlobalFlag: "__T3_TERMINAL_DEBUG__", +}); + export function isTerminalDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) - ); + return logger.isEnabled(); } export function terminalDebugLog(message: string, data?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (data !== undefined) { - console.log(`[t3-terminal] ${message}`, data); - } else { - console.log(`[t3-terminal] ${message}`); - } + logger.log(message, data); } diff --git a/apps/mobile/src/lib/debugLog.ts b/apps/mobile/src/lib/debugLog.ts new file mode 100644 index 000000000000..447973503333 --- /dev/null +++ b/apps/mobile/src/lib/debugLog.ts @@ -0,0 +1,64 @@ +/** + * Namespaced, filterable debug logging shared by mobile subsystems. + * + * Ordinary, expected conditions — a queued send failing while the device is + * offline, for example — go through a debug logger instead of `console.warn` + * so warning output stays reserved for failures someone can act on. Output + * uses `console.log` with a `[t3-]` prefix, matching the existing + * cloud and terminal debug logs. (client-runtime cannot host this: its + * tooling bans `console.*` in favor of Effect logging.) + * + * A logger is silent in every build, including development, unless enabled. + * Toggle it from a JS debugger or the Metro console, including on release/TestFlight builds: + * - `globalThis.__T3_DEBUG__ = true` enables every namespace; + * - `globalThis.__T3_DEBUG__ = ["thread-outbox"]` enables only listed ones. + * + * Subsystems whose traces are useful by default in development (`__DEV__`) + * opt in with `enabledInDev`; `legacyGlobalFlag` keeps an older + * subsystem-specific global (e.g. `__T3_CLOUD_DEBUG__`) working. + */ + +export interface DebugLogger { + readonly isEnabled: () => boolean; + readonly log: (event: string, data?: Record) => void; +} + +export interface DebugLoggerOptions { + /** Log whenever `__DEV__` is true, without the global filter. Defaults to false. */ + readonly enabledInDev?: boolean; + /** Name of a legacy subsystem-specific global boolean, e.g. `"__T3_CLOUD_DEBUG__"`. */ + readonly legacyGlobalFlag?: string; +} + +function globalValue(name: string): unknown { + return typeof globalThis === "undefined" + ? undefined + : (globalThis as Record)[name]; +} + +export function createDebugLogger( + namespace: string, + options: DebugLoggerOptions = {}, +): DebugLogger { + const isEnabled = () => { + if (options.enabledInDev === true && typeof __DEV__ !== "undefined" && __DEV__) { + return true; + } + if (options.legacyGlobalFlag !== undefined && globalValue(options.legacyGlobalFlag) === true) { + return true; + } + const filter = globalValue("__T3_DEBUG__"); + return filter === true || (Array.isArray(filter) && filter.includes(namespace)); + }; + const log = (event: string, data?: Record) => { + if (!isEnabled()) { + return; + } + if (data === undefined) { + console.log(`[t3-${namespace}] ${event}`); + } else { + console.log(`[t3-${namespace}] ${event}`, data); + } + }; + return { isEnabled, log }; +} diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af..022c0ad6addb 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,6 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; +import { createDebugLogger } from "../lib/debugLog"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; @@ -55,6 +56,7 @@ import { type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadOutboxFailureAction, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; import { @@ -82,6 +84,92 @@ import { useRemoteConnectionStatus, } from "./use-remote-environment-registry"; +// Ordinary offline behavior (a socket dropping mid-request, a retryable +// attachment upload failure) must not spam `console.warn` on every backoff +// retry; it goes to the filterable `[t3-thread-outbox]` debug log instead. +// Failures the server decided stay on `console.warn`. +const threadOutboxDebug = createDebugLogger("thread-outbox"); + +/** + * On the queued-request path (settings sync, startTurn) the RPC client + * reports ordinary transport drops as the raw socket/worker reason tags, and + * reserves `RpcClientDefect` for client-side protocol violations and decoding + * failures — unlike the shared config-subscription stream, which + * deliberately re-wraps transport causes under that tag. Defects still retry, + * but they are not ordinary offline behavior and must not hide behind the + * offline debug log. + */ +function isRpcClientDecodeDefect(error: unknown): boolean { + if ( + typeof error !== "object" || + error === null || + !("_tag" in error) || + error._tag !== "RpcClientError" + ) { + return false; + } + const reason: unknown = (error as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + reason._tag === "RpcClientDefect" + ); +} + +function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean { + return shouldRetryThreadOutboxDelivery(error) && !isRpcClientDecodeDefect(error); +} + +/** + * Logs one queued-message delivery failure and returns the retry-or-restore + * decision for the caller. Ordinary transport retries — what an offline + * device or a flapping socket produces on every backoff attempt — go to the + * debug log. Server-decided failures warn. Settings-sync failures always + * resolve to a retry even when the server rejected the command, so the + * error, not the resolved action, must decide the log level there; routing + * every retry to debug could hide a permanently rejected update forever. + */ +function logThreadOutboxDeliveryFailure(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; + readonly context: Record; +}): ThreadOutboxFailureAction { + const action = resolveThreadOutboxFailureAction({ + stage: input.stage, + error: input.error, + interrupted: input.interrupted, + }); + const details = { ...input.context, stage: input.stage, action }; + const ordinaryTransportRetry = + action === "retry" && + !isRpcClientDecodeDefect(input.error) && + (input.interrupted || + input.stage !== "settings-sync" || + shouldRetryThreadOutboxDelivery(input.error)); + if (ordinaryTransportRetry) { + threadOutboxDebug.log("queued message delivery failed", details); + } else { + console.warn("[thread-outbox] queued message delivery failed", details); + } + return action; +} + +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ +function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: unknown): void { + const context = { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }; + if (isOrdinaryThreadOutboxTransportFailure(error)) { + threadOutboxDebug.log("attachment upload failed; retrying", { ...context, error }); + } else { + console.warn("[thread-outbox] failed to upload attachments", { ...context, error }); + } +} + function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } @@ -215,14 +303,13 @@ export async function completeQueuedMessageDelivery( ); if (!removed) { forgetAcknowledgedThreadMessage(queuedMessage); - console.warn( - "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", - { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - }, - ); + // Losing the cleanup race to a user edit is an expected outcome the + // caller handles by keeping the newer message; it is not a warning. + threadOutboxDebug.log("delivered message was edited before cleanup", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }); return "edited"; } return "removed"; @@ -667,18 +754,16 @@ export function useThreadOutboxDrain(): void { return null; } const error = Cause.squash(commandResult.cause); - const action = resolveThreadOutboxFailureAction({ + const action = logThreadOutboxDeliveryFailure({ stage, error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), - }); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - stage, - cause: commandResult.cause, - action, + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, + }, }); return { action, @@ -772,7 +857,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, @@ -900,7 +985,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage,

WgLM5v;5uQG6JgwjKYlGTgP-7sr+zB>b?hJ?ObALFj zb!wgA@D4^+_D$@crXV_`r&pa$9Cd@9@Xvzn@7>n>H>a(C{__-meAs%kdwP7b^?qmT zXs3*A={$S3yeP}kQtxdaZ5{oKrwN0z&a-DS(VJH5^zdl+@aUkmdwhIwbh@+qdhh+- z$=<>HW1jxMSAwWd0kqDqlKq|4*6D}i-O~>zuYcHBl2qRAZtd(IogVLQ9kt$`9&Vky z<(WTG4sE zXM~;|_^X|%6L;c-PM!EHKob$0DuY9FcPfB~)!ht+_#O4%cnF@FDFTwYqW_y5)9wWay~|0Vv6DSryy4M9kc{qtY= z9nyD%e`J-sxK`s|G2)m_`EbX-F@TsKZ_$i()wRnU(VbA zRzN41um8{g_oVcMj2qT?@38k2G7BcMu5D7@;$ePAY=j@;cy|< zsOWo9(b5wzioi)z4`GUgYFjTsKb*w;h%T++RRU-+Nn?IEBbDH0=?Q7K+sWwcy7YuN z6rCaM+?JjYLY@E?G17(KTfRR<-5w;LKz5geGNheq=?MWb1?yfa`LtV{lJHi2*sJ3O zHsz1H`ScU{U;p#}{C~_D1H*9^$4s9S-*L48dPWz=bW)f?#3r3u>cv))c-L5;x|i}p zAj(er9_xSnUQhb{r>ZQ*qTnR_Oo-@D$Qv~Pog`*#K`LoTizB?1iv`=9PGjmlI(e`N z0luNU*l4EWboDAL%T%*I>D|Z;h3Fqblc{>skwfcLH@$JCxw?4PKF#_Ot>CZ*ThE^H zu0(%-?)p2n+lp-X+c+NFux@uASHfoMJx3P3``i@A7MOJ)oCm8b)hADs&a-FkP6TIS z9s|$?#8tp1TTpcIMr(CT2QV};j9V0rm|bSm`)9hgu1{U>sJOK$E|CpyWEDXfm3 z$O03dJLcI{ol|sOQRD4nG;A8%X`D2+t;RMQw6S$!v$2iFw(T@_W7|Ia z=KKER-j_S>824q3J@(VtJZsN2=Wjy&fW=_O;T%jH_>$%>O{c>x5$DSsAb~pNYv;iJ zbaP$pRjs6m)&#Q7y66;I5#Z(K-w;|}*{2%((d|}MZ~QH_VrS^)u?kgMqK@VLyS%L6`3dN zP%QuA31KdueM7xV0Vk2AjM@}7^{ev7v^62s*XZjh`t^Sv9$73Qx~bf)oY_jFEd8A5 zwuuAb23!zNbOxfm2==ie*@TMeoh(nHmCDMf)NECSxYQeAZhKT)?9!ee<%ZZ@wDyJgl%W;+ zdr&ycvD}4wKBeFu38FTfwT<2f_BFNom&oThpL!p`#KBB{m=Kbj!pt`D0ySf#q zu4Jm7di*GhqiLF-a9Zsgy_uZg5lJunyRA}T)@r5i9^Rp18i;U-`kvA8DS%^i7>(q? zHNB7=i(js_|CKQ#2l-PC6?xbeoCoNQ=^|6J1yNB+&6vH?M7s~Z46!%LERexMX4?jp zO0O(MTZOIP4|QiReRC2CvmS{SmSFe1Dt7h)&BgcdnXN81?H`$N(VC4*{ZxR^AA2ee zok)4tMAww;+fz}x67gJ45+usmUw~mRhw`y4(wtGGR(mn98dvq}`a|=L*u%KkT&?u;99r?1KIt_>sFR)RTVWog6Td`bSPKO z15_^zE+qZO;(QKLMZPwirf#8%ve)=Z=@}_AoN2NT`&T*W5^)vfk=&NJBZyC!9N`e! zj@6bR@hT3PvmEy~sh&SOIouX2X>^>sa+Vuvj78rChC$@3md{rN8{)baNiF_3SijLY z+Wa4C^P7iT^hZ> ztrWP=&{USw$J1?NDjBhbz8mQA99wffBuT}5xHXQ)6Yy6Qesix3Oa*654`Tfb%gDZm ze(LiQ$lUkl%&o-fWg9LXbUfcOD2Vc*DT8I^Vk@D35tuWvqCd~KT(wA`vE!^YT~k-Y zd@UAO*&VF`L%xOMh|5nAb>u==C(c5)lt4xaal`l0U)UEh$(eb>BJQ@PQ){6RH&;{y zqj9>IXL}Jq4>YH2Ji%E~vyiO@Z)`SUmuN6fp4fX@*M(9WB~ZYdx{K) z-=lWAgdcu+v4RqcwO=fCa)JFGuRz{Taj&G5hmK$`z?f}UY^)uE7HDVfbw>GX&1LUj zt!bNvd=X=dht6QcR#5z@QdEQeKQVAp2F^~LNiie~D&tu%1pzjzz@H=aI*}7C7Mxpr zB^gYL+!$P@id?G#kl5$LkGFA86CU4w-EP!bD&lLS;Qh8(Y;iEGy@L5q__PP7_Ry}@ zf>gVkW%D=dRzP_|t1Z@npCS*+{@=zerNxc!0e2SOW?h=%&x)jBX;`D6r>t`IL0!n| zluO7@%YF!Efj`~V`bha2vOdw}>boiaC05YQjJ%>PyFHA$z(r+jL%TPqo-6T{W2H)v zJ5G1}OSR@Ok(I5Zsj0wL9^yOMv;+|oko=#&D&VuCIykIJaV)mrMEe!IpirHURBNe<5isCLPQwe|E0FEGq z2t?M%YoGTL)0=U@PeR_iNY#jX#!jd^{AVBmW}aJ5=e#eWWIgg>{N8ze-vf*yeuMYU zbDu0l?qGyOx3resi;S*l1dcQ^y-cx~d|iD?e@N`DnDOI!_G~;S$##6kcr*w@B&i)1 z9IZC;8Rf=$hyJ@YdwSd~G|@{Z-N2KLw~ZfEWc(oOZ&_2@JKQ8Vn2j4UfVRwHQG*kn zMNiY>AnC(rBhLCQ>Cg>LJxW_SGoWVXPtt6B16GF33e_~H2 z8gAtf<2p!j_`;sBwRXO`{!Ct}MzyM679^^yE5xbwc5D^!r{@KY@b3!7(laqgWnHgi z0UvcV#PeJG=DNfLz6Lu~UyKqjf}ZyF9&PE4dO28@L`eP%1GehITnh)GMj2aDRYIAy zSdhi;@sI@Z%DyC|KHshB_x0m|Sxgk+*(By=(~cggW9HpBmL)4a^q%L3xaV>l6>h_N zj>3a&cAq@WYkCEv@=vjBbSDt^9~k!Nw@ja9BB4p`>mPbwU9WFC%Kv0}OyW#0SFU(0 zGkjvUzh%Z(u~OXER#YNsh~ztUp+IeljOn@mVquV2lURP*#CFek9Z~{|qDG~0d$^J` zr%KOqG>=tbO6YP}rYwbHsu-?XqhyKLS{@azX(qJ{opcGYUH3*Wq(e%OrfY3`gEJu@ z5GXU5_jOZ2DAs9i--d}!(>x-1X&v%$^WhD|d$=X@#4N`N-n|){z2yPlHamcUCB9cX zc7%YzSe_!Q@$W<)?A${WlnLw#rJDqy=B5Fx&|F-AXUh{MkFIzf#}kYdwJL27?4vhd&$7tJo^j(7AISX~wNU#a0a51hY)+=DY-s zIfD^2pxtrd^+$uAvLll!RNR(i?eh94pP@62@Km3o%%%Bom}wI4ZH>mBaC@8;bk?Fd z&8Ori5C*O;v-UVa?$lmVXV#n+D+RWxr}fE}jWWL11og)a9|h=yAQDk0&U_uraDJIL zprF>_@iZC@9#f*gEMmtV4(uRwkgDdNv|!P2Rr?ZW)4b;W@z*k3$@q<|Z=Y7g0@c!R zkG6q`_7pNN?NyF7FWQQGX_LAFJZm}*3SJVOz`)`(0PA{obtLu!x`ye)AdvF2-J38_ zG$7>YSmX3pe4U0Dw|FTipTG>cBt8?3)mMo8>9rRK0FZm2zqf9)<_-Wcv{_@*4&0ym zhtTM!Q?lF1dy%Ry+%tQwyb)}Bi(t`QbZaiY+aG)m37YuiJgt)CA&L8Aj}uxM&r{$e zncd_<(Q~NnqE~rLo~7^8(b15fwmM&a)~O6wjO9)SK9X_fC;g)piOEoR#HOI9lxBdl{^qHZ)?H+?+(Hs8 zaeN^W?qstwr-)z~@+LSQ<(kl8+u0DYs@7_IVUUO)O%ntiaN#BPT}X(qbFJpU#b*lz zfIbH;z8q?b@{gT#Prn%I>1R{}&o4REfSn}}nxXo`@KyB)6lvq}R^7-0J~Xs>kMuU8 zBOXT+ca_iF!2Ok|R(-0eYdWTJrmD9>4M1$z?A`mRUa5j{b|Nf0R#rR-1_deSBQw)h zNBv(_d*|E*Z07} zBZxM9Q}Z#S1Dqpds5A8;WVSVN8v=&*@47F`WO}dJ^9TSSEKE)dTbZ|x{+B+lA~~6q z&4qKD#FbK@7orf7ogAWcR-kWW@fz{ZPe|9oC7IFd{}>+kDDxU36gAroN~YN!K4+1f zLiRpgf6tA#B;|M$6}{>}zn)3`TxX97>SPQFpyScprG-!F!E{I&X_KqjL+U`vs%mIR-q(PllVPj4S%2xl zDWyqGXy)_N$=<^Xo_A;T)mZZT$e&S=#_FZ#Z%|U6Ns9<~$iqV>y`+AxD@I$X_&m+| zXfT7m1I>ae+B)tA5^W}WjJ&BQNv&c~NoZ1+Fn#9EF$+B+4c{KWth&r^QA&%+&U1Jt z92ds*idfQPA~Xp1Ce0w}3)rU)d^pyTYjA?@;beqMFbL5l>Fgn;qP=s@r8bvF9=pA8 zvfaxpeD<0kM3lQ9XyI>!y_M`c!oQ$f_^50P$iCBsfB6ah{QkYj-fyPo~3R7R!JArjCcML&CTO-?VGkZKY)vCdGeO8vNh%Sm^)C*CI zOc5F(2P1=FNEZR=70H~%7~K1@_=AHof2Y}?o#0I}%M>FQ3Y9+n$Z<4&Ib(O}^$P>0 zbA*+1_^T)4*>=lXaBdhWxdX=CUsFW~wM>(waY2cOq{to|aL88!(OX;y#L=B8%juc5 zd=_C0-;R9CJ-!G}rE6@xMAK)_ty8|-@D%44kzu5L>RxHwObY#}A?;`-;wkN#a*pKR zc+mGm$GasaJj?52tzKqxp5>fI-VJT!VGy5?nhn-X0aSIOD?6kX^ocvG2|mO*M=9=* z(cj#P&$eGJ$f?F>7=Fko<0 zS0!J;rH6dIf-cxl4pYrkPTh(oy*b9Z8nQ*$<_)7e8lHKi4c$G9=|eZ?VY>IL<7oPF zg!Rcg^gC6Qd7Xv-9{t6^_TO>V^mz@pFD2Eh0wL0i|1h&BsbnwIPF+31JKAI!{H?vT zGlJ(=@TeGCa<>}=6a~_E{+T!Ht$}P*h>bZ!=VH2a;@TQ@TJ8wPQAaHgl0|7^cnxGJ zP>aeM`xa(0Bg2pCv6Yr6lMtrA-Hqx86WCXV=eqb+;EZ$NVec+p%S4Mie1m{cJHoko zXG%{lldax|;qCR$*7`$k8mLmyo!w~M@qWqL0C*h(a&k`ZpekVc9%uy%EdhrwfF6)@ z3jE(YLC)_vR9W(CAipZ1dT@6`T~p>9kdl&e@MLJXr^JxjY;uGc4Pk~+ZHX>?k;XR- zRd$Z#xG%TZx1f)i388y98)^T@PvDR4OcaX&^;5L%_cfnMz*k&|J@Crc8?lJ1*PD~_ z?m2oidjpsl*|Dmv6i2>q4-!_oPrmXT3k_g!@K^t8(^yDve_AtdAgH6r7&pOim-4Q= zS`@X%b%n`tJWS>czblJ=3rAg_D6{XFg34w*0n>A2`e%G=wafvtRl+kAHeSPUE{kuu zw)q~CRa*RXnu}zY2vW4SFnzQ)dNTgArH;*>Zo_gZp=D#dQrrHJ`|vqI*~COZf%PwK zhlajFDyap!`h!yJJc(iV9Sx#eEFISgO`{Fg&D>xb`s|`1Xs%N;9%P$X?@C?oC^ri( z%Vd(>B#Zaf=JR3m=d^{3gyt7{tJ_g+?+fG0QC+X%n)~JC?BV8H?n-{N+G}n`{`yt7 zo5ifxRoBeqEUz_}b&V{ky5l`Y-kiy7Q(Y&)1jgZ}J#Rt2DR=sq_1%a`F0%r0WPedo zwo`5o=EMq0W*MX%xFR+Hs7r1qSrkqCGck zsbK_veR+s-WuH7TzISKuD+6y#%rXc9i{VSMzVd+Xx@kx2^pe+MrQDs(BrwWPt%P5i zHG@Y@pN$3;Po*oJFO`?ZysltsqIt(sZ(V2xn=P6%W(>F%v{nm04XOMKOyT#(3?)WuXx1qm2$e7!u#~MlD3Ibvwo{O zH79dZRRtV1KiHMBHeY40XL15gu+A8_(QHNPzQ*Z)mT72H%J&xNRhXP=59?EZZWOQL z;a3IRo^r?AzaY^>>Mn83`Shg?ILp}o2VjUnw)k!CRsSJZi$_A8vx7UrhJ!Qs5387V1?EeysqC78d?He|mlaLLNYv;X?3WIWRc3=>C5H{pX)}w6jF8d@WsEfO>`Mt$j-;6sTcAk{uLa1iRa0c zatTKyW#|>zl%=0ImlbOkSYYZ%3D)VCDUA^muW5`{FfguD>!NVWZ&DERbEW zDJL7eG`uM&@O-gvQRMl$35xWU6XY-mtEhI{Y<5-JJn!6GxP3!&tk=?QEpJ#m|IrNG z8v^$gtKWl-1Djcxe#)X+^ZctI`=`RndSl$eq#?V}^38m>Md+%3(V^_3CzV|(>@uyim$>Ow<1icu7_tv)3Lzwohx&^PQT2zTO(0`x#XWWZ4Ln+EE=*%<+fy!m8 zIU}Ej;Iy^MPNG4^j}o}frAg;_+I4HsF?l^hJBS7P zJhAq+w!eOp+cdrOfMglJ%kS#v?Eb6Tg#2RGubn$V!+XP58PxqA?VUoAaH>wPxiB*s z$)P!;F7TKt`i9qkt>298AiRD}RJHb8Cn(b1MgFs=vLd z=bmc!0*S=oO6Z$tPzZiFEXlxVTPgnfy{Kd(|NPY;kOlTMKjp7X=?mlaYH zA>(cfyE}85cjRA6DjdUV0c*NR%hnZCAy+8kw_5592+6-CKI*Zo7={*>BPMQ?XP(t4 zNIudvnCj!Dpt>~lyAcgu^H8e!+ug4n8t;j*Uw&gCUJ9X0iO&}?mKlpM%h$s2d>Nto zo=Ka0rPYHXqi}oh9@Z*o)ra6azP`WUOM5d$+*HVat$s-&Sg(#SD8mq>s+G~iYB~|X zp~<~jwy&2lAE?UoE4B#Fi}+^7SrId~XqD-+O0^v4ga?F-wKvQ>s&wZC{YCbB*3H1Q zOAYoW6y6CsRm7-=TyOWrTihkq+OF>R#l>)?{&@<5fdv}vc1f|VFBk5C__;)8(BE^# zt`3Z3&#aHol;x?@M~%K+OG|ee#180<4D=)O3_H%#Oy6`Q05cTWsK0G~3xVdr(=Z7m74O1y#C799cQz%8PRqFx!?oNt(P$dgXsXZ{9I~o6A8VW!40ce*9GEc+5-YE0)IPVi z`1O-ohaPMBXCx!k=02Uhmt0qZ#1gA}cC0_yT?+$(E<@!W!-RUIDxMqxm9;j;*l$m= z-?bg70oxgkqn;yl0$*8VFmdT8I0=4tQPt8#sf%f|m+K|v$t(V8Ho@PV%#_u(=MpYO zffERQhZ6H_iet&~ugdBmZ}I1t%p|Y$`A!BWan00f%q=_p3nzv>c{XOQ@$cPGk#YT6 z*=q6rmhuIyzJ_`crmZ!0FKDUl^!WQ|J=nO0b1w^59@jy3e@+aw43WPiOPdW@1@GsGy znFsykjq)k69EoviWak>GhTF~)7|2esgZR4B5%7&JKxIr;UBtJypk@GPObsm%$gu}^ z*L9?RNISZQ8N(p&c%j<2Y&v1?^CL-KCJX}GYCXFV_j^Y&Z(4Vu#Sv_1FU5g2hi|3k zB>l?FVReUwA5aDn{$_YN6{f@5L31hxk?DAM|Lv4Mx|^tEG;4KpsZJ zl;^ti1xvEr$UB}&?db_$GG6w>5*>;);)1D$cIC~$=pj$Wt&xi6%YdVG&tTh%MZQL> z6|04f8=f|XMuRN(GeU#QBIm8rL1R%ouT#ixX1`Cj3_I+_ZRVVBqoY=nXcb?ag>4AE zXOYgE*^iaqe#v)Weqawt;~&u>`W-Qi3;&uvZ3R%vY(X2lqvro}NOz|_*N>$=mdqP6 zmvLNTJsm|eXK7&*!=|e;_n&etmTVAV+-~|r;zqrcKYss0P^)uck?ODQY0EKv_h+9H z3K!m@T=-Y=?Re%d*h=%E1|EL|2Oj1u9`3umWLV7zHd-kRmSv6AL&lvU?<&ofIwQm9Qc9? zsAQVl?{0nLN&yaTHub<6;~=MJJ0@Tf5NrpyNK&M_-b_C|+ff|?oefR=j{x}OX0sEf z2cuTJ2ZJ?OSO5|Sx{=FPq~cU?r4@fVCvv9{96A=za)FCrhg;1aeq{nTslr-INXY2vfp;(Q zyWKdNjt|}YgNL`To86^vKj1LsoPS+a@Pab_+9s&IJmp-mOxSW^{_7j;oz>mClYHMO zPo$2=QIwhd!*%duTMzWs@c;~Ky+7&z<2L_WPLenms#*Z!F6JVB=*f{hZ`}(?jnD7Z zhI_HqbCqtFsJs-IqT0XN0qz)ra$Q9hEKf- zv8OD8Zs;Ju5gnwC#SQybmc9hU_w6-2P5(!g`#6bCZ{7c+K-vJ^@OOvoFPk?vCLhk9 z^ivQh_+Dtkhs8d^q6*}Y(-wT5)~%eCs)eXp7eQOInkO5e9@h5DIw_b!sJ<`QJbx}q zyPGwd?aP#wVQErXeOZ1*v#%bH`Nm-9ZEJ+0K7FTrg?1H&D(eNjgMhV{TOI(we*-~vbLn|){Oh6Ka;iPWC74C6CmP9I zf^AuMrn2i_+ugpT_YkO*LT)osC0*|$3t8W!SUE+)O-%F!wGiPbKMcdbw3x0%c+8(v z$M&~c6Ar#iTQAmq_!}~U%W`U2GS_w6esNIGt;)ABZP}etvC%l{0q9Vjt5fQY0fO&R zfrS~T<;5Ck5Jqr)&&8}{RS<9r15l`pww&LQRrSZ`h=M<8Jrs^au_Hw|MSnYtF$QGf z;6N(&>hkVv#)^=mPXV&&uCd{(Y@YdLJzkkBOPZEgo6$W4iI(oKW~Wc$3bf+qA`IEE zFgx2+KYlIa7qL2<4I}m21c8lVdO*2E2ta=?s3!V1_1U^|CdPq_mHT8?Y959KUjsg5 zmm>Q8oyK~6cYL5oTvr3Rz_`jpPu@=Z8@Akd2D9w)QHfck^Inn-<+<0NGl^fhYPpb( zh|>mkVcjHIMoRw@{p9bj`aj4tu`{Pgr#5dLCFkf!0`WaVJ`Qgn8 z;0ab4(5>{^51MH=qLu(+)5q>5aCh6cP2j^o;tk|n0=Ms=E@1lc!xRDt!N6F&fxEl9 z()j>TR}*_I@Q(cmd@y$CBLCE26-1*O%v-dhd7fnjvKf*kb&HHKP={9Kdr+JC7n%mT zDg@a6MSI&51%KBs8gx+X!JFwX?mcFQe>U_M$(^a6%C)W&K@FUGmNOMMJQ?7$sdLzm+WOkl{(JgA*>X))ZxEiiueS%`o6L1Q+xADeJjCh)S0epUWdCfd_}^rC zuf6p9?#zmWFtfJD+y)#m-37+0yH`gXvd8Zcg1^X(i)bANnDy*OEISf{*T*ZROXD&s zaieXt-twPu*n1T$gjJ^>Ri>Fn3mH|qzpV#lsif1v?+%B4to=BIe#52-GMJhATJ*sP z*uZsl(35W&wuvSies{-wIZ=UQLXakA8soAVKHkpvT-a-t6w#5gmvnFu`H45g;G(v4 zTFg#c%2*9i9Jha}Y9Qol|CIz5-)})H@#H&tz|7%mm$t7fugYKfDSJvi7W6_rN3UDY zoB>3*T?|fqQ3-`vQ#&e`DP{xA91A^|2@A*uIDF%K*D5iI3rz)^YwtJq;;kSiqitye zg%U#%cS7oGi7nOcVl)a^KTFpb8yuUY2WZLJpdEMzTVzitxj%=x>B)&GhOJemE>-Y@ zaQ{JL%y(dX`9x2O9WvW>@>1~~#vM8@#$d#x_Ipi!A(0ffe5E@JvX$)GBL70Mg>A78 zmK`Fg?JP;h;`ubTX z1*UORjL4w02DIE!9_zgC9c43`D(B9ADi2vI7-H`7OPfIBg09-TozPD+*T$A%v2FiW{fGY@31FCdUIWRj0SdkgeCT~kb> zNmj^Uj}m=Q&=M07TR#`4@u{a`^l~}$}(COhUhk+EoytGAcp&V zm7VyqPrdRW8YCYtHZfujArCrop?nQL`l`?o_Xz2Fe~?UNlps-Crk|L*SEqz zoaUOpa#mG;(vP0XtdJ50*S~nl*f1GniF|>A(iGUt31E-7V@_h})|MyXw={+~j=F2b zt@ON+Za0Z0&^m(E1nT#pG{rvhLsCt6bx}*-eA~)CAeZe1Hco%$2~Iketm2vUeTK)(lLX3;q9W z9yS190TR`6FvFA3>FM*+bmKh_csQj2Kv*05A8-DYZ%Nj?Ri}23RZY)i;3|>wS10FA z)W!)my}C0O_$CrcBQ*kpU=LaAVyB`HHOk(!R(UvOXZARiL#FliENzS5V2{ROtK7{k zBcHsqRn0lC(OR}T72?7O2Zd#}j6RUjXDVN$TIC8)y;(Vyep>O>FaL7AqNmo+CFFy| z`-)o*7@UGU%)7vcr=Tuy_woz;nrEV-!&87h6QJ`(cQ9D_U0nOc zosJv7iTs^9dspYMD`$I)-HjrdmjP<`&v^2=a2jGh^2z|ZzB}#=ao~cDJ!?G%ESJ4P zg)Es)B;_1?=)QSXV@7KmclDbJhX~(9Ikh%Na}KU)}_{yLKAj9;op{PZ2A@HW@Iry_h{dU3Gh>BBJ z#Zhzb&VpMrD_~O)BDU>x3(}lh@hCi!&TXX z)j)ZHYLbV1d1(-EM?MCKZ-CjC6FuPng?_*7o~8j#4?&JF#HSGU0dL)HiF|;ewyu80 z2jW!)9J&T`o$Su~WFNd6cC3^fK)Q)rXIXit#VZ;v4SZKsiW_X(D*zfFj~$3I_mV_yi7h(nzDz2yW1bv(bpIv!@7aqVUT^ zA?8FW2LE@O)KW|>wt9_pWwz8@e28ws0-~^;Pj`W|STJUBbEgPY?Te-jLD#&Z@gXcC zLaCB`siIsgmpo1QBAoKtJ(Wmm6`cRp7wRmq&Nn(-CV%EyS8?LaaBj#VtD#+tuQ|UJDW>hK+fQ~&@i@9HUNY?g1Z+C^g956>)Z>OPdNBa)gOK$hi0C!v;lOhQ{NppQTfPd^d|6%|HZjy( zdofgVd7^C-TR8Y9EB34xnRoJ)NC(Pq&^EKU{XYG+Xwe;a`pr%ZC?RpzB(?0v2?rYS zqJVq%TA=#`T#XI**#2~DHH;)M4*$YF?$&{-_E!eQEBU?-iotYS`~B?q#mvqT(j8b$ zoGmFU!ywA)&P)eCQOP#f?pTLI7q~@l2NE8D)fW?h`2jEiKw^i$*RlLl2Ku!) zm&@+*^2o={>YM2s!Ektf=KS6xdo43JPu-z@V!)_dv`>^2%oy{d9ma^Sbxr(00r8uO{*4o~k@Vm1rh)Lj2XpJYr&z1EAHu3Yi1VTsY4pIIp0O)BFz{ z`q+JC`%%u^v+@e+?7X-D7NRRB2VXavYk|&<%ky{H4A8+M^U71x55v)zl$d-hS0AT8 zwM2bNxNrkL8o-pW)sZms6CF1zbc_33A%d7J4QH^jd`eKHgF)$7MgKs#@hMUFIN}eC z)uw;3wtUjysiSjQ|n@(Ja^cvWxUXIf=MHA>cCoNH8TrCM8Gd6d+pS+XdH z;l>4dWFn;bh~LaENjWXX>rC#I)Ky6HK4rcqqq{FXQBKlw_yQycHYY#cXz? zwr&=`tqsnC8H4O@xzVPCH@f+2~DR~9U`;Jwq+bf zbV+t0lECX3^xyQ4pHn$uKQOpWaYNVs1g+oq@WkhR)N_HOV=o7on3c5VWS4^1?S=y?IhB)=QDhLGtygY z{^+*Ls(NSsv&rgLm5`1M{V!iWZe<6ZiSkg^V~eHH;UibR35F7V;Zv#83sLk#b`UOK7d{8UYJ$O3;An z4(MgeJA)2(vW7bn=)J98WWC5)=xJ{3X}<4ij_zrm>}jr?g%pZ~bHG?LoqD?G4zGh> ze#|Av;M>5MttM<5_}8?Zg2U1C-Mf19kf9b$slb50$sSzDqkMW!-6>GRJ+z)u_x-CN z^9AYt_&0WwQ~|@c;6bs6n9U*|f}xDgUk^Cvd74h2D{EsbEaIz&9)2R=fIRw}-jzO? z(RE-vIv+_kAK2zLK5`#RsaN1L{>uy|ne1p6+1=o$BH~N_sC!07+Z9Qimo1jV)=XzA zp_;DZ(HLpgey1DTa8*DMLWbU0LshK9s?~qM#1?P--g1Qw9L@uyuYi~|kV6UBRWR)V z!EfDh0L~2{3N{=8ke)$g0P}x$K0SU2kAn?@YV#=(H-NR5jZc3Ppvkp_eVx-)^#<^U zNBE|rq#GVnvpmun8Nyf?Lec3#9DIVThXqzdLF)e9={1HDe{*Q|rm?Qd5g%`1US&mn zsQkO?SPT{7s!0b5r1uf7s*um$jz_FwqAbQIRE@7r5KhpR{h8z1`&#J3+C^LF{nq-L ziT&u^r?J`&FB-M`I9=~+d?o3&~ zz-JDN5vH$J6lj>uu;Ac5XMP&tVAx7s|JuXAnQqwHn*L)Tv%bd&UuXB@bgWQmI zmHgsy9yf1|Gi$%p^e9ikQp1@NqulOi{%|nF8B^G!41g+u{&6oP-#rffFLF3=Yy%0* z-hOE+C_gJ){TCbi)1%kzm^etASb#)_95gA;aSV(rXL$x9fW@&H4vO~!nbME z`CdTP#lCHaDB_DzPbF!b4{|i zdfq7f7Ao;pQe6%p4ariM@o|k^cbwUlj0FD;Ahs@iw?}K=r`?pM)5K`=$p2v z6qUf@6!h^3>Oxh0%u|k&fhB17mj(%s@{U!!Z*bL8jBM;d=0PW9Qw@;;jUp`(5bx%}{p$q@M{y-hv#!%u~SNbNADu z%NaUbi6)KeAJdw z*-)Sx6s+6kmT^73>PErZA7}qLN`Vo`Z%kCwkE%vH9-cwCxb@xReLo@n_a{O&{05b=b0VL`75 zg@-OZj?kLyxnt+Z%aFvh_S}h8iGg?95ZKeLBYD2k35rKPU!$SK&Ea5FMdop147@sK zq?FhxrXq!Jj*WZ1<{pQ z>N>6vlBuXDX->euwxet@$IARNpxt;$I}pKX=-n@by1~RFA}PEFB^3V#ZBdY(@5jjy z+enGz9Jix#!~EgUmw?}M#wDtPGIyznCySVWwyr={D;y z)}CcNyw@D-BW1iOmT;Ymu7ivBL0`_taV;7m-uhEMUewKt{I5W?#9Pj$2w3PKu)zZm zXE8!wz{Jy4;u5~V^IcXYOsVw{zO=$I;5gcwu%Hlr-(VD^2QL%$K{uz<2n z)oE6_({E6hsLj}Gs$+WzZq1@Gy>jz8^Glsg80#PJhh^-^N8YcZhtq*Xsmt;FGn((e zx2$5Q{O86w#){{Ulm7p75#n%ol)bytS}@1<|o3gUPvqmks3#UoZT(MJlIKKaowY*?@v5G*IwYa!#XZ; zjEkvH{n2RgS8A}&X)#kL;j<2Puo;*#4i(h<2SU=XmSm+&fD29OOodj#F(uP%#r1CWV>>_MHb?37g^Cy(V_a;e4B2E zJJp&m&Tb|oNK-7X8p!{4GBCpi3l$b-=<0m>k+oC0PKHY{S{`MY!gL-G2&;>o_zj8l z?jQ-c)$S{=BMEER+j$7<%9=N~d(=l)fbb_B&8h9fPv|O?EE!V*7iX$-?dMfKCyn=~ z*BVZc#czJ!Bx?mnV7Vu-u<Q@O?onyzF)bGqxBs%4Jpe^2;PQXP}6pZ%Uvf7`!1epszIHGS24o}6`^ z?^`>-zxjZX-tpZVDFdxx5MEVh{7L^`IrI63!5v{hD6XgO~np7$rE z;w_~5N|RS+oYX+t9`6*QzsaI@gBm%$v~<~!Ti&SEhPX#($1gFy_@xjlxa_6X9~}=Z z8&v|Sjt1>zQ|&tA5$4VNet~42965m~e<;K#)tQ$9z4W4>EqQUy_g!tyycMQ!);#<#L*w)xbZKiW;*;nn5UkGHSve-cW zc`(=~i>rMsU%Q5spfhUB=WagZGKM^e<2WS1gnYj1v030m9z z%Meq^FweKy2BskvUqbr<@r@6B`Et-U3@kM_VLy--o|7rC*n=L+vu%#;QNQW z{p$eP&Q7yYxBgI3{S%MDrzXHp`x^10^0nN`=~$3Qfpsxa`5~TLRjdIPy5br`D5CC& zH&T+zJ$hJ_s*ckchWSQvY&ph*CR6La_qD-bxI3NU!l&o9NfEV-D3}6<^h|!|UnuRv z@@JKs#QUexmSg!IGy399NwMzOvFh1(844u3(z~qB$;3&lL)!l< zOF6X0LSGk)xJYbMo+z>39~J6*)$++!kzo)i@${(ClM>4~OPT_yCScOk-Ipo%E|OGS zuE1vhrTA_6sem^L5os=XmZcfiK7?O0eOfLe@WC!lqaseR$-eoUHk3{%Gy?E$UH`P4 zZz?~IexB@w2Oofq;vsCC$*0-tL`i%-gvAICl@N3g-YG-3_;}!%;M@w8Q*E`~{x{3E zWo3mO_1P_FH_>~}q9GGhW|<)KIOi-bTI^ME!?W8s7fzcHOj2C;fv5JRCryjuYUXB} z{$WXs3W!yM?U{LF+ib1f{&a(>N+!YOBzzI~dZ65=giK*(iaQ<5PwL{d16?h;%F|?A z@EKDhcSiLYI)-2XfV^nW^7tPd*yt$D^Y|*^_aywyH$`>E{yxr&xUZgTLyP;qi(gXM zH;1aIc&hxiKS#$c!%lu_B{q&3IcUW-RmGC<3s3s&3B3nn4AS9WYUU53kzhU#JYL&& zr1W{nBMY8|+pHEBQ4)7+uEFI#%zSv#zXhV&T8Sp*l0?(&++{5P)|D0ZJjx3;ii#W7 zPcsxE;v5BQdc5Yq!@D=U`wre9M{zeF4~Vn7_#k+H2ZXWK0Djk3M8R*Z(M?*U`EM(& zAAeh^|JM7dA^)=?-7!JXCO{f`eF*rs;I<7{m!n}p>s_qAX|mO}_A-@9^|IP)c5QT% zjq{V}q<-6g36Ou>;2$^ayH1C-8#Hs7CCSxq*$TVS(1e6N6&|bd^40<`@c18G3H2bR z%Aa-Qcujf2S?;?6Gc0Gjtc}PB5oXuM$HxfUtSF!+o0<~;_0o}xK>n@60*tf7azc)h z!rZfzL`fGA(lAO2_VFY8P6^!&`&OaqsZjAr(UJ>2uh$;c8tmi8V9}4|MVIA8Z_A4= zYT^mp(6Gqv7`5QTG3SL^K!KZb9j_M?Hi+Q7LZ5SX-5-db()Zj}c{}0GG`Feg-JmWuY6Y+fr^r zAT3@!%ajWt&9i~z*GEEQ4uURzO zX0_Wa8abs^Wa+6{54%(`Qi1UQ-~ahP{-6K%|Nh^ZZD@^J7{b*)ezZ1eHPBWA#wMtE z08lldz3WGD!h5Vp8E6@5Q@aBXpN6SmtiIj9OuU-;CiwO(-nYY-x(__^?3-2HUM3+r z$P3G~(tMEH&2(4(Atb?8tL0L{9=vDSFV9qS8e^2V&IGhS@iv^Tbh(5`(qv^LH*eSE zI{k(+Be=*OgYK=?6@)cBI32>sI+MC{iZh~HB03^(Wv4@2TlWOOv@Xi%svOW-11MK0 zj9O7(p8EwA*QdJzz)ar@*J0t0{Qd(eK)#m$@7iPE{&#(IWAXm;$MOFS`~AL*hwr5M zj`w794;SB0d}jf?u6ePaBtpEa$vC z&f?-un;}30`Y>>l@xJBKdoM#{OTYgT6Yo4f*wKAR?LPET*J^`HXqZ%;Gfo4rND9+;fK48a`*LS6YG^z}em!angG=;t=hv9^FQ9L08XStL^}7pi%$_ZfOw= zppsx)k-raFr(}b=omxEw0F`vvjfQe$VqlUVwdD6-PG8nTz^vY6U9SflVmcR`zKu;q zEG$oq4LMBV!q#Rp=@93nGa-w|h_u>laLJaj=gA^v=YL*&wfG;8H#Yq9e_{Xe2aEqS z*9+)N+Xuq?KRuZ7{R7h8k$B!P*YHel{DRPe;2w@bv)<`cg>Fz{e<3_>==p7vr1>(ldU|Q!<{_Y-(3m-W>J~f{$(Qc46dHn|2lkDyrU`B;C zfcr3{`Fy4_(<1u<@HqhLT%{j^wjwIOs^b~(bGc>6Mrll`wQ4b?e8xA=Hl)fUM`bo= z%o^6_lI%dONird?u%&U);G&iXdkH&W({vhMjTm~LI4 z4O{PhftG4sn~E6`i7~LE-uU5se z{lB04X|BuIOi}=sKv;T+X`Ee@L<}^8sx<_D8K06@`ID(X@3ueaB4EkB9 zs*a0><20ccZ)sP4LYi5L!t5BMC#bcDO|cJmOUpK9KPT!5`E(EuyBP=TmMB?{a^k?D zHJh*c{TwCH`uivubDhRO{g5eOtc2euSkj8~=kYKuxM zx1n4m1DT3r!UASVn*^#ujVH8|P#NiyzRBb&Rh*anZQ4-vD5Wjjz^;Lyq;u|&@8_wP ziANGzWwQ~A?sF}$PL*wofv`h+`9v@pK}v~M!iU#|wkeeQ_z^VWy@Q+a@SRE0(~@!G zPNU&9r%;<_tPLxB&)Y{6+S&Bc!~#8iI}^`#`9KXCZMSo31`;OCv@F3BE^2xWTKVxu zK;TDqe#2SR1zn`aGK!RmBT+}$gYAw^6hE%aTwfOaEG@ta;g=&e9`&Mv=Kwf@%zZ55 z%R>7OD={51{+?%dtVrMSq3DJ^q~a)6ofd<4d;?>Uqt zT%rt8v_@3)djt~r`zSfiEmf$-^cSoJ?W2NU-=STs1||*Ua*5V5tHlyr4a876Mi?0D z#~*l)sbW^|i6Jy}%Pi{Gwk1+5D%c>+;r*%0*@$N>8pZ>B^ke-j8gOS2XA--;ZGr*& zD1h3-$)y^b75b>kok7V7Y3UaIal&qI_>kSDV|E)23mDQUi_I#7A zd#)hbG+Ge{r#UADeS!Fd>uGckhtXBS8974O{@J-+GJv<(Vaw?-xsyFA%=gDd3PkHh zNpgqoJ~>``0Q){LEj+r!$P`3K6FN)_mPdV_+_3@At~sD!uqU{@j<9L0 z5Enbv(cEM;C7j~42y3PNyr@CWG#u@ZY@ci-pNo*Jf}!AbzdP{*+3GRyYom@pUYIwpy(bpy_E>a6QOF z-cPkukb|^gyO#nOR}RAZO@)HG&dEPrU7S$B91cds-9B0z*8@FB{S5H%ZDKPlbA)LP zmdJ)h6JLRZ6i101S=sriUgPtpyq{(qsL5Xos>v{Bl7@S&JRR_QUHpkOlxYFQm)4a5 zAg(uuEi3U z{oJEhp}Me%6N4* z)Y-r|Kr#;RB$m76?AB9tKGD3%-5cH;C;U0^V2}Iau$QClxxH6II_YmfE()XqE z(=RObE_p|ub>h3+gML+PRelScAQ;-U5isFax(j7dsB|uhjuN5HME+3UrHY|5kw4UT ztuX_W@9<5C27)1nBOKoXiZ*YgrEu~pGe?E=AI)!V@$cdN|I`!e!&&}Uc>mv8YeTLN z`~81yV||hT<7>bF4`g&l=aJtv1+aoZija@bv*>ytO{a|4z+uAuk0LVR3|~UqhtQ1< z`8_ZZHHo8ukgEvYmLEP+zex`1A>(BtgGU)!$9`1C1^zd=UW&5IB2k zG*A*6l45Gt!_&IKJ(00+-`#@k25#s+K>fUgfJ?K{+y*rLZJ9fwHsjj>W z;y-zy+s_Ny9@>gWxu(S|NVDVpzg@oGKRr9VJlH>Yad>%na(;Sr2 z@bdidZ|9dU4^RGl{(^Pb+QwzO-G1U^LOMEmd317kdAxuA;_~e9UeKaT(rYp$K7{3!_2o6s zo`MUgEV+$`z4R7HB0q@h#{o~09P2^OCZ5G!Njw_y-r4x-eavt3+R|=` zp^La9xHc|ga8V-f9Bdd3(3GKvW(B=Cx*n!^5qERQsmOZ#YJ7cd8IV8&E6i;v==VIs zXZHawqFz))ZVv{gtf|4`E174MqT!=I;3(sxEba08Lm#%PX zp%>@&JGnL@91q_O)7xQVn+XHJIQL%Em5rNx%M|tCK3_|({?5Ba4YW?;tI-%rs_EA& z`L~2|%KMO+6CVErB289jce2dV*vrS+@vw*o5FjW|6CfWQz;%*dMalV1j20}SipUJ6 z+5UxttxR32PxiKL zxi@IZ`JJ*wQE1j6R-Ke6K4J_z`A3Z`kn27iD6(k_|LSyzvSp9eR4cY^)!SpWRrpqG z*Ua-ibR%_A{0Vq~7wBY8A||A58i)HgV`?+7Ajd$)%B5CM?4;850rd)oev@ z4Gz-Sh`|uVwtxF*65t1#q|=X&CaBkZ`^0YDlMKz0qmk1q;F`Ic`U-QqQI99Q;C4H= zPEG(f2?n;&w8#AFPEA4sgl~A76HtbiAJO3E@TLZ`^FUkiGd|a zGcVeJIb!%Yk}c48K>SquC}a(<`qV1|oR@)?+9>QfM8jRHVjzE{WopJ=X_fAuoqx&#-G`|y^TH|hzj5y4=yQQ4 z)u4_LN5O`sLfNq{ECai?a)7MU?&ut{kH*eINHAtPRmC;PpR$hYBOarMQW6R%9Ft zq{t!z-ktZh9qn0A9ivrnz<*+`;E{#k$w1-p6%sAbiK&v=sQIe-} zDIt37Z92s1fILcY?!P7K5~3mT6ea{+^`OQ2F^n1gUlfK{966X-1Vd?YeFO{j8Ux{N zOG^#VLsSObmH2wF^+*72#`AHaedn|2magXj<9 zv)$l+j$2gu5gkU|8{LX!yvyVFJZBTENh4ViW&S7`=Llq3cYyW}X7m~jNQz~@D21>V zaX-)r4tH=W74hmtKso7a_}D;fUQe%Ybs*x9DR>T5JYwuy3maW^VCCmBbuU_`zAe zG9IWr+t?}5whFgH{$6;Y06TOeqjItOLzjNDrpbfYh!rmG!i%izIrr3My zMFfYA{-{ZmU6Vsvxnrq)@)Yc*@ihV2pkC5&>fCVfo*YEk9m~5}nk0mf=@7ng?drPB z-hv_B+ieylx6xg0@R6R20!xrr@|!g2K?eg~=*G2ZT%@(P+q50}N@8QT8F1ikIvAkO zGLBUYvzK;rz3ATJWW3#Gy*NiP!Xc5pO7T@g_icP7;T;EYvu5E~E@0^+1Q#WHK5}({ zI-SB<2@cD!wbVbi{`yJ)l51#lSRpq->+RuYMjDY*J{o zW&U=byMR|G<$WZ=RZzre`qDwu-px&GRZ)k~lDT6Yw$b*>n_(wVDeV54YHRmwbuF!T zY^iSdjP90HJw?%`By)~`Ra1=RJe7WwWh$Avkw4Tm(ea^il-*%1IPtH*3^ooK%%w-< zx^YMEyN|%_c0}~8daV%$5MLdoYc*_N5J!JocgWMUN2!sZ2!vGvbzOzhkBot20P5}M z1-%;69YgQJb|N9)FNU@={rg#T?3=f`oV)pS1cLgW8BC;8a`P^^JL7ZQ?E5nvg1Rh& zhdtgQq>nwho;lfGtcx?bJktSr6eo$RmX@q6J`?IO<2cH=seeLEMA#{}AvL(BtdxcK zMtDbzs1>%wZheM3(-|>z5o^^OPaYo25`H`rJE4&>Yz~aB@!{Fw;*QIhQ>u9oUi|FQ z$cb|LXj;Cru32H-sjYlqHPsC3bWa<%BP$V_xQm53xiNLp9v9wW288(a7cFaq##BoLrZ?7D^+Lmw4@<&s@vp*4A`otpnQ5ga zlQ?ZkiqO&MJV_!zK=s)X2T0I7(~(L8L4bPTC?GF8L6MGLg7m4R2(ydK;s~a4TT;{N zu0s9x&-V7%#T#|dd25@jj0<-)l4T(jWHg0=79si5yE9%C`0j!&sKnF}9y%&LUfn4Z zvN=36+Z51Got-jCt5J?mn0(X}q^T7NO8@|Tw*Z*9Q985yboTjC&?M0O_jgiM{Bk!qcGCc!0dSEmf3GogRk^?d3ZE{Gnnc6Hyh zwKH)p@-4p(u=&+B8&bd))&~`+6oO@}XMGT+3|qGCmZfI4or#mx(1lyt61&;5TDvnD zFx{QoU$7PQ1xMJ7-twFDHdo2G&rsKPH0;IQ2p5LKen^fykHeR_Absso{13 zE@uG`g}B{WDch$cin+3R63HOjUl*6|&(*bmByO$s1;;Vp$WV!j+=MF7;aq`e%d#~{?#mg1&$x5{u>WXoBiVpCHi zdX&qEQ(F0H2G9&+YAXAw1^io+e{Fc^6A?tMjW8%I*FPm0kVN5etpLm3rf;WSMIk#3{Q@aHh-6JanTrb*K#f%4Yy!VB z*{cWJ9dl$?8d55z^qI{iYQ@bp6&f=48j7%*xL&w`;Qvs$p6V z!9IRHLc7EA7S75F+ZTvKU=TKl^HCCYIV*TJh~ec>v}I9IMBN))$zbY(@v41Lf8l(@ zB6fpmsljFlr4;r)&SQ9ty-w~%H_|*qcK{f=pmv-cYh8)vJ2?hcC%UOd!{T|G?RU|_ z>ncv-;;vI0rZuPfuGf50i)t=VKR>#aAss46#;|Li6UbwNKc|E(dl8Zp*YBqp*!rDk>in_7UMtQ>Zd(=ZLn_?s&F} z&K0YcZNyhXdw6PxiYi-*QZ!T3@kD2qRiV15ft?Ol$+P@I59N(^eX%OrE*@$Kr^;fq zWNYjrTW25H2K&f1*+;hJ zW`xg;2yoQ>G+efmQKkz7k5YibqT#*!n1=yUu8R!dr+pM+VgUE0YDd`CCfp~!9cN? zsD?wV&v5@J-h%Sm7|myNhx$-}cYZ^XZF@$HJW%?WqM9~+YQ3?ptP|&$vCkK|o5X*JfNZBw z(?<(pt@w30(kdVjvTb?j8x}rQ46tkS0keRI5U6fxl?80;uG5PUA!zIWHq z>~mND%tcg=v3$w6KhC1z?}WAe%yNI^t@gju7*KvIM;#-6fzTzHj*-8<0@H!cOV$et ziREZxOf!PTI7|Ei(3I35AF9g~{GM9LWD8joCDu($Nei6`73gV=QW7nBnm>DhR1|cE zxkG}OK%vN^YeXJfLU6A6Ks{J;snd6CaG4rk)Y{^26sBmr) z?85U3l*ow|uTI{~Q^W1=NOdn|q%v|h;K*OCjHB+hzVcsxd-T)C-yZ$+Xyv-eYPCiS zL|da7Q-d)f1q?UC0#xmn%IpS>5O zOz}JXO$({E>wX*9+F*geOXV6~^1$Y(SeO<92&-o`*dJA5BT;*!O7TBgjl-IB;e=L((0?d zWX+3fRoRnFa6YS&G0+&_XkAkZHVxL?HhKrId^-1KXWqL1mR7d6xsLQv>~5|b{REszR;6CQ={VM)+4$_rO6NPfmi7bK zCZ53}p!E0spC7M2Ue%9LB%t&L{q{T>#L3;Zu;y#&FEP(sgLIgpLIQq3#0g%buOhPy zZ`fo;glhfX(Ik;s9Q*}3BBX_tFU^d@U<`lfE&X0fgJpY_*pFmw7tD6tvEz!`aa+)O=o9v?4P&*!Ax2^iy>Tp}~F%gAgw zmMlaY>f9ZmT?KvOe?}jO6N5#Hoe9ZmVFk&-L*B}f0M$^WqdpV!BSsOA00nER5{~PH zc}ptr#>xugc@htcWf`Jq8N~2q)WZG;Ml_OnGrP3c-@+gWvKP{=)-dU{#t~`cL^qy$Qn{hg|7Pe(JpwhL<3f=tg!fcm}b1U zG?h01qfe%0T2jqxQCg8y6Vext0oZ4W#{-ScfiEZv)slHy*k)_$;9Xf^hr@iFaSVnf z&7Z-k50V(NB)Ut-kO@A%9uCAc3I6?koELHb?m!vb)VdrlOf^dgFc>|T{?7r-;4}CR z40{IwkrW=Mp6VLLQV(ShIvmkT#%pVR$epUn?~sh!j!q}q({C*eqm+21o^wvo9Z~0 z4wTGywJfB#HP4qaSBJA5YREmkrQ)o~F7U@262YPyeC3v;Beov`1bs(V78F==Q=uZ4SZY6GG(~?(Z8d8=i zKYn{G_leapn}i;3^hjx$eU-MK?oL7`eFnh>@ub$)1fM8zdi&?82-YjNj{*HpvU{rT z3R^b$2>$^OYfMlFj0BKSj{W$mxT)LpHTm&?aUuU z2{=>#*IwP)dhF`|Ha0gOFZ6%kg8t8hjwsXp3C$nX_qiHAN597&TD`ZVlKF}?YaQ9z zS5Ob+i4|GoJ}NoyVAoyZ!EPfE3`uP}L&|W_4|v#{2$Yi3mnpHNZIHsA8d|~2(X%+? zm|1e0)xd6}>6F8R(>AMtC$d#$TUM1^EHU0Pv~Qa))25G>@!+))3`D}Qi_X&$;5t~Q z5PFQWlYhD{S+c;zk|i9VX@o$s83^)+F&&+HN1huj2?q}U-U!)1P25B+o9UN5b;JB0 zZF3&%0ca-wU*CMZ+4kOl9>bf^g8zR#{!hakH^m=#9EhK%ysv-t#$BOoA@Mf#BhK>! z{nL6bao%+9^K>La*r_@d9+Cjenc74SEnUxz*#EQaqaT~VJ!P6fS$o>apCYgSZKjT%qNsOY9K zR@2r*C`~d;Dwna@?`{l06=ly)C}tvN*st6nv&>SZmt_0NcrZleeZG$}r0iG(?W~<1 zo*kZF{(AK6{KZ)f65_z0$FELaoxeIcI#2~7hwWXGMm_bmZZntTqm#>nS1*4#J`qh= zhi$c=$U<3ge7Jx1%jx0q;mP^s>-}fXj!yo}I_zgvYyb7@(^r4HJb1Bxs$o1LA z@a+8Pc>nzH8P+*t9k#lr`!I#r_VVof@HOkOwG9n{^nP}}|MckP(f@jQd5mB-G?=5a z%l(7%%k#st^UK4NzaE{wI)N4#m!LE){n6Rw=^-?O|0k_#4ISfL!9RQP>X(<#E}tDf zef7)9!6Bsy>#*-ix;Jm;WH#l|4{SUyqJo==X<{BCxGngz-=IM-MK!Wvt<^wTs+PZ_ zg3}0W5`tVCszgUM5cN4^tE$&;0S)TtgdYN6F0hIWOYa4lK$s?&gvo1a!O)tz;5dVe zoQ~HT=yzoU^>FiXFurrbm;(|dfZ?$N4?V$L0!i4QnS@BpsvL2XGy>j-pBr}Q9y5md z#1}XzqF1K3Xy$4}Wo;sMEI^9 zq*Y?-VjYsnaeIa1y zK~x2{QbUIG9)}D+cEs^0`od3U$l9KsqX`sLyvWkqH#i{6+ZE4Wk{#G>T}CjEWC%!7 z!Xdy65)fZ06o7P(yP70aejsGhRh?*HR@5rF#CyMrJ+HX^_U)O40CgsM7UI`X0r z)2Lyet*DzRwS_atxVI-7{tDv-d1+e+r_kF?@1trDgxFi@3!%6C92OfIMSvOC9a>Nh zDaTh{!yEMl6;YaypK%1Xgl_~sQf84MmVIGH`kjOZBs($(X#^HQJv!P`-rqTnMmyq0 z*_5Zl?rt59f#YaYZv+6Kyl>i{@j|wj?ap{%wG+$wAWFL9L^=_Prgh4-!JBzP}Q8CWA)vi|a@rOlw9uIg%Ywq}dSvkjQ>9|lftGhDrmCQRv zKwn@q&12I+6PlkmA45}Gzf>-1CiFV`pJ8GT{SDWm{wyZl1jr_^fE~+PZ9W}U^%58Hk>_#`AjD1Zt!T3huf?L`^sUOTu7oT+7&Xi+y~PdU zLxs!g+^((8;nZ1vG*Qr|Av8>;LxYguyZ}h0>an)&n;Ep#ORkLn*)!4ldm0bxwU0H$ zBissvGlUbiIoqQpKOTTlwE5T|IeGqgvDnF2RTCG;enfgWpxW8J% zRc*)%XxRz?<)|e)5qU+7l7ji+2Mel`E`*tpdbd)$q)EY8%KIhj5#gTN4XrOo6Ua8H z^&5K8xC#whsZoVgM$(%G=w;LunqKA)k6QPkmj+Y%c(gPhX!%UdQg2WT5l^`g3co&I zicp!Q9$JXdqcVbn6nDvn@z^=WFmZ2CD1W>6n`T zN+aD3;3t;wVXx-Oz^K#!RkK!ZNFA?<>MW~Z3~m8Y+Zn45!O9u<&yczr2g@j*B5lIq z%ME38BjLfP@-%r*^aBRco&|}O$Zk)nnfaEdqMW#Ez)D1E z0aG@=rmi*?wjv$PX>^+2x;ci_%~wR4<4NM&KE0r1fovBlUxe&wIdC&BjTjaf8BdZx z;Ig5Rb2^hc-}cQx0&($FJ0m02gK@(`7x`weCT|HV6YQ}iJV0CG-6T-od7VezP$3Q( zpsT_<%AjLF5E30qM~0HRK}E!AC@>g}tsP3C ze_nfBvwNP>Ki`CXc0%WzVB=r-h`Z0xc2UMy6t1vmLR&G+DCFJUmRl|q9{4<5E-ZU? zV$zkD%9nLbl~B8B=%Z@kidhiOlUP+Nji?Xe&HYZqrTa3LYHVdbQz?{cF@Z8R@PLfqQ&~X?Gk5@Y5MT)3yu1-&4$yCy zHGC!cWM{?5BPD**()7~M5iKnq)Z`Si4Tl4O63YdwuW4v`b7|LrU5urKuGeVrVaz>b zPfY`Xcw?i|)HViJ`Las#CtL2(mfx+0@1&5BIWtIpn>h}^WQ|D5%lx23Ghkt8MS=Gc z|Lz0=42uBaJu3isNd#*dxw=ii(MZ*vyY)Zud5d6Ha3>n$6{ScO?O{(! zl`yH3DM{~5LYSDk)>ml32HWgssdSRlb&nXUvje^4c6gJ3;>JK4rFkr!_oAyjO~!?Z zbeaOZ;*R*ojnWwOZg{JMfF{FqXsRc?Uu?7GKmF-Xe=0-ZG`+RMPKKQa1DNw9AxOIc zY*yPdV%DtMk)gVB(8DQ7;E0e%jR?M1kw3_ri&-w{~s)-!L_lZ@bLJ&Z=Pq6@2 zOYQsh#~Z7gt2TM`rK1W-12KANUoQ}r=xepV_NVp74%WhjGK{+@`-Bll0k0zdUk(o` z^UVl=MxmXix3B>a91ZedffQ0>b%?KeJp`CtUq$t`&CRCz|8}dr*05nqxz=5l7HQgn zrDAU)p!L~J77yRi4gN_x3*%PFj-^*!B0Q2JRN6Uo(njyUtgZSdUeifk0ui4FQ=zy?DguG(}Tmy zXGf>gl~!w|I~vsx@Ijh@t_FkHQxW*n+xzox;{qZ2S&2)DV4hrnEN-45`@s|6Awzlp$XD2cDKD7#~~ zadCr0Q3Q#4y-QhGe>8c0`szOq56&-7_Ky#z?Ciz<>ESbE9gik-kW=}MjzoQPkQJZ5 zXa9L}#G+m_D&##9;!6yocnCy>|6i7cFo-1_#F1d2tcUzB&0?M}6;XE0i%ZE{FG-Py z{Oi-BzwVzOVrSl*)cnst`@m;K-kLz_b;d_o+U0qkW(>W>@n)ql>!yQIobXFhG!rwV z>Mv)9m%p4p|GBejj~q7_i^fF?xkAB5y&m;?IUBnO|NmXR0#Oaj2~c=CVG4nK$!07g=9h(jAET!eCgPwF&RgMzeo_2)+~&ks-S zJ`VqO@bZ^uhwSw5&xe0wwbqX?OTR%kk32~MP*9L9u>b~n0EK7g`{zdomF0o5QyeW} z$uMS}>bmdOzdU^@`>uYPfAW!Sx}A4c69VEy@ejN^#>+R-(ERCa6{+>j_=^4A$Um3> zJOCF~wKrdKEO0i Date: Tue, 22 Sep 2026 16:46:27 -0700 Subject: [PATCH 04/17] feat(web): show settings scope as a sentence at the top of the page (#13139) Co-authored-by: Claude Opus 5.5 --- .../settings/ProjectDefaultsSettings.tsx | 250 +++++++++--------- .../settings/ProjectSettingsPanel.tsx | 14 +- .../components/settings/ProjectsSettings.tsx | 5 +- ...ProviderSettingsPanel.environment.test.tsx | 1 + .../settings/SettingsBreadcrumb.tsx | 198 +------------- .../settings/SettingsScopeSentence.tsx | 207 +++++++++++++++ .../settings/SnapShotSettings.test.tsx | 1 + .../components/settings/settingsLayout.tsx | 2 + apps/web/src/components/ui/button.tsx | 3 + apps/web/src/routes/settings.tsx | 41 ++- docs/user/project-settings.md | 7 +- 11 files changed, 383 insertions(+), 346 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsScopeSentence.tsx diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index fe7576933857..c39b8cb3c5b4 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -129,97 +129,151 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting updateSettings({ defaultModelSelection: value }); }; + const modelRow = ( + setModel(null)} /> + ) : null + } + control={ + selection && activeEntry ? ( +