From e2e6ce6a29451e9fac612ab83f7a104b6d6475a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:51:07 -0700 Subject: [PATCH 01/65] feat(server): report image dimensions with signed asset URLs (#10198) Co-authored-by: Claude Code --- apps/server/src/assets/AssetAccess.test.ts | 24 +++ apps/server/src/assets/AssetAccess.ts | 66 ++++++++- apps/server/src/assets/MediaFile.ts | 23 +++ packages/client-runtime/src/state/assets.ts | 6 + packages/contracts/src/assets.ts | 8 + packages/shared/package.json | 4 + packages/shared/src/imageDimensions.test.ts | 140 ++++++++++++++++++ packages/shared/src/imageDimensions.ts | 154 ++++++++++++++++++++ 8 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/imageDimensions.test.ts create mode 100644 packages/shared/src/imageDimensions.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 53e33cf7fd5b..b83b8684432c 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -83,6 +83,30 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("reports pixel dimensions from an image header and nothing for other files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-dimensions-" }); + const png = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, + 0x06, 0x40, 0, 0, 0x03, 0x84, + ]); + yield* fs.writeFile(path.join(root, "shot.png"), png); + yield* fs.writeFileString(path.join(root, "clip.mp4"), "video"); + yield* fs.writeFileString(path.join(root, "broken.png"), "not a png"); + const issue = (name: string) => + issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: name }, + workspaceRoot: root, + }); + + expect((yield* issue("shot.png")).imageDimensions).toEqual({ width: 1600, height: 900 }); + expect((yield* issue("clip.mp4")).imageDimensions).toBeUndefined(); + expect((yield* issue("broken.png")).imageDimensions).toBeUndefined(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("resolves relative media paths from the thread workspace, including outside it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index a0d849bf603a..956c4ac44211 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,6 +21,11 @@ import { WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; +import { + IMAGE_DIMENSIONS_HEADER_BYTES, + readImageDimensions, + type ImageDimensions, +} from "@t3tools/shared/imageDimensions"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -44,7 +49,7 @@ import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; -import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; +import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { Effect.orElseSucceed(() => null), ); +/** + * Reads pixel dimensions from an image's header so clients can reserve the + * exact box before the bytes arrive. Best effort: an unreadable or unsupported + * file just leaves the field out, and the client measures after decode. Only + * formats the parser understands are opened; SVG and the rest are skipped. + */ +const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); + +/** From the identity-checked, non-blocking handle the caller already holds. */ +const readImageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) => + readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe( + Effect.map(readImageDimensions), + Effect.orElseSucceed((): ImageDimensions | null => null), + ); + +/** + * Opens through `openMediaFile` so a path swapped for a FIFO cannot block the + * request; a regular open would wait for a writer that never comes. + */ +const readImageDimensionsFromHeader = (filePath: string) => + openMediaFile(filePath).pipe( + Effect.flatMap((file) => + file === null ? Effect.succeed(null) : readImageDimensionsFromOpenFile(filePath, file), + ), + Effect.scoped, + Effect.orElseSucceed((): ImageDimensions | null => null), + ); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; @@ -236,6 +269,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let claims: AssetClaims; let fileName: string; let sourcePath: string | undefined; + let imageDimensions: ImageDimensions | null = null; switch (input.resource._tag) { case "media-file": { @@ -265,18 +299,33 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } - const identity = yield* openMediaFile(canonicalFile).pipe( - Effect.map((file) => - file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null, + const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has( + path.extname(canonicalFile).toLowerCase(), + ); + const opened = yield* openMediaFile(canonicalFile).pipe( + Effect.flatMap((file) => + file === null + ? Effect.succeed(null) + : Effect.map( + wantsDimensions + ? readImageDimensionsFromOpenFile(canonicalFile, file) + : Effect.succeed(null), + (dimensions) => ({ + identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() }, + dimensions, + }), + ), ), Effect.scoped, Effect.mapError( (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), ), ); - if (!identity) { + if (!opened) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); } + const identity = opened.identity; + imageDimensions = opened.dimensions; claims = { version: 1, kind: "media-file-exact", @@ -347,6 +396,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (HEADER_IMAGE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { + imageDimensions = yield* readImageDimensionsFromHeader(canonicalFile); + } claims = isWorkspaceImagePreviewPath(resolved.relativePath) ? { version: 1, @@ -390,6 +442,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i INLINE_DOCUMENT_EXTENSIONS.has(extension) ? INLINE_DOCUMENT_MIME_TYPES[extension] : undefined; + if (!isGenericFile) { + imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); + } claims = { version: 1, kind: "attachment", @@ -547,6 +602,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, ...(sourcePath !== undefined ? { sourcePath } : {}), + ...(imageDimensions !== null ? { imageDimensions } : {}), }; }); diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts index e1053555b052..7fb0c1135607 100644 --- a/apps/server/src/assets/MediaFile.ts +++ b/apps/server/src/assets/MediaFile.ts @@ -19,6 +19,18 @@ class MediaFileOpenError extends Schema.TaggedErrorClass()( } } +class MediaFileReadError extends Schema.TaggedErrorClass()( + "MediaFileReadError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read media file '${this.path}'.`; + } +} + class MediaFileStatError extends Schema.TaggedErrorClass()( "MediaFileStatError", { @@ -95,6 +107,17 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* ( ); }); +/** Reads the leading bytes of an already-validated media file, never past the end. */ +export const readMediaFileHeader = (filePath: string, file: OpenMediaFile, byteCount: number) => + Effect.tryPromise({ + try: async () => { + const buffer = new Uint8Array(byteCount); + const { bytesRead } = await file.handle.read(buffer, 0, byteCount, 0); + return buffer.subarray(0, bytesRead); + }, + catch: (cause) => new MediaFileReadError({ path: filePath, cause }), + }); + export const statMediaFile = Effect.fn("statMediaFile")(function* ( filePath: string, file: OpenMediaFile, diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index f2f82def3dd7..0030cec6d0c5 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,5 +1,6 @@ import { type AssetCreateUrlResult, + type AssetImageDimensions, AssetResource, EnvironmentId, WS_METHODS, @@ -60,6 +61,8 @@ export type AssetUrlState = readonly url: string; /** The host path the server chose to serve, when it differs from what was asked for. */ readonly sourcePath?: string; + /** Pixel size from the image header, when the server could read one. */ + readonly imageDimensions?: AssetImageDimensions; }; export function assetUrlStateFromResult( @@ -74,6 +77,9 @@ export function assetUrlStateFromResult( _tag: "Success", url, ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + ...(result.value.imageDimensions !== undefined + ? { imageDimensions: result.value.imageDimensions } + : {}), }; } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 5b003374edf3..927dbbac0d7a 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -52,12 +52,20 @@ export const AssetCreateUrlInput = Schema.Struct({ }); export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; +export const AssetImageDimensions = Schema.Struct({ + width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), + height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), +}); +export type AssetImageDimensions = typeof AssetImageDimensions.Type; + export const AssetCreateUrlResult = Schema.Struct({ relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), expiresAt: Schema.Number, sourcePath: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), ), + /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */ + imageDimensions: Schema.optional(AssetImageDimensions), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index d6915120b806..a9f60297f4a0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -211,6 +211,10 @@ "types": "./src/filePreview.ts", "import": "./src/filePreview.ts" }, + "./imageDimensions": { + "types": "./src/imageDimensions.ts", + "import": "./src/imageDimensions.ts" + }, "./video": { "types": "./src/video.ts", "import": "./src/video.ts" diff --git a/packages/shared/src/imageDimensions.test.ts b/packages/shared/src/imageDimensions.test.ts new file mode 100644 index 000000000000..1b86bb97493d --- /dev/null +++ b/packages/shared/src/imageDimensions.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { readImageDimensions } from "./imageDimensions.ts"; + +function bytes(...parts: ReadonlyArray>): Uint8Array { + const out: number[] = []; + for (const part of parts) { + if (typeof part === "string") for (const c of part) out.push(c.charCodeAt(0)); + else if (typeof part === "number") out.push(part); + else out.push(...part); + } + return Uint8Array.from(out); +} + +const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; +const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff]; +const u16le = (n: number) => [n & 0xff, (n >>> 8) & 0xff]; + +describe("readImageDimensions", () => { + it("reads a PNG IHDR", () => { + const png = bytes( + [0x89], + "PNG", + [0x0d, 0x0a, 0x1a, 0x0a], + u32(13), + "IHDR", + u32(1600), + u32(900), + ); + expect(readImageDimensions(png)).toEqual({ width: 1600, height: 900 }); + }); + + it("reads a GIF logical screen", () => { + expect(readImageDimensions(bytes("GIF89a", u16le(320), u16le(240)))).toEqual({ + width: 320, + height: 240, + }); + }); + + it("reads a JPEG start-of-frame after an APP segment", () => { + const app1 = bytes([0xff, 0xe1], u16(2 + 6), "Exif\0\0"); + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(1400), u16(720)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({ + width: 720, + height: 1400, + }); + }); + + it("swaps the axes for a JPEG whose EXIF orientation rotates it 90 degrees", () => { + // Big-endian TIFF with one IFD0 entry: tag 0x0112 (orientation), SHORT, count 1, value 6. + const tiff = [ + 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + const exif = ["Exif\0\0", tiff] as const; + const app1 = bytes([0xff, 0xe1], u16(2 + 6 + tiff.length), ...exif); + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(3024), u16(4032)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({ + width: 3024, + height: 4032, + }); + // A later XMP APP1 segment must not clear the rotation. + const xmp = bytes([0xff, 0xe1], u16(2 + 29), "http://ns.adobe.com/xap/1.0/\0"); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...xmp], [...sof0]))).toEqual({ + width: 3024, + height: 4032, + }); + // Orientation 1 leaves the frame size alone. + const upright = [...tiff]; + upright[19] = 0x01; + const app1Upright = bytes([0xff, 0xe1], u16(2 + 6 + upright.length), "Exif\0\0", upright); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1Upright], [...sof0]))).toEqual({ + width: 4032, + height: 3024, + }); + }); + + it("steps over standalone TEM and restart markers", () => { + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(10), u16(20)); + expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0x01], [0xff, 0xd3], [...sof0]))).toEqual( + { width: 20, height: 10 }, + ); + }); + + it("does not mistake a Huffman table marker for a frame", () => { + const dht = bytes([0xff, 0xc4], u16(4), [0, 0]); + const sof2 = bytes([0xff, 0xc2], u16(17), [8], u16(10), u16(20)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...dht], [...sof2]))).toEqual({ + width: 20, + height: 10, + }); + }); + + it("reads each WebP container flavour", () => { + const riff = (chunk: string, body: ReadonlyArray) => + bytes("RIFF", u32(0), "WEBP", chunk, u32(body.length), body); + // VP8: frame tag (3), start code (3), then 14-bit width and height. + expect( + readImageDimensions(riff("VP8 ", [0, 0, 0, 0x9d, 0x01, 0x2a, ...u16le(800), ...u16le(600)])), + ).toEqual({ width: 800, height: 600 }); + // VP8L: signature 0x2f, then width-1 (14 bits) and height-1 (14 bits) packed LE. + const packed = (800 - 1) | ((600 - 1) << 14); + expect( + readImageDimensions( + riff("VP8L", [ + 0x2f, + packed & 0xff, + (packed >>> 8) & 0xff, + (packed >>> 16) & 0xff, + (packed >>> 24) & 0xff, + ]), + ), + ).toEqual({ width: 800, height: 600 }); + // VP8X: flags (4), then 24-bit width-1 and height-1. + expect( + readImageDimensions( + riff("VP8X", [ + 0, + 0, + 0, + 0, + 799 & 0xff, + (799 >> 8) & 0xff, + 0, + 599 & 0xff, + (599 >> 8) & 0xff, + 0, + ]), + ), + ).toEqual({ width: 800, height: 600 }); + }); + + it("returns null for unsupported, truncated, or zero-sized input", () => { + expect(readImageDimensions(bytes(""))).toBeNull(); + expect(readImageDimensions(bytes([0x89], "PNG"))).toBeNull(); + expect(readImageDimensions(bytes("GIF89a", u16le(0), u16le(240)))).toBeNull(); + expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0xd9]))).toBeNull(); + expect(readImageDimensions(new Uint8Array())).toBeNull(); + }); +}); diff --git a/packages/shared/src/imageDimensions.ts b/packages/shared/src/imageDimensions.ts new file mode 100644 index 000000000000..8eceb305b764 --- /dev/null +++ b/packages/shared/src/imageDimensions.ts @@ -0,0 +1,154 @@ +/** + * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP + * file so a client can reserve the exact box before the bytes arrive. Any + * other format, a truncated header, or a malformed file yields null; callers + * fall back to measuring after decode. + */ +export interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +/** + * Enough for every supported header. A JPEG's frame header can sit behind + * several 64 KiB metadata segments (EXIF, an ICC profile, XMP), so allow a + * few of them before giving up. + */ +export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024; + +export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null { + const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes); + return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null; +} + +const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + +function readPng(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 24) return null; + if ( + bytes[0] !== 0x89 || + bytes[1] !== 0x50 || + bytes[2] !== 0x4e || + bytes[3] !== 0x47 || + bytes[12] !== 0x49 || + bytes[13] !== 0x48 || + bytes[14] !== 0x44 || + bytes[15] !== 0x52 + ) { + return null; + } + const data = view(bytes); + return { width: data.getUint32(16), height: data.getUint32(20) }; +} + +function readGif(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 10) return null; + if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null; + const data = view(bytes); + return { width: data.getUint16(6, true), height: data.getUint16(8, true) }; +} + +function readWebp(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 16) return null; + if ( + bytes[0] !== 0x52 || + bytes[1] !== 0x49 || + bytes[2] !== 0x46 || + bytes[3] !== 0x46 || + bytes[8] !== 0x57 || + bytes[9] !== 0x45 || + bytes[10] !== 0x42 || + bytes[11] !== 0x50 + ) { + return null; + } + const data = view(bytes); + const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!); + switch (chunk) { + case "VP8 ": + if (bytes.length < 30) return null; + // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code. + return { + width: data.getUint16(26, true) & 0x3fff, + height: data.getUint16(28, true) & 0x3fff, + }; + case "VP8L": { + if (bytes.length < 25) return null; + // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the + // 32 bits after the signature byte. + const packed = data.getUint32(21, true); + return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 }; + } + case "VP8X": + if (bytes.length < 30) return null; + // Extended: 24-bit canvas dimensions minus one. + return { + width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1, + height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1, + }; + default: + return null; + } +} + +function readJpeg(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + const data = view(bytes); + let offset = 2; + let rotated = false; + while (offset + 9 <= bytes.length) { + if (bytes[offset] !== 0xff) return null; + const marker = bytes[offset + 1]!; + // Padding bytes between segments. + if (marker === 0xff) { + offset += 1; + continue; + } + // Start-of-frame markers carry the dimensions; skip the arithmetic-coding + // and Huffman-table markers that share the range. + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + const height = data.getUint16(offset + 5); + const width = data.getUint16(offset + 7); + return rotated ? { width: height, height: width } : { width, height }; + } + if (marker === 0xd9 || marker === 0xda) return null; + // TEM and the restart markers stand alone, with no length field. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + const length = data.getUint16(offset + 2); + // Viewers apply the EXIF orientation before display, so a phone photo + // stored on its side takes the swapped size on screen. + if (marker === 0xe1 && !rotated) { + rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length); + } + offset += 2 + length; + } + return null; +} + +/** Whether EXIF orientation 5-8 (a 90° rotation) applies. `start` is the APP1 payload. */ +function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean { + end = Math.min(end, bytes.length); + // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset. + if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") { + return false; + } + const tiff = start + 6; + const data = view(bytes); + const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false; + const ifd = tiff + data.getUint32(tiff + 4, littleEndian); + if (ifd + 2 > end) return false; + const entries = data.getUint16(ifd, littleEndian); + for (let i = 0; i < entries; i += 1) { + const entry = ifd + 2 + i * 12; + if (entry + 12 > end) return false; + if (data.getUint16(entry, littleEndian) === 0x0112) { + const orientation = data.getUint16(entry + 8, littleEndian); + return orientation >= 5 && orientation <= 8; + } + } + return false; +} From 7451d17a69ff7585f302933beeae1037d2c708a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:51:08 -0700 Subject: [PATCH 02/65] fix(mobile): size a chat image's frame before its bytes arrive (#10199) Co-authored-by: Claude Code --- .../src/features/threads/ThreadFeed.tsx | 48 ++++++++++++------- .../features/threads/ThreadMarkdownImage.tsx | 47 +++++++++++++----- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 360b980edc95..03f0b88f5f4c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -177,6 +177,7 @@ import { } from "../files/filePath"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; import { + MarkdownImageAvailableWidthContext, ThreadMarkdownImage, ThreadMarkdownImageUnavailable, ThreadMarkdownImageView, @@ -206,6 +207,10 @@ function formatMessageTime(input: string): string { // text fits at the current font settings. Larger accessibility text is measured. const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); +// Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on +// assistant rows. Images size their frame from these before their own layout. +const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; +const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5; // Let neighboring rows move out of the new rows' space before showing their text. const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS, @@ -1331,6 +1336,8 @@ function renderFeedEntry( readonly reviewCommentBubbleWidth: number; readonly themeAppearance: "light" | "dark"; readonly userBubbleMaxWidth: number; + /** Width assistant markdown lays out in, so images can size their frame before layout. */ + readonly markdownContentWidth: number; }, ) { const entry = info.item; @@ -1452,14 +1459,18 @@ function renderFeedEntry( }} > {message.text.trim().length > 0 ? ( - + + + ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( @@ -1516,14 +1527,16 @@ function renderFeedEntry( {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} > {renderedText.trim().length > 0 ? ( - + + + ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( @@ -1942,6 +1955,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); const userBubbleMaxWidth = contentWidth * 0.85; + const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT; @@ -2620,6 +2634,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, themeAppearance, userBubbleMaxWidth, + markdownContentWidth, skills: props.skills, onUseArtifactTemplate: props.onUseArtifactTemplate, })} @@ -2641,6 +2656,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, themeAppearance, userBubbleMaxWidth, + markdownContentWidth, onCopyWorkRow, markdownLinkHandlers, onPressPreview, diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index c506bf1875eb..a67225f1a336 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -1,5 +1,5 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { useEffect, useId, useState } from "react"; +import { createContext, useContext, useEffect, useId, useState } from "react"; import { ActivityIndicator, Image, @@ -15,32 +15,56 @@ import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { PresentationSource } from "../../components/NativePresentation"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { useAssetUrlState } from "../../state/assets"; -import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { + MARKDOWN_IMAGE_MAX_WIDTH, + type MarkdownImageDisplaySize, + resolveMarkdownImageDisplaySize, +} from "./markdownImageSize"; + +/** + * Width the feed lays markdown out in. The feed already knows this from its + * viewport, so an image can size its frame on the first render instead of + * waiting for its own onLayout, which would change the row's height once + * more after the list has positioned the rows below it. It is an upper + * bound: a list item or blockquote indents its column, and the measured + * width takes over once it is known. + */ +export const MarkdownImageAvailableWidthContext = createContext(0); export function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; + /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ + readonly knownSize?: { readonly width: number; readonly height: number } | undefined; readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); const mediaActions = useMediaActions(props.actionsSource); - const [availableWidth, setAvailableWidth] = useState(0); - const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const contextWidth = useContext(MarkdownImageAvailableWidthContext); + const [measuredWidth, setMeasuredWidth] = useState(0); + const availableWidth = + measuredWidth > 0 && contextWidth > 0 + ? Math.min(contextWidth, measuredWidth) + : contextWidth || measuredWidth; + const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); useEffect(() => { - setSourceSize(null); + setDecodedSize(null); }, [props.sourceKey]); useEffect(() => { setFailedUri(null); }, [props.uri]); - const displaySize = - sourceSize === null + // The decoded size is what the platform actually drew, so it wins over the + // server's header hint once it exists. + const sourceSize = decodedSize ?? props.knownSize ?? null; + const displaySize: MarkdownImageDisplaySize | null = + sourceSize === null || availableWidth <= 0 ? null : resolveMarkdownImageDisplaySize({ sourceWidth: sourceSize.width, @@ -54,7 +78,7 @@ export function ThreadMarkdownImageView(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} + onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)} style={{ alignSelf: "stretch", gap: 6 }} > {props.uri === null || failed ? ( @@ -97,14 +121,12 @@ export function ThreadMarkdownImageView(props: { > setFailedUri(props.uri)} /> @@ -173,6 +195,7 @@ export function ThreadMarkdownImage(props: { : `workspace:${props.resource.path}` } unavailable={assetUrl._tag === "Failure"} + knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} alt={props.alt} actionsSource={props.actionsSource} onPressPreview={props.onPressPreview} From d0f855bfa91b1d961a17a02ec3e383f9671caba1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:51:08 -0700 Subject: [PATCH 03/65] fix(web): size the chat image slot from server-reported dimensions (#10200) Co-authored-by: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 24 +++++++++- .../ChatMarkdown.workspace-images.test.tsx | 46 ++++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 2 +- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 55de831a52e7..4e592c406b1d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1235,9 +1235,15 @@ function markdownImageCopy(alt: string, src: string, title: string | undefined): return `![${escapedAlt}](${src}${titleSuffix})`; } +/** + * `maxHeightRem` folds a height cap into the width bound: `max-height` alone + * would not feed back through `aspect-ratio` once `width` is definite, so a + * tall image would keep a box wider than the picture it draws. + */ function authoredImageSizeStyle( width: string | number | undefined, height: string | number | undefined, + maxHeightRem = 30, ): CSSProperties | undefined { const parsedWidth = Number(width); const parsedHeight = Number(height); @@ -1248,7 +1254,7 @@ function authoredImageSizeStyle( width: parsedWidth, height: "auto", aspectRatio: `${parsedWidth} / ${parsedHeight}`, - maxWidth: `min(100%, 30rem, ${(30 * parsedWidth) / parsedHeight}rem)`, + maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, }; } if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; @@ -1522,6 +1528,8 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly srcFragment?: string; /** Reserve a slot while loading; off for images that share a line with text. */ readonly standalone?: boolean | undefined; + /** Caps the box height in rem while keeping the image's ratio; 30 by default. */ + readonly maxHeightRem?: number | undefined; readonly style?: CSSProperties | undefined; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; @@ -1538,6 +1546,18 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; const relativePath = reference?.relativePath; const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + // The server reads the pixel size from the file header, so the slot can be + // the image's final box instead of a 16:9 guess. An authored size wins; a + // caller's height cap shrinks the box while keeping the ratio. + const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; + const maxHeightRem = props.maxHeightRem ?? 30; + const style = + props.style ?? + (knownSize + ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) + : maxHeightRem !== 30 + ? { maxHeight: `${maxHeightRem}rem` } + : undefined); const actionsSource: MediaActionSource = { kind: props.kind ?? "image", name: props.alt || (props.kind ?? "image"), @@ -1581,7 +1601,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props copyMarkdown={props.copyMarkdown} standalone={props.standalone ?? true} className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} - style={props.style} + style={style} actionsSource={actionsSource} onImageExpand={props.onImageExpand} /> diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index ad3e951d89a9..17d9c04b06cc 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ resources: [] as Array, assetState: "success" as "success" | "loading" | "failure", + imageDimensions: undefined as { width: number; height: number } | undefined, })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); @@ -14,7 +15,11 @@ vi.mock("../assets/assetUrls", () => ({ testState.resources.push(resource); if (testState.assetState === "loading") return { _tag: "Loading" }; if (testState.assetState === "failure") return { _tag: "Failure" }; - return { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; + return { + _tag: "Success", + url: "https://signed.test/workspace-image.svg", + ...(testState.imageDimensions ? { imageDimensions: testState.imageDimensions } : {}), + }; }, })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); @@ -42,7 +47,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn(), })); -import ChatMarkdown from "./ChatMarkdown"; +import ChatMarkdown, { ChatMarkdownAssetImage } from "./ChatMarkdown"; import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; const threadRef = { @@ -92,6 +97,7 @@ describe("ChatMarkdown workspace images", () => { beforeEach(() => { testState.resources = []; testState.assetState = "success"; + testState.imageDimensions = undefined; }); it.each([ @@ -245,6 +251,42 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain(' { + testState.imageDimensions = { width: 720, height: 1400 }; + + const style = firstInlineStyle(render("![shot](.t3/workspace-image.svg)")); + + expect(style).toMatchObject({ width: "720px", "aspect-ratio": "720 / 1400" }); + }); + + it("folds a caller's height cap into the width bound so the ratio holds", () => { + testState.imageDimensions = { width: 720, height: 1400 }; + + const html = renderToStaticMarkup( + , + ); + + expect(firstInlineStyle(html)).toMatchObject({ + "aspect-ratio": "720 / 1400", + "max-width": `min(100%, 30rem, ${(16 * 720) / 1400}rem)`, + }); + }); + + it("lets an authored size override server-reported dimensions", () => { + testState.imageDimensions = { width: 720, height: 1400 }; + + const style = firstInlineStyle( + render('sized'), + ); + + expect(style).toMatchObject({ width: "96px", "aspect-ratio": "96 / 128" }); + }); + it("reserves a slot for an image that is alone in a list item", () => { expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4b41e98e0627..c1b09899b8de 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3342,7 +3342,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { alt={viewedImage.alt} srcFragment={viewedImage.srcFragment} workspaceRoot={workspaceRoot} - style={{ maxHeight: "16rem" }} + maxHeightRem={16} onImageExpand={onImageExpand} /> From 0671e34279d42faf69fccfd46ceae305ccfa7c6d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:54:59 -0700 Subject: [PATCH 04/65] refactor(shared): remove unused runtime helpers and exports (#10166) --- apps/web/src/sourceControlPresentation.ts | 2 - packages/shared/src/Net.ts | 1 + packages/shared/src/agentAwareness.ts | 2 +- packages/shared/src/composerTrigger.ts | 12 ----- packages/shared/src/httpReadiness.ts | 2 +- packages/shared/src/model.ts | 9 +--- packages/shared/src/observability.ts | 2 +- packages/shared/src/orchestrationTiming.ts | 2 +- packages/shared/src/relayClient.ts | 2 +- packages/shared/src/schemaJson.ts | 4 +- packages/shared/src/schemaYaml.ts | 56 ++-------------------- packages/shared/src/sourceControl.ts | 13 +---- packages/shared/src/usageLimits.ts | 2 +- 13 files changed, 16 insertions(+), 93 deletions(-) diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 116f27b95f97..4e757e6954a5 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -3,8 +3,6 @@ import type { ElementType } from "react"; import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; export { DEFAULT_CHANGE_REQUEST_TERMINOLOGY, - formatChangeRequestAction, - formatCreateChangeRequestPhrase, getChangeRequestTerminology, resolveChangeRequestPresentation, type ChangeRequestPresentation, diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 4644576296bc..e3b653692880 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -63,6 +63,7 @@ export class NetService extends Context.Service()( "@t3tools/shared/Net/NetService", ) {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = () => { /** * Returns true when a TCP server can bind to {host, port}. diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index c0f5842eb7c5..77c7c845db31 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -43,7 +43,7 @@ export interface ProjectThreadAwarenessInput { >; } -export function buildAgentAwarenessDeepLink(input: { +function buildAgentAwarenessDeepLink(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; }): string { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index c68a9963ffa8..6176d10d7908 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -115,18 +115,6 @@ export function detectComposerTrigger( }; } -export function parseStandaloneComposerSlashCommand( - text: string, -): Exclude | null { - const match = /^\/(plan|default)\s*$/i.exec(text.trim()); - if (!match) { - return null; - } - const command = match[1]?.toLowerCase(); - if (command === "plan") return "plan"; - return "default"; -} - export function replaceTextRange( text: string, rangeStart: number, diff --git a/packages/shared/src/httpReadiness.ts b/packages/shared/src/httpReadiness.ts index be1b3475ff3e..5aad9d488aae 100644 --- a/packages/shared/src/httpReadiness.ts +++ b/packages/shared/src/httpReadiness.ts @@ -5,7 +5,7 @@ import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -export const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; +const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; /** * Normalizes an arbitrary readiness probe failure into a plain, structured value diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 940778fba1ea..d6cb26f25be3 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -35,7 +35,7 @@ function getRawSelectionValueById( return selection?.value; } -export function getProviderOptionSelectionValue( +function getProviderOptionSelectionValue( selections: ReadonlyArray | null | undefined, id: string, ): string | boolean | undefined { @@ -298,11 +298,6 @@ export function readCustomModelEntries(value: unknown): CustomModelDefinition[] return entries; } -/** Slugs of a `customModels` setting, in stored order. */ -export function readCustomModelSlugs(value: unknown): string[] { - return readCustomModelEntries(value).map((entry) => entry.slug); -} - /** * Write a definition back to the compact stored shape: a bare slug when it * carries nothing custom, otherwise an entry with only the set fields. @@ -361,7 +356,7 @@ export function resolveSelectableModel( } /** Trim a string, returning null for empty/missing values. */ -export function trimOrNull(value: T | null | undefined): T | null { +function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; const trimmed = value.trim() as T; return trimmed || null; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 67057c548806..9692a05f7592 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -303,7 +303,7 @@ export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttri return truncated ?? attributes; } -export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { +function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { const status = span.status as Extract; const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 98829ae052ad..2ae82c22a691 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -28,7 +28,7 @@ export function formatDuration(durationMs: number): string { return parts.join(" "); } -export function isLatestTurnSettled( +function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, ): boolean { diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 3e65b2438d82..4743f12b19d5 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -20,7 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; -export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; +const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; export type RelayClientExecutableSource = "override" | "managed" | "path"; diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index e132b7084b74..076ff67b62df 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -199,12 +199,12 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { * strips trailing commas and JS-style comments before parsing. * Encoding produces strict JSON via `JSON.stringify`. */ -export const fromLenientJsonString = new SchemaTransformation.Transformation( +const fromLenientJsonString = new SchemaTransformation.Transformation( parseLenientJsonGetter, SchemaGetter.stringifyJson(), ); -export const prettyJsonString = SchemaGetter.parseJson().compose( +const prettyJsonString = SchemaGetter.parseJson().compose( SchemaGetter.stringifyJson({ space: 2 }), ); diff --git a/packages/shared/src/schemaYaml.ts b/packages/shared/src/schemaYaml.ts index 70e3c987ae00..55b5b97122ae 100644 --- a/packages/shared/src/schemaYaml.ts +++ b/packages/shared/src/schemaYaml.ts @@ -32,32 +32,8 @@ function formatYamlParseError(error: unknown): string { return `Invalid YAML (code=${error.code}${location}).`; } -/** - * Parses a YAML string into a value. - * - * **When to use** - * - * Use when you need a schema getter to parse a present encoded YAML string - * during decoding. - * - * **Details** - * - * Parse failures become `SchemaIssue.InvalidValue` values. - * - * **Example** (Parse YAML) - * - * ```ts - * import { parseYaml } from "@t3tools/shared/schemaYaml" - * - * const parse = parseYaml() - * // Getter - * ``` - * - * @see {@link stringifyYaml} for the inverse operation - */ -export function parseYaml( - options?: YamlParseOptions, -): SchemaGetter.Getter { +/** Parses YAML during decoding, reporting parse failures as InvalidValue issues. */ +function parseYaml(options?: YamlParseOptions): SchemaGetter.Getter { return SchemaGetter.transformOrFail((input: E) => Effect.try({ try: () => parseYamlString(input, options) as unknown, @@ -66,32 +42,8 @@ export function parseYaml( ); } -/** - * Stringifies a present value as YAML. - * - * **When to use** - * - * Use when you need a schema getter to serialize a present decoded value to - * YAML text during encoding. - * - * **Details** - * - * Stringify failures become `SchemaIssue.InvalidValue` values. - * - * **Example** (Stringify YAML) - * - * ```ts - * import { stringifyYaml } from "@t3tools/shared/schemaYaml" - * - * const stringify = stringifyYaml() - * // Getter - * ``` - * - * @see {@link parseYaml} for the inverse operation - */ -export function stringifyYaml( - options?: YamlStringifyOptions, -): SchemaGetter.Getter { +/** Serializes YAML during encoding, reporting stringify failures as InvalidValue issues. */ +function stringifyYaml(options?: YamlStringifyOptions): SchemaGetter.Getter { return SchemaGetter.transformOrFail((input: unknown) => Effect.try({ try: () => stringifyYamlValue(input, options), diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index df88de595a3f..93b7c41ad44f 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -92,23 +92,12 @@ export function resolveChangeRequestPresentation( } } -export function resolveChangeRequestPresentationForKind( +function resolveChangeRequestPresentationForKind( kind: SourceControlProviderKind, ): ChangeRequestPresentation { return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); } -export function formatChangeRequestAction( - verb: "View" | "Create", - presentation: ChangeRequestPresentation, -): string { - return `${verb} ${presentation.shortName}`; -} - -export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string { - return `create ${presentation.shortName}`; -} - export function getChangeRequestTerminology( provider: SourceControlProviderInfo | null | undefined, ): ChangeRequestTerminology { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 8341796e39c3..e7582b1ecc64 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -161,7 +161,7 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } -export function resetMillis(window: ServerProviderUsageWindow): number | null { +function resetMillis(window: ServerProviderUsageWindow): number | null { if (window.resetsAt === undefined) return null; const at = Date.parse(window.resetsAt); return Number.isFinite(at) ? at : null; From dbfd5173131deb1a7ad2a9438b8941ade43f9a89 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:54:59 -0700 Subject: [PATCH 05/65] refactor(client-runtime): remove unused runtime exports and helpers (#10167) --- apps/web/src/components/Sidebar.logic.ts | 6 +- .../components/chat/MessagesTimeline.logic.ts | 1 - apps/web/src/logicalProject.ts | 1 - .../client-runtime/src/authorization/index.ts | 1 - .../src/authorization/remote.ts | 1 - .../src/codexArtifactTemplates.ts | 6 +- .../src/connection/connectivity.ts | 2 +- .../src/connection/credentialStore.ts | 4 - .../client-runtime/src/connection/driver.ts | 1 + .../client-runtime/src/connection/index.ts | 13 +-- .../src/connection/onboarding.ts | 7 +- .../src/connection/profileStore.ts | 4 - .../client-runtime/src/connection/registry.ts | 1 + .../client-runtime/src/connection/resolver.ts | 1 + .../src/connection/supervisor.ts | 12 --- .../client-runtime/src/connection/wakeups.ts | 2 +- .../client-runtime/src/relay/discovery.ts | 1 + .../src/relay/errorPresentation.ts | 2 +- .../client-runtime/src/relay/managedRelay.ts | 1 + packages/client-runtime/src/rpc/client.ts | 5 -- packages/client-runtime/src/rpc/index.ts | 2 +- packages/client-runtime/src/rpc/session.ts | 3 +- packages/client-runtime/src/state/auth.ts | 2 +- .../client-runtime/src/state/connections.ts | 2 +- .../src/state/environmentHttpAuth.ts | 4 +- .../client-runtime/src/state/gitActions.ts | 82 ------------------- .../src/state/projectGrouping.ts | 13 --- .../client-runtime/src/state/pullRequests.ts | 3 +- packages/client-runtime/src/state/runtime.ts | 27 +----- packages/client-runtime/src/state/server.ts | 4 +- .../src/state/sharedSettings.ts | 2 +- packages/client-runtime/src/state/shell.ts | 2 +- .../src/state/terminalSession.ts | 2 +- .../client-runtime/src/state/threadSort.ts | 2 +- packages/client-runtime/src/state/threads.ts | 6 +- packages/client-runtime/src/state/vcs.ts | 5 +- .../client-runtime/src/state/vcsAction.ts | 8 +- .../client-runtime/src/state/vcsStatus.ts | 6 -- .../client-runtime/src/voice-input/index.ts | 1 - .../src/work-log/presentation.ts | 2 +- 40 files changed, 42 insertions(+), 208 deletions(-) delete mode 100644 packages/client-runtime/src/state/vcsStatus.ts diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 92cdf72c51fe..d5d7d1f23a44 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -600,11 +600,7 @@ export function sortThreadsForSidebar< // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. -export { - generateSpreadPinOrderKeys, - pinOrderKeyBetween, - planPinnedReorder, -} from "@t3tools/client-runtime/state/thread-sort"; +export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 36c90c89563c..03d1e46faf32 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -16,7 +16,6 @@ export { normalizeCompactToolLabel, summarizeToolGroup, toolGroupAction, - workLogEntryIsLocalCodeSearch, } from "@t3tools/client-runtime/work-log/presentation"; import { formatDuration, diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index f696cb462246..41df8c2013c4 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,7 +1,6 @@ export { buildProjectGroups, deriveLogicalProjectKey, - deriveLogicalProjectKeyFromRef, deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, derivePhysicalProjectKeyFromPath, diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts index d232aea0d30a..05acbc1829e7 100644 --- a/packages/client-runtime/src/authorization/index.ts +++ b/packages/client-runtime/src/authorization/index.ts @@ -2,6 +2,5 @@ export * from "./remote.ts"; export { type AuthorizedRemoteEnvironment, type AuthorizedRemoteHttpEnvironment, - RemoteEnvironmentAuthorization, } from "./service.ts"; export * as TokenStore from "./tokenStore.ts"; diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 398a592e499d..538c0aa114a3 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -16,7 +16,6 @@ import { } from "../rpc/http.ts"; export { - RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthInvalidJsonError, RemoteEnvironmentAuthTimeoutError, RemoteEnvironmentAuthUndeclaredStatusError, diff --git a/packages/client-runtime/src/codexArtifactTemplates.ts b/packages/client-runtime/src/codexArtifactTemplates.ts index ad46a7d6c2b0..052a60f11d58 100644 --- a/packages/client-runtime/src/codexArtifactTemplates.ts +++ b/packages/client-runtime/src/codexArtifactTemplates.ts @@ -1,4 +1,4 @@ -export const CODEX_ARTIFACT_TEMPLATE_KINDS = [ +const CODEX_ARTIFACT_TEMPLATE_KINDS = [ "document", "presentation", "spreadsheet", @@ -13,7 +13,7 @@ export const CODEX_ARTIFACT_TEMPLATE_KINDS = [ export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number]; -export const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; +const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; export type CodexArtifactTemplateGalleryKind = (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number]; @@ -26,7 +26,7 @@ export interface CodexArtifactTemplate { readonly skillName: string; } -export const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = { +const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = { document: "Document template", presentation: "Presentation template", spreadsheet: "Spreadsheet template", diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts index 6b40680ce35c..9bf9dbdb2409 100644 --- a/packages/client-runtime/src/connection/connectivity.ts +++ b/packages/client-runtime/src/connection/connectivity.ts @@ -13,7 +13,7 @@ export class Connectivity extends Context.Service< } >()("@t3tools/client-runtime/connection/connectivity") {} -export const make = (service: Connectivity["Service"]) => Connectivity.of(service); +const make = (service: Connectivity["Service"]) => Connectivity.of(service); export const layer = (service: Connectivity["Service"]) => Layer.succeed(Connectivity, make(service)); diff --git a/packages/client-runtime/src/connection/credentialStore.ts b/packages/client-runtime/src/connection/credentialStore.ts index 0107bc91fb10..a25970a15f0f 100644 --- a/packages/client-runtime/src/connection/credentialStore.ts +++ b/packages/client-runtime/src/connection/credentialStore.ts @@ -1,6 +1,5 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import type * as Option from "effect/Option"; import type { ConnectionCredential } from "./catalog.ts"; @@ -22,6 +21,3 @@ export class ConnectionCredentialStore extends Context.Service< export const make = (service: ConnectionCredentialStore["Service"]) => ConnectionCredentialStore.of(service); - -export const layer = (service: ConnectionCredentialStore["Service"]) => - Layer.succeed(ConnectionCredentialStore, make(service)); diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index f29f913dd548..ceec663c1bb5 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -36,6 +36,7 @@ export class ConnectionDriver extends Context.Service< } >()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const resolver = yield* ConnectionResolver.ConnectionResolver; const sessions = yield* RpcSession.RpcSessionFactory; diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 53a041bbf307..127a6288fa1b 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -1,11 +1,7 @@ export * from "./catalog.ts"; export * as Connectivity from "./connectivity.ts"; export * as CredentialStore from "./credentialStore.ts"; -export { - ConnectionDriver, - type ConnectionDriverProgress, - type EnvironmentConnectionLease, -} from "./driver.ts"; +export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; export * from "./errors.ts"; export * as Connection from "./layer.ts"; export * from "./model.ts"; @@ -14,12 +10,6 @@ export { ConnectionOnboarding, type PairingConnectionInput, type SshConnectionInput, - prepareBearerConnectionUpdate, - preparePairingRegistration, - prepareSshRegistration, - registerPairingConnection, - registerSshConnection, - updateBearerConnection, } from "./onboarding.ts"; export * from "./presentation.ts"; export * as ProfileStore from "./profileStore.ts"; @@ -28,6 +18,5 @@ export { EnvironmentRegistry, PlatformEnvironmentRemovalError, } from "./registry.ts"; -export { ConnectionResolver } from "./resolver.ts"; export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts"; export * as Wakeups from "./wakeups.ts"; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2cc..3bc0e56dca82 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -118,7 +118,7 @@ export const preparePairingRegistration = Effect.fn( }); }); -export const registerPairingConnection = Effect.fn( +const registerPairingConnection = Effect.fn( "clientRuntime.connection.onboarding.registerPairingConnection", )(function* (input: PairingConnectionInput) { const registration = yield* preparePairingRegistration(input); @@ -130,7 +130,7 @@ export const registerPairingConnection = Effect.fn( const isBearerCredential = Schema.is(BearerConnectionCredential); const isBearerProfile = Schema.is(BearerConnectionProfile); -export const updateBearerConnection = Effect.fn( +const updateBearerConnection = Effect.fn( "clientRuntime.connection.onboarding.updateBearerConnection", )(function* (input: BearerConnectionUpdateInput) { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; @@ -233,7 +233,7 @@ export const prepareSshRegistration = Effect.fn( }); }); -export const registerSshConnection = Effect.fn( +const registerSshConnection = Effect.fn( "clientRuntime.connection.onboarding.registerSshConnection", )(function* (input: SshConnectionInput) { const registration = yield* prepareSshRegistration(input); @@ -242,6 +242,7 @@ export const registerSshConnection = Effect.fn( return registration.target.environmentId; }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const presentation = yield* ClientCapabilities.ClientPresentation; diff --git a/packages/client-runtime/src/connection/profileStore.ts b/packages/client-runtime/src/connection/profileStore.ts index 3432a7fe16e2..bc8d08f25f7d 100644 --- a/packages/client-runtime/src/connection/profileStore.ts +++ b/packages/client-runtime/src/connection/profileStore.ts @@ -1,6 +1,5 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import type * as Option from "effect/Option"; import type { ConnectionProfile } from "./catalog.ts"; @@ -19,6 +18,3 @@ export class ConnectionProfileStore extends Context.Service< export const make = (service: ConnectionProfileStore["Service"]) => ConnectionProfileStore.of(service); - -export const layer = (service: ConnectionProfileStore["Service"]) => - Layer.succeed(ConnectionProfileStore, make(service)); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 6907c43d6037..563afe62ce5b 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -125,6 +125,7 @@ interface EnvironmentServiceScope { readonly scope: Scope.Closeable; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registryScope = yield* Scope.Scope; const storage = yield* Persistence.ConnectionTargetStore; diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index cbffca7b29e9..885529249a62 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -218,6 +218,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const primary = yield* makePrimaryBroker(); const bearer = yield* makeBearerBroker(); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 0246a21397f4..45ef02292056 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -5,7 +5,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -798,14 +797,3 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( retryNow, }); }); - -export const layer = ( - entry: ConnectionCatalogEntry, - options?: EnvironmentSupervisorOptions, -): Layer.Layer< - EnvironmentSupervisor, - never, - | Connectivity.Connectivity - | ConnectionDriver.ConnectionDriver - | ConnectionWakeups.ConnectionWakeups -> => Layer.effect(EnvironmentSupervisor, make(entry, options)); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 8573a49c1474..721b941645c0 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -27,7 +27,7 @@ export class ConnectionWakeups extends Context.Service< } >()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} -export const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); +const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); export const layer = (service: ConnectionWakeups["Service"]) => Layer.succeed(ConnectionWakeups, make(service)); diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 855bb2654edb..8a8eae1d6cd9 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -102,6 +102,7 @@ function relayAccountId(clerkToken: string): Option.Option { } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { const relay = yield* ManagedRelay.ManagedRelayClient; const session = yield* ClientCapabilities.CloudSession; diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index a9364752103d..b9a53e5ce8b8 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -11,7 +11,7 @@ export const DPOP_UNKNOWN_HINT = export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID."; -export function dpopFailureHint(reason: DpopFailureReason | undefined): string { +function dpopFailureHint(reason: DpopFailureReason | undefined): string { if (reason === "time_window") return DPOP_CLOCK_HINT; if (reason === undefined) return DPOP_UNKNOWN_HINT; return DPOP_RETRY_HINT; diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index d9f81c356e22..70c43631dab2 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -429,6 +429,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ManagedRelayClient.make")(function* ( options: ManagedRelayClientLayerOptions, ) { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 175d633e242f..e7c5117954cb 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -325,8 +325,3 @@ export function subscribe( > { return subscribeDynamic(tag, () => Effect.succeed(input), options); } - -export const config = Effect.gen(function* () { - const session = yield* currentSession(); - return yield* session.initialConfig; -}).pipe(Effect.withSpan("EnvironmentRpc.config")); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index 76608388f0ae..d5b1a6858f41 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,4 +1,4 @@ export * from "./client.ts"; export * from "./http.ts"; export * from "./protocol.ts"; -export { type RpcSession, RpcSessionFactory } from "./session.ts"; +export { type RpcSession } from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index aeef14968ff8..2402c1324565 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -145,6 +145,7 @@ function mapSessionRpcError( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("RpcSessionFactory.make")(function* ( options: RpcSessionOptions = {}, ) { @@ -369,5 +370,3 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( export const layerWithOptions = (options: RpcSessionOptions) => Layer.effect(RpcSessionFactory, make(options)); - -export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts index 074b89627af3..504dabb91b34 100644 --- a/packages/client-runtime/src/state/auth.ts +++ b/packages/client-runtime/src/state/auth.ts @@ -61,7 +61,7 @@ export function applyAuthAccessStreamEvent( } } -export function projectAuthAccessSnapshot( +function projectAuthAccessSnapshot( current: AuthAccessSnapshot, event: AuthAccessStreamEvent, ): readonly [AuthAccessSnapshot, ReadonlyArray] { diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 6dfa5001a486..a81739db1b7e 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -20,7 +20,7 @@ export interface EnvironmentCatalogState { readonly entries: ReadonlyMap; } -export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ +const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ isReady: false, entries: new Map(), }); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index cfc42f560715..019b52ddd359 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -27,7 +27,7 @@ export interface EnvironmentHttpAuthHeaders { * per-request via `FetchHttpClient.RequestInit`, which the fetch client reads * from the fiber context at request time. */ -export const withEnvironmentCredentials = ( +const withEnvironmentCredentials = ( authorization: PreparedHttpAuthorization | null, request: Effect.Effect, ): Effect.Effect => @@ -46,7 +46,7 @@ export const withEnvironmentCredentials = ( * for relay/DPoP connections, so bearer/primary connections work even when no * signer is available. */ -export const buildEnvironmentAuthHeaders = ( +const buildEnvironmentAuthHeaders = ( authorization: PreparedHttpAuthorization | null, method: HttpMethod.HttpMethod, url: string, diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index 436db17110f1..d3571e65d2e6 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -1,10 +1,8 @@ import type { GitRunStackedActionInput, - GitRunStackedActionResult, GitStackedAction, VcsStatusResult, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -44,44 +42,6 @@ export type GitActionRequestInput = Pick< "action" | "commitMessage" | "featureBranch" | "filePaths" >; -export function buildGitActionProgressStages(input: { - action: GitStackedAction; - hasCustomCommitMessage: boolean; - hasWorkingTreeChanges: boolean; - pushTarget?: string; - featureBranch?: boolean; - shouldPushBeforePr?: boolean; -}): string[] { - const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; - const prStages = [ - "Preparing PR...", - "Generating PR content...", - "Creating GitHub pull request...", - ]; - - if (input.action === "push") { - return [pushStage]; - } - if (input.action === "create_pr") { - return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; - } - - const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; - const commitStages = !shouldIncludeCommitStages - ? [] - : input.hasCustomCommitMessage - ? ["Committing..."] - : ["Generating commit message...", "Committing..."]; - if (input.action === "commit") { - return [...branchStages, ...commitStages]; - } - if (input.action === "commit_push") { - return [...branchStages, ...commitStages, pushStage]; - } - return [...branchStages, ...commitStages, pushStage, ...prStages]; -} - export function buildMenuItems( gitStatus: VcsStatusResult | null, isBusy: boolean, @@ -396,45 +356,3 @@ export function resolveDefaultBranchActionDialogCopy(input: { continueLabel: "Push & create PR", }; } - -export function resolveThreadBranchUpdate( - result: GitRunStackedActionResult, -): { branch: string } | null { - if (result.branch.status !== "created" || !result.branch.name) { - return null; - } - - return { - branch: result.branch.name, - }; -} - -export function resolveLiveThreadBranchUpdate(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): { branch: string | null } | null { - if (!input.gitStatus) { - return null; - } - - if (input.gitStatus.refName === null && input.threadBranch !== null) { - return null; - } - - if (input.threadBranch === input.gitStatus.refName) { - return null; - } - - if ( - input.threadBranch !== null && - input.gitStatus.refName !== null && - !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.refName) - ) { - return null; - } - - return { - branch: input.gitStatus.refName, - }; -} diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 43785d85dbbf..ce5c984214fd 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -152,19 +152,6 @@ export function deriveLogicalProjectKeyFromSettings( }); } -export function deriveLogicalProjectKeyFromRef( - projectRef: ScopedProjectRef, - project: - | Pick - | null - | undefined, - options?: { - readonly groupingMode?: SidebarProjectGroupingMode; - }, -): string { - return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); -} - export function deriveProjectGroupLabel(input: { readonly representative: Pick; readonly members: ReadonlyArray>; diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index d0da18a7226a..bf90faf2188a 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -29,11 +29,12 @@ export { pullRequestDiffLoaderLayer, } from "./pullRequestDiffHttp.ts"; +/** @public Required to name the error in consumers' inferred pull request results. */ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( "EnvironmentHttpConnectionNotReadyError", )<{ readonly message: string }> {} -export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; +const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; function createPullRequestRefreshAtomFamily( runtime: Atom.AtomRuntime, diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 56489a4ba668..affd5aa90ec1 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -390,31 +390,6 @@ export function createRuntimeCommand( }; } -export function createRuntimeStreamCommand( - runtime: Atom.AtomRuntime, - options: { - readonly label: string; - readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Stream.Stream; - readonly scheduler?: AtomCommandScheduler; - readonly concurrency?: AtomCommandConcurrency; - }, -): AtomCommand { - const scheduler = options.scheduler ?? createAtomCommandScheduler(); - const concurrency = options.concurrency ?? { mode: "parallel" as const }; - return { - label: options.label, - run: (registry, input) => - settleAtomCommandResult(() => - scheduler.schedule(registry, concurrency, input, () => { - const atom = runtime - .atom(options.execute(input, registry)) - .pipe(Atom.withLabel(options.label)); - return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); - }), - ), - }; -} - export function reportAtomCommandResult( result: AtomCommandResult, options: AtomCommandOptions = {}, @@ -462,7 +437,7 @@ function parseEnvironmentRpcKey(key: string): { }; } -export function runInEnvironment( +function runInEnvironment( environmentId: EnvironmentIdType, effect: Effect.Effect, ): Effect.Effect< diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 9f94663388ec..e209acfd1e7d 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -455,7 +455,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf }, ); -export function serverConfigStateChanges( +function serverConfigStateChanges( environmentId: EnvironmentId, subscription: ServerConfigSubscriptionOptions, ) { @@ -580,7 +580,7 @@ export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWel }, ); -export function serverWelcomeStateChanges(environmentId: EnvironmentId) { +function serverWelcomeStateChanges(environmentId: EnvironmentId) { return followStreamInEnvironment( environmentId, Stream.unwrap( diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index c578de9c7062..128d5c25464b 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -20,7 +20,7 @@ import * as Struct from "effect/Struct"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; /** Server keys that hold a user preference rather than machine config. */ -export const SHARED_SERVER_SETTING_KEYS = [ +const SHARED_SERVER_SETTING_KEYS = [ "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index c150bbb75b8c..69799bbd1168 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -269,7 +269,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return state; }); -export function shellStateChanges(environmentId: EnvironmentId) { +function shellStateChanges(environmentId: EnvironmentId) { return followStreamInEnvironment( environmentId, Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index 55cc4eef28f6..b1ef6500d414 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -96,7 +96,7 @@ export function nextTerminalAttachSeedState(): TerminalBufferState { }; } -export function terminalBufferStateFromSnapshot( +function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index e54343a45628..cf89d4a21ac9 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -209,7 +209,7 @@ export function pinOrderKeyBetween(before: string | null, after: string | null): drop lands next to keyless threads, so single-key insertion has nothing to anchor on). Two base-26 digits give 675 slots — far beyond any real pinned section — with monotonicity enforced as a belt-and-braces. */ -export function generateSpreadPinOrderKeys(count: number): string[] { +function generateSpreadPinOrderKeys(count: number): string[] { const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; const step = space / (count + 1); const keys: string[] = []; diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index cdd07087d06a..1311469c0ee0 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -47,7 +47,7 @@ function statusWithoutLiveData(data: Option.Option): Enviro * observed threads stays around 100K gzipped while median threads load fully. */ export const INITIAL_THREAD_USER_TURN_LIMIT = 10; -export const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; +const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; function pageStateFromSnapshot( page: OrchestrationThreadDetailPage | undefined, @@ -101,7 +101,7 @@ const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); * instance is shared with the sync `requestOlderThreadTurns` entry point so * the apps get working wiring without providing anything. */ -export class ThreadOlderTurnRequests extends Context.Reference( +class ThreadOlderTurnRequests extends Context.Reference( "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", { defaultValue: () => defaultOlderTurnRequestRegistry }, ) {} @@ -812,7 +812,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return state; }); -export function threadStateChanges( +function threadStateChanges( environmentId: EnvironmentIdType, threadId: ThreadIdType, resumeCache?: ThreadResumeCache, diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a932e7398d04..6c93e6204dc8 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -33,7 +33,7 @@ const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_IDLE_TTL_MS = 30_000; // Rows keep the last status they rendered, so the live stream only needs a // short grace period when virtualization or scrolling releases its consumer. -export const VCS_STATUS_IDLE_TTL_MS = 10_000; +const VCS_STATUS_IDLE_TTL_MS = 10_000; const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))), @@ -214,7 +214,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange return Stream.concat(cachedRefs, refreshedRefs); }); -export function cachedVcsRefsChanges( +function cachedVcsRefsChanges( environmentId: EnvironmentId, input: VcsListRefsInput, expectedRevision: number, @@ -345,4 +345,3 @@ export function createVcsEnvironmentAtoms( export * from "./gitActions.ts"; export * from "./vcsAction.ts"; export * from "./vcsRef.ts"; -export * from "./vcsStatus.ts"; diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f0c3791e35b2..46dfd74e9726 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -163,14 +163,14 @@ const decodeVcsActionTargetKey = Schema.decodeUnknownSync( Schema.Tuple([EnvironmentId, Schema.String]), ); -export const vcsActionStateAtom = Atom.family((key: string) => { +const vcsActionStateAtom = Atom.family((key: string) => { return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel(`vcs-action:${key}`), ); }); -export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( +const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel("vcs-action:null"), ); @@ -191,7 +191,7 @@ export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { } } -export function getVcsActionStateAtom(target: VcsActionTarget) { +function getVcsActionStateAtom(target: VcsActionTarget) { const key = getVcsActionTargetKey(target); return key === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); } @@ -217,7 +217,7 @@ export function beginVcsActionState( }; } -export function failVcsActionState( +function failVcsActionState( operation: VcsActionOperation, actionId: string, error: unknown, diff --git a/packages/client-runtime/src/state/vcsStatus.ts b/packages/client-runtime/src/state/vcsStatus.ts deleted file mode 100644 index 0a301fa86f3c..000000000000 --- a/packages/client-runtime/src/state/vcsStatus.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - -export interface VcsStatusTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} diff --git a/packages/client-runtime/src/voice-input/index.ts b/packages/client-runtime/src/voice-input/index.ts index c8c8da455ed1..2af9cf3e5ac4 100644 --- a/packages/client-runtime/src/voice-input/index.ts +++ b/packages/client-runtime/src/voice-input/index.ts @@ -1,7 +1,6 @@ export { VoiceInputController, VOICE_RECORDING_LIMIT_SECONDS, - resolveTranscriptCommit, voiceInputBlocksSubmission, voiceInputFreezesEditor, type VoiceDraftSnapshot, diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index d47f44566452..050bb7145d45 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -305,7 +305,7 @@ function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); } -export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { +function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { return ( entry.itemType === "web_search" && /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) From 5716dec97e4eaefea59369b07a8d54191fc33e31 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:55:00 -0700 Subject: [PATCH 06/65] refactor(contracts): keep RPC implementation exports private (#10168) --- packages/contracts/src/providerRuntime.ts | 2 +- packages/contracts/src/rpc.ts | 382 ++++++++++------------ 2 files changed, 174 insertions(+), 210 deletions(-) diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 0d17f86e1942..af1baac74f9d 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -103,7 +103,7 @@ const RuntimeErrorClass = Schema.Literals([ ]); export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; -export const TOOL_LIFECYCLE_ITEM_TYPES = [ +const TOOL_LIFECYCLE_ITEM_TYPES = [ "command_execution", "file_change", "mcp_tool_call", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 4e8ae2e54134..69c0ab43e890 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -378,31 +378,31 @@ export const WS_METHODS = { subscribeResourceTelemetry: "subscribeResourceTelemetry", } as const; -export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { +const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { payload: ServerUpsertKeybindingInput, success: ServerUpsertKeybindingResult, error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); -export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { +const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { payload: ServerRemoveKeybindingInput, success: ServerRemoveKeybindingResult, error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); -export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { +const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { payload: Schema.Struct({}), success: Schema.Struct({}), error: EnvironmentAuthorizationError, }); -export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { +const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { +const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** * When supplied, only refresh this specific provider instance. When @@ -419,7 +419,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]), }); -export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { +const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), @@ -427,130 +427,124 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); -export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { +const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { payload: ProviderConsumeResetCreditInput, success: ProviderConsumeResetCreditResult, error: ProviderSetupRpcError, }); -export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { +const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { +const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { payload: ProviderAuthCompleteInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { +const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { payload: ProviderAuthCancelInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { +const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { +const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, stream: true, }); -export const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { +const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { +const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { payload: ProviderInstallCancelInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { +const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, stream: true, }); -export const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { +const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { +const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { payload: ServerSelfUpdateInput, success: ServerSelfUpdateResult, error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); -export const WsServerUpdateServerWithProgressRpc = Rpc.make( - WS_METHODS.serverUpdateServerWithProgress, - { - payload: ServerSelfUpdateInput, - success: ServerSelfUpdateProgressEvent, - error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), - stream: true, - }, -); +const WsServerUpdateServerWithProgressRpc = Rpc.make(WS_METHODS.serverUpdateServerWithProgress, { + payload: ServerSelfUpdateInput, + success: ServerSelfUpdateProgressEvent, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), + stream: true, +}); -export const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { +const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { payload: DesktopUpdateCommitInput, success: ServerSelfUpdateResult, error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); -export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { +const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { +const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { payload: Schema.Struct({ patch: ServerSettingsPatch }), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { +const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { payload: Schema.Struct({}), success: SourceControlDiscoveryResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { +const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { payload: Schema.Struct({}), success: ServerTraceDiagnosticsResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { +const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { payload: Schema.Struct({}), success: ServerProcessDiagnosticsResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetProcessResourceHistoryRpc = Rpc.make( - WS_METHODS.serverGetProcessResourceHistory, - { - payload: ServerProcessResourceHistoryInput, - success: ServerProcessResourceHistoryResult, - error: EnvironmentAuthorizationError, - }, -); +const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { + payload: ServerProcessResourceHistoryInput, + success: ServerProcessResourceHistoryResult, + error: EnvironmentAuthorizationError, +}); -export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( +const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( WS_METHODS.serverGetResourceTelemetryHistory, { payload: ResourceTelemetryHistoryInput, @@ -559,13 +553,13 @@ export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( }, ); -export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { +const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { payload: Schema.Struct({}), success: ResourceTelemetryRetryResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { +const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { payload: UsageSummaryInput, success: UsageSummary, error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), @@ -575,42 +569,42 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm * Refetches the model rate table ahead of its daily TTL, so a model released * since the last fetch gets priced. The next usage summary uses the new table. */ -export const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { +const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { payload: Schema.Struct({}), success: UsagePricing, error: EnvironmentAuthorizationError, }); -export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { +const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, error: EnvironmentAuthorizationError, }); -export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { +const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, error: EnvironmentAuthorizationError, }); -export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { +const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { payload: Schema.Struct({}), success: RelayClientInstallProgressEventSchema, error: Schema.Union([RelayClientInstallFailedError, EnvironmentAuthorizationError]), stream: true, }); -export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { +const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { payload: ClientActivityReportInput, error: EnvironmentAuthorizationError, }); -export const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { +const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { payload: HostPowerSnapshot, error: EnvironmentAuthorizationError, }); -export const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { +const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { payload: Schema.Struct({}), success: BackgroundPolicySnapshot, error: EnvironmentAuthorizationError, @@ -622,7 +616,7 @@ const PullRequestRpcError = Schema.Union([ EnvironmentAuthorizationError, ]); -export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { +const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { payload: PullRequestListInput, success: PullRequestListResult, error: PullRequestRpcError, @@ -633,214 +627,199 @@ export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { * 40-60% of the listing read that answers everything else on the row, so the rows arrive first * and their stats a moment later. */ -export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, { +const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, { payload: PullRequestListStatsInput, success: PullRequestListStatsResult, error: PullRequestRpcError, }); -export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { +const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { payload: PullRequestRef, success: PullRequestSummary, error: PullRequestRpcError, }); -export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { +const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { payload: PullRequestRef, success: PullRequestDetail, error: PullRequestRpcError, }); -export const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { +const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { payload: PullRequestRef, success: PullRequestActivity, error: PullRequestRpcError, }); -export const WsPullRequestsThreadCommentsRpc = Rpc.make(WS_METHODS.pullRequestsThreadComments, { +const WsPullRequestsThreadCommentsRpc = Rpc.make(WS_METHODS.pullRequestsThreadComments, { payload: PullRequestThreadCommentsInput, success: PullRequestThreadCommentsResult, error: PullRequestRpcError, }); -export const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFileContents, { +const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFileContents, { payload: PullRequestDiffFileContentsInput, success: PullRequestDiffFileContentsResult, error: PullRequestRpcError, }); -export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { +const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { payload: PullRequestActionInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, { +const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, { payload: PullRequestUpdateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { +const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { payload: PullRequestCommentInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, { +const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, { payload: PullRequestCommentUpdateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, { +const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, { payload: PullRequestSubmitReviewInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsReplyToThreadRpc = Rpc.make(WS_METHODS.pullRequestsReplyToThread, { +const WsPullRequestsReplyToThreadRpc = Rpc.make(WS_METHODS.pullRequestsReplyToThread, { payload: PullRequestThreadReplyInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSetThreadResolutionRpc = Rpc.make( - WS_METHODS.pullRequestsSetThreadResolution, - { - payload: PullRequestThreadResolutionInput, - success: Schema.Void, - error: PullRequestRpcError, - }, -); +const WsPullRequestsSetThreadResolutionRpc = Rpc.make(WS_METHODS.pullRequestsSetThreadResolution, { + payload: PullRequestThreadResolutionInput, + success: Schema.Void, + error: PullRequestRpcError, +}); -export const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, { +const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, { payload: PullRequestReactionInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, { +const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, { payload: PullRequestInvalidateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSubscribeRefreshesRpc = Rpc.make( - WS_METHODS.pullRequestsSubscribeRefreshes, - { - payload: Schema.Struct({}), - success: NonNegativeInt, - error: EnvironmentAuthorizationError, - stream: true, - }, -); +const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(WS_METHODS.pullRequestsSubscribeRefreshes, { + payload: Schema.Struct({}), + success: NonNegativeInt, + error: EnvironmentAuthorizationError, + stream: true, +}); /** * Read on its own rather than as part of the detail: the people who may be asked are only wanted * once somebody opens the menu, and reading them with every change request would spend a request * per host on a list nobody looked at. */ -export const WsPullRequestsReviewerCandidatesRpc = Rpc.make( - WS_METHODS.pullRequestsReviewerCandidates, - { - payload: PullRequestRef, - success: PullRequestReviewerCandidateList, - error: PullRequestRpcError, - }, -); +const WsPullRequestsReviewerCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsReviewerCandidates, { + payload: PullRequestRef, + success: PullRequestReviewerCandidateList, + error: PullRequestRpcError, +}); -export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequestsRequestReviewers, { +const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequestsRequestReviewers, { payload: PullRequestReviewerRequestInput, success: Schema.Void, error: PullRequestRpcError, }); /** Read when the label menu opens, for the same reason the reviewer candidates are. */ -export const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { +const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { payload: PullRequestRef, success: PullRequestLabelCandidateList, error: PullRequestRpcError, }); -export const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { +const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { payload: PullRequestLabelChangeInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsSourceControlLookupRepositoryRpc = Rpc.make( - WS_METHODS.sourceControlLookupRepository, - { - payload: SourceControlRepositoryLookupInput, - success: SourceControlRepositoryInfo, - error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), - }, -); +const WsSourceControlLookupRepositoryRpc = Rpc.make(WS_METHODS.sourceControlLookupRepository, { + payload: SourceControlRepositoryLookupInput, + success: SourceControlRepositoryInfo, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); -export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { +const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); -export const WsSourceControlPublishRepositoryRpc = Rpc.make( - WS_METHODS.sourceControlPublishRepository, - { - payload: SourceControlPublishRepositoryInput, - success: SourceControlPublishRepositoryResult, - error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), - }, -); +const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, { + payload: SourceControlPublishRepositoryInput, + success: SourceControlPublishRepositoryResult, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); -export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { +const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { +const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { payload: ProjectSearchContentsInput, success: ProjectSearchContentsResult, error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), }); -export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { +const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { +const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), }); -export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { +const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); -export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { +const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), }); -export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { +const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { payload: FilesystemBrowseInput, success: FilesystemBrowseResult, error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); -export const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { +const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { payload: AgentSessionScanInput, success: AgentSessionScanResult, error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), }); -export const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { +const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { payload: AgentSessionImportInput, success: AgentSessionImportResult, error: Schema.Union([ @@ -851,97 +830,97 @@ export const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, ]), }); -export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { +const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); -export const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { +const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { payload: AttachmentCreateUploadUrlInput, success: AttachmentCreateUploadUrlResult, error: Schema.Union([AttachmentUploadSigningKeyError, EnvironmentAuthorizationError]), }); -export const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { +const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { payload: AttachmentDeleteInput, error: EnvironmentAuthorizationError, }); -export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { +const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { payload: ProviderUploadFeedbackInput, success: ProviderUploadFeedbackResult, error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]), }); -export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { +const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, }); -export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { +const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { payload: VcsPullInput, success: VcsPullResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { +const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { payload: VcsStatusInput, success: VcsStatusResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { +const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, }); -export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { +const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { payload: GitPullRequestRefInput, success: GitResolvePullRequestResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { +const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { payload: GitPreparePullRequestThreadInput, success: GitPreparePullRequestThreadResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { +const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { payload: VcsListRefsInput, success: VcsListRefsResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { +const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { +const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { payload: VcsRemoveWorktreeInput, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { +const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { payload: VcsCreateRefInput, success: VcsCreateRefResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { +const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { payload: VcsSwitchRefInput, success: VcsSwitchRefResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { +const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { payload: VcsInitInput, error: Schema.Union([VcsError, EnvironmentAuthorizationError]), }); @@ -951,172 +930,160 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { * Not the persisted T3 Review model. Future review sessions should use * review.open* + review.getSnapshot. */ -export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { +const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { payload: ReviewDiffPreviewInput, success: ReviewDiffPreviewResult, error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); -export const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, { +const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, { payload: ReviewDiffFileContentsInput, success: ReviewDiffFileContentsResult, error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); -export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { +const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { +const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { payload: TerminalAttachInput, success: TerminalAttachStreamEvent, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), stream: true, }); -export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { +const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { payload: TerminalWriteInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { +const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { payload: TerminalResizeInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { +const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { payload: TerminalClearInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { +const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { payload: TerminalRestartInput, success: TerminalSessionSnapshot, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { +const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { payload: TerminalCloseInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { +const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { payload: PreviewOpenInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { +const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { payload: PreviewNavigateInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { +const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { payload: PreviewResizeInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { +const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { payload: PreviewRefreshInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { +const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { payload: PreviewCloseInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { +const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { payload: PreviewListInput, success: PreviewListResult, error: EnvironmentAuthorizationError, }); -export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { +const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { +const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { payload: PreviewAutomationHost, success: PreviewAutomationStreamEvent, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), stream: true, }); -export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { +const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { payload: PreviewAutomationResponse, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), }); -export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { +const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { payload: PreviewAutomationHostFocus, error: EnvironmentAuthorizationError, }); -export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { +const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { payload: Schema.Struct({}), success: PreviewEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( - WS_METHODS.subscribeDiscoveredLocalServers, - { - payload: Schema.Struct({ - configuredUrls: Schema.optional(ConfiguredLocalServerUrls), - }), - success: DiscoveredLocalServerList, - error: EnvironmentAuthorizationError, - stream: true, - }, -); +const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscoveredLocalServers, { + payload: Schema.Struct({ + configuredUrls: Schema.optional(ConfiguredLocalServerUrls), + }), + success: DiscoveredLocalServerList, + error: EnvironmentAuthorizationError, + stream: true, +}); -export const WsOrchestrationDispatchCommandRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.dispatchCommand, - { - payload: ClientOrchestrationCommand, - success: OrchestrationRpcSchemas.dispatchCommand.output, - error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationDispatchCommandRpc = Rpc.make(ORCHESTRATION_WS_METHODS.dispatchCommand, { + payload: ClientOrchestrationCommand, + success: OrchestrationRpcSchemas.dispatchCommand.output, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationGetWorkflowScriptRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.getWorkflowScript, - { - payload: OrchestrationRpcSchemas.getWorkflowScript.input, - success: OrchestrationRpcSchemas.getWorkflowScript.output, - error: Schema.Union([OrchestrationGetWorkflowScriptError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationGetWorkflowScriptRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getWorkflowScript, { + payload: OrchestrationRpcSchemas.getWorkflowScript.input, + success: OrchestrationRpcSchemas.getWorkflowScript.output, + error: Schema.Union([OrchestrationGetWorkflowScriptError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { +const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { payload: OrchestrationGetTurnDiffInput, success: OrchestrationRpcSchemas.getTurnDiff.output, error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]), }); -export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.getFullThreadDiff, - { - payload: OrchestrationGetFullThreadDiffInput, - success: OrchestrationRpcSchemas.getFullThreadDiff.output, - error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getFullThreadDiff, { + payload: OrchestrationGetFullThreadDiffInput, + success: OrchestrationRpcSchemas.getFullThreadDiff.output, + error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, { +const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, { payload: OrchestrationSearchThreadsInput, success: OrchestrationRpcSchemas.searchThreads.output, error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), }); -export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( +const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, { payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input, @@ -1125,31 +1092,28 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( }, ); -export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { +const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { payload: OrchestrationRpcSchemas.subscribeShell.input, success: OrchestrationRpcSchemas.subscribeShell.output, error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), stream: true, }); -export const WsOrchestrationSubscribeThreadRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.subscribeThread, - { - payload: OrchestrationRpcSchemas.subscribeThread.input, - success: OrchestrationRpcSchemas.subscribeThread.output, - error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), - stream: true, - }, -); +const WsOrchestrationSubscribeThreadRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeThread, { + payload: OrchestrationRpcSchemas.subscribeThread.input, + success: OrchestrationRpcSchemas.subscribeThread.output, + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), + stream: true, +}); -export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { +const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { +const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { payload: Schema.Struct({}), success: TerminalMetadataStreamEvent, error: EnvironmentAuthorizationError, @@ -1174,28 +1138,28 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon stream: true, }); -export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { +const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { payload: Schema.Struct({}), success: ServerLifecycleStreamEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { +const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), success: AuthAccessStreamEvent, error: Schema.Union([AuthAccessStreamError, EnvironmentAuthorizationError]), stream: true, }); -export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { +const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { payload: Schema.Struct({}), success: BackgroundPolicySnapshot, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { +const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { payload: Schema.Struct({}), success: ResourceTelemetrySnapshot, error: EnvironmentAuthorizationError, From 50bc62a83d2b5f4530b26e59095a74a48047c309 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:55:00 -0700 Subject: [PATCH 07/65] refactor(contracts): trim unused client API runtime exports (#10169) --- apps/server/src/process/externalLauncher.ts | 1 - packages/contracts/src/browserImport.ts | 6 ++---- packages/contracts/src/editor.ts | 2 -- packages/contracts/src/environmentHttp.ts | 8 ++++---- packages/contracts/src/keybindings.ts | 6 +++--- 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index fbde96ff58b1..184c8b519a96 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -45,7 +45,6 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, - isExternalLauncherError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 2c7eb24821d3..e560bbadaed0 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; -export const BROWSER_IMPORT_SOURCE_IDS = [ +const BROWSER_IMPORT_SOURCE_IDS = [ "chrome", "edge", "brave", @@ -139,9 +139,7 @@ export const BrowserImportResult = Schema.Struct({ }); export type BrowserImportResult = typeof BrowserImportResult.Type; -export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< - Record -> = { +const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly> = { notInstalled: "Not installed on this machine.", needsKeychainApproval: "Needs Keychain access to read its cookies.", keychainItemMissing: diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index b7a7dd7c7307..4115b07d7aad 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -203,5 +203,3 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherEditorSpawnError, ]); export type ExternalLauncherError = typeof ExternalLauncherError.Type; - -export const isExternalLauncherError = Schema.is(ExternalLauncherError); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a895697e36b0..80d2a7a25dab 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -408,13 +408,13 @@ export const AuthOtherClientSessionsRevokeResult = Schema.Struct({ }); export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type; -export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( +class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", { success: ExecutionEnvironmentDescriptor, }), ) {} -export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") +class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") .add( HttpApiEndpoint.get("session", "/api/auth/session", { headers: OptionalBearerHeaders, @@ -538,7 +538,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr ) {} /** Large, compressible pull-request payloads travel over HTTP rather than the RPC socket. */ -export class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullRequests").add( +class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullRequests").add( HttpApiEndpoint.post("diff", "/api/pull-requests/diff", { headers: OptionalBearerHeaders, payload: PullRequestDiffInput, @@ -553,7 +553,7 @@ export class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullReque }).middleware(EnvironmentAuthenticatedAuth), ) {} -export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") +class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", { headers: OptionalBearerHeaders, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 71b2547e532c..43e7645b3ebf 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; -export const MAX_KEYBINDING_WHEN_LENGTH = 256; +const MAX_KEYBINDING_WHEN_LENGTH = 256; export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; @@ -34,7 +34,7 @@ export const MODEL_PICKER_JUMP_KEYBINDING_COMMANDS = [ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; -export const THREAD_KEYBINDING_COMMANDS = [ +const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", "thread.copyReference", @@ -44,7 +44,7 @@ export const THREAD_KEYBINDING_COMMANDS = [ ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; -export const MODEL_PICKER_KEYBINDING_COMMANDS = [ +const MODEL_PICKER_KEYBINDING_COMMANDS = [ "modelPicker.toggle", ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, ] as const; From 2ae3b712b4b7dc7a3f44e9ceff404dfe1572ab93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:55:00 -0700 Subject: [PATCH 08/65] refactor(contracts): keep unused settings and relay exports private (#10170) --- packages/contracts/src/relay.ts | 14 +++++++------- packages/contracts/src/settings.ts | 22 +++++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 37221262ebad..cac14af5c7c8 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -875,7 +875,7 @@ export const RelayHealthResponse = Schema.Struct({ }); export type RelayHealthResponse = typeof RelayHealthResponse.Type; -export const RelayHealthGroup = HttpApiGroup.make("health") +const RelayHealthGroup = HttpApiGroup.make("health") .add( HttpApiEndpoint.get("health", "/health", { success: RelayHealthResponse, @@ -884,7 +884,7 @@ export const RelayHealthGroup = HttpApiGroup.make("health") ) .annotate(OpenApi.Description, "Service health and readiness."); -export const RelayMetadataGroup = HttpApiGroup.make("metadata") +const RelayMetadataGroup = HttpApiGroup.make("metadata") .add( HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { success: RelayAuthorizationServerMetadata, @@ -946,7 +946,7 @@ export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( }, ).annotate(OpenApi.Summary, "Unregister a mobile device"); -export const RelayMobileGroup = HttpApiGroup.make("mobile") +const RelayMobileGroup = HttpApiGroup.make("mobile") .add( RelayRegisterDeviceEndpoint, RelayRegisterLiveActivityEndpoint, @@ -956,7 +956,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") .middleware(RelayDpopClientAuth); -export const RelayClientGroup = HttpApiGroup.make("client") +const RelayClientGroup = HttpApiGroup.make("client") .add( HttpApiEndpoint.get("listEnvironments", "/v1/environments", { headers: RelayBearerRequestHeaders, @@ -1025,7 +1025,7 @@ export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( "Bootstrap endpoint. Send the DPoP proof JWT in the dpop header and the Clerk token in subject_token. The returned access token is bound to the proof key.", ); -export const RelayTokenGroup = HttpApiGroup.make("token") +const RelayTokenGroup = HttpApiGroup.make("token") .add(RelayExchangeDpopAccessTokenEndpoint) .annotate(OpenApi.Description, "OAuth token exchange for DPoP-bound client access."); @@ -1056,12 +1056,12 @@ export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( }, ).annotate(OpenApi.Summary, "Check environment status"); -export const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") +const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") .add(RelayConnectEnvironmentEndpoint, RelayGetEnvironmentStatusEndpoint) .annotate(OpenApi.Description, "DPoP-authenticated client access to linked environments.") .middleware(RelayDpopClientAuth); -export const RelayServerGroup = HttpApiGroup.make("server") +const RelayServerGroup = HttpApiGroup.make("server") .add( HttpApiEndpoint.post( "publishAgentActivity", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 623780c1fb8b..ce9082477372 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -31,11 +31,11 @@ import { export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; -export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; -export const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; +const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; @@ -51,7 +51,7 @@ export const SidebarProjectGroupingMode = Schema.Literals([ "separate", ]); export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; -export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; +const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; export const SidebarThreadPreviewCount = Schema.Int.check( @@ -61,7 +61,7 @@ export const SidebarThreadPreviewCount = Schema.Int.check( }), ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; -export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; export const SidebarAutoSettleAfterDays = Schema.Number.check( @@ -71,7 +71,7 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check( }), ); export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; -export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; +const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -81,7 +81,7 @@ export const GlassOpacity = Schema.Int.check( }), ); export type GlassOpacity = typeof GlassOpacity.Type; -export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; export const MIN_APPEARANCE_CONTRAST = 50; export const MAX_APPEARANCE_CONTRAST = 200; @@ -89,7 +89,7 @@ export const AppearanceContrast = Schema.Int.check( Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }), ); export type AppearanceContrast = typeof AppearanceContrast.Type; -export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; +const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; export const MIN_PANEL_ANIMATION_DURATION_MS = 0; export const MAX_PANEL_ANIMATION_DURATION_MS = 400; export const PanelAnimationDurationMs = Schema.Int.check( @@ -99,7 +99,7 @@ export const PanelAnimationDurationMs = Schema.Int.check( }), ); export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type; -export const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; +const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; /** * Font size preferences, in CSS pixels. The ranges are deliberately narrow: * the interface size scales every rem-based dimension in the app, so the @@ -135,7 +135,7 @@ export const TerminalFontSize = Schema.Int.check( Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), ); export type TerminalFontSize = typeof TerminalFontSize.Type; -export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; +const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; @@ -143,7 +143,7 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; -export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; +const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; const LegacyConfirmQuit = Schema.Boolean.pipe( Schema.decodeTo( @@ -423,7 +423,7 @@ export type ProviderSettingsOrder = readonl string >[]; -export function makeProviderSettingsSchema( +function makeProviderSettingsSchema( fields: Fields, options?: { readonly order?: ProviderSettingsOrder | undefined; From 5fe5c6fe9bb72c6e8ce9c1724a3887381d3f5cf7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:55:01 -0700 Subject: [PATCH 09/65] ci: enforce runtime exports while allowing types and schemas (#10171) --- docs/operations/development.md | 6 +- knip.jsonc | 6 ++ package.json | 6 +- pnpm-lock.yaml | 3 + scripts/knip-schemas.test.ts | 151 +++++++++++++++++++++++++++++++++ scripts/knip-schemas.ts | 96 +++++++++++++++++++++ scripts/package.json | 1 + 7 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 scripts/knip-schemas.test.ts create mode 100644 scripts/knip-schemas.ts diff --git a/docs/operations/development.md b/docs/operations/development.md index 39686807d6dd..8016a777b223 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -72,8 +72,10 @@ Windows investigation while that suite is not a required gate. ### Unused code `vp run knip:check` checks unused files and dependencies across the repo, then -unused exports and types in `packages/tailscale` and `packages/effect-codex-app-server`. -CI enforces both checks. +unused runtime exports in every internal package under `packages/`. CI enforces both checks. +Exported types and Effect schemas are allowed without consumers. The schema preprocessor +recognizes schema types, including aliases and schema classes; functions that create or decode +schemas remain checked. Completely unused files remain checked too. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. The full export audit still has findings and is not a repo-wide CI gate. Extend the diff --git a/knip.jsonc b/knip.jsonc index 25706930133b..aa665d09602c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -1,5 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", + // Exported types are part of our contracts even before they have consumers. + "rules": { "types": "off", "nsTypes": "off" }, // These executables are supplied by the OS or installed separately from npm. "ignoreBinaries": ["eas", "file", "mkfifo", "pkill", "pkg-config", "plutil", "sips"], "workspaces": { @@ -10,6 +12,10 @@ "vite": { "entry": [".github/**/*.test.cjs"] }, "vitest": { "entry": [".github/**/*.test.cjs"] }, }, + "scripts": { + // Knip loads its preprocessor through a CLI option, not a source import. + "entry": ["knip-schemas.ts"], + }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. "entry": [ diff --git a/package.json b/package.json index 1bfdac638f9e..5e72b463a10a 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "typecheck": "vp run -r --concurrency-limit 2 typecheck", "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", - "knip": "knip", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --workspace packages/effect-codex-app-server --workspace packages/ssh --exports --no-config-hints", - "knip:production": "knip --production", + "knip": "knip --preprocessor ./scripts/knip-schemas.ts", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd6ed98740aa..e9987c20f28f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -991,6 +991,9 @@ importers: '@types/pngjs': specifier: 6.0.5 version: 6.0.5 + typescript: + specifier: 'catalog:' + version: 6.0.3 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) diff --git a/scripts/knip-schemas.test.ts b/scripts/knip-schemas.test.ts new file mode 100644 index 000000000000..1184557d0e47 --- /dev/null +++ b/scripts/knip-schemas.test.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the real Knip CLI against a disposable on-disk project. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import { expect, it } from "vite-plus/test"; + +const require = NodeModule.createRequire(import.meta.url); +const cli = NodePath.join(NodePath.dirname(require.resolve("knip")), "cli.js"); +const preprocessor = NodePath.join(import.meta.dirname, "knip-schemas.ts"); + +it("allows types and schemas through the real Knip CLI without hiding runtime or file findings", () => { + // Keeping the disposable project here gives it the same Effect installation as the scripts. + const cwd = NodeFS.mkdtempSync(NodePath.join(import.meta.dirname, ".knip-test-")); + const write = (file: string, content: string) => + NodeFS.writeFileSync(NodePath.join(cwd, file), content); + const run = (filtered: boolean) => + NodeChildProcess.spawnSync( + NodeProcess.execPath, + [ + cli, + "--directory", + cwd, + "--config", + NodePath.join(cwd, "knip.json"), + "--no-config-hints", + "--include", + "files,dependencies,exports,nsExports,types,nsTypes,duplicates", + "--reporter", + "json", + ...(filtered ? ["--preprocessor", preprocessor] : []), + ], + { encoding: "utf8" }, + ); + try { + write( + "package.json", + JSON.stringify({ private: true, type: "module", dependencies: { effect: "*" } }), + ); + write( + "tsconfig.json", + JSON.stringify({ compilerOptions: { module: "NodeNext", strict: true } }), + ); + write( + "knip.json", + JSON.stringify({ + entry: ["entry.ts"], + project: ["*.ts"], + includeEntryExports: true, + include: [ + "exports", + "nsExports", + "types", + "nsTypes", + "duplicates", + "files", + "dependencies", + ], + rules: { types: "off", nsTypes: "off" }, + }), + ); + write( + "entry.ts", + ` + import * as S from "effect/Schema"; + import * as ns from "./namespace.ts"; + console.log(ns); + export { Remote as Reexported } from "./remote.ts"; + export const Text = S.String; + export const Alias = Text; + const Internal = S.Boolean; + export type Internal = typeof Internal.Type; + export const PublicAlias = Internal; + export default Text; + export const Record = S.Struct({ name: Text }).annotate({ title: "record" }); + export const Branded = S.String.pipe(S.brand("Name")); + export class Failure extends S.TaggedErrorClass()("Failure", { reason: Text }) {} + export class Person extends S.Class("Person")({ name: Text }) {} + export type UnusedType = { name: string }; + export interface UnusedInterface { name: string } + `, + ); + write( + "remote.ts", + ` + import { Schema as S } from "effect"; + export const Remote = S.Struct({ id: S.Number }); + throw new Error("The preprocessor must never evaluate application modules"); + `, + ); + write( + "namespace.ts", + ` + import * as S from "effect/Schema"; + export const Unused = S.Number; + export type UnusedType = string; + `, + ); + const baseline = run(false); + expect(baseline.status, baseline.stderr).toBe(1); + expect(baseline.stdout).toContain('"Text"'); + const allowed = run(true); + expect(allowed.status, allowed.stderr + allowed.stdout).toBe(0); + expect(JSON.parse(allowed.stdout).issues).toEqual([]); + + NodeFS.appendFileSync( + NodePath.join(cwd, "entry.ts"), + ` + export const decode = S.decodeUnknownSync(Text); + export const makeSchema = () => S.String; + export const LooksLikeSchema = { ast: "not a schema" }; + export const ordinary = 123; + export const duplicate = ordinary; + `, + ); + NodeFS.appendFileSync(NodePath.join(cwd, "namespace.ts"), `export const helper = () => 1;`); + write("unused.ts", `export const orphan = 1;`); + write( + "package.json", + JSON.stringify({ + private: true, + type: "module", + dependencies: { effect: "*", "unused-knip-fixture-dependency": "*" }, + }), + ); + const rejected = run(true); + expect(rejected.status, rejected.stderr).toBe(1); + const issues = JSON.parse(rejected.stdout).issues; + const entry = issues.find((issue: { file: string }) => issue.file === "entry.ts"); + expect(entry.exports.map((issue: { name: string }) => issue.name).sort()).toEqual([ + "LooksLikeSchema", + "decode", + "duplicate", + "makeSchema", + "ordinary", + ]); + expect(entry.duplicates).toHaveLength(1); + expect( + issues.find((issue: { file: string }) => issue.file === "namespace.ts").nsExports, + ).toEqual([expect.objectContaining({ name: "helper" })]); + expect(issues.find((issue: { file: string }) => issue.file === "unused.ts").files).toHaveLength( + 1, + ); + expect( + issues.find((issue: { file: string }) => issue.file === "package.json").dependencies, + ).toEqual([expect.objectContaining({ name: "unused-knip-fixture-dependency" })]); + } finally { + NodeFS.rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/scripts/knip-schemas.ts b/scripts/knip-schemas.ts new file mode 100644 index 000000000000..fafa7ccd678a --- /dev/null +++ b/scripts/knip-schemas.ts @@ -0,0 +1,96 @@ +// @effect-diagnostics nodeBuiltinImport:off - Knip and the TypeScript compiler host use synchronous Node paths. +import * as NodePath from "node:path"; +import type { Preprocessor } from "knip"; +import ts from "typescript"; + +// Effect 4 schemas carry this marker, including aliases and Schema.Class constructors. +// Checking the type avoids evaluating application modules or exempting schema factories/decoders. +const schemaTypeId = "~effect/Schema/Schema"; + +const preprocess: Preprocessor = (options) => { + const categories = ["exports", "nsExports", "duplicates"] as const; + const projects = new Map>(); + for (const category of categories) { + for (const [filePath, issues] of Object.entries(options.issues[category])) { + if (Object.keys(issues).length === 0) continue; + const configPath = ts.findConfigFile( + NodePath.dirname(NodePath.resolve(options.cwd, filePath)), + ts.sys.fileExists, + ); + const files = projects.get(configPath) ?? new Set(); + files.add(filePath); + projects.set(configPath, files); + } + } + + for (const [configPath, files] of projects) { + const config = configPath + ? ts.getParsedCommandLineOfConfigFile( + configPath, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + }, + }, + ) + : undefined; + if (config?.errors.length) { + throw new Error( + config.errors + .map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")) + .join("\n"), + ); + } + const program = ts.createProgram( + [...files].map((file) => NodePath.resolve(options.cwd, file)), + { + module: ts.ModuleKind.NodeNext, + allowJs: true, + ...config?.options, + noEmit: true, + }, + ); + const checker = program.getTypeChecker(); + for (const filePath of files) { + const source = program.getSourceFile(NodePath.resolve(options.cwd, filePath)); + const module = source && checker.getSymbolAtLocation(source); + if (!source || !module) continue; + const schemas = new Set( + checker.getExportsOfModule(module).flatMap((symbol) => { + const exported = + symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + // Duplicate exports can name a private value with a public type of the same name. + const target = + exported.flags & ts.SymbolFlags.Value + ? exported + : checker.resolveName(symbol.name, source, ts.SymbolFlags.Value, false); + if (!target) return []; + const type = checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration ?? source); + const marker = type.getProperty(schemaTypeId); + if (!marker) return []; + const markerType = checker.getTypeOfSymbolAtLocation(marker, source); + return markerType.isStringLiteral() && markerType.value === schemaTypeId + ? [symbol.name] + : []; + }), + ); + for (const category of categories) { + const issues = options.issues[category][filePath]; + if (!issues) continue; + for (const [key, issue] of Object.entries(issues)) { + const symbols = issue.symbols ?? [{ symbol: issue.symbol }]; + if (symbols.length > 0 && symbols.every(({ symbol }) => schemas.has(symbol))) { + delete issues[key]; + options.counters[category]--; + } + } + if (Object.keys(issues).length === 0) delete options.issues[category][filePath]; + } + } + } + return options; +}; + +export default preprocess; diff --git a/scripts/package.json b/scripts/package.json index 042d69a8d9f9..0d080008ead3 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@types/pngjs": "6.0.5", + "typescript": "catalog:", "vite-plus": "catalog:" } } From 54441e63d824d4df11d031fc4811d1af9077e67f Mon Sep 17 00:00:00 2001 From: Matthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:57:13 -0400 Subject: [PATCH 10/65] fix(web): copy provider update commands from compact rows (#9888) --- .../settings/ProviderInstanceCard.tsx | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 5c96de9d0545..76bd595b2135 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -598,15 +598,19 @@ export function ProviderInstanceCard({ selected ? "bg-muted/45" : "hover:bg-muted/25", )} > - + } + /> + Copy update command + + ) : ( + + + + ) ) : null} @@ -637,7 +663,7 @@ export function ProviderInstanceCard({ - + Date: Sat, 5 Sep 2026 18:57:25 -0400 Subject: [PATCH 11/65] fix(web): align provider header action sizes and spacing (#9890) --- .../src/components/settings/ProviderInstanceCard.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 76bd595b2135..b3faa7b0509f 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -677,7 +677,7 @@ export function ProviderInstanceCard({ } const editorHeaderAction = ( -
+
{driverOption?.badgeLabel ? ( {driverOption.badgeLabel} @@ -695,7 +695,7 @@ export function ProviderInstanceCard({ render={ } /> @@ -784,14 +784,14 @@ export function ProviderInstanceCard({ {onDelete ? ( ) : null} From 3fb8942a427d564cfee4724ad6e40eb9c8881aea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:42 -0700 Subject: [PATCH 12/65] fix(mobile): restore live tool shimmer and add a Thinking row (#10173) Co-authored-by: Claude Fable 5 --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../src/features/threads/ThreadFeed.tsx | 6 + .../src/features/threads/thread-work-log.tsx | 22 ++++ apps/mobile/src/lib/threadActivity.test.ts | 113 +++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 32 ++++- 5 files changed, 170 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 26fdfd24a4fb..8667ebb72475 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -20,6 +20,7 @@ import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; import IconBolt from "@tabler/icons-react-native/IconBolt"; import IconBox from "@tabler/icons-react-native/IconBox"; +import IconBrain from "@tabler/icons-react-native/IconBrain"; import IconCamera from "@tabler/icons-react-native/IconCamera"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; @@ -109,6 +110,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "bell.badge": IconBellRinging, "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, + brain: IconBrain, camera: IconCamera, "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 03f0b88f5f4c..ee715d1f7d26 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -155,6 +155,7 @@ import { collapsedWorkLogHeight, ThreadDisclosureChevron, ThreadWorkGroupToggle, + ThreadThinkingRow, ThreadWorkLog, THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT, @@ -1371,6 +1372,10 @@ function renderFeedEntry( ); } + if (entry.type === "thinking") { + return ; + } + if (entry.type === "work-toggle") { return ( ; + readonly iconSubtleColor: ColorValue; +}) { + return ( + + + + ); +} + function ToolActivityIconView(props: { readonly environmentId: EnvironmentId; readonly icon?: ToolActivityIcon; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index df3dd3197d0d..8ab726226bdc 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1566,7 +1566,8 @@ describe("buildThreadFeed", () => { summaryToolIcon: "browser", hasFailure, live: true, - shimmer: false, + // A successful trailing call keeps shining; a failure hands off to "Thinking". + shimmer: !hasFailure, }, { type: "activity-group", @@ -1580,6 +1581,7 @@ describe("buildThreadFeed", () => { }, ], }, + ...(hasFailure ? [{ type: "thinking", turnId }] : []), ]); const terminalGroup = terminalRows[1]; if (terminalGroup?.type !== "activity-group") return; @@ -2128,7 +2130,7 @@ describe("buildThreadFeed", () => { ( [ { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, - { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false }, + { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: true }, { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, @@ -2228,8 +2230,12 @@ describe("buildThreadFeed", () => { shimmer, }); expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + // Exactly one live activity: the shimmering call, or "Thinking" once it fails. + expect(rows.filter((entry) => entry.type === "thinking")).toHaveLength(shimmer ? 0 : 1); + expect(rows.at(-1)?.type).toBe(shimmer ? "work-toggle" : "thinking"); const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); + expect(stoppedRows.some((entry) => entry.type === "thinking")).toBe(false); expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ { live: false, shimmer: false }, { @@ -2253,6 +2259,109 @@ describe("buildThreadFeed", () => { }, ); + it("shows one Thinking row while a turn works without live tool activity", () => { + const turnId = TurnId.make("turn-thinking"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-thinking"), + projectId: ProjectId.make("project-1"), + title: "Thinking", + latestTurn, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "hello", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ], + }), + ); + + const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); + expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); + expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId }); + // The row identity is stable across re-derivations so the list can reuse it. + expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( + rows[1], + ); + // Idle threads show no live activity. + expect( + deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), null).map( + (entry) => entry.type, + ), + ).toEqual(["message"]); + }); + + it("hands a settled tool run off to Thinking once assistant text streams after it", () => { + const turnId = TurnId.make("turn-streaming-tail"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-streaming-tail"), + projectId: ProjectId.make("project-1"), + title: "Streaming tail", + latestTurn, + messages: [ + { + id: MessageId.make("assistant-1"), + role: "assistant", + text: "Here is what I found", + turnId, + streaming: true, + createdAt: "2026-04-01T00:00:05.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("read-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read file", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + itemType: "file_read", + toolCallId: "read-1", + title: "Read file", + status: "completed", + detail: "src/index.ts", + }, + }), + ], + }), + ); + + const rows = deriveThreadFeedPresentation( + feed, + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + expect(rows.map((entry) => entry.type)).toEqual(["work-toggle", "message", "thinking"]); + expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + }); + it("preserves serialized shell wrappers with non-matching boundary quotes", () => { const turnId = TurnId.make("turn-serialized-shell-wrapper"); const command = diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ef75fce56af4..8cb657f858f4 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -169,6 +169,12 @@ export type ThreadFeedEntry = readonly turnId: TurnId; readonly label: string; readonly expanded: boolean; + } + | { + readonly type: "thinking"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; }; export type ThreadFeedLatestTurn = Pick< @@ -201,6 +207,7 @@ const turnFoldRowsCache = new WeakMap< ThreadFeedEntry, Extract >(); +let cachedThinkingRow: Extract | null = null; export function isContextCompactionActivityGroup( entry: Extract, @@ -1510,7 +1517,8 @@ export function deriveThreadFeedPresentation( activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", + (entry) => + entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", ); const activeTailGroup = sourceFeed.findLast( (entry) => entry.type !== "message" || !isEmptyMessage(entry), @@ -1570,12 +1578,27 @@ export function deriveThreadFeedPresentation( ); } } + // A working turn always shows one live activity. When no tool row is + // shimmering (no tools yet, or the latest failed), that row is "Thinking". + if ( + activeWorkStartedAt !== null && + !result.some((row) => row.type === "work-toggle" && row.shimmer) + ) { + result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); + } return result; } +function thinkingRow(createdAt: string, turnId: TurnId | null) { + if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) { + cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId }; + } + return cachedThinkingRow; +} + function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, unsettledTurnId: TurnId | null, isWorking: boolean, @@ -1696,6 +1719,9 @@ function appendToolGroupRows( const active = latestActiveActivity !== undefined; const live = activeTail || active; const latestActivity = latestActiveActivity ?? activities.at(-1)!; + // Like web, the trailing run keeps shining after its latest call succeeds; + // only a failed, declined, or stopped call hands the live slot to "Thinking". + const shimmer = active || (activeTail && latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live ? liveToolActivitySummary(latestActivity, live) @@ -1751,7 +1777,7 @@ function appendToolGroupRows( ...(summaryToolIcon ? { summaryToolIcon } : {}), hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", live, - shimmer: active, + shimmer, }); if (!expanded) { return; From 1cb49c3df2e0fb4bc2c6e88b0f102c1f9eb8cee5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:43 -0700 Subject: [PATCH 13/65] fix(mobile): even out the working pill's spacing (#10209) Co-authored-by: Claude Fable 5 --- .../features/threads/floating-working-control.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index cac2686ab218..a429044fccdd 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -18,8 +18,12 @@ import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -const CONTROL_HEIGHT = 44; -const CONTROL_COMPOSER_GAP = 8; +const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem +// The collapsed composer capsule starts 6 below its overlay's top edge, so +// the pill sits at (gap - 6) above the overlay to leave the same gap to the +// capsule as the feed's end inset leaves between it and the last row. +const CONTROL_GAP = 8; +const COMPOSER_CAPSULE_INSET = 6; const GLASS_MERGE_SPACING = 12; const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); @@ -40,7 +44,8 @@ const UniwindGlassContainer = withUniwind(GlassContainer, { }); const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); -export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; +const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; +export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; /** * What the floating pill says. Syncing and working share one element so the @@ -81,7 +86,7 @@ export function FloatingWorkingControl(props: { From 7eda989d38a30d5e35c9efbd946aae3d6f9c065a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:43 -0700 Subject: [PATCH 14/65] fix(mobile): only make work rows expandable when the body adds something (#10210) Co-authored-by: Claude Fable 5 --- .../src/features/threads/thread-work-log.tsx | 20 +---- apps/mobile/src/lib/threadActivity.test.ts | 90 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 46 ++++++++-- 3 files changed, 130 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 57d9a5d91fec..0b22b35cfce5 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -34,7 +34,7 @@ import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; -import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -307,21 +307,6 @@ export function ShimmeringWorkContent(props: { ); } -function stripShellWrapper(value: string): string { - const trimmed = value.trim(); - const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); - return (match?.[1] ?? trimmed).trim(); -} - -function compactActivityDetail(detail: string | null): string | null { - if (!detail) { - return null; - } - - const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); - return cleaned.length > 0 ? cleaned : null; -} - function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { switch (icon) { case "agent": @@ -697,8 +682,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( const fullDetail = expanded ? row.getFullDetail() : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); - const previewText = - toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; + const previewText = workEntryRowLabel(row.workEntry); const displayText = !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 8ab726226bdc..c263a9f2fe93 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -21,6 +21,7 @@ import { isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, + workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; @@ -702,6 +703,95 @@ describe("buildThreadFeed", () => { const [row] = group.activities; expect(row?.workEntry.detail).toBe(command); expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); + // Opening it would only repeat the command the row already shows. + expect(row?.canExpand).toBe(false); + }); + + it.each([ + { + name: "a task summary that is its own detail", + activity: { + kind: "task.completed" as const, + tone: "info" as const, + summary: "Task completed", + payload: { + taskId: "bh2p996o4", + status: "completed", + title: "Check CI on the new head", + summary: "Check CI on the new head", + detail: "Check CI on the new head", + agentKind: "background", + taskType: "local_bash", + }, + }, + label: "Check CI on the new head", + canExpand: false, + }, + { + name: "a runtime warning with only its message", + activity: { + kind: "runtime.warning" as const, + tone: "info" as const, + summary: "Bash is unusable in this environment", + payload: { detail: "Bash is unusable in this environment" }, + }, + label: "Bash is unusable in this environment", + canExpand: false, + }, + { + name: "a multi-line task report", + activity: { + kind: "task.completed" as const, + tone: "info" as const, + summary: "Task completed", + payload: { + taskId: "ae3f85a", + status: "completed", + title: "Audit the PR", + detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.", + agentKind: "agent", + taskType: "local_agent", + }, + }, + label: "**Tooling note:** Bash is unusable. # Audit No blockers.", + canExpand: true, + }, + { + name: "a command whose output differs from the command", + activity: { + kind: "tool.completed" as const, + tone: "tool" as const, + summary: "Command run", + payload: { + itemType: "command_execution", + title: "Command run", + detail: "Bash: printf hello", + data: { toolName: "Bash", command: "printf hello", rawOutput: { content: "hello" } }, + }, + }, + label: "printf hello", + canExpand: true, + }, + ])("only lets $name expand when the body adds something: $canExpand", (input) => { + const thread = makeThread({ + id: ThreadId.make("thread-expand-rule"), + projectId: ProjectId.make("project-1"), + title: "Expand rule", + activities: [ + makeActivity({ + id: EventId.make("expand-rule"), + createdAt: "2026-09-01T00:00:00.000Z", + ...input.activity, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(workEntryRowLabel(row!.workEntry)).toBe(input.label); + expect(row?.canExpand).toBe(input.canExpand); }); it("drops a truncated Claude echo of a long command", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8cb657f858f4..fa01142d046b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -878,13 +878,43 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { return blocks.length > 0 ? blocks.join("\n\n") : null; } -function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { - return ( - (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || - Boolean((entry.rawCommand ?? entry.command)?.trim()) || - Boolean(entry.detail?.trim()) || - (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) - ); +/** + * A row only opens when its body says more than its collapsed line. A row + * whose only detail is the single-line text it already shows (a runtime + * warning, a task summary, a short command) has nothing to reveal. + * Multi-line text still expands: the collapsed row truncates it to one line. + * Cheap field checks come first so large tool payloads are not serialized + * for every row (see the deferred-expansion test). + */ +function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { + if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; + if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; + const parts = [entry.rawCommand ?? entry.command, entry.detail] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + if (parts.length === 0) return false; + if (parts.length > 1 && new Set(parts).size > 1) return true; + const only = parts[0]!; + return only.includes("\n") || collapseWhitespace(only) !== collapseWhitespace(collapsedText); +} + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +/** The one-line text a collapsed work row shows. */ +export function workEntryRowLabel(entry: WorkLogEntry): string { + const presentation = resolveWorkEntryToolPresentation(entry); + if (presentation) return presentation.displayName; + const preview = workEntryPreview(entry); + const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview)); + return compactPreview || workEntryHeading(entry); } function memoizeValue(build: () => T): () => T { @@ -2098,7 +2128,7 @@ function toThreadFeedActivityEntry( turnId: entry.turnId, summary, detail, - canExpand: workEntryHasExpandedBody(entry), + canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)), getFullDetail, getCopyText, icon: workEntryIcon(entry), From 579a77588684fc4012e28754cc4f662aa24730c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:44 -0700 Subject: [PATCH 15/65] fix(mobile): fold subagent lifecycle rows into one batch per spawn (#10211) Co-authored-by: Claude Fable 5 --- apps/mobile/src/lib/threadActivity.test.ts | 229 ++++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 254 ++++++++++++++++++--- 2 files changed, 450 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c263a9f2fe93..6131dc9a1b31 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -745,12 +745,12 @@ describe("buildThreadFeed", () => { tone: "info" as const, summary: "Task completed", payload: { - taskId: "ae3f85a", + taskId: "bpxcizf97", status: "completed", title: "Audit the PR", detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.", - agentKind: "agent", - taskType: "local_agent", + agentKind: "background", + taskType: "local_bash", }, }, label: "**Tooling note:** Bash is unusable. # Audit No blockers.", @@ -2745,16 +2745,104 @@ describe("quiet timeline: nested agents", () => { const rows = buildThreadFeed(thread).flatMap((entry) => entry.type === "activity-group" ? entry.activities : [], ); + // The agent folds into its spawn batch, which stays live after a resume. expect(rows).toMatchObject([ { lifecycleStatus: "inProgress", - summary: "Reviewer", - workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" }, + summary: "Kicked off 1 subagent · 1 working", + workEntry: { agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] } }, }, ]); }, ); + it("folds a turn's direct spawns into one batch row that tracks their states", () => { + const turnId = TurnId.make("turn-spawn"); + const agent = ( + id: string, + kind: "task.started" | "task.progress" | "task.completed" | "task.updated", + taskId: string, + status: string, + seconds: number, + extra: Record = {}, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `${taskId} ${status}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId, + agentKind: "agent", + taskType: "local_agent", + title: `Agent ${taskId}`, + status, + ...extra, + }, + }); + const shell = makeActivity({ + id: EventId.make("shell-1"), + kind: "task.completed", + summary: "Task completed", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + taskId: "sh-1", + agentKind: "background", + taskType: "local_bash", + status: "completed", + title: "Run tests", + detail: "Run tests", + }, + }); + const activities = [ + agent("a-start", "task.started", "a", "running", 1), + agent("b-start", "task.started", "b", "running", 2), + agent("a-progress", "task.progress", "a", "running", 3, { detail: "Reading files" }), + shell, + agent("b-progress", "task.progress", "b", "running", 6, { detail: "Grepping" }), + ]; + const rowsFor = (extraActivities: ReadonlyArray>) => + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-spawn"), + projectId: ProjectId.make("project-1"), + title: "Spawns", + activities: [...activities, ...extraActivities], + }), + ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + + const running = rowsFor([]); + expect(running.map((row) => [row.id, row.summary])).toEqual([ + ["a-progress", "Kicked off 2 subagents · 2 working"], + ["shell-1", "Run tests"], + ]); + expect(running[0]).toMatchObject({ + lifecycleStatus: "inProgress", + workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } }, + }); + + const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]); + expect(oneDone[0]).toMatchObject({ + id: "a-progress", + summary: "Kicked off 2 subagents · 1 working", + lifecycleStatus: "inProgress", + }); + + const allDone = rowsFor([ + agent("a-done", "task.completed", "a", "completed", 7), + agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }), + ]); + expect(allDone[0]).toMatchObject({ + id: "a-progress", + summary: "Ran 2 subagents · 1 failed", + lifecycleStatus: "failed", + status: "failure", + }); + expect(allDone).toHaveLength(2); + }); + it.each(["cancelled", "failed", "interrupted", "idle"] as const)( "replaces Antigravity batch progress with %s", (status) => { @@ -2802,19 +2890,146 @@ describe("quiet timeline: nested agents", () => { const rows = buildThreadFeed(thread).flatMap((entry) => entry.type === "activity-group" ? entry.activities : [], ); + // Turn-less batches never share a spawn group, so each keeps its own row. expect(rows).toHaveLength(2); expect(rows[0]).toMatchObject({ lifecycleStatus: status === "failed" ? "failed" : "stopped", - detail, - workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" }, + summary: `Ran 1 subagent · ${status === "failed" ? "1 failed" : "1 stopped"}`, + workEntry: { + taskId: "trajectory:4", + toolTitle: "Antigravity subagent batch", + agentSpawn: { agents: [{ detail }] }, + }, }); + expect(rows[0]?.getFullDetail()).toContain(detail); expect(rows[1]).toMatchObject({ lifecycleStatus: "inProgress", + summary: "Kicked off 1 subagent · 1 working", workEntry: { taskId: "trajectory:5" }, }); }, ); + it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => { + const turnId = TurnId.make("turn-workflow"); + const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`; + const thread = makeThread({ + id: ThreadId.make("thread-workflow"), + projectId: ProjectId.make("project-1"), + title: "Workflow", + activities: [ + makeActivity({ + id: EventId.make("wf-progress"), + kind: "task.progress", + summary: "Workflow running", + createdAt: at(1), + turnId, + payload: { + taskId: "wf-1", + taskType: "local_workflow", + workflowName: "review", + agentKind: "agent", + title: "review", + status: "running", + }, + }), + // Members are synthesized with timelineBypass and never render alone. + ...[0, 1].map((index) => + makeActivity({ + id: EventId.make(`member-${index}`), + kind: "task.progress", + summary: `Agent ${index}`, + createdAt: at(2 + index), + turnId, + payload: { + taskId: `wf-1:wf:${index}`, + agentKind: "agent", + title: `Reviewer ${index}`, + description: `Reviewer ${index}`, + status: index === 0 ? "completed" : "running", + parentAgentId: "wf-1", + timelineBypass: true, + }, + }), + ), + makeActivity({ + id: EventId.make("wf-done"), + kind: "task.completed", + summary: "Task completed", + createdAt: at(10), + turnId, + payload: { + taskId: "wf-1", + taskType: "local_workflow", + workflowName: "review", + agentKind: "agent", + status: "completed", + title: "review", + }, + }), + ], + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toHaveLength(1); + // The member that never reported its own end settles with the coordinator. + expect(rows[0]).toMatchObject({ + id: "wf-progress", + summary: "Ran 2 subagents · completed", + lifecycleStatus: "completed", + workEntry: { + agentSpawn: { + workflowId: "wf-1", + agentTaskIds: ["wf-1", "wf-1:wf:0", "wf-1:wf:1"], + }, + }, + }); + expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed"); + }); + + it("treats a Codex child's idle turn end as a finished batch member", () => { + const turnId = TurnId.make("turn-codex"); + const child = ( + id: string, + kind: "task.started" | "task.updated", + status: string, + seconds: number, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `${status}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId: "child-1", + agentKind: "agent", + title: "math_one", + status, + timelineBypass: true, + }, + }); + const thread = makeThread({ + id: ThreadId.make("thread-codex"), + projectId: ProjectId.make("project-1"), + title: "Codex children", + activities: [ + child("c-start", "task.started", "running", 1), + child("c-running", "task.updated", "running", 2), + child("c-idle", "task.updated", "idle", 5), + ], + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + summary: "Ran 1 subagent · completed", + lifecycleStatus: "completed", + }); + }); + it("keeps a nested agent's terminal row but hides its background work", () => { const thread = makeThread({ id: ThreadId.make("thread-nested"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fa01142d046b..1d6fb6c0e252 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -110,7 +110,20 @@ export interface WorkLogEntry { toolLifecycleStatus?: WorkLogToolLifecycleStatus; sourceActivityKind?: OrchestrationThreadActivity["kind"]; toolCallId?: string; - agentSpawn?: boolean; + /** + * One row per workflow run or per-turn batch of direct spawns, like web's + * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row + * also carries each agent's terminal state to derive its status label. + */ + agentSpawn?: { + readonly workflowId: string | null; + readonly agentTaskIds: ReadonlyArray; + readonly agents: ReadonlyArray<{ + readonly title: string; + readonly status: WorkLogToolLifecycleStatus | undefined; + readonly detail: string | undefined; + }>; + }; toolData?: unknown; } @@ -119,6 +132,9 @@ interface DerivedWorkLogEntry extends WorkLogEntry { collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; + isWorkflowCoordinator?: boolean; + /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ + isBackgroundTask?: boolean; } type RawThreadFeedEntry = @@ -388,10 +404,12 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { /** * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal - * activity lives in the Agents sheet, not the work log. Terminal rows are - * kept — with no Agents surface on mobile they are the terminal signal - * (a surface that hides rows must keep its own terminal signal). That means - * task.completed and terminal task.updated, including Antigravity cancellation. + * activity lives in the Agents sheet, not the work log. Agent lifecycle rows + * pass even when bypassed or owned by another agent, because they fold into + * their spawn batch rather than rendering on their own; that is how Codex + * children (all bypassed) and Claude workflow members reach the batch row. + * Terminal rows are kept regardless — with no Agents surface on mobile they + * are the terminal signal. */ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { const payload = @@ -401,20 +419,26 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean if (!payload) { return false; } - const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity); - if (payload.timelineBypass === true && !isTerminalTaskRow) { - return true; - } - // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is - // the only signal mobile gets (no Agents sheet), so it stays. Only an - // agent's own background work (stamped "background") is internal — same - // rule as web (review finding: hiding on agentId alone dropped nested - // completions with no replacement UI). + const isTaskRow = + activity.kind === "task.progress" || + activity.kind === "task.updated" || + activity.kind === "task.completed"; const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; - if (!ownedByAgent) { - return false; + if (isTaskRow) { + if (!ownedByAgent && payload.timelineBypass !== true) { + return false; + } + // An agent's own shells stay internal; the agents themselves fold into + // their batch. A bypassed batch marker keeps its terminal row. + if (typeof payload.taskId === "string" && payload.agentKind === "agent") { + return false; + } + if (ownedByAgent) { + return true; + } + return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity)); } - return !(isTerminalTaskRow && payload.agentKind === "agent"); + return payload.timelineBypass === true || ownedByAgent; } function deriveWorkLogEntries( @@ -511,8 +535,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolCallId) { entry.toolCallId = toolCallId; } - if (isTaskActivity && payload?.agentKind === "agent") { - entry.agentSpawn = true; + if (isTaskActivity && payload) { + if (payload.agentKind !== "agent") { + entry.isBackgroundTask = true; + } + if ( + payload.taskType === "local_workflow" || + (typeof payload.workflowName === "string" && payload.workflowName.length > 0) + ) { + entry.isWorkflowCoordinator = true; + } } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); @@ -579,6 +611,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } + // A Codex child that finishes its turn reports "idle" (resumable, not + // terminal). For the batch row that is a finished member. + if (!toolLifecycleStatus && isTaskActivity && payload?.status === "idle") { + toolLifecycleStatus = "completed"; + } if (toolLifecycleStatus) { entry.toolLifecycleStatus = toolLifecycleStatus; } @@ -589,13 +626,108 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo return entry; } +/** + * Spawn-group key for a subagent lifecycle row. Workflow members and their + * coordinator share the coordinator's group; direct spawns batch per turn. + * Same keys as web's session-logic so both clients fold the same rows. + */ +function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { + const taskId = entry.taskId ?? ""; + const workflowSlot = taskId.indexOf(":wf:"); + if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`; + if (entry.isWorkflowCoordinator) return `wf:${taskId}`; + return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; +} + +/** + * The batch row keeps the group's anchor identity (id, createdAt, turnId, + * label) so it renders where the run launched instead of drifting to the + * newest progress tick, and gains each member's latest lifecycle state. + */ +function agentSpawnRow( + anchor: DerivedWorkLogEntry, + workflowId: string | null, + agentTaskIds: ReadonlyArray, + members: NonNullable["agents"], +): DerivedWorkLogEntry { + // A finished coordinator settles members that never reported their own + // end; Claude stops synthesizing member ticks once the workflow is done. + const coordinator = workflowId === null ? undefined : members[agentTaskIds.indexOf(workflowId)]; + const agents = + coordinator?.status !== undefined && coordinator.status !== "inProgress" + ? members.map((agent) => + agent.status === undefined || agent.status === "inProgress" + ? { ...agent, status: coordinator.status } + : agent, + ) + : members; + const agentSpawn = { workflowId, agentTaskIds, agents }; + // The batch row has no detail of its own: its body lists the members. + const { detail: _detail, ...anchorWithoutDetail } = anchor; + return { + ...anchorWithoutDetail, + // The row's own lifecycle is the batch's: live while any member is, then + // the worst terminal state, so the group summary and shimmer follow it. + toolLifecycleStatus: agentSpawnLifecycleStatus(agents), + agentSpawn, + }; +} + +function agentSpawnMember( + entry: DerivedWorkLogEntry, + previous?: NonNullable["agents"][number], +) { + return { + title: entry.toolTitle ?? previous?.title ?? entry.label, + status: entry.toolLifecycleStatus ?? previous?.status, + detail: entry.detail ?? previous?.detail, + }; +} + +function mergeAgentSpawnEntries( + existing: DerivedWorkLogEntry, + entry: DerivedWorkLogEntry, +): DerivedWorkLogEntry { + const spawn = existing.agentSpawn!; + const taskId = entry.taskId ?? ""; + const memberIndex = spawn.agentTaskIds.indexOf(taskId); + if (memberIndex === -1) { + return agentSpawnRow( + existing, + spawn.workflowId, + [...spawn.agentTaskIds, taskId], + [...spawn.agents, agentSpawnMember(entry)], + ); + } + const agents = spawn.agents.map((agent, index) => + index === memberIndex ? agentSpawnMember(entry, agent) : agent, + ); + return agentSpawnRow(existing, spawn.workflowId, spawn.agentTaskIds, agents); +} + +function agentSpawnLifecycleStatus( + agents: NonNullable["agents"], +): WorkLogToolLifecycleStatus { + const statuses = agents.map((agent) => agent.status); + if (statuses.some((status) => status === undefined || status === "inProgress")) { + return "inProgress"; + } + if (statuses.includes("failed")) return "failed"; + if (statuses.includes("stopped")) return "stopped"; + return "completed"; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; - // Subagent rows collapse by identity, not adjacency (quiet-timeline - // guarantee; mirrors web's session-logic). + // Task rows collapse by identity, not adjacency (quiet-timeline guarantee; + // mirrors web's session-logic). Background tasks keep one row per taskId; + // agent spawns fold into one row per spawn group, decided at the FIRST row + // seen for a taskId because later rows can arrive under synthetic turns. const taskRowIndex = new Map(); + const spawnRowIndex = new Map(); + const spawnGroupByTaskId = new Map(); const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = @@ -604,13 +736,32 @@ function collapseDerivedWorkLogEntries( entry.sourceActivityKind === "task.completed" || entry.sourceActivityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { - const existingIndex = taskRowIndex.get(entry.taskId); + if (entry.isBackgroundTask) { + const existingIndex = taskRowIndex.get(entry.taskId); + if (existingIndex !== undefined) { + collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + continue; + } + taskRowIndex.set(entry.taskId, collapsed.length); + collapsed.push(entry); + continue; + } + const groupKey = spawnGroupByTaskId.get(entry.taskId) ?? agentSpawnGroupKey(entry); + spawnGroupByTaskId.set(entry.taskId, groupKey); + const existingIndex = spawnRowIndex.get(groupKey); if (existingIndex !== undefined) { - collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry); continue; } - taskRowIndex.set(entry.taskId, collapsed.length); - collapsed.push(entry); + spawnRowIndex.set(groupKey, collapsed.length); + collapsed.push( + agentSpawnRow( + entry, + groupKey.startsWith("wf:") ? groupKey.slice(3) : null, + [entry.taskId], + [agentSpawnMember(entry)], + ), + ); continue; } const lifecycleKey = toolLifecycleCollapseMapKey(entry); @@ -821,6 +972,16 @@ function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { } function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { + if (entry.agentSpawn) { + switch (entry.toolLifecycleStatus) { + case "failed": + return "failure"; + case "completed": + return "success"; + default: + return "neutral"; + } + } if (!workLogEntryIsToolLike(entry)) { return null; } @@ -834,6 +995,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { } function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { + if (entry.agentSpawn) return "agent"; if ( entry.sourceActivityKind === "user-input.requested" || entry.sourceActivityKind === "user-input.resolved" @@ -860,6 +1022,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { } function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { + if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn); const blocks: string[] = []; const appendBlock = (value: string | null | undefined) => { const trimmed = value?.trim(); @@ -887,6 +1050,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { * for every row (see the deferred-expansion test). */ function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { + if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; const parts = [entry.rawCommand ?? entry.command, entry.detail] @@ -910,6 +1074,7 @@ function stripShellWrapper(value: string): string { /** The one-line text a collapsed work row shows. */ export function workEntryRowLabel(entry: WorkLogEntry): string { + if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn); const presentation = resolveWorkEntryToolPresentation(entry); if (presentation) return presentation.displayName; const preview = workEntryPreview(entry); @@ -950,7 +1115,44 @@ function capitalizePhrase(value: string): string { return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } +/** + * Batch label for a spawn row, matching web's CTA wording. Web reads live + * agent state from its Agents panel; mobile has only the lifecycle states + * folded into the row, so "working" means a member has not reported a + * terminal state yet. + */ +export function agentSpawnLabel(spawn: NonNullable): string { + const members = agentSpawnMembers(spawn); + const count = Math.max(members.length, 1); + const subjects = `${count} subagent${count === 1 ? "" : "s"}`; + const working = members.filter( + (agent) => agent.status === undefined || agent.status === "inProgress", + ).length; + const failed = members.filter((agent) => agent.status === "failed").length; + const stopped = members.filter((agent) => agent.status === "stopped").length; + if (working > 0) { + return `Kicked off ${subjects} · ${working} working`; + } + const status = failed > 0 ? `${failed} failed` : stopped > 0 ? `${stopped} stopped` : "completed"; + return `Ran ${subjects} · ${status}`; +} + +/** Workflow coordinators sit in their own batch but are not a member. */ +function agentSpawnMembers(spawn: NonNullable) { + return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); +} + +function agentSpawnExpandedBody(spawn: NonNullable): string | null { + const lines = agentSpawnMembers(spawn).map((agent) => { + const status = + agent.status === undefined || agent.status === "inProgress" ? "working" : agent.status; + return `${agent.title} · ${status}${agent.detail ? `\n ${agent.detail}` : ""}`; + }); + return lines.length > 0 ? lines.join("\n") : null; +} + function workEntryHeading(workEntry: WorkLogEntry): string { + if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn); const presentation = resolveWorkEntryToolPresentation(workEntry); if (presentation) return presentation.displayName; if (!workEntry.toolTitle) { @@ -1706,7 +1908,7 @@ function appendActivityGroupRows( groupableRun = []; }; for (const activity of activities) { - if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) { + if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) { groupableRun.push(activity); continue; } From 89cc7434f0a5373f1b9c73ee579480e8f862fa5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:44 -0700 Subject: [PATCH 16/65] fix(mobile): stop clipping expanded tool groups (#10212) Co-authored-by: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 5 + .../src/features/threads/thread-work-log.tsx | 128 +++++++++++------- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ee715d1f7d26..d53ae65be062 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1331,6 +1331,7 @@ function renderFeedEntry( readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; + readonly screenColor: string; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; readonly reviewCommentColors: ReviewCommentColors; @@ -1597,6 +1598,7 @@ function renderFeedEntry( rowSizing={props.workRowSizing} scrollPositions={props.workGroupScrollPositions} iconSubtleColor={iconSubtleColor} + edgeFadeColor={props.screenColor} themeAppearance={props.themeAppearance} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} @@ -1986,6 +1988,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; + const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { @@ -2634,6 +2637,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { renderMarkdownImage, renderViewedImage, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, @@ -2656,6 +2660,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { terminalAssistantMessageIds, unsettledTurnId, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 0b22b35cfce5..13a78b8f1454 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -385,6 +385,8 @@ interface ThreadWorkLogProps { readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly iconSubtleColor: ColorValue; + /** Feed background, painted as the scroll-edge fade over a long group. */ + readonly edgeFadeColor: string; readonly themeAppearance: "light" | "dark"; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string, anchorKey: string) => void; @@ -430,6 +432,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { {props.activities[0]?.groupedToolDetail ? ( ; + readonly edgeFadeColor: string; readonly expandedRows: Readonly>; readonly groupId: string; readonly rowSizing: ReturnType; @@ -485,21 +489,17 @@ function ThreadWorkGroupList(props: { const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); - const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; - const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height; - // Opaque covers remove each edge fade at the scroll boundary. Scroll offset - // stays on the UI thread; only content-size changes update React state. - const topCoverStyle = useAnimatedStyle(() => ({ - opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), + // Each edge fades only while content continues past it. Scroll offset stays + // on the UI thread; only content-size changes update React state. + const topFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), })); - const bottomCoverStyle = useAnimatedStyle(() => ({ - opacity: - 1 - - Math.min( - 1, - Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, - ), + const bottomFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min( + 1, + Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, + ), })); const rememberPosition = useCallback(() => { if (!loadedRef.current) return; @@ -525,7 +525,7 @@ function ThreadWorkGroupList(props: { } }, []); const onContentSizeChange = useCallback( - (_width: number, nextHeight: number) => { + (nextHeight: number) => { const previous = previousContent.current; const detailsChanged = previous.expandedRows !== props.expandedRows; const followAppend = @@ -565,6 +565,24 @@ function ThreadWorkGroupList(props: { }, [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition], ); + // The native ScrollView reports its content size a frame or more after + // LegendList has laid the rows out, so a detail toggle rendered the group + // at its old height while the rows below already moved. Read the size + // LegendList computes on the JS thread instead; it settles in the same + // commit as the row measurement that changed it. + const onContentSizeChangeRef = useRef(onContentSizeChange); + useLayoutEffect(() => { + onContentSizeChangeRef.current = onContentSizeChange; + }, [onContentSizeChange]); + const subscribeToContentSize = useCallback((list: LegendListRef | null) => { + listRef.current = list; + if (!list) return; + const unsubscribe = list.getState().listen("totalSize", () => { + onContentSizeChangeRef.current(list.getState().contentLength); + }); + onContentSizeChangeRef.current(list.getState().contentLength); + return unsubscribe; + }, []); const getFixedItemSize = useCallback( (row: ThreadFeedActivity, index: number) => props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined @@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: { ); return ( - - - - - - - - - - - - - - - - } - > + { loadedRef.current = true; @@ -654,12 +646,47 @@ function ThreadWorkGroupList(props: { scrollsToTop={false} bounces={false} keyboardShouldPersistTaps="handled" - // MaskedView bridges through a native host whose absolute-fill bounds - // can lag behind a resize. Keep the list's viewport at the group's - // current height when expanding details or appending calls. - style={[StyleSheet.absoluteFill, { height }]} + style={{ height }} /> - + + + + + + + + ); +} + +/** A screen-colored gradient painted over the list edge that still has content past it. */ +function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) { + const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; + return ( + + + + + + + + + ); } @@ -670,7 +697,12 @@ function workLogRowKey(row: ThreadFeedActivity): string { const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, - "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions" + | "activities" + | "copiedRowId" + | "edgeFadeColor" + | "expandedRows" + | "rowSizing" + | "scrollPositions" > & { readonly row: ThreadFeedActivity; readonly copied: boolean; From c2cfe59ac356768deb2a7d3e3461c715aa50a7a1 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sun, 6 Sep 2026 00:59:54 +0200 Subject: [PATCH 17/65] fix(web): defer browser discovery in integrations (#9797) --- .../settings/IntegrationsSettings.test.tsx | 65 +++++++++++++++++++ .../settings/IntegrationsSettings.tsx | 7 +- 2 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/settings/IntegrationsSettings.test.tsx diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx new file mode 100644 index 000000000000..1a8dc7d6aae8 --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -0,0 +1,65 @@ +import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { listBrowserImportSources } = vi.hoisted(() => ({ + listBrowserImportSources: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../preview/previewBridge", () => ({ + previewBridge: { listBrowserImportSources }, +})); +vi.mock("../../env", () => ({ isElectron: true })); +vi.mock("../../state/environments", () => ({ + useEnvironments: () => ({ environments: [], isReady: true }), + usePrimaryEnvironment: () => null, +})); +vi.mock("../../hooks/useSettings", () => ({ + PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment", + useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector(DEFAULT_CLIENT_SETTINGS), + useClientSettingsHydrated: () => true, + usePrimarySettingsAvailable: () => true, + usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS, + useUpdatePrimarySettings: () => vi.fn(), +})); +vi.mock("./settingsLayout", async (importOriginal) => ({ + ...(await importOriginal()), + SettingsPageContainer: ({ children }: { children: ReactNode }) => children, +})); + +import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; + +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + listBrowserImportSources.mockClear(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +async function openSettings() { + await act(() => { + renderer = create( + + + , + ); + }); +} + +describe("Integrations browser discovery", () => { + it("does not scan browser files when entering or revisiting settings", async () => { + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + + await act(() => renderer?.unmount()); + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a3e96107b29c..e950bab31cbe 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -36,7 +36,7 @@ import { } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useRef, useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; @@ -804,11 +804,6 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { .catch(() => setSources((previous) => previous ?? [])); }, []); - // Loaded once so the first open is instant instead of flashing a spinner. - useEffect(() => { - loadSources(); - }, [loadSources]); - // Runs one import for the wizard. A new profile is registered only once the // import succeeds — the cookies land in its partition first — so a blocked // attempt never leaves an empty profile behind. From 88fc41c1b90ff21a7d30d69e0ebffbc0c3511b9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 16:13:50 -0700 Subject: [PATCH 18/65] refactor(web): test file cache identity through its public helpers (#10219) --- .../components/files/fileContentRevision.test.ts | 16 ++++------------ .../src/components/files/fileContentRevision.ts | 2 +- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/files/fileContentRevision.test.ts b/apps/web/src/components/files/fileContentRevision.test.ts index e2ec7f9f1cad..4a8bb3d5522b 100644 --- a/apps/web/src/components/files/fileContentRevision.test.ts +++ b/apps/web/src/components/files/fileContentRevision.test.ts @@ -1,19 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { - fileContentRevision, - projectFileCacheKey, - projectFileEditorCacheKey, -} from "./fileContentRevision"; +import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; -describe("fileContentRevision", () => { +describe("file cache identity", () => { it("changes for same-length edits", () => { - expect(fileContentRevision("nodeVersion")).not.toBe(fileContentRevision("nodeVeasdrs")); - }); - - it("keeps identical contents stable", () => { - expect(projectFileCacheKey("/repo", "file.json", "contents")).toBe( - projectFileCacheKey("/repo", "file.json", "contents"), + expect(projectFileCacheKey("/repo", "file.json", "nodeVersion")).not.toBe( + projectFileCacheKey("/repo", "file.json", "nodeVeasdrs"), ); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index e51d464925bd..b4e1698a34dc 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -1,4 +1,4 @@ -export function fileContentRevision(contents: string): string { +function fileContentRevision(contents: string): string { let hash = 2_166_136_261; for (let index = 0; index < contents.length; index += 1) { hash ^= contents.charCodeAt(index); From 748fe0f8b68b7951df6797d4c22d2ca01314a3dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 16:15:32 -0700 Subject: [PATCH 19/65] refactor(web): test favicon fallback through the component (#10220) --- .../preview/PreviewFaviconIcon.test.tsx | 82 +++++++++---------- .../components/preview/PreviewFaviconIcon.tsx | 9 +- 2 files changed, 38 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx index d950a99b59fc..5ab8552b36fe 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx @@ -1,51 +1,43 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ favicon: null as string | null })); - -vi.mock("~/browserFaviconStore", () => ({ - useFaviconForThreadUrl: () => mocks.favicon, -})); - -import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon"; - -const threadRef = { - environmentId: EnvironmentId.make("env-1"), - threadId: ThreadId.make("thread-1"), -}; - -describe("preview favicon image", () => { - it("renders a captured source before later fallback sources", () => { - expect( - renderToStaticMarkup( - fallback} - />, - ), - ).toContain('src="data:image/png;base64,AAAA"'); - const captured = "data:image/png;base64,AAAA"; - const google = "https://public.example/icon"; - expect(selectFaviconSource([captured, google], new Set())).toBe(captured); - expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google); - expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull(); - expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe( - "data:image/png;base64,BBBB", +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/browserFaviconStore", () => ({ useFaviconForThreadUrl: () => null })); + +import { FaviconImage } from "./PreviewFaviconIcon"; + +let renderer: ReactTestRenderer | undefined; + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("falls through failed favicon sources and retries when the source list changes", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const captured = "data:image/png;base64,AAAA"; + const remote = "https://public.example/icon"; + await act(async () => { + renderer = create( + fallback} />, ); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); - it("uses a stored project icon or falls back to the browser mockup", () => { - mocks.favicon = null; - const html = renderToStaticMarkup( - , - ); - expect(html).not.toContain(", + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findByType("img").props.src).toBe(remote); + + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + expect(renderer!.root.findByType("span").children).toEqual(["fallback"]); + + await act(async () => { + renderer!.update( + fallback} + />, ); - expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"'); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); }); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx index 111facfd82dd..b2e1fee3639e 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx @@ -6,13 +6,6 @@ import { cn } from "~/lib/utils"; import { BrowserMockup } from "./BrowserMockup"; -export function selectFaviconSource( - sources: ReadonlyArray, - failed: ReadonlySet, -): string | null { - return sources.find((candidate) => !failed.has(candidate)) ?? null; -} - export function FaviconImage(props: { sources: ReadonlyArray; fallback: ReactNode; @@ -35,7 +28,7 @@ function FaviconImageAttempt(props: { className?: string | undefined; }) { const [failed, setFailed] = useState>(() => new Set()); - const source = selectFaviconSource(props.sources, failed); + const source = props.sources.find((candidate) => !failed.has(candidate)); if (!source) return props.fallback; return ( Date: Sun, 6 Sep 2026 00:17:36 +0100 Subject: [PATCH 20/65] feat: show provider usage limits with /usage-limits (#9875) --- apps/mobile/src/connection/runtime.ts | 4 +- .../features/threads/ComposerUsageLimits.tsx | 103 ++++++++ .../features/threads/NewTaskDraftScreen.tsx | 24 ++ .../src/features/threads/ThreadComposer.tsx | 55 +++- .../features/threads/ThreadDetailScreen.tsx | 83 ++++++ .../threads/use-composer-command-menu.ts | 29 ++ .../src/features/usage/UsageLimitsSection.tsx | 42 ++- apps/mobile/src/state/server.ts | 1 + apps/server/src/server.test.ts | 249 +++++++++++++++--- apps/server/src/ws.ts | 164 +++++++----- apps/web/src/components/ChatView.tsx | 156 +++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 16 ++ .../components/chat/ComposerUsageLimits.tsx | 97 +++++++ apps/web/src/components/usage/UsageLimits.tsx | 31 ++- apps/web/src/connection/runtime.ts | 6 +- apps/web/src/state/server.ts | 1 + docs/user/usage.md | 5 + packages/client-runtime/src/rpc/session.ts | 3 + packages/client-runtime/src/state/server.ts | 4 + packages/contracts/src/providerUsageLimits.ts | 21 ++ packages/contracts/src/rpc.ts | 6 + packages/shared/src/usageLimits.test.ts | 197 ++++++++++++++ packages/shared/src/usageLimits.ts | 146 +++++++++- 23 files changed, 1315 insertions(+), 128 deletions(-) create mode 100644 apps/mobile/src/features/threads/ComposerUsageLimits.tsx create mode 100644 apps/web/src/components/chat/ComposerUsageLimits.tsx diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index deee27ef040d..ce478962b8b5 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -31,7 +31,9 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })), + Layer.provideMerge( + Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..bcd76722c8e5 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -0,0 +1,103 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; + +/** + * The /usage-limits result, docked above the composer. It is the Usage → Limits + * card one size down, so the two read as the same thing. The surface is opaque + * because nothing blurs the feed behind it. + */ +export function ComposerUsageLimits({ + report, + environmentId, + onClose, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; + readonly onClose: () => void; +}) { + const now = Date.parse(report.createdAt); + const { height } = useWindowDimensions(); + const close = ( + + + + ); + return ( + + + {report.accounts.map((account, index) => { + const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); + return ( + + ) : undefined + } + /> + ); + })} + {report.accounts.length === 0 ? ( + // Nothing but notices, so the close control needs a row of its own. + + Usage limits + {close} + + ) : null} + {report.notices.map((notice) => ( + + {notice} + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e1cc7405bde2..a50895bd33da 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; @@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: { const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. + // T3 owns /usage-limits only where Limits has data for the selected provider. + const offersUsageLimits = + flow.selectedProviderStatus !== null && + hasProviderUsageLimits( + flow.selectedProviderStatus.driver, + selectedEnvironmentServerConfig?.providers ?? [], + selectedEnvironmentServerConfig?.usageLimitSources ?? [], + ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, @@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: { selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, + offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, @@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: { ); return; } + // T3's own limits command is answered by the thread composer; a new task would + // send it to the agent. A provider's same-named command, or a prompt carrying + // attachments, goes through as usual. + if ( + offersUsageLimits && + isUsageLimitsCommand(initialMessageText) && + draft.attachments.length === 0 + ) { + Alert.alert( + "Usage limits", + "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", + ); + return; + } // A failed-send restore can leave the draft over the cap on purpose (it // never drops the user's files); starting anyway would upload everything // and have the server reject the turn. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index af3359ec8c79..d61d7b92d0fb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,7 +7,13 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + UsageLimitsReport, } from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { @@ -20,7 +26,7 @@ import { useState, type RefObject, } from "react"; -import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; +import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -124,6 +130,8 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ + readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + selectedProviderStatus !== null && + hasProviderUsageLimits( + selectedProviderStatus.driver, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + ); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const report = collectProviderUsageLimits( + currentModelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + Date.now(), + ); + onShowUsageLimits(report); + if (!report) { + Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); + } + return report !== null; + }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, @@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, + offersUsageLimits: usageLimitsOffered, + // With attachments aboard the pick just inserts the text, so it sends as a prompt. + onUsageLimits: + usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); - const { onSendMessage } = props; - const handleSend = useCallback(async () => { + // Typed out in full rather than picked from the menu. Attachments mean the + // user is sending a prompt, so those go through as usual. + if ( + usageLimitsOffered && + isUsageLimitsCommand(props.draftMessage) && + props.draftAttachments.length === 0 + ) { + if (openUsageLimits()) onChangeDraftMessage(""); + return; + } if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; @@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer inFlightThreadIdsRef.current.delete(threadKey); } }, [ + props.draftMessage, + props.draftAttachments.length, + onChangeDraftMessage, + openUsageLimits, + usageLimitsOffered, onSendMessage, props.environmentId, props.environmentLabel, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d0e553ebdcf9..52ab91a5f496 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -19,6 +19,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, + UsageLimitsReport, UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; @@ -57,6 +58,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; @@ -70,6 +72,7 @@ import type { ThreadFeedEntry, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -359,6 +362,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly threadKey: string; + readonly now: number; + } | null>(null); + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = [ + selectedThreadKey, + props.selectedThread.modelSelection.instanceId, + props.selectedThread.latestTurn?.turnId ?? "", + props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", + ].join(":"); + // Drop the snapshot as soon as the key changes so it cannot resurface stale. + if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { + setUsageLimitsPanel(null); + } + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey + ? collectProviderUsageLimits( + props.selectedThread.modelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + usageLimitsPanel.now, + ) + : null, + [ + props.selectedThread.modelSelection.instanceId, + props.serverConfig, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const showUsageLimits = useCallback( + (report: UsageLimitsReport | null) => + setUsageLimitsPanel( + report === null + ? null + : { + key: usageLimitsKey, + threadKey: selectedThreadKey, + now: Date.parse(report.createdAt), + }, + ), + [selectedThreadKey, usageLimitsKey], + ); + const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); + // A send may resolve after navigating away, so only the originating + // thread's panel is cleared; a panel opened elsewhere in the meantime stays. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ), + [], + ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of @@ -623,6 +688,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; } + // A sent message makes the snapshot stale; a refused send leaves it in place. + clearUsageLimitsFor(targetThreadKey); + setSubmittedMessageId(messageId); setAnchorMessageId( resolveThreadFeedSubmissionAnchor({ @@ -637,6 +705,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; }, [ anchorMessageId, + clearUsageLimitsFor, props.onSendMessage, props.selectedThread.latestTurn, props.selectedThreadQueueCount, @@ -778,6 +847,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onScrollToEnd={handleScrollToEnd} /> + {usageLimitsReport && activeUserInputRequestId === null ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; + /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */ + readonly onUsageLimits?: () => void; }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); const previousOwnerKeyRef = useRef(ownerKey); @@ -267,6 +281,7 @@ export function useComposerCommandMenu({ atMessageStart: trigger.rangeStart === 0, hasThread, hasCompactableConversation, + offersUsageLimits, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus, }); @@ -390,12 +405,25 @@ export function useComposerCommandMenu({ selectedProviderStatus, skills, trigger, + offersUsageLimits, ]); const onSelect = useCallback( (item: ComposerCommandItem) => { if (!trigger) return; + if ( + item.type === "provider-slash-command" && + item.command.name === USAGE_LIMITS_COMMAND.name && + onUsageLimits + ) { + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + onUsageLimits(); + return; + } + const result = resolveComposerCommandSelection({ draftMessage, trigger, @@ -414,6 +442,7 @@ export function useComposerCommandMenu({ draftMessage, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, selectedProviderStatus?.showInteractionModeToggle, trigger, ], diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 0668efa30053..ca0608738fe9 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -69,9 +69,9 @@ function WindowRow(props: { = 90 - ? "h-full rounded-full bg-destructive" + ? "h-full rounded-full bg-red-500" : used >= 70 - ? "h-full rounded-full bg-warning" + ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ @@ -99,7 +99,7 @@ function WindowRow(props: { } /** One account: icon, name and plan on a single line, then its windows. */ -function AccountLimits(props: { +export function AccountLimits(props: { readonly driver: Driver; readonly label: string; readonly instanceLabel: string; @@ -107,14 +107,23 @@ function AccountLimits(props: { readonly limits: ServerProvider["usageLimits"]; readonly now: number; readonly first: boolean; + /** Tighter padding for the composer card. */ + readonly dense?: boolean; + /** Sits at the end of the heading row, such as a close control. */ + readonly trailing?: ReactNode; readonly footer?: ReactNode; }) { - const { limits, now } = props; + const { limits, now, dense = false } = props; const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); + const padding = dense ? "px-4 py-3" : "p-4"; return ( - + @@ -130,6 +139,7 @@ function AccountLimits(props: { ) : null} + {props.trailing} {notice ? ( {notice} @@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record = { * credit the provider granted the user, so it goes through the native * confirm alert rather than firing on a bare tap. */ -function ResetCredits(props: { +export function ResetCredits(props: { readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; readonly credits: ServerProviderResetCredits; readonly now: number; + /** A smaller pill for the composer card. */ + readonly dense?: boolean; }) { - const { environmentId, instanceId, credits, now } = props; + const { environmentId, instanceId, credits, now, dense = false } = props; const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); @@ -217,10 +229,20 @@ function ResetCredits(props: { accessibilityState={{ disabled: busy }} disabled={busy} onPress={confirm} - className="rounded-full bg-subtle-strong px-3 py-1.5" + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1" + : "rounded-full bg-subtle-strong px-3 py-1.5" + } > - - {busy ? "Using credit…" : "Use a reset credit"} + + {busy ? "Using…" : "Use reset"} ) : null} diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 2157c72e13ef..28cd2af57062 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -8,6 +8,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cfebfcf157c3..56889c1e63b2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -39,6 +39,7 @@ import { type ServerLifecycleStreamEvent, ThreadId, TurnId, + UsageLimitSourceId, WS_METHODS, WsRpcGroup, EditorId, @@ -498,6 +499,7 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; providerInstanceRegistry?: Partial; @@ -756,8 +758,9 @@ const buildAppUnderTest = (options?: { }), Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]), - streamChanges: Stream.empty, + streamChanges: Stream.make([]), refresh: Effect.void, + ...options?.layers?.usageLimitSources, }), ), ), @@ -6246,10 +6249,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => - Effect.gen(function* () { - const nextProviders = [ - { + it.effect.each([false, true])( + "routes websocket rpc subscribeServerConfig emits provider status updates (limits: %s)", + (hasLimits) => + Effect.gen(function* () { + const nextProviders = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...(hasLimits + ? { + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + } + : {}), + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([]), + streamChanges: Stream.succeed(nextProviders), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, []); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: hasLimits + ? [ + { + ...nextProviders[0], + slashCommands: [ + { + name: "usage-limits", + description: "Show this provider's usage limits", + }, + ], + }, + ] + : nextProviders, + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc subscribeServerConfig keeps the limits command from clients that do not ask for it", + () => + Effect.gen(function* () { + const codex = { instanceId: ProviderInstanceId.make("codex"), driver: ProviderDriverKind.make("codex"), enabled: true, @@ -6261,43 +6348,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => { models: [], slashCommands: [], skills: [], - }, - ] as const; - - yield* buildAppUnderTest({ - layers: { - keybindings: { - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], - }), - streamChanges: Stream.empty, + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [{ id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }], }, - providerRegistry: { - getProviders: Effect.succeed([]), - streamChanges: Stream.succeed(nextProviders), + }; + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.succeed([{ ...codex, version: "1.0.1" }]), + }, }, - }, - }); + }); - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), - ), - ); + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); - const [first, second] = Array.from(events); - assert.equal(first?.type, "snapshot"); - if (first?.type === "snapshot") { - assert.deepEqual(first.config.providers, []); - } - assert.deepEqual(second, { - version: 1, - type: "providerStatuses", - payload: { providers: nextProviders }, - }); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { providers: [{ ...codex, version: "1.0.1" }] }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc subscribeServerConfig republishes commands when only a limits source changes", + () => + Effect.gen(function* () { + const codex = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-04-11T00:00:00.000Z", + accounts: [ + { + id: "work", + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + }, + ], + }; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + // The registry emits no change: only the source refresh can carry it. + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.empty, + }, + usageLimitSources: { + current: Effect.succeed([]), + // Replay the empty snapshot, then a later refresh, as the live stream does. + streamChanges: Stream.concat(Stream.make([]), Stream.make([hub])), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + ...codex, + slashCommands: [ + { name: "usage-limits", description: "Show this provider's usage limits" }, + ], + }, + ], + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6261f7bc5287..fa29d5bd9847 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,7 @@ +import { + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, +} from "@t3tools/shared/usageLimits"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -1221,59 +1225,67 @@ const makeWsRpcLayer = ( ); }; - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; - const settings = ServerSettings.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ); - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; - - return { - environment, - auth, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors, - // Same discovery-with-timeout treatment as editors: a slow probe - // must not stall server.getConfig, so it degrades to no targets. - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined - ? { otlpMetricsUrl: config.otlpMetricsUrl } - : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, - shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), - threadResumeCompletionMarker: true, - threadSnapshotPagination: true, - }; - }); + // Only clients that answer /usage-limits themselves see it in the catalogs; + // an older client would send the injected command to the provider. + const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const currentProviders = yield* providerRegistry.getProviders; + const providers = options.usageLimitsCommand + ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) + : currentProviders; + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; + + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors, + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined + ? { otlpTracesUrl: config.otlpTracesUrl } + : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + threadResumeCompletionMarker: true, + threadSnapshotPagination: true, + }; + }); const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster @@ -1750,9 +1762,13 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }), [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverGetConfig, + loadServerConfig({ usageLimitsCommand: false }), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -2692,6 +2708,8 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { + const usageLimitsCommand = input.usageLimitsCommand === true; + const config = yield* loadServerConfig({ usageLimitsCommand }); const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -2702,7 +2720,33 @@ const makeWsRpcLayer = ( }, })), ); - const providerStatuses = providerRegistry.streamChanges.pipe( + const providerStatuses = Stream.zipLatestWith( + // The registry stream carries changes only. Seed it with the current + // providers so a source refresh that lands before any provider change + // still pairs up and reaches the client. + Stream.concat( + Stream.fromEffect(providerRegistry.getProviders), + providerRegistry.streamChanges, + ), + usageLimitSources.streamChanges.pipe( + // Quota updates already have their own stream. Republish the model + // catalog only when the set of providers offered the command changes. + Stream.changesWith( + usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, + ), + ), + (providers, sources) => + usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + ).pipe( + // Both sides replay their current value, so the first pairing normally + // repeats the snapshot the client already holds. Compare against that + // snapshot rather than dropping blindly: a refresh that landed between + // the snapshot and the subscription still goes out. + (updates) => Stream.concat(Stream.make(config.providers), updates), + Stream.changesWith( + (previous, next) => JSON.stringify(previous) === JSON.stringify(next), + ), + Stream.drop(1), Stream.map((providers) => ({ version: 1 as const, type: "providerStatuses" as const, @@ -2763,11 +2807,7 @@ const makeWsRpcLayer = ( ); return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), + Stream.make({ version: 1 as const, type: "snapshot" as const, config }), liveUpdates, ); }), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6173c760ceeb..c9c2c60badb5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,10 @@ +import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; +import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { type AssistantCitation, type ApprovalRequestId, @@ -451,6 +458,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { @@ -1505,6 +1513,11 @@ export default function ChatView(props: ChatViewProps) { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); + // Anything beyond the prompt text: attachments, terminal or element contexts, annotations. + const composerHasNonPromptContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; + }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); @@ -2620,6 +2633,111 @@ export default function ChatView(props: ChatViewProps) { hasComposerAttachments: composerHasAttachments, }); const activePendingApproval = pendingApprovals[0] ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly threadKey: string; + readonly now: number; + } | null>(null); + // Null while the provider list or the thread itself is unavailable, such as + // during a reconnect; the panel then stays hidden rather than being dropped. + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = + activeProviderInstanceId === null || (isServerThread && activeThread === undefined) + ? null + : [ + routeThreadKey, + activeProviderInstanceId, + activeThread?.latestTurn?.turnId ?? "", + activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? "", + ].join(":"); + // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. + if ( + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key !== usageLimitsKey + ) { + setUsageLimitsPanel(null); + } + const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES; + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key === usageLimitsKey && + activeProviderInstanceId !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsPanel.now, + ) + : null, + [ + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const usageLimitsBanner = useMemo( + () => + usageLimitsReport !== null && usageLimitsPanel !== null + ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting". + usageLimitsBannerItem( + `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`, + usageLimitsReport, + environmentId, + () => setUsageLimitsPanel(null), + ) + : null, + [environmentId, usageLimitsPanel, usageLimitsReport], + ); + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + activeProviderStatus !== null && + hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const now = Date.now(); + const report = + activeProviderInstanceId !== null && usageLimitsKey !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + now, + ) + : null; + if (report && usageLimitsKey !== null) { + setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now }); + return true; + } + setUsageLimitsPanel(null); + toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + return false; + }, [ + activeProviderInstanceId, + providerStatuses, + routeThreadKey, + usageLimitSources, + usageLimitsKey, + ]); + // Responses can resolve after navigating away; only the originating thread's panel clears. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ), + [], + ); const { beginLocalDispatch, resetLocalDispatch, @@ -5599,8 +5717,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + // The user asked for this one, so it leads the notice tier instead of trailing it. + const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ + ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -5609,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) { ]; } return [ + ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -5663,6 +5785,7 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, + usageLimitsBanner, wokeThreadBannerItem, ]); useEffect(() => { @@ -6076,6 +6199,23 @@ export default function ChatView(props: ChatViewProps) { }, ) => { e?.preventDefault(); + // Typed out in full rather than picked from the menu. Attachments or contexts + // mean the user is sending a prompt, so those go through as usual. + if ( + usageLimitsOffered && + usageLimitsKey !== null && + !directAnnotation && + !composerHasNonPromptContent && + isUsageLimitsCommand(promptRef.current) + ) { + if (openUsageLimits()) { + promptRef.current = ""; + setComposerDraftPrompt(composerDraftTarget, ""); + composerRef.current?.resetCursorState(); + } + return; + } + const notifyDirectAnnotationAttached = () => { if (!directAnnotation) return; toastManager.add( @@ -6718,6 +6858,10 @@ export default function ChatView(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + // The turn is under way and will spend quota, so that thread's limits + // snapshot is stale. Uploads may have outlasted a navigation, so only + // the sending thread's panel clears. + clearUsageLimitsFor(routeThreadKey); if (turnUsesAttachmentUploads) { releaseDraftAttachments(composerAttachmentsSnapshot); } @@ -7138,6 +7282,7 @@ export default function ChatView(props: ChatViewProps) { } if (failure === null) { + clearUsageLimitsFor(routeThreadKey); acknowledgeActiveThreadWoke(); sendInFlightRef.current = false; return; @@ -7174,6 +7319,8 @@ export default function ChatView(props: ChatViewProps) { startThreadTurn, environmentId, composerRef, + clearUsageLimitsFor, + routeThreadKey, ], ); @@ -7949,6 +8096,15 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} + // With attachments or contexts aboard the pick just inserts the + // text, so it sends as a prompt like the typed path would. + onUsageLimitsCommand={ + usageLimitsOffered && + usageLimitsKey !== null && + !composerHasNonPromptContent + ? openUsageLimits + : undefined + } environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 30acb44dd423..9772efc3290e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import { import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { Fragment, memo, @@ -1191,6 +1192,8 @@ export interface ChatComposerProps { sendDisabledReason: string | null; isPreparingWorktree: boolean; bannerItems: readonly ComposerBannerStackItem[]; + /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ + onUsageLimitsCommand?: (() => void) | undefined; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -2684,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; }, [readComposerSnapshot]); + const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; @@ -2734,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (item.type === "provider-slash-command") { + if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + onUsageLimitsCommand(); + } + return; + } const replacement = `/${item.command.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -2774,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) applyPromptReplacement, handleInteractionModeChange, planModeUiEnabled, + onUsageLimitsCommand, resolveActiveComposerTrigger, ], ); diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..384492b5789e --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -0,0 +1,97 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { limitsNotice } from "@t3tools/shared/usageLimits"; +import { GaugeIcon } from "lucide-react"; + +import { getDriverOption } from "../settings/providerDriverMeta"; +import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; +import { ComposerBanner } from "./ComposerBanner"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +/** Driver name, then the instance when there could be more than one of that driver. */ +function accountLabel(account: UsageLimitsReport["accounts"][number]): string { + if (!account.instanceId) return account.label; + const driver = getDriverOption(account.driver)?.label ?? String(account.driver); + const instance = + account.displayName?.trim() || + (String(account.instanceId) !== String(account.driver) ? account.instanceId : ""); + // The default instance is often named after its driver; saying it twice adds nothing. + return instance && instance.toLowerCase() !== driver.toLowerCase() + ? `${driver} · ${instance}` + : driver; +} + +/** The /usage-limits result as a composer notice: it stacks under warnings and dismisses like one. */ +export function usageLimitsBannerItem( + id: string, + report: UsageLimitsReport, + environmentId: EnvironmentId, + onDismiss: () => void, +): ComposerBannerStackItem { + const [first] = report.accounts; + const single = report.accounts.length === 1 && first ? first : null; + const summary = single + ? [accountLabel(single), single.plan].filter(Boolean).join(" · ") + : `${report.accounts.length} accounts`; + return { + id, + variant: "info", + priority: "notice", + icon: , + title: "Usage limits", + description: summary, + dismissLabel: "Dismiss usage limits", + onDismiss, + children: , + }; +} + +function UsageLimitsBannerBody({ + report, + environmentId, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; +}) { + const now = Date.parse(report.createdAt); + return ( + + + {report.accounts.map((account) => { + const notice = limitsNotice(account.limits); + return ( +
+ {report.accounts.length > 1 ? ( + + {[accountLabel(account), account.plan].filter(Boolean).join(" · ")} + + ) : null} + {notice ? ( + {notice} + ) : ( + + )} + {account.instanceId && account.limits.resetCredits ? ( + + ) : null} +
+ ); + })} + {report.notices.map((notice) => ( + + {notice} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 77f85953984c..5584cfc169f5 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -153,24 +153,31 @@ function WindowBar({ ); } -/** One account's windows as rows: label and percent, bar, pace and countdown. */ -function LimitWindows({ +/** + * One account's windows as rows: label and percent, bar, pace and countdown. + * Compact rows fit the composer panel with narrower columns. + */ +export function LimitWindows({ driver, windows, now, + compact = false, }: { readonly driver: ServerProvider["driver"]; readonly windows: ReadonlyArray; readonly now: number; + readonly compact?: boolean; }) { const color = barColor(driver); return ( -
- {windows.map((window, index) => { - // Windows that reset together show the countdown once. - const previous = windows[index - 1]; - const sharesReset = - previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt; +
+ {windows.map((window) => { const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( @@ -182,9 +189,9 @@ function LimitWindows({ - + {pace ? : null} - {sharesReset ? "" : (resetsIn ?? "")} + {resetsIn ?? ""} ); @@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record = { * Banked reset credits with a confirmed redeem action. Redeeming spends a * credit the provider granted the user, so it never fires on a bare click. */ -function ResetCredits({ +export function ResetCredits({ environmentId, instanceId, credits, @@ -341,7 +348,7 @@ function ResetCredits({ {summary} {credits.availableCount > 0 ? ( ) : null} {status ? {status} : null} diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index ac2316560d98..b5e78287f263 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -32,7 +32,11 @@ type ConnectionLayerSource = const providedClientConnectionLayer = snapshotLoaderLayer.pipe( Layer.provideMerge( - Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }), + Connection.layerWithOptions({ + environmentThemes: true, + usageLimitSources: true, + usageLimitsCommand: true, + }), ), Layer.provideMerge( Layer.mergeAll( diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 0eacc933da49..31b9436621c9 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt initialConfigValueAtom: environmentSession.initialConfigValueAtom, environmentThemes: true, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/docs/user/usage.md b/docs/user/usage.md index a0231856ef69..dc92b3f65917 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -45,6 +45,11 @@ next reset. If a window looks stale, refresh Limits to re-check every provider and hub. +Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the +current model's limits without leaving the conversation. The result opens above the composer and +closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh +anything. The command is offered only for providers that appear under **Usage → Limits**. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 2402c1324565..3e353f6be4df 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -57,6 +57,8 @@ export interface RpcSession { export interface RpcSessionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + /** This client answers /usage-limits itself, so the server may advertise it. */ + readonly usageLimitsCommand?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -153,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( const serverConfigInput: ServerConfigSubscriptionInput = { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index e209acfd1e7d..5df29a8629d4 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -358,6 +358,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven export interface ServerConfigSubscriptionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; } export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( @@ -425,6 +426,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf yield* subscribe(WS_METHODS.subscribeServerConfig, { ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -621,6 +623,7 @@ export function createServerEnvironmentAtoms( readonly environmentThemes?: boolean; /** Whether this surface renders quota from configured usage-limit sources. */ readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; }, ) { const configScheduler = createAtomCommandScheduler(); @@ -636,6 +639,7 @@ export function createServerEnvironmentAtoms( serverConfigStateChanges(environmentId, { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }), ) .pipe( diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0478b113d61d..554e68c1b200 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({ outcome: ProviderConsumeResetCreditOutcome, }); export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; + +/** A point-in-time view of one provider's limits, built for the /usage-limits panel. */ +export const UsageLimitsReport = Schema.Struct({ + createdAt: IsoDateTime, + accounts: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + driver: ProviderDriverKind, + label: TrimmedNonEmptyString, + plan: Schema.optional(TrimmedNonEmptyString), + email: Schema.optional(TrimmedNonEmptyString), + sourceLabel: Schema.optional(TrimmedNonEmptyString), + instanceId: Schema.optional(ProviderInstanceId), + displayName: Schema.optional(Schema.String), + accentColor: Schema.optional(Schema.String), + limits: ServerProviderUsageLimits, + }), + ), + notices: Schema.Array(Schema.String), +}); +export type UsageLimitsReport = typeof UsageLimitsReport.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 69c0ab43e890..12c653f70cc4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1132,6 +1132,12 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon environmentThemes: Schema.optional(Schema.Boolean), /** Whether this client understands `usageLimitSourcesUpdated` events. */ usageLimitSources: Schema.optional(Schema.Boolean), + /** + * Whether this client answers `/usage-limits` itself. The server injects + * that command into provider catalogs only for such clients; an older + * client would send it to the provider as an ordinary prompt. + */ + usageLimitsCommand: Schema.optional(Schema.Boolean), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 83ac6906c61e..6b1952199dd2 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -9,6 +9,10 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + isUsageLimitsCommand, + collectProviderUsageLimits, + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -304,3 +308,196 @@ describe("collectLimitSources", () => { ]); }); }); + +describe("/usage-limits", () => { + const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; + const selected = provider({ + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com" }, + }); + const sources = [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: limits.checkedAt, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "SAME@example.com", + usageLimits: limits, + }, + { id: "oss", driver: selected.driver, plan: "Codex OSS", usageLimits: limits }, + { id: "other-provider", driver: ProviderDriverKind.make("claude"), usageLimits: limits }, + ], + }, + ]; + + it("keeps accounts and custom instances separate, filtering by driver", () => { + const report = collectProviderUsageLimits( + selected.instanceId, + [ + selected, + provider({ + instanceId: ProviderInstanceId.make("codex-work"), + displayName: "Work", + usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, + }), + provider({ + driver: ProviderDriverKind.make("claude"), + instanceId: ProviderInstanceId.make("claude"), + usageLimits: limits, + }), + ], + sources, + now, + ); + expect(report?.createdAt).toBe("2026-09-03T12:00:00.000Z"); + expect(report?.accounts.map((account) => account.id)).toEqual([ + "codex", + "codex-work", + "hub:oss", + ]); + expect(report?.accounts[0]).toMatchObject({ + instanceId: selected.instanceId, + email: selected.auth.email, + }); + expect(report?.accounts[1]).toMatchObject({ + displayName: "Work", + limits: { resetCredits: { availableCount: 2 } }, + }); + expect(report?.accounts[2]).toMatchObject({ + label: "Accounts · oss", + sourceLabel: "CLI Proxy", + plan: "Codex OSS", + }); + expect(report?.notices).toEqual([]); + }); + + it("supports a source-only provider and keeps duplicates when the native probe failed", () => { + expect( + collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["hub:duplicate", "hub:oss"]); + const failed = provider({ usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }); + expect( + collectProviderUsageLimits(selected.instanceId, [failed], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["codex", "hub:duplicate", "hub:oss"]); + expect(collectProviderUsageLimits(selected.instanceId, [provider({})], [], now)).toBeNull(); + expect( + collectProviderUsageLimits( + selected.instanceId, + [provider({ enabled: false, usageLimits: limits })], + [], + now, + ), + ).toBeNull(); + }); + + it("surfaces source errors only for sources that carry the selected driver", () => { + const failing = { ...sources[0]!, error: "token expired" }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [failing], now)?.notices, + ).toEqual(["Accounts: token expired"]); + const claudeOnly = { ...failing, accounts: failing.accounts.slice(2) }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, + ).toEqual([]); + // A read failure clears the accounts, so the error must not depend on a match. + const unreadable = { ...failing, accounts: [] }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, + ).toEqual(["Accounts: token expired"]); + // A source-only provider still gets the report, carrying only the error. + const sourceOnly = collectProviderUsageLimits( + selected.instanceId, + [provider({})], + [unreadable], + now, + ); + expect(sourceOnly?.accounts).toEqual([]); + expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]); + }); + + it("advertises global and workspace commands only for providers present in Limits", () => { + const withWorkspace = provider({ + workspaceSnapshots: [ + { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] }, + ], + }); + const [supported] = withUsageLimitsCommands([withWorkspace], sources); + expect(supported?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]); + expect( + supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + // A provider's own command of the same name is left alone without coverage. + const ownCommand = provider({ + slashCommands: [{ name: "usage-limits", description: "Provider's own" }], + }); + expect(withUsageLimitsCommands([ownCommand], [])[0]?.slashCommands).toEqual([ + { name: "usage-limits", description: "Provider's own" }, + ]); + const unreadable = { ...sources[0]!, accounts: [], error: "token expired" }; + expect( + withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map( + (command) => command.name, + ), + ).toEqual(["usage-limits"]); + expect( + withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + }); +}); + +describe("sameUsageLimitCommandCoverage", () => { + const codexAccount = { + id: "a", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }, + }; + const base = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-09-03T11:00:00.000Z", + }; + it("ignores quota movement but not the drivers offered the command", () => { + const withCodex = [{ ...base, accounts: [codexAccount] }]; + const withCodexLater = [ + { + ...base, + accounts: [ + { + ...codexAccount, + usageLimits: { ...codexAccount.usageLimits, checkedAt: "2026-09-03T12:00:00.000Z" }, + }, + ], + }, + ]; + expect(sameUsageLimitCommandCoverage(withCodex, withCodexLater)).toBe(true); + expect(sameUsageLimitCommandCoverage(withCodex, [{ ...base, accounts: [] }])).toBe(false); + }); + it("treats a failed read as a change in coverage, in both directions", () => { + const empty = [{ ...base, accounts: [] }]; + const failed = [{ ...base, accounts: [], error: "token expired" }]; + expect(sameUsageLimitCommandCoverage(empty, failed)).toBe(false); + expect(sameUsageLimitCommandCoverage(failed, empty)).toBe(false); + expect( + sameUsageLimitCommandCoverage(failed, [{ ...base, accounts: [], error: "still down" }]), + ).toBe(true); + }); +}); + +describe("isUsageLimitsCommand", () => { + it("recognizes only the standalone local action", () => { + expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); + expect(isUsageLimitsCommand("/usage-limits explain")).toBe(false); + expect(isUsageLimitsCommand("Explain /usage-limits")).toBe(false); + expect(isUsageLimitsCommand("/usage")).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index e7582b1ecc64..a792b20d6fd3 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -7,6 +7,9 @@ */ import { type EnvironmentId, + type UsageLimitsReport, + type ProviderInstanceId, + type ServerProviderSlashCommand, isProviderAvailable, type ServerProvider, type ServerProviderUsageLimits, @@ -15,6 +18,8 @@ import { type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; @@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( - provider: ServerProvider, + provider: Pick, driverLabel: (driver: ServerProvider["driver"]) => string | undefined, ): string { return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); @@ -209,3 +214,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): if (resetsAt === null) return null; return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; } + +/** Limit commands are served by T3 from the same snapshots as Usage → Limits. */ +export const USAGE_LIMITS_COMMAND = { + name: "usage-limits", + description: "Show this provider's usage limits", +} satisfies ServerProviderSlashCommand; + +/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ +export function isUsageLimitsCommand(prompt: string): boolean { + return prompt.trim().toLowerCase() === "/usage-limits"; +} + +/** + * Whether Limits has anything to say about this driver. A source that failed to + * read keeps no accounts, so its error counts for every driver rather than + * disappearing until the next successful refresh. + */ +export function hasProviderUsageLimits( + driver: ServerProvider["driver"], + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): boolean { + return ( + providersWithLimits(providers).some((provider) => provider.driver === driver) || + sources.some( + (source) => + source.accounts.some((account) => account.driver === driver) || + (source.error !== undefined && source.accounts.length === 0), + ) + ); +} + +/** + * The drivers a set of sources would offer the command to, where a source that + * failed to read counts for every driver. Two snapshots with the same coverage + * need no catalog republish, however much their quotas moved. + */ +export function sameUsageLimitCommandCoverage( + previous: UsageLimitSourceSnapshots, + next: UsageLimitSourceSnapshots, +): boolean { + const coverage = (sources: UsageLimitSourceSnapshots) => + new Set( + sources.flatMap((source) => + source.error !== undefined && source.accounts.length === 0 + ? ["*"] + : source.accounts.map((account) => String(account.driver)), + ), + ); + const before = coverage(previous); + const after = coverage(next); + return before.size === after.size && [...before].every((driver) => after.has(driver)); +} + +/** Advertise on workspace catalogs too, which replace the global command list. */ +export function withUsageLimitsCommands( + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): ServerProvider[] { + return providers.map((provider) => { + if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider; + const commands = (items: readonly ServerProviderSlashCommand[]) => [ + ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name), + USAGE_LIMITS_COMMAND, + ]; + return { + ...provider, + slashCommands: commands(provider.slashCommands), + ...(provider.workspaceSnapshots + ? { + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommands: commands(snapshot.slashCommands), + })), + } + : {}), + }; + }); +} + +/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */ +export function collectProviderUsageLimits( + instanceId: ProviderInstanceId, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): UsageLimitsReport | null { + const selected = providers.find((provider) => provider.instanceId === instanceId); + if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; + const native = providersWithLimits(providers).filter( + (provider) => provider.driver === selected.driver, + ); + const nativeAccounts = new Set( + native.flatMap((provider) => { + const key = accountKey(provider.driver, provider.auth.email); + return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable + ? [key] + : []; + }), + ); + const accounts: Array = []; + const notices: string[] = []; + for (const provider of native) { + if (!provider.usageLimits) continue; + accounts.push({ + id: provider.instanceId, + driver: provider.driver, + label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`, + ...(provider.auth.label ? { plan: provider.auth.label } : {}), + instanceId: provider.instanceId, + ...(provider.displayName ? { displayName: provider.displayName } : {}), + ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), + ...(provider.auth.email ? { email: provider.auth.email } : {}), + limits: provider.usageLimits, + }); + } + for (const source of sources) { + const matching = source.accounts.filter((account) => account.driver === selected.driver); + for (const account of matching) { + const key = accountKey(account.driver, account.email); + if (key && nativeAccounts.has(key)) continue; + accounts.push({ + id: `${source.id}:${account.id}`, + driver: account.driver, + label: `${source.label} · ${account.id}`, + sourceLabel: "CLI Proxy", + ...(account.plan ? { plan: account.plan } : {}), + ...(account.email ? { email: account.email } : {}), + limits: account.usageLimits, + }); + } + // A source that failed to read has no accounts left to match on, so its + // error is reported to every provider rather than silently dropped. + if (source.error && (matching.length > 0 || source.accounts.length === 0)) { + notices.push(`${source.label}: ${source.error}`); + } + } + return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; +} From 585ce2c2ab396e4c3c5ffcfc422449c07896ddf9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 16:18:13 -0700 Subject: [PATCH 21/65] refactor(web): test formatted timestamps instead of formatter options (#10221) --- apps/web/src/timestampFormat.test.ts | 57 +++++++++------------------- apps/web/src/timestampFormat.ts | 2 +- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 578587510db1..ca73095f7fd4 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -8,37 +8,9 @@ import { formatRelativeTimeLabel, formatShortTimestamp, getRelativeTimeState, - getTimestampFormatOptions, resolveTimestampLocale, } from "./timestampFormat"; -describe("getTimestampFormatOptions", () => { - it("omits hour12 when locale formatting is requested", () => { - expect(getTimestampFormatOptions("locale", true)).toEqual({ - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }); - }); - - it("builds a 12-hour formatter with seconds when requested", () => { - expect(getTimestampFormatOptions("12-hour", true)).toEqual({ - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - }); - - it("builds a 24-hour formatter without seconds when requested", () => { - expect(getTimestampFormatOptions("24-hour", false)).toEqual({ - hour: "numeric", - minute: "2-digit", - hour12: false, - }); - }); -}); - describe("resolveTimestampLocale", () => { it("defers to the runtime default when the host reports no locale", () => { expect(resolveTimestampLocale(null)).toBeUndefined(); @@ -57,19 +29,26 @@ describe("resolveTimestampLocale", () => { expect(resolveTimestampLocale("not a locale")).toBeUndefined(); expect(resolveTimestampLocale("en_GB")).toBeUndefined(); }); +}); - it("renders the host locale's hour cycle under the locale setting", () => { - const formatAt1544 = (systemLocale: string | null) => - new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { - ...getTimestampFormatOptions("locale", false), - timeZone: "UTC", - }) - .format(new Date("2026-04-07T15:44:00.000Z")) - // ICU separates the day period with a narrow no-break space. - .replace(/[  ]/g, " "); +describe("formatShortTimestamp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); - expect(formatAt1544("en-GB")).toBe("15:44"); - expect(formatAt1544("en-US")).toBe("3:44 PM"); + it.each([ + ["en-GB", "15:44"], + ["en-US", "3:44 PM"], + ])("honors %s and the explicit hour-cycle settings", async (locale, localTime) => { + vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => locale } }); + vi.resetModules(); + const { formatShortTimestamp: format } = await import("./timestampFormat"); + const date = new Date(2026, 3, 7, 15, 44).toISOString(); + // ICU can separate the day period with a narrow no-break space. + expect(format(date, "locale").replace(/[  ]/g, " ")).toBe(localTime); + expect(format(date, "12-hour").replace(/[  ]/g, " ")).toMatch(/^3:44 [ap]m$/i); + expect(format(date, "24-hour")).toBe("15:44"); }); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index c6a9bdd29e10..0f87204efde4 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,6 +1,6 @@ import { type TimestampFormat } from "@t3tools/contracts/settings"; -export function getTimestampFormatOptions( +function getTimestampFormatOptions( timestampFormat: TimestampFormat, includeSeconds: boolean, ): Intl.DateTimeFormatOptions { From be53bbd85044e937c359a89b6004c3d3c38ffcf1 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sun, 6 Sep 2026 00:22:15 +0100 Subject: [PATCH 22/65] feat(usage): show remaining quota instead of used (#9889) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/features/usage/UsageLimitsSection.tsx | 29 +++++++++++-------- apps/web/src/components/usage/UsageLimits.tsx | 24 ++++++++------- docs/user/usage.md | 6 ++-- packages/shared/src/usageLimits.test.ts | 10 +++++++ packages/shared/src/usageLimits.ts | 11 +++++-- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index ca0608738fe9..460827b22649 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -18,6 +18,7 @@ import { limitsNotice, paceOf, providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useState } from "react"; import { Alert, Pressable, View } from "react-native"; @@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null { } /** - * One window as a bar spanning its whole duration: the fill is quota spent, - * the hairline is how far into the window the clock is. Pace sits under the - * left edge, the countdown under the right, so a row reads in one glance. + * One window as a bar spanning its whole duration: the fill is quota left, + * the hairline is how much of the window is left, so even spending keeps the + * fill on the line. Pace sits under the left edge, the countdown under the + * right, so a row reads in one glance. */ function WindowRow(props: { readonly window: ServerProviderUsageWindow; @@ -54,37 +56,40 @@ function WindowRow(props: { readonly now: number; }) { const { window, now } = props; - const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( {window.label} - {used}% + + {remaining}% left + = 90 + remaining <= 10 ? "h-full rounded-full bg-red-500" - : used >= 70 + : remaining <= 30 ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ - { flex: used }, - used < 70 && props.color ? { backgroundColor: props.color } : null, + { flex: remaining }, + remaining > 30 && props.color ? { backgroundColor: props.color } : null, ]} /> - + - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 5584cfc169f5..1a72af35c909 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -20,6 +20,7 @@ import { type LimitPace, paceOf, providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useState } from "react"; @@ -95,14 +96,16 @@ function WindowBar({ readonly now: number; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); - const used = Math.max(0, Math.min(100, window.usedPercent)); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + // The fill is quota left, so the even-spending mark is the time left. + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) : null; - const summary = `${window.label}: ${Math.round(used)}% used${ - elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed` + const summary = `${window.label}: ${remaining}% left${ + timeLeft === null ? "" : `, ${timeLeft}% of the window left` }${resetsIn ? `, ${resetsIn}` : ""}`; return ( @@ -118,27 +121,26 @@ function WindowBar({ } >
- {used > 0 ? ( + {remaining > 0 ? (
) : null} - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null}
- {Math.round(used)}% used - {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""} + {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""} - {elapsed !== null ? ( + {timeLeft !== null ? ( The line is where even spending would be. ) : null} {resetsAt ? ( @@ -185,7 +187,7 @@ export function LimitWindows({ {window.label} - {Math.round(window.usedPercent)}% + {remainingPercent(window)}% left diff --git a/docs/user/usage.md b/docs/user/usage.md index dc92b3f65917..4e4196a46337 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,9 +39,9 @@ the dialog. ## Track subscription limits -**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also -compares quota consumed with time elapsed in each window, so you can judge your pace before the -next reset. +**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and +Claude subscriptions. For windows with timing data, each bar also marks how much of the window is +left, so you can judge your pace before the next reset. If a window looks stale, refresh Limits to re-check every provider and hub. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 6b1952199dd2..fede8813e913 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -20,6 +20,7 @@ import { limitsNotice, paceOf, providersWithLimits, + remainingPercent, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => { }); }); +describe("remainingPercent", () => { + it("inverts and clamps the reported usage", () => { + expect(remainingPercent(window)).toBe(60); + expect(remainingPercent({ ...window, usedPercent: 0 })).toBe(100); + expect(remainingPercent({ ...window, usedPercent: 100 })).toBe(0); + expect(remainingPercent({ ...window, usedPercent: 33.4 })).toBe(67); + }); +}); + describe("isUsageLimitsCommand", () => { it("recognizes only the standalone local action", () => { expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index a792b20d6fd3..5cb5303320bd 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -166,6 +166,11 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } +/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ +export function remainingPercent(window: ServerProviderUsageWindow): number { + return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); +} + function resetMillis(window: ServerProviderUsageWindow): number | null { if (window.resetsAt === undefined) return null; const at = Date.parse(window.resetsAt); @@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu export type LimitPace = "ahead" | "on" | "under"; /** - * Usage against the clock. The bar is the whole window, so the elapsed share - * is also where even spending would have put the fill; within five points of - * it counts as on pace. + * Usage against the clock. Spending evenly leaves the same share of quota as + * there is time left in the window; within five points of that counts as on + * pace, further ahead means the window may run dry first. */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); From f12d39359f0f76a64ff2d77959c5baf821df15be Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:26:02 +0200 Subject: [PATCH 23/65] fix(ui): unify loading and refresh feedback across clients (#9561) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/features/usage/UsageRouteScreen.tsx | 25 +-- apps/mobile/src/state/usage.ts | 35 ++-- .../BranchToolbarBranchSelector.tsx | 5 +- apps/web/src/components/DiffPanel.tsx | 6 +- apps/web/src/components/LegacySidebar.tsx | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 8 +- .../chat/ComposerActivityStatus.tsx | 8 +- .../chat/ComposerServerUpdateStatus.tsx | 12 +- .../components/clerk/ClerkUserProfilePage.tsx | 5 +- .../src/components/files/FileBreadcrumbs.tsx | 10 +- .../src/components/files/FileBrowserPanel.tsx | 6 +- .../src/components/files/FilePreviewPanel.tsx | 11 +- .../components/onboarding/FirstRunGate.tsx | 4 +- .../components/preview/PreviewChromeRow.tsx | 4 +- .../PullRequestActivityUnavailableState.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 25 ++- .../pullRequest/PullRequestListEmptyState.tsx | 7 +- .../pullRequest/PullRequestListFilters.tsx | 4 +- .../PullRequestsUnavailableState.tsx | 15 +- .../pullRequest/pullRequestPresentation.tsx | 6 +- .../search/ProjectContentSearchDialog.tsx | 5 +- .../settings/DiagnosticsSettings.tsx | 4 +- .../settings/ProviderInstanceCard.tsx | 5 +- .../settings/ProviderSettingsPanel.tsx | 5 +- .../settings/ResourceTelemetryDiagnostics.tsx | 11 +- .../components/settings/SettingsPanels.tsx | 5 +- .../settings/SourceControlSettings.tsx | 7 +- .../settings/ThemeSearchSection.tsx | 11 +- .../sidebar/DesktopUpdateStatusIcon.tsx | 10 +- .../sidebar/SidebarProviderUpdatePill.tsx | 5 +- apps/web/src/components/ui/refresh-icon.tsx | 20 ++ apps/web/src/components/ui/spinner.tsx | 10 +- apps/web/src/components/ui/toast.tsx | 7 +- apps/web/src/components/usage/UsagePage.tsx | 51 +++-- apps/web/src/routes/_chat.index.tsx | 5 +- apps/web/src/routes/_chat.pull-requests.tsx | 14 +- apps/web/src/state/usage.ts | 35 ++-- packages/client-runtime/package.json | 4 + .../client-runtime/src/state/runtime.test.ts | 18 ++ packages/client-runtime/src/state/runtime.ts | 8 +- .../client-runtime/src/state/usage.test.ts | 184 ++++++++++++++++++ packages/client-runtime/src/state/usage.ts | 61 ++++++ 42 files changed, 506 insertions(+), 183 deletions(-) create mode 100644 apps/web/src/components/ui/refresh-icon.tsx create mode 100644 packages/client-runtime/src/state/usage.test.ts create mode 100644 packages/client-runtime/src/state/usage.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 17841cbd74fa..164b09556511 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -11,7 +11,7 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -91,10 +91,8 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // The pull spinner tracks re-scans of environments that have answered - // before. The initial scan renders its own placeholder, and an unreachable - // environment stays pending forever — neither may pin the spinner on. - const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); + const [refreshingUsage, setRefreshingUsage] = useState(false); + const refreshingRef = useRef(false); const showingLimits = tab === "limits"; const selectWindow = (days: number) => { setWindowSelection({ @@ -103,17 +101,22 @@ export function UsageRouteScreen() { }); }; const refreshWindow = () => { + if (refreshingRef.current) return; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setRefreshingUsage(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setRefreshingUsage(false); + }); }; return ( diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index f5bdc0d0858b..8686a37e2c9c 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -72,7 +72,7 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (input?: UsageSummaryInput) => Promise; } export function useUsage(input: UsageSummaryInput): UsageView { @@ -98,26 +98,17 @@ export function useUsage(input: UsageSummaryInput): UsageView { const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so pull-to-refresh always rescans. - // - // Each environment refetches model pricing first, so a model released since - // its last daily fetch gets priced by the rescan. The rescan runs whether or - // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of environments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [environments, windowKey]); + const refresh = useCallback( + (nextInput?: UsageSummaryInput) => + refreshUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + presentations: environmentPresentations, + environmentIds: environments.map(({ environmentId }) => environmentId), + input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + }), + [environments, windowKey], + ); const merged = useMemo(() => { const answered: EnvironmentUsage[] = environments.flatMap((environment) => diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 27bf2ede9b9a..f6bdd64756d3 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, @@ -5,7 +6,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { useCallback, useDeferredValue, @@ -864,7 +865,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - } > - + {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 872fa1e4ec60..bacdfa62118c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, ArrowUpDownIcon, @@ -6,7 +7,6 @@ import { ContainerIcon, FolderPlusIcon, Globe2Icon, - LoaderIcon, SearchIcon, SquarePenIcon, TerminalIcon, @@ -2648,7 +2648,7 @@ function LocalSecondaryStatus() { variant="default" className="rounded-2xl border-border/40 bg-accent/40 text-muted-foreground" > - + Connecting {connecting.join(", ")} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9772efc3290e..0a54a2a55abb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { ApprovalRequestId, AssistantCitation, @@ -789,7 +790,6 @@ import { LockIcon, LockOpenIcon, PenLineIcon, - RotateCcwIcon, SparklesIcon, XIcon, } from "lucide-react"; @@ -5273,7 +5273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> } > - + } > - + } > - + - - + + - ); + return ; } if (status === "failed") { return ; diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx index 00f20e53fbe1..2ddf8f56dd2a 100644 --- a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -1,4 +1,5 @@ -import { RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; + import type { ReactNode } from "react"; import { cn } from "../../lib/utils"; @@ -55,7 +56,7 @@ export function ClerkUserProfileRefreshButton({ disabled={disabled || isPending} onClick={onClick} > -
) : (
- +
); } @@ -252,7 +253,7 @@ function AttachmentBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
- +
); } @@ -308,7 +309,7 @@ function WorkspaceBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
- +
); } @@ -1262,7 +1263,7 @@ export default function FilePreviewPanel({
) : relativePath && file.data === null ? (
- +
) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index a9df5cb00b59..6debb0376bd9 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -1,7 +1,7 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { Atom } from "effect/unstable/reactivity"; -import { RotateCcwIcon } from "lucide-react"; import { useEffect, useLayoutEffect, useState } from "react"; import { @@ -235,7 +235,7 @@ function FirstRunRecovery({ } }} > - + {settingsReadFailed ? "Retry" : "Reload"}
diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 8dbf9f0904f0..7ca0c496a04b 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ArrowLeft, ArrowRight, @@ -5,7 +6,6 @@ import { ExternalLink, MousePointerClick, PictureInPicture2, - RotateCw, } from "lucide-react"; import { type FormEvent, @@ -166,7 +166,7 @@ export function PreviewChromeRow({ /> } > - + {loading ? "Loading…" : "Refresh"} diff --git a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx index d87dfa45b0ff..2aa1413438ee 100644 --- a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx @@ -1,4 +1,4 @@ -import { RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { cn } from "~/lib/utils"; @@ -23,7 +23,7 @@ export function PullRequestActivityUnavailableState({

Could not load pull request activity

{error}

diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 76e50b60003b..e4aa17b383c8 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { @@ -36,7 +37,6 @@ import { PanelRightIcon, PencilIcon, PlayIcon, - RefreshCwIcon, RotateCcwIcon, TriangleAlertIcon, } from "lucide-react"; @@ -728,10 +728,16 @@ export function PullRequestDetailPanel({ // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const [isInvalidating, setIsInvalidating] = useState(false); const refreshFromHost = useCallback(async () => { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - setRefreshToken((token) => token + 1); + setIsInvalidating(true); + try { + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); + setRefreshToken((token) => token + 1); + } finally { + setIsInvalidating(false); + } }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); @@ -1670,8 +1676,14 @@ export function PullRequestDetailPanel({ - void refreshFromHost()}> - + void refreshFromHost()} + > + Refresh @@ -2315,6 +2327,7 @@ export function PullRequestDetailPanel({ {detailQuery.error && !detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx index 4dd92dbf2243..8c5f862700bc 100644 --- a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; /** * What the list shows when it has no rows to show. * @@ -11,7 +12,7 @@ * with no project to read from — leave the button out, since pressing it could only repeat what * is already happening or ask nobody. */ -import { PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { PlusIcon, SearchIcon } from "lucide-react"; import { openCommandPalette } from "../../commandPaletteBus"; import { Button } from "../ui/button"; @@ -149,7 +150,7 @@ export function PullRequestListEmptyState({ {/* The hosts answered this query once; a pull request opened since then would answer differently, and nothing on screen says which of the two the reader is looking at. */} @@ -175,7 +176,7 @@ export function PullRequestListEmptyState({ ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index f77e2b845082..1c703bce3e2b 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { EnvironmentId, ProjectId, @@ -17,7 +18,6 @@ import { GitPullRequestDraftIcon, LayersIcon, ListFilterIcon, - LoaderIcon, SearchIcon, TagIcon, UserRoundIcon, @@ -120,7 +120,7 @@ export function PullRequestSearchInput({ return ( - {busy ? : } + {busy ? : } void; + refreshing?: boolean; gitHubUrl?: string; }) { return ( @@ -35,8 +38,14 @@ export function PullRequestsUnavailableState({ {onRetry || gitHubUrl ? ( {onRetry ? ( - ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 8611ddc28dde..3c41e0956fed 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { PullRequestActor, PullRequestCheck, @@ -15,7 +16,6 @@ import { GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, - LoaderIcon, TriangleAlertIcon, } from "lucide-react"; import { Children, isValidElement, type ReactNode } from "react"; @@ -119,7 +119,7 @@ export function PullRequestStateGlyph({ } const CHECK_STATUS_PRESENTATION = { - pending: { label: "Running", Icon: LoaderIcon, toneClassName: "animate-spin text-amber-500" }, + pending: { label: "Running", Icon: Spinner, toneClassName: "text-amber-500" }, "action-required": { label: "Awaiting action", Icon: CircleDotIcon, @@ -136,7 +136,7 @@ const CHECK_STATUS_PRESENTATION = { neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, } as const satisfies Record< PullRequestCheckStatus, - { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } + { label: string; Icon: typeof CircleCheckIcon | typeof Spinner; toneClassName: string } >; function isWorkflowApprovalCheck(check: Pick): boolean { diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 6be17ed33243..26015c5b3393 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,5 +1,6 @@ +import { Spinner } from "~/components/ui/spinner"; import type { ProjectContentMatch } from "@t3tools/contracts"; -import { LoaderCircle } from "lucide-react"; + import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -225,7 +226,7 @@ function OpenContentSearchDialog(props: {
{search.isPending ? ( - Searching… + Searching… ) : search.error ? ( {search.error} diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 0b23fb2d2072..ec3bf854d7b0 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { AlertTriangleIcon, ChevronDownIcon, @@ -5,7 +6,6 @@ import { CopyIcon, FolderOpenIcon, InfoIcon, - RefreshCwIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { @@ -766,7 +766,7 @@ function DiagnosticsRefreshButton({ onClick={onClick} aria-label={label} > - + } /> diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index b3faa7b0509f..327b48c2d44a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,10 +1,11 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { ArrowUpCircleIcon, CopyIcon, DownloadIcon, - LoaderIcon, LockIcon, LockOpenIcon, PlusIcon, @@ -739,7 +740,7 @@ export function ProviderInstanceCard({ disabled={isUpdating} onClick={onRunUpdate} > - {isUpdating ? : } + {isUpdating ? : } {isUpdating ? "Updating" : "Update now"} ) : null} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index fb4620b054c1..74676e3ff167 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -24,7 +25,7 @@ import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; -import { PlusIcon, RefreshCwIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; @@ -993,7 +994,7 @@ export function EnvironmentProviderSettings({ aria-busy={isRefreshingProviders} onClick={() => void refreshProviders()} > - + Refresh provider status {isRefreshingProviders ? ( diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index ca934952f8a9..003e46869a91 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ActivityIcon, AlertTriangleIcon, @@ -9,8 +10,6 @@ import { GaugeIcon, HardDriveIcon, MemoryStickIcon, - RefreshCwIcon, - RotateCcwIcon, } from "lucide-react"; import type { BackgroundBooleanState, @@ -982,9 +981,7 @@ export function ResourceTelemetryDiagnostics() { onClick={telemetry.refresh} aria-label="Refresh resource telemetry" > - + } /> @@ -1095,7 +1092,7 @@ export function ResourceTelemetryDiagnostics() { headerAction={ collectorNeedsRetry ? ( ) : null @@ -1233,7 +1230,7 @@ export function ResourceTelemetryDiagnostics() { onClick={history.refresh} aria-label="Refresh resource history" > - +
} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e79464d1757c..fe782c5757de 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,5 @@ -import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; +import { Spinner } from "~/components/ui/spinner"; +import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -3029,7 +3030,7 @@ export function ArchivedThreadsPanel() { title={ {isLoadingArchive ? ( - + ) : ( )} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index ee1fa66a3db4..736b7f1b4b99 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,4 +1,5 @@ -import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useEffect, useState, type ReactNode } from "react"; @@ -487,7 +488,7 @@ function EmptySourceControlDiscovery({ @@ -532,7 +533,7 @@ export function SourceControlSettingsPanel() { disabled={discovery.isPending} aria-label="Rescan server environment" > - + } /> diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index b270bf7b8e4d..eb6620a136fb 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -1,10 +1,5 @@ -import { - ExternalLinkIcon, - PackagePlusIcon, - PaletteIcon, - RefreshCwIcon, - SearchIcon, -} from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { importOpenVsxThemeExtension, @@ -417,7 +412,7 @@ export function ThemeSearchSection({ {isInstalling ? ( ) : isInstalled ? ( - + ) : ( )} diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 60fa379ce30e..833559e1cc27 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,8 +1,7 @@ -import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; import type { AnimationEventHandler } from "react"; -import { cn } from "../../lib/utils"; - const DOWNLOAD_PROGRESS_RADIUS = 14; const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; @@ -118,8 +117,9 @@ export function DesktopUpdateStatusIcon({ if (status === "downloaded") return ; return ( - ); diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 84dd7f4b5634..066b7e583253 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -1,7 +1,8 @@ +import { Spinner } from "~/components/ui/spinner"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import type { ServerProvider } from "@t3tools/contracts"; -import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; import { primaryServerProvidersAtom } from "../../state/server"; @@ -173,7 +174,7 @@ export function SidebarProviderUpdatePill() { onClick={openProviderSettings} > {displayedView.tone === "loading" ? ( - + ) : displayedView.tone === "success" ? ( ) : displayedView.tone === "error" ? ( diff --git a/apps/web/src/components/ui/refresh-icon.tsx b/apps/web/src/components/ui/refresh-icon.tsx new file mode 100644 index 000000000000..6fbddeb365a6 --- /dev/null +++ b/apps/web/src/components/ui/refresh-icon.tsx @@ -0,0 +1,20 @@ +import { RefreshCwIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; + +/** Keep the refresh glyph in place while its owning action is running. */ +export function RefreshIcon({ + refreshing = false, + className, + ...props +}: React.ComponentPropsWithoutRef & { refreshing?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx index 362b78f95463..44f0ffd50816 100644 --- a/apps/web/src/components/ui/spinner.tsx +++ b/apps/web/src/components/ui/spinner.tsx @@ -1,11 +1,13 @@ -import { Loader2Icon } from "lucide-react"; +import { LoaderCircleIcon } from "lucide-react"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; import { cn } from "~/lib/utils"; -function Spinner({ className, ...props }: React.ComponentProps) { +function Spinner({ className, ...props }: React.ComponentPropsWithoutRef) { return ( - diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 69fd0ebf3664..0f6483c2ae67 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,5 +1,7 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { Toast } from "@base-ui/react/toast"; import { useEffect, @@ -20,7 +22,6 @@ import { CircleCheckIcon, CopyIcon, InfoIcon, - LoaderCircleIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; @@ -83,7 +84,7 @@ const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, info: InfoIcon, - loading: LoaderCircleIcon, + loading: Spinner, success: CircleCheckIcon, warning: TriangleAlertIcon, } as const; @@ -357,7 +358,7 @@ function ToastBodyContent({ className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" data-slot="toast-icon" > - +
) : null}
("cost"); const showingLimits = metric === "limits"; + const [isRefreshing, setIsRefreshing] = useState(false); + const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); @@ -138,26 +140,39 @@ export function UsagePage() { }); }; const refreshWindow = () => { + if (refreshingRef.current) return; + if (showingLimits) { - for (const [environmentId, presentation] of presentations) { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - void refreshProviders({ environmentId, input: {} }); - } - } + refreshingRef.current = true; + setIsRefreshing(true); + void Promise.all( + Array.from(presentations, ([environmentId, presentation]) => { + if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; + if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { + return refreshProviders({ environmentId, input: {} }); + } + }), + ).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); return; } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setIsRefreshing(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); }; const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined @@ -225,10 +240,12 @@ export function UsagePage() {
@@ -282,10 +299,12 @@ export function UsagePage() {
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 6a53ee024548..339c7eae223b 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,6 +1,7 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; +import { LinkIcon, PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { openCommandPalette } from "../commandPaletteBus"; @@ -96,7 +97,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 37522ea3b0a2..7aab91d1b82a 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,3 +1,5 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { Spinner } from "~/components/ui/spinner"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; import type { @@ -26,10 +28,8 @@ import { LayersIcon, ListChecksIcon, PenLineIcon, - LoaderIcon, Maximize2Icon, Minimize2Icon, - RefreshCwIcon, SearchIcon, } from "lucide-react"; import { @@ -1581,7 +1581,11 @@ function PullRequestsRouteView() { ) : firstLoad ? ( ) : listQuery.error && entries.length === 0 ? ( - listQuery.refresh()} /> + listQuery.refresh()} + /> ) : carriedToNothing ? ( ) : entries.length === 0 ? ( @@ -1658,7 +1662,7 @@ function PullRequestsRouteView() {
{loadingMore ? ( - + {sentCursors === null ? "Updating pull requests" : "Loading more"} ) : canContinue || pageSize < MAX_PAGE_SIZE ? ( @@ -2318,7 +2322,7 @@ function PullRequestRefreshControl({ onClick={onRefresh} disabled={refreshing} > - + ); } diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index be65400c9800..617ac93e4b7c 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -13,7 +13,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; @@ -70,7 +70,7 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (input?: UsageSummaryInput) => Promise; } export function useUsage( @@ -108,26 +108,17 @@ export function useUsage( [environments, selectedEnvironmentIds], ); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so the button always rescans. - // - // Each environment refetches model pricing first, so a model released since - // its last daily fetch gets priced by the rescan. The rescan runs whether or - // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of selectedEnvironments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [selectedEnvironments, windowKey]); + const refresh = useCallback( + (nextInput?: UsageSummaryInput) => + refreshUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + presentations: environmentPresentations, + environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + }), + [selectedEnvironments, windowKey], + ); const merged = useMemo(() => { const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad51474..c7d921393444 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -147,6 +147,10 @@ "types": "./src/state/runtime.ts", "default": "./src/state/runtime.ts" }, + "./state/usage": { + "types": "./src/state/usage.ts", + "default": "./src/state/usage.ts" + }, "./state/server": { "types": "./src/state/server.ts", "default": "./src/state/server.ts" diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 3f12f44d5f4c..ca4ac5ac911d 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -683,6 +683,24 @@ describe("executeAtomQuery", () => { registry.dispose(); }); + + it("settles when its caller aborts a waiting query", async () => { + const registry = AtomRegistry.make(); + const controller = new AbortController(); + const resultPromise = executeAtomQuery(registry, Atom.make(Effect.never), { + reportDefect: false, + signal: controller.signal, + }); + + controller.abort(); + + const result = await resultPromise; + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.hasInterruptsOnly(result.cause)).toBe(true); + } + registry.dispose(); + }); }); describe("runtime command runner", () => { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index affd5aa90ec1..3e61909ee711 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -336,6 +336,8 @@ export interface AtomQueryOptions extends AtomCommandOptions { * verification flows where a cached failure must not satisfy a retry. */ readonly refresh?: boolean; + /** Interrupt the query wait when its caller no longer wants the result. */ + readonly signal?: AbortSignal; } export async function executeAtomQuery( @@ -362,7 +364,11 @@ export async function executeAtomQuery( }); }), ); - return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter); + return executeAtomCommand( + () => Effect.runPromiseExit(query, { signal: options.signal }), + options, + reporter, + ); } export function createRuntimeCommand( diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts new file mode 100644 index 000000000000..29f029c9d863 --- /dev/null +++ b/packages/client-runtime/src/state/usage.test.ts @@ -0,0 +1,184 @@ +import { + EnvironmentId, + UsageDay, + USAGE_CONTRACT_VERSION, + type UsageSummary, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentPresentation } from "../connection/presentation.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import { refreshUsage } from "./usage.ts"; + +const input = { + sinceDay: UsageDay.make("2026-09-05"), + untilDay: UsageDay.make("2026-09-05"), + timeZone: "UTC", +}; +const pricing = { status: "fresh" as const, source: "test", fetchedAt: null, knownModels: 1 }; +const summary: UsageSummary = { + ...input, + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-09-05T12:00:00Z", + buckets: [], + sources: [], + pricing, + scanDurationMs: 1, +}; +const registries: AtomRegistry.AtomRegistry[] = []; +afterEach(() => { + for (const registry of registries.splice(0)) registry.dispose(); +}); + +function harness(ids = ["a"]) { + const registry = AtomRegistry.make(); + registries.push(registry); + const environments = ids.map((id) => { + const environmentId = EnvironmentId.make(id); + const rates = Promise.withResolvers< + AsyncResult.Success | AsyncResult.Failure + >(); + const scan = Promise.withResolvers(); + const scanStarted = Promise.withResolvers(); + const presentation = Atom.make({ + connection: { phase: "connected" }, + } as EnvironmentPresentation | null); + const query = Atom.make( + Effect.promise(() => { + scanStarted.resolve(); + return scan.promise; + }), + ); + return { environmentId, rates, scan, scanStarted, presentation, query }; + }); + function get(environmentId: EnvironmentId) { + const environment = environments.find((entry) => entry.environmentId === environmentId); + if (!environment) throw new Error(`Unknown environment: ${environmentId}`); + return environment; + } + const options = { + registry, + environmentIds: environments.map((entry) => entry.environmentId), + input, + server: { + usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => + get(environmentId).query, + refreshUsageRates: { + label: "test:rates", + run: ( + _registry: AtomRegistry.AtomRegistry, + { environmentId }: { environmentId: EnvironmentId }, + ) => get(environmentId).rates.promise, + }, + }, + presentations: { + presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, + }, + } satisfies Parameters[0]; + return { registry, environments, refresh: () => refreshUsage(options) }; +} + +describe("manual usage refresh", () => { + it.each(["success", "failure"])("waits for the rescan after a pricing %s", async (result) => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + expect(finished).toBe(false); + entry.rates.resolve( + result === "success" + ? AsyncResult.success(pricing) + : AsyncResult.fail(new Error("Pricing offline")), + ); + await entry.scanStarted.promise; + expect(finished).toBe(false); + entry.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when an environment disconnects during the rescan", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await entry.scanStarted.promise; + registry.set(entry.presentation, null); + await refreshing; + }); + + it("waits for healthy environments without waiting for a recovering environment", async () => { + const { registry, environments, refresh } = harness(["healthy", "recovering"]); + const [healthy, recovering] = environments; + registry.set(recovering!.presentation, null); + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + for (const entry of environments) entry.rates.resolve(AsyncResult.success(pricing)); + await healthy!.scanStarted.promise; + expect(finished).toBe(false); + healthy!.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when connected state has no usable RPC session", async () => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve( + AsyncResult.fail( + new EnvironmentRpcUnavailableError({ + environmentId: entry.environmentId, + message: "No session", + }), + ), + ); + await refreshing; + }); + + it("replaces a scan that started before pricing was refreshed", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let reads = 0; + const rescanned = Promise.withResolvers(); + const query = Atom.make( + Effect.promise(() => { + reads += 1; + if (reads > 1) { + rescanned.resolve(); + return Promise.resolve(summary); + } + return new Promise(() => {}); + }), + ); + entry.query = query; + const unmount = registry.mount(query); + expect(reads).toBe(1); + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await rescanned.promise; + await refreshing; + expect(reads).toBe(2); + unmount(); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts new file mode 100644 index 000000000000..10a565a0c24f --- /dev/null +++ b/packages/client-runtime/src/state/usage.ts @@ -0,0 +1,61 @@ +import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type { AtomRegistry } from "effect/unstable/reactivity"; + +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type { createEnvironmentPresentationAtoms } from "./presentation.ts"; +import { executeAtomQuery, runAtomCommand, squashAtomCommandFailure } from "./runtime.ts"; +import type { createServerEnvironmentAtoms } from "./server.ts"; + +const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); + +/** Refresh pricing, then await each selected environment's rescan while it remains connected. */ +export async function refreshUsage({ + registry, + server, + presentations, + environmentIds, + input, +}: { + registry: AtomRegistry.AtomRegistry; + server: Pick< + ReturnType, + "usageSummary" | "refreshUsageRates" + >; + presentations: Pick, "presentationAtom">; + environmentIds: readonly EnvironmentId[]; + input: UsageSummaryInput; +}): Promise { + await Promise.all( + environmentIds.map(async (environmentId) => { + const query = server.usageSummary({ environmentId, input }); + const presentation = presentations.presentationAtom(environmentId); + const controller = new AbortController(); + const abortWhenDisconnected = () => { + if (registry.get(presentation)?.connection.phase !== "connected") controller.abort(); + }; + const unsubscribe = registry.subscribe(presentation, abortWhenDisconnected); + abortWhenDisconnected(); + try { + const ratesResult = await runAtomCommand( + registry, + server.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ); + const sessionUnavailable = + ratesResult._tag === "Failure" && + isEnvironmentRpcUnavailable(squashAtomCommandFailure(ratesResult)); + // Invalidate even on failure so reconnects cannot reuse the old summary. + registry.refresh(query); + if (sessionUnavailable || controller.signal.aborted) return; + await executeAtomQuery(registry, query, { + reportFailure: false, + signal: controller.signal, + }); + } finally { + unsubscribe(); + } + }), + ); +} From bd16b86d50c1df49afeb7c0a7568a4908ade4048 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 17:18:50 -0700 Subject: [PATCH 24/65] fix(client-runtime): report terminated thread loads (#10216) --- .../client-runtime/src/rpc/client.test.ts | 98 ++++- packages/client-runtime/src/rpc/client.ts | 95 +++-- .../src/state/threads-atoms.test.ts | 338 +++++++++++++++++- packages/client-runtime/src/state/threads.ts | 34 +- 4 files changed, 498 insertions(+), 67 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index f2141add930f..9e4e8a600d55 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -465,36 +465,112 @@ describe("environment RPC", () => { }), ); - it.effect("does not classify subscription defects as expected failures", () => + it.effect.each(["input", "stream"] as const)( + "does not classify %s subscription defects as expected failures", + (where) => + Effect.gen(function* () { + const defect = new Error("subscription invariant failed"); + let expectedFailureCount = 0; + let inputs = 0; + let streams = 0; + const observedDefects: unknown[] = []; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => { + streams += 1; + return where === "stream" ? Stream.die(defect) : Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const exit = yield* subscribeDynamicWithSession( + WS_METHODS.subscribeTerminalEvents, + () => + Effect.sync(() => { + inputs += 1; + }).pipe(Effect.andThen(where === "input" ? Effect.die(defect) : Effect.succeed({}))), + { + onDefect: (cause) => + Effect.sync(() => { + observedDefects.push(Cause.squash(cause)); + }), + onExpectedFailure: () => + Effect.sync(() => { + expectedFailureCount += 1; + }), + retryExpectedFailureAfter: "250 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); + } + expect(inputs).toBe(1); + expect(streams).toBe(where === "input" ? 0 : 1); + expect(expectedFailureCount).toBe(0); + expect(observedDefects).toEqual([defect]); + }), + ); + + it.effect("reports an initializer defect once after an expected failure retries", () => Effect.gen(function* () { - const defect = new Error("subscription invariant failed"); - let expectedFailureCount = 0; + const defect = new Error("Synthetic retry initializer defect"); + const expectedFailure = yield* Deferred.make(); + const observations: string[] = []; + const observedDefects: unknown[] = []; + let inputs = 0; const client = { - [WS_METHODS.subscribeTerminalEvents]: () => Stream.die(defect), + [WS_METHODS.subscribeTerminalEvents]: () => { + observations.push("stream"); + return Stream.fail(new Error("subscription not ready")); + }, } as unknown as WsRpcProtocolClient; const { activeSession, supervisor } = yield* makeHarness(); - yield* SubscriptionRef.set(activeSession, Option.some(session(client))); - const exit = yield* subscribe( + const fiber = yield* subscribeDynamicWithSession( WS_METHODS.subscribeTerminalEvents, - {}, + () => + Effect.sync(() => { + inputs += 1; + observations.push(`input ${inputs}`); + return inputs; + }).pipe( + Effect.flatMap((attempt) => (attempt === 1 ? Effect.succeed({}) : Effect.die(defect))), + ), { - onExpectedFailure: () => + onDefect: (cause) => Effect.sync(() => { - expectedFailureCount += 1; + observations.push("defect"); + observedDefects.push(Cause.squash(cause)); }), + onExpectedFailure: () => + Effect.sync(() => { + observations.push("expected failure"); + }).pipe(Effect.andThen(Deferred.succeed(expectedFailure, undefined)), Effect.asVoid), + retryExpectedFailureAfter: "250 millis", }, ).pipe( Stream.runDrain, Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.exit, + Effect.forkChild, ); - + yield* Deferred.await(expectedFailure); + yield* TestClock.adjust("250 millis"); + const exit = yield* Fiber.join(fiber); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); } - expect(expectedFailureCount).toBe(0); + expect(observations).toEqual(["input 1", "stream", "expected failure", "input 2", "defect"]); + expect(observedDefects).toEqual([defect]); }), ); }); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index e7c5117954cb..bc13d429ac96 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -171,6 +171,10 @@ export function runStream( } interface SubscriptionOptions { + /** Reports protocol or programming defects without changing their recovery policy. */ + readonly onDefect?: ( + cause: Cause.Cause>, + ) => Effect.Effect; readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; @@ -228,47 +232,60 @@ function subscribeDynamicMapped( }); return mapStream(session, method(input)).pipe( Stream.ensuring(completeObservation), - Stream.catchCause((cause) => { - const hasOnlyExpectedFailures = - cause.reasons.length > 0 && - cause.reasons.every((reason) => reason._tag === "Fail"); - const isTransportFailure = - hasOnlyExpectedFailures && - cause.reasons.every( - (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), - ); - if (isTransportFailure) { - return Stream.fromEffect( - Effect.logWarning( - "Durable RPC subscription lost its transport; waiting for the next session.", - { - cause: Cause.pretty(cause), - method: tag, - environmentId: supervisor.target.environmentId, - }, - ), - ).pipe(Stream.drain); - } - if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { - const handled = Stream.fromEffect( - options.onExpectedFailure(cause), - ).pipe(Stream.drain); - if (options.retryExpectedFailureAfter === undefined) { - return handled; - } - return handled.pipe( - Stream.concat( - Stream.fromEffect( - Effect.sleep(options.retryExpectedFailureAfter), - ).pipe(Stream.drain), - ), - Stream.concat(subscribeToSession()), - ); - } - return Stream.failCause(cause); - }), ); }), + ).pipe( + Stream.tapCause((cause) => + options?.onDefect !== undefined && + cause.reasons.some( + (reason) => + reason._tag === "Die" || + (reason._tag === "Fail" && + isRpcClientError(reason.error) && + reason.error.reason._tag === "RpcClientDefect"), + ) + ? options.onDefect(cause) + : Effect.void, + ), + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { + const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( + Stream.drain, + ); + if (options.retryExpectedFailureAfter === undefined) { + return handled; + } + return handled.pipe( + Stream.concat( + Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe( + Stream.drain, + ), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), ), ); return subscribeToSession(); diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 27229a7aff61..54b6f9e73e97 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -11,6 +11,8 @@ import { type OrchestrationThreadStreamItem, } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -18,7 +20,10 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { RpcClientError } from "effect/unstable/rpc"; +import { Socket } from "effect/unstable/socket"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { EnvironmentRegistry } from "../connection/registry.ts"; @@ -30,6 +35,7 @@ import { type SupervisorConnectionState, } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { ConnectionWakeups, type ConnectionWakeup } from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; @@ -38,6 +44,7 @@ import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts"; import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { createEnvironmentThreadStateAtoms, + makeEnvironmentThreadState, requestOlderThreadTurns, ThreadSnapshotLoader, type EnvironmentThreadState, @@ -54,7 +61,7 @@ const THREAD: OrchestrationThread = { id: THREAD_ID, projectId: ProjectId.make("project-1"), title: "Cached thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "ModelA" }, runtimeMode: "full-access", interactionMode: "default", branch: "main", @@ -86,10 +93,15 @@ const CONNECTED_STATE: SupervisorConnectionState = { const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: { readonly snapshot?: OrchestrationThreadDetailSnapshot; readonly connected?: boolean; + readonly httpNone?: boolean; + readonly initialLoad?: Effect.Effect>; + readonly stream?: Stream.Stream; }) { + const clock = yield* Clock.Clock; + const wakeups = yield* Queue.unbounded(); const subscriptions = yield* Queue.unbounded<{ readonly afterSequence: number | undefined; - readonly events: Queue.Queue; + readonly events: Queue.Queue; readonly closed: Deferred.Deferred; }>(); const olderLoads = yield* Queue.unbounded<{ @@ -106,7 +118,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => Stream.unwrap( Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const closed = yield* Deferred.make(); yield* Effect.acquireRelease( Effect.sync(() => { @@ -119,7 +131,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }).pipe(Effect.andThen(Deferred.succeed(closed, undefined))), ); yield* Queue.offer(subscriptions, { afterSequence: input.afterSequence, events, closed }); - return Stream.fromQueue(events); + return options?.stream ?? Stream.fromQueue(events); }), ), } as unknown as WsRpcProtocolClient; @@ -179,6 +191,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }); const runtime = Atom.runtime( Layer.mergeAll( + Layer.succeed(Clock.Clock, clock), + Layer.succeed(ConnectionWakeups, { changes: Stream.fromQueue(wakeups) }), Layer.succeed(EnvironmentRegistry, environmentRegistry), Layer.succeed( EnvironmentCacheStore, @@ -208,8 +222,12 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? if (window?.beforeCursor === undefined) { return Effect.sync(() => { httpLoads += 1; - return Option.some(snapshot); - }); + }).pipe( + Effect.andThen( + options?.initialLoad ?? + Effect.succeed(options?.httpNone ? Option.none() : Option.some(snapshot)), + ), + ); } return Effect.gen(function* () { const response = @@ -235,6 +253,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? const registry = yield* makeRegistry; return { + runtime, + supervisor, registry, makeRegistry, rawAtoms: raw, @@ -246,6 +266,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? connectionState, session, sessionRef, + wakeups, counts: () => ({ httpLoads, diskLoads, opened, active }), }; }); @@ -275,6 +296,311 @@ describe("createEnvironmentThreadStateAtoms", () => { vi.restoreAllMocks(); }); + it.effect("exposes snapshot loader defects before the RPC subscription starts", () => + Effect.gen(function* () { + const completed = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: true, + initialLoad: Effect.die( + new Error("SYNTHETIC_RAW_SNAPSHOT_DEFECT_SHOULD_NOT_REACH_THREAD_UI"), + ).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))), + }); + const unmount = h.registry.mount(h.stateAtom); + yield* Deferred.await(completed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.status).toBe("empty"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(Option.none()); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + unmount(); + }), + ); + + it.effect.each([ + { kind: "protocol", httpNone: true }, + { kind: "protocol", httpNone: false }, + { kind: "fatal", httpNone: true }, + { kind: "fatal", httpNone: false }, + ] as const)( + "retains a terminated $kind load diagnostic across connection updates (empty: $httpNone)", + ({ kind, httpNone }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + const error = new Error("SYNTHETIC_RAW_DEFECT_SHOULD_NOT_REACH_THREAD_UI"); + yield* Queue.failCause( + first.events, + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ), + ); + yield* Deferred.await(first.closed); + // The real finalizer has run; advancing the atom runtime's test clock + // also verifies that a defect does not enter the domain retry loop. + yield* TestClock.adjust("1 second"); + const failed = h.registry.get(h.stateAtom); + expect(failed.status).toBe(httpNone ? "empty" : "cached"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(httpNone ? Option.none() : Option.some(THREAD)); + expect(h.counts().opened).toBe(1); + expect(h.counts().active).toBe(0); + + // Session publication can precede connected, and a fatal child cannot + // restart just because its supervisor reconnects. + for (const connection of [ + AVAILABLE_CONNECTION_STATE, + { ...CONNECTED_STATE, phase: "connecting" as const }, + CONNECTED_STATE, + ]) { + yield* SubscriptionRef.set(h.connectionState, connection); + yield* TestClock.adjust("0 millis"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + } + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + if (kind === "fatal") { + yield* TestClock.adjust("1 second"); + expect(h.counts().opened).toBe(1); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + unmount(); + return; + } + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + expect(recovered.data).toEqual(Option.some(THREAD)); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retries a protocol failure on foreground without replacing the session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "incompatible snapshot", + cause: new Error("incompatible snapshot"), + }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("0 millis"); + expect(Option.isSome(h.registry.get(h.stateAtom).error)).toBe(true); + yield* Queue.offer(h.wakeups, "application-active"); + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("keeps transport loss nonterminal and recovers with a replacement session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006, closeReason: "connection lost" }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + status: "synchronizing", + error: Option.none(), + data: Option.none(), + }); + expect(h.counts().opened).toBe(1); + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + const next = yield* Queue.take(h.subscriptions); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retains ordinary domain error reporting and same-session retries", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail(first.events, new Error("thread not found yet")); + yield* Deferred.await(first.closed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.error).toEqual(Option.some("thread not found yet")); + yield* TestClock.adjust("250 millis"); + const next = yield* Queue.take(h.subscriptions); + expect(h.counts().opened).toBe(2); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect.each([ + { kind: "protocol", deleted: false }, + { kind: "fatal", deleted: false }, + { kind: "domain", deleted: false }, + { kind: "protocol", deleted: true }, + ] as const)( + "keeps buffered outcomes after a $kind failure (deleted: $deleted)", + ({ kind, deleted }) => + Effect.gen(function* () { + const burst = yield* Deferred.make(); + const error = new Error( + kind === "domain" + ? "buffered thread failure" + : "SYNTHETIC_BUFFERED_DEFECT_SHOULD_NOT_REACH_THREAD_UI", + ); + const items: OrchestrationThreadStreamItem[] = [ + { kind: "snapshot", snapshot: SNAPSHOT }, + { kind: "synchronized" }, + { + kind: "event", + event: { + eventId: EventId.make("buffered-event"), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + sequence: 8, + occurredAt: THREAD.createdAt, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title: "Buffer drained", + updatedAt: THREAD.createdAt, + }, + }, + }, + ]; + if (deleted) { + items.push({ + kind: "event", + event: { + eventId: EventId.make("buffered-deletion"), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + sequence: 9, + occurredAt: THREAD.createdAt, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.deleted", + payload: { threadId: THREAD_ID, deletedAt: THREAD.createdAt }, + }, + }); + } + const failure = + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + kind === "domain" + ? error + : new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ); + const h = yield* makeHarness({ + connected: true, + httpNone: true, + stream: Stream.fromEffect(Deferred.await(burst)).pipe( + Stream.flatMap(() => Stream.fromIterable(items)), + Stream.concat(Stream.failCause(failure)), + ), + }); + yield* Effect.gen(function* () { + const state = yield* makeEnvironmentThreadState(THREAD_ID); + const initial = yield* Deferred.make(); + const drained = yield* Deferred.make(); + yield* SubscriptionRef.changes(state).pipe( + Stream.runForEach((value) => + Deferred.succeed(initial, undefined).pipe( + Effect.andThen( + ( + deleted + ? value.status === "deleted" + : Option.getOrNull(value.data)?.title === "Buffer drained" + ) + ? Deferred.succeed(drained, undefined) + : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* Deferred.await(initial); + const subscription = yield* Queue.take(h.subscriptions); + yield* Deferred.succeed(burst, undefined); + yield* Deferred.await(subscription.closed); + yield* Deferred.await(drained); + const final = yield* SubscriptionRef.get(state); + if (deleted) { + expect(final.status).toBe("deleted"); + expect(final.data).toEqual(Option.none()); + expect(final.error).toEqual(Option.none()); + return; + } + expect(Option.getOrThrow(final.data).title).toBe("Buffer drained"); + expect(final.error).toEqual( + Option.some(kind === "domain" ? error.message : "Could not synchronize the thread."), + ); + expect(final.status).toBe("cached"); + }).pipe( + Effect.provideService(EnvironmentSupervisor, h.supervisor), + Effect.provide(h.registry.get(h.runtime.layer)), + Effect.scoped, + ); + }), + ); + it.effect("shares one live stream and closes it after the last detail consumer leaves", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 1311469c0ee0..83b85bf02f09 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -310,8 +310,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); - const setSynchronizing = SubscriptionRef.update(state, (current) => - current.status === "deleted" + const setConnecting = SubscriptionRef.update(state, (current) => + current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -320,7 +320,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ); const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" + current.status === "live" || current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -341,14 +341,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), })); }); - const setStreamError = (cause: Cause.Cause) => + const setStreamError = (message: string) => Ref.set(awaitingCompletion, false).pipe( Effect.andThen( SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - error: Option.some(formatThreadError(cause)), + error: Option.some(message), })), ), ); @@ -362,8 +362,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.update(state, (current) => ({ data: Option.some(thread), - status: waiting ? ("synchronizing" as const) : ("live" as const), - error: Option.none(), + // Buffered values from the failed attempt can still arrive after its error. + status: Option.isSome(current.error) + ? ("cached" as const) + : waiting + ? ("synchronizing" as const) + : ("live" as const), + error: current.error, page: page === "keep" ? current.page : page, })); // Active threads can update many times per second and retain large tool @@ -423,7 +428,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.kind === "synchronized") { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.update(state, (current) => - Option.isSome(current.data) && current.status !== "deleted" + Option.isSome(current.data) && current.status !== "deleted" && Option.isNone(current.error) ? { ...current, status: "live" as const, error: Option.none() } : current, ); @@ -639,7 +644,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Stream.runForEach((connectionState) => { switch (connectionProjectionPhase(connectionState)) { case "synchronizing": - return setSynchronizing; + return setConnecting; case "disconnected": return setDisconnected; case "ready": @@ -661,7 +666,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const resumingLive = yield* Ref.make(initialState.status === "live"); const markSynchronizing = Effect.gen(function* () { if (yield* Ref.get(resumingLive)) return; - yield* setSynchronizing; + // Connection notifications do not establish that a terminated load restarted. + // Clear its diagnostic only when this subscription actually tries again. + yield* SubscriptionRef.update(state, (current) => + current.status === "deleted" + ? current + : { ...current, status: "synchronizing" as const, error: Option.none() }, + ); }); yield* markSynchronizing; @@ -757,7 +768,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }; }), { - onExpectedFailure: setStreamError, + onDefect: () => setStreamError("Could not synchronize the thread."), + onExpectedFailure: (cause) => setStreamError(formatThreadError(cause)), retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, From 050690d1bc048c1f94096c17a1c9231c3ad61a83 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 21:29:41 -0400 Subject: [PATCH 25/65] fix(server): settle threads using actual pull request terminal timestamps (#9934) --- apps/server/src/git/GitManager.test.ts | 19 ++++++-- apps/server/src/git/GitManager.ts | 18 +++++++- .../ThreadSettlementPolicy.test.ts | 43 +++++++++++++------ .../orchestration/ThreadSettlementPolicy.ts | 9 ++-- .../ThreadSettlementReactor.test.ts | 23 ++++++++-- .../orchestration/ThreadSettlementReactor.ts | 5 ++- .../AzureDevOpsPullRequestProvider.ts | 2 + .../BitbucketPullRequestProvider.ts | 4 +- .../pullRequest/GitHubPullRequestCli.test.ts | 8 +++- .../src/pullRequest/GitHubPullRequestCli.ts | 4 ++ .../src/pullRequest/PullRequestProvider.ts | 4 ++ .../src/pullRequest/PullRequestService.ts | 4 ++ .../src/sourceControl/AzureDevOpsCli.test.ts | 2 + .../AzureDevOpsSourceControlProvider.test.ts | 7 ++- .../AzureDevOpsSourceControlProvider.ts | 4 ++ .../src/sourceControl/GitHubCli.test.ts | 10 ++++- apps/server/src/sourceControl/GitHubCli.ts | 6 ++- .../GitHubSourceControlProvider.test.ts | 6 ++- .../GitHubSourceControlProvider.ts | 4 +- .../src/sourceControl/GitLabCli.test.ts | 10 ++++- apps/server/src/sourceControl/GitLabCli.ts | 2 + .../GitLabSourceControlProvider.test.ts | 7 ++- .../GitLabSourceControlProvider.ts | 2 + .../sourceControl/azureDevOpsPullRequests.ts | 11 ++++- .../src/sourceControl/gitHubPullRequests.ts | 5 +++ .../src/sourceControl/gitLabMergeRequests.ts | 6 +++ .../pullRequest/PullRequestDetailPanel.tsx | 8 ++++ packages/contracts/src/git.ts | 8 ++-- packages/contracts/src/pullRequest.ts | 2 + packages/contracts/src/sourceControl.ts | 2 + 30 files changed, 197 insertions(+), 48 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 643e1e7a0d5b..b8f4090453be 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -511,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -555,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -1148,6 +1148,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "open", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-03T15:00:00.000Z", }); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); @@ -1179,6 +1181,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "develop", headRefName: "main", state: "MERGED", + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00Z", }, ]), @@ -1190,6 +1193,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00.000Z", }); }), @@ -1241,6 +1246,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-04T15:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( @@ -1305,6 +1312,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-05T15:00:00.000Z", }); expect( @@ -1691,7 +1700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1757,7 +1766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2142,6 +2151,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-05-02T10:00:00.000Z", }); }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2d8af0c9e8bb..76f2ebc6b510 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -100,7 +100,12 @@ export class GitManager extends Context.Service< readonly cwd: string; readonly branch: string; }) => Effect.Effect< - { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + } | null, GitManagerServiceError >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; @@ -171,6 +176,8 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; + closedAt?: string | null; + mergedAt?: string | null; updatedAt: Option.Option; } @@ -406,6 +413,8 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -2157,7 +2166,12 @@ export const make = Effect.gen(function* () { return null; } const statusPr = toStatusPr(latest); - return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + return { + state: statusPr.state, + updatedAt: statusPr.updatedAt, + closedAt: latest.closedAt ?? null, + mergedAt: latest.mergedAt ?? null, + }; }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 05fb16203e0b..61c512e6fd91 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -6,7 +6,7 @@ import { TurnId, type OrchestrationThreadShell, } from "@t3tools/contracts"; -import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; +import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const makeThread = ( @@ -36,7 +36,7 @@ const makeThread = ( const decide = ( thread: OrchestrationThreadShell, - pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + pullRequest: SettlementPullRequest | null = null, settings: { days?: number | null; merge?: boolean } = {}, ) => resolveAutoSettlementAt({ @@ -77,7 +77,7 @@ describe("resolveAutoSettlementAt", () => { latestTurn: null, updatedAt: "2026-08-27T00:00:00.000Z", }), - pullRequest: { state: "closed", updatedAt: NOW }, + pullRequest: { state: "closed", closedAt: NOW }, now: NOW, autoSettleAfterDays: null, autoSettleOnMerge: true, @@ -100,10 +100,10 @@ describe("resolveAutoSettlementAt", () => { }); it("settles closed requests and honors the merge setting", () => { - expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); - expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "closed", closedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false })).toBe(true); expect( - decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false, days: null }), ).toBe(false); }); @@ -111,17 +111,36 @@ describe("resolveAutoSettlementAt", () => { expect( decide( makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), - { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" }, { days: null }, ), ).toBe(false); }); + it.each(["closed", "merged"] as const)( + "ignores metadata edits after resumed work for %s requests", + (state) => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { + state, + closedAt: "2026-08-26T00:00:00.000Z", + mergedAt: "2026-08-26T00:00:00.000Z", + updatedAt: NOW, + }, + { days: null }, + ), + ).toBe(false); + expect(decide(makeThread(), { state, updatedAt: NOW }, { days: null })).toBe(false); + }, + ); + it("does not inherit a terminal pull request older than the thread", () => { expect( decide( makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), - { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { state: "closed", closedAt: "2026-08-19T00:00:00.000Z" }, { days: null }, ), ).toBe(false); @@ -129,9 +148,9 @@ describe("resolveAutoSettlementAt", () => { it("requires a comparable PR timestamp for immediate settlement", () => { const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); - expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); - expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); - expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + expect(decide(recentThread, { state: "closed", closedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", mergedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", closedAt: null })).toBe(true); }); it("uses user request time instead of completion time as the PR anchor", () => { @@ -145,7 +164,7 @@ describe("resolveAutoSettlementAt", () => { assistantMessageId: null, }, }); - expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + expect(decide(thread, { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); }); it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index eac5a960a482..7c55fa37d1f3 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -2,7 +2,9 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: string | null; } const DAY_MS = 24 * 60 * 60 * 1_000; @@ -49,14 +51,15 @@ function pullRequestSettles( if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { return false; } - if (pullRequest.updatedAt === null) return false; + const terminalAt = pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt; + if (terminalAt == null) return false; const userAnchor = latestTimestamp([ thread.createdAt, thread.latestUserMessageAt, thread.latestTurn?.requestedAt, ]); if (userAnchor === null) return false; - const pullRequestAt = Date.parse(pullRequest.updatedAt); + const pullRequestAt = Date.parse(terminalAt); const userAnchorAt = Date.parse(userAnchor); if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; return pullRequestAt >= userAnchorAt; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index d3b24d5f77b1..eefc18f7b461 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -127,6 +127,8 @@ function makePullRequestSummary(input: { headBranch: "feature", baseBranch: "main", updatedAt: input.updatedAt ?? NOW, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, }; } @@ -372,7 +374,9 @@ describe("ThreadSettlementReactor", () => { }), ]), branchPullRequest: () => - Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + Ref.get(pullRequest).pipe( + Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })), + ), }); yield* Effect.gen(function* () { @@ -472,7 +476,12 @@ describe("ThreadSettlementReactor", () => { ]), branchPullRequest: () => Ref.get(state).pipe( - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => ({ + state: pullRequestState, + updatedAt: NOW, + closedAt: NOW, + mergedAt: NOW, + })), ), onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), }); @@ -603,7 +612,12 @@ describe("ThreadSettlementReactor", () => { : Effect.void, ), Effect.andThen(Ref.get(state)), - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => ({ + state: pullRequestState, + updatedAt: NOW, + closedAt: NOW, + mergedAt: NOW, + })), ), }); @@ -749,7 +763,8 @@ describe("ThreadSettlementReactor", () => { makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), ], ), - branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + branchPullRequest: () => + Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }), pullRequestSummary: (input) => Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 6539135adfe6..70de3c41d7e9 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -119,7 +119,7 @@ export const make = Effect.gen(function* () { ) { return { state: "merged", - updatedAt: mergedPullRequest.mergedAt, + mergedAt: mergedPullRequest.mergedAt, } satisfies SettlementPullRequest; } if (!projects.has(thread.linkedPullRequest.projectId)) { @@ -135,7 +135,8 @@ export const make = Effect.gen(function* () { ); return { state: summary.state, - updatedAt: summary.updatedAt, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, } satisfies SettlementPullRequest; } if (thread.branch === null) return null; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 3d501a32d61b..ae586ee0a61c 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -90,6 +90,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq additions: 0, deletions: 0, createdAt: pullRequest.createdAt, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, // Azure keeps labels on work items rather than on the pull request. diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 47d41eeee6d9..3b5b93d11c46 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -175,8 +175,8 @@ export const make = Effect.gen(function* () { deletions: diffStat.deletions, changedFiles: diffStat.changedFiles, body: pullRequest.body, - mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, + mergedAt: null, + closedAt: null, reviewers: pullRequest.reviewers, checks, // Bitbucket publishes no per-repository list of allowed strategies, so the ones it diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 1e6ca0ed43a6..f61b7c3233f6 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -192,7 +192,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", baseRefName: "main", headRefName: "feat/summary", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }), ); @@ -211,7 +213,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", headBranch: "feat/summary", baseBranch: "main", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }); expect(mockedGetPullRequest).toHaveBeenCalledOnce(); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5d5c2062c08c..89a50f93ced7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -469,6 +469,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly baseBranch: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; }, GitHubPullRequestCliError @@ -1652,6 +1654,8 @@ export const make = Effect.gen(function* () { baseBranch: summary.baseRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 22028ced5ddf..5f1aba8ba9b6 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -78,6 +78,8 @@ export interface ProviderChangeRequest { readonly additions: number; readonly deletions: number; readonly createdAt: string; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; /** Accounts with a review requested. Team-level requests are excluded by each provider. */ readonly reviewRequestLogins: ReadonlyArray; @@ -98,6 +100,8 @@ export interface ProviderChangeRequestSummary { readonly state: PullRequestState; /** Present when the host says an open pull request is still a draft. */ readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a8d3dc2fdeaf..2229a4f652c0 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1257,6 +1257,8 @@ export const make = Effect.gen(function* () { ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, })), ); @@ -2316,6 +2318,8 @@ export const make = Effect.gen(function* () { ...(detail.isDraft === true ? { isDraft: true } : {}), headBranch: detail.headBranch, baseBranch: detail.baseBranch, + closedAt: detail.closedAt, + mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f0cb52003029..24b28af13fe4 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -163,6 +163,8 @@ describe("AzureDevOpsCli.layer", () => { }); assert.strictEqual(result[0]?.state, "merged"); + assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); + assert.strictEqual(result[0]?.closedAt, null); expect(mockRun).toHaveBeenCalledWith({ operation: "AzureDevOpsCli.execute", command: "az", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index cacdd1a3cd97..8e55d453b224 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -22,7 +22,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), }); @@ -39,7 +40,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: false, }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8a840c524eba..20a74cc8a5d7 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -62,6 +62,8 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -73,6 +75,8 @@ function toChangeRequest(summary: { headRefName: summary.headRefName, state: summary.state, ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 3f08e92e2c1e..f72259b677eb 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -93,6 +93,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isDraft: true, updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, @@ -107,7 +109,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, @@ -154,6 +156,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -207,6 +211,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-list", state: "open", + closedAt: null, + mergedAt: null, }, ]); }).pipe(Effect.provide(layer)), @@ -259,6 +265,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "t3code/codex-turn-mapping", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 49b2ea31a08c..85736a95c5dd 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -206,6 +206,8 @@ export interface GitHubPullRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -367,7 +369,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -399,7 +401,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 7faa2fe351ef..a025ce5ec800 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -60,6 +60,8 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", @@ -125,6 +127,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = baseRefName: "main", headRefName: "feature/merged", state: "merged", + mergedAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00.000Z", }, ]), @@ -150,10 +153,11 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); assert.deepStrictEqual( changeRequests[0]?.updatedAt, Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 1a20b587256a..74f08a9a9127 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -31,6 +31,8 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt === undefined ? Option.none() @@ -154,7 +156,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index eb56b434b2f8..c5f22fe3088f 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -46,7 +46,8 @@ layer("GitLabCli.layer", (it) => { web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", target_branch: "main", source_branch: "feature/mr-threads", - state: "opened", + state: "closed", + closed_at: "2026-08-23T10:00:00Z", source_project_id: 101, target_project_id: 100, source_project: { @@ -71,7 +72,9 @@ layer("GitLabCli.layer", (it) => { url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/mr-threads", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/t3code", headRepositoryOwnerLogin: "octocat", @@ -107,6 +110,7 @@ layer("GitLabCli.layer", (it) => { target_branch: " main ", source_branch: " feature/mr-list ", state: "merged", + merged_at: "2026-08-23T11:00:00Z", }, ]), ), @@ -130,6 +134,8 @@ layer("GitLabCli.layer", (it) => { baseRefName: "main", headRefName: "feature/mr-list", state: "merged", + closedAt: null, + mergedAt: "2026-08-23T11:00:00Z", }, ]); expect(mockedRun).toHaveBeenCalledWith( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index ab8dfbb5f334..9f76a6182ce4 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -247,6 +247,8 @@ export interface GitLabMergeRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e0665214..3cd442a6e169 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -24,7 +24,8 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", headRepositoryOwnerLogin: "fork", @@ -43,7 +44,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2ec1f9b9a228..28211c6b8509 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -27,6 +27,8 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8ac682399e1d..24c0e49fd8f4 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; } @@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const state = normalizeAzureDevOpsPullRequestState(raw.status); + const terminalAt = Option.match(raw.closedDate ?? Option.none(), { + onNone: () => null, + onSome: DateTime.formatIso, + }); return { number: raw.pullRequestId, title: raw.title, url: normalizeAzureDevOpsPullRequestUrl(raw), baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), - state: normalizeAzureDevOpsPullRequestState(raw.status), + state, ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 9e4f282e1c8a..822de1e02797 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -96,6 +99,8 @@ function normalizeGitHubPullRequestRecord( headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: raw.closedAt ?? null, + mergedAt: raw.mergedAt ?? null, updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index 3b032e245bbc..0525260df51b 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitLabMergeRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -44,6 +46,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -112,6 +116,8 @@ function normalizeGitLabMergeRequestRecord( headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e4aa17b383c8..227d13d18bb7 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -627,6 +627,14 @@ export function PullRequestDetailPanel({ : { ...resolvedCoreDetail, ...sharedSummary, + closedAt: + sharedSummary.closedAt === undefined + ? resolvedCoreDetail.closedAt + : sharedSummary.closedAt, + mergedAt: + sharedSummary.mergedAt === undefined + ? resolvedCoreDetail.mergedAt + : sharedSummary.mergedAt, // A summary may come from an older server that does not report draft state. Keep the // detail's required value instead of making the complete detail shape partial. isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 4b63b877923f..345adcc7849c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -201,11 +201,9 @@ const VcsStatusChangeRequest = Schema.Struct({ /** Optional for compatibility with older servers and providers. */ isDraft: Schema.optional(Schema.Boolean), /** - * Last provider-side activity (ISO). For a merged/closed change request - * this bounds when it reached that state, so clients can tell a PR that - * terminated during a thread's life from one that was already history - * when the thread was created. Optional for old servers and providers - * whose lookups do not report it. + * Last provider-side activity (ISO), including comments and metadata edits. + * This is not the time a change request closed or merged. Optional for old + * servers and providers whose lookups do not report it. */ updatedAt: Schema.optional(Schema.NullOr(Schema.String)), }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index f766578bb1a3..812489a5fb9f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -638,6 +638,8 @@ export const PullRequestSummary = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: IsoDateTime, }); export type PullRequestSummary = typeof PullRequestSummary.Type; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index be3d70aefadd..b013eea3bb6f 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -31,6 +31,8 @@ export const ChangeRequest = Schema.Struct({ state: ChangeRequestState, /** Present when the provider can tell that an open change request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From eee05575ebd514db36f61d7eb05d2258a10c96bd Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:50:02 +0200 Subject: [PATCH 26/65] fix(clients): persist project icons across reloads and reconnects (#10138) --- apps/mobile/src/components/ProjectFavicon.tsx | 44 ++- .../environment-cache-store.test.ts | 6 + .../src/connection/environment-cache-store.ts | 11 +- .../src/lib/projectFaviconCache.test.ts | 79 +++++ apps/mobile/src/lib/projectFaviconCache.ts | 112 ++++++ .../mobile/src/persistence/mobile-database.ts | 23 +- apps/mobile/src/state/assets.ts | 10 +- apps/mobile/src/state/client-cache-state.ts | 8 +- apps/web/src/assets/projectFaviconCache.ts | 85 +++++ .../src/components/ProjectFavicon.test.tsx | 18 +- apps/web/src/components/ProjectFavicon.tsx | 34 +- .../components/preview/PreviewView.test.tsx | 3 +- apps/web/src/connection/storage.ts | 3 + apps/web/src/state/assets.ts | 13 +- packages/client-runtime/package.json | 4 + .../src/projectFaviconCache.test.ts | 323 ++++++++++++++++++ .../client-runtime/src/projectFaviconCache.ts | 263 ++++++++++++++ .../client-runtime/src/state/assets.test.ts | 165 ++++++++- packages/client-runtime/src/state/assets.ts | 57 ++++ packages/shared/src/projectFavicon.ts | 8 + 20 files changed, 1207 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/src/lib/projectFaviconCache.test.ts create mode 100644 apps/mobile/src/lib/projectFaviconCache.ts create mode 100644 apps/web/src/assets/projectFaviconCache.ts create mode 100644 packages/client-runtime/src/projectFaviconCache.test.ts create mode 100644 packages/client-runtime/src/projectFaviconCache.ts diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c60709baf4c9..932fc6779f20 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -5,9 +5,13 @@ import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useAssetUrl } from "../state/assets"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { projectFaviconUrlAtom } from "../state/assets"; + import { beginProjectFaviconRequest, createProjectFaviconRequest, @@ -16,6 +20,8 @@ import { markProjectFaviconLoaded, } from "./projectFaviconCache"; +const EMPTY_FAVICON_URL = Atom.make(null); + /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; @@ -26,20 +32,23 @@ export function ProjectFavicon(props: { readonly faviconPath?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( - props.environmentId, - props.workspaceRoot === null || props.workspaceRoot === undefined - ? null - : { - _tag: "project-favicon", + const faviconUrl = useAtomValue( + props.workspaceRoot == null + ? EMPTY_FAVICON_URL + : projectFaviconUrlAtom({ + environmentId: props.environmentId, cwd: props.workspaceRoot, - ...(props.faviconPath ? { path: props.faviconPath } : {}), - }, + faviconPath: props.faviconPath, + }), ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + // Inline images are self-contained; remote URLs key on their revision so signed-token + // rotation reuses the disk cache while a changed icon starts from the loading state. const cacheKey = renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; return ( @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) + ? "loaded" + : "loading", ); const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { {requestIsActive ? ( Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + listCache: (kind) => + Effect.sync(() => + [...values.entries()] + .filter(([key]) => key.split(":")[1] === kind) + .map(([, payload]) => payload), + ), saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => Effect.sync(() => { values.set(cacheId(environmentId, kind, cacheKey), payload); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index ad5ef13b62d5..ccf4945b3bef 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,6 +15,7 @@ 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"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -115,6 +116,7 @@ function loadDecodedCache(input: { export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { const database = yield* MobileDatabase.MobileDatabase; + attachProjectFaviconDatabase(database); return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ @@ -126,7 +128,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()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - database - .clearEnvironmentCache(environmentId) - .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + Effect.promise(() => projectFaviconCache.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/projectFaviconCache.test.ts new file mode 100644 index 000000000000..adde56fbf0b1 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; + +const native = vi.hoisted(() => ({ + load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ + width: options.maxWidth, + height: options.maxHeight, + release: vi.fn(), + })), + write: vi.fn(async () => {}), + path: vi.fn(async () => "/cache/thumbnail"), + read: vi.fn(), + remove: vi.fn(), +})); +vi.mock("expo-image", () => ({ + Image: { + loadAsync: native.load, + writeToCacheAsync: native.write, + getCachePathAsync: native.path, + }, +})); +vi.mock("expo-file-system", () => ({ + File: class { + size = 24_000; + base64 = native.read; + delete = native.remove; + }, +})); + +import { downscaleProjectFavicon } from "./projectFaviconCache"; + +const png = "iVBORw0KGgoAAAAA"; +const image = { url: "https://remote/icon.png" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.read.mockReset().mockResolvedValue(png); + native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ + width: maxWidth, + height: maxWidth, + release: vi.fn(), + })); +}); + +describe("mobile project icon thumbnails", () => { + it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { + native.read.mockResolvedValueOnce( + `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); + expect(thumbnail).toBe(`data:image/png;base64,${png}`); + expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); + expect(native.remove).toHaveBeenCalledTimes(2); + for (const call of native.load.mock.results) + expect((await call.value).release).toHaveBeenCalledOnce(); + }); + + it("releases a decoded image when its request was canceled", async () => { + const controller = new AbortController(); + const release = vi.fn(); + native.load.mockImplementationOnce(async () => { + controller.abort(); + return { width: 96, height: 96, release }; + }); + await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); + expect(release).toHaveBeenCalledOnce(); + expect(native.write).not.toHaveBeenCalled(); + }); + + it("rejects an image the native decoder did not downsize", async () => { + const release = vi.fn(); + native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); + await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( + "not resized", + ); + expect(native.write).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts new file mode 100644 index 000000000000..26a6d848d11d --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -0,0 +1,112 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, + type ProjectFaviconEntry, +} from "@t3tools/client-runtime/project-favicon-cache"; +import * as Effect from "effect/Effect"; + +import * as MobileDatabase from "../persistence/mobile-database"; + +const CACHE_KIND = "project-favicon"; +const CACHE_SCHEMA_VERSION = 1; + +let database: MobileDatabase.MobileDatabase["Service"] | undefined; + +/** + * The cache is a module singleton because the favicon atom family holds it outside + * any Effect runtime. Its rows live in `client_cache`, so the environment cache store + * hands over the database it already owns instead of the cache re-entering the runtime. + */ +export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { + database = service; +} + +const runDatabase = ( + use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, +) => + database + ? Effect.runPromise(use(database)) + : Promise.reject(new Error("Project icon storage is not attached.")); + +/** + * Rasterizes a bitmap that is too large to inline. The native decoder writes the + * downsized frame to expo-image's disk cache, which is the only encode path it + * exposes; the temporary entry is removed once its bytes are read. + */ +export async function downscaleProjectFavicon( + image: { readonly url: string }, + signal: AbortSignal, +) { + const [{ Image }, { File }] = await Promise.all([ + import("expo-image"), + import("expo-file-system"), + ]); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + signal.throwIfAborted(); + const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); + const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; + try { + signal.throwIfAborted(); + if (decoded.width > size || decoded.height > size) { + throw new Error("Project icon was not resized."); + } + await Image.writeToCacheAsync(decoded, cacheKey); + const path = await Image.getCachePathAsync(cacheKey); + if (!path) throw new Error("Project icon thumbnail was not written."); + const file = new File(path.startsWith("file:") ? path : `file://${path}`); + try { + if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; + const base64 = await file.base64(); + // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. + const mimeType = base64.startsWith("/9j/") + ? "image/jpeg" + : base64.startsWith("iVBORw0KGgo") + ? "image/png" + : null; + if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); + const dataUrl = `data:${mimeType};base64,${base64}`; + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } finally { + file.delete(); + } + } finally { + decoded.release(); + } + } + 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({ + storage: { + list: () => + runDatabase((database) => + database.listCache(CACHE_KIND).pipe( + Effect.map((payloads) => + payloads.flatMap((payload): Array => { + try { + return [JSON.parse(payload)]; + } catch { + return []; + } + }), + ), + ), + ), + put: (key, entry: ProjectFaviconEntry) => + runDatabase((database) => + database.saveCache( + entry.environmentId, + CACHE_KIND, + key, + CACHE_SCHEMA_VERSION, + JSON.stringify(entry), + ), + ), + remove: (key, entry) => + runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 71876932b789..aca830f24c71 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ "connection-vcs-refs", ] as const; -export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export const ClientCacheKind = Schema.Literals([ + "shell", + "thread", + "server-config", + "vcs-refs", + "project-favicon", +]); export type ClientCacheKind = typeof ClientCacheKind.Type; export interface ClientCacheSummaryRow { @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ "open", "migrate", "load-cache", + "list-cache", "save-cache", "remove-cache", "clear-cache-kind", @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect, MobileDatabaseError>; + readonly listCache: ( + kind: ClientCacheKind, + ) => Effect.Effect, MobileDatabaseError>; readonly saveCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("load-cache"), }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), ), + listCache: Effect.fn("MobileDatabase.listCache")((kind) => + Effect.tryPromise({ + try: () => + database.getAllAsync<{ readonly payload: string }>( + "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", + kind, + ), + catch: databaseError("list-cache"), + }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), + ), saveCache: Effect.fn("MobileDatabase.saveCache")( (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.tryPromise({ @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] const fail = Effect.fail(error); return MobileDatabase.of({ loadCache: () => fail, + listCache: () => fail, saveCache: () => fail, removeCache: () => fail, clearCacheKind: () => fail, diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 400bdb6b705a..15cbd1d9a89f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -6,6 +6,7 @@ import { import { assetUrlStateFromResult, createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; @@ -15,14 +16,21 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection } from "./session"; +import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); + const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-connection-state:empty"), ); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index 3912857b5751..c210c54f2fdd 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,6 +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 * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => - MobileDatabase.pipe( + Effect.promise(() => + scope.type === "all" + ? projectFaviconCache.clearAll() + : projectFaviconCache.clearEnvironment(scope.environmentId), + ).pipe( + Effect.andThen(MobileDatabase), Effect.flatMap((database) => scope.type === "all" ? database.clearAllCaches diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts new file mode 100644 index 000000000000..e6fcc817b969 --- /dev/null +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -0,0 +1,85 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, +} from "@t3tools/client-runtime/project-favicon-cache"; + +const DATABASE_NAME = "t3code:project-favicons"; +const DATABASE_VERSION = 2; +const STORE_NAME = "images"; +let database: Promise | undefined; + +function openDatabase() { + return (database ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + for (const name of request.result.objectStoreNames) { + if (name !== STORE_NAME) request.result.deleteObjectStore(name); + } + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); + })); +} + +function completed(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); +} + +async function withStore( + mode: IDBTransactionMode, + use: (store: IDBObjectStore) => IDBRequest | void, +) { + const transaction = (await openDatabase()).transaction(STORE_NAME, mode); + const request = use(transaction.objectStore(STORE_NAME)); + await completed(transaction); + return request?.result; +} + +/** Rasterizes a bitmap that is too large to inline, retrying at half size. */ +export async function downscaleProjectFavicon( + image: { readonly mimeType: string; readonly bytes: Uint8Array }, + signal: AbortSignal, +) { + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); + try { + signal.throwIfAborted(); + const canvas = document.createElement("canvas"); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + const scale = Math.min(1, size / bitmap.width, size / bitmap.height); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable."); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/webp", 0.85); + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } + throw new Error("Project icon thumbnail exceeds the cache limit."); + } finally { + bitmap.close(); + } +} + +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], + put: async (key, entry) => { + await withStore("readwrite", (store) => store.put(entry, key)); + }, + remove: async (key) => { + await withStore("readwrite", (store) => store.delete(key)); + }, + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bfb5487031b7..98458cfa2dcd 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon" const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", - lastResource: null as unknown, + lastTarget: null as unknown, })); const hooks = vi.hoisted(() => { @@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({ DynamicIcon: "dynamic-icon", iconNames: ["alarm-clock", "folder-code"], })); -vi.mock("../assets/assetUrls", () => ({ - useAssetUrlState: (_environmentId: unknown, resource: unknown) => { - testState.lastResource = resource; - return { _tag: "Success", url: testState.faviconUrl }; +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => testState.faviconUrl, +})); +vi.mock("../state/assets", () => ({ + projectFaviconUrlAtom: (input: unknown) => { + testState.lastTarget = input; }, })); @@ -212,10 +214,10 @@ describe("ProjectFavicon", () => { faviconPath: "brand/icon.svg", }); - expect(testState.lastResource).toEqual({ - _tag: "project-favicon", + expect(testState.lastTarget).toMatchObject({ + environmentId: "environment-test", cwd: "/workspace-test", - path: "brand/icon.svg", + faviconPath: "brand/icon.svg", }); }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 2ebc6e267443..19467adde404 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; import { - getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; import { @@ -30,12 +30,12 @@ import { import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAtomValue } from "@effect/atom-react"; +import { projectFaviconUrlAtom } from "../state/assets"; import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Map(); const DynamicIcon = lazy(() => import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), ); @@ -103,8 +103,7 @@ export function ProjectFavicon(input: { className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const state = useProjectFaviconAsset(input); - const src = state._tag === "Success" ? state.url : null; + const src = useAtomValue(projectFaviconUrlAtom(input)); if (input.projectIcon?.kind === "emoji") { return ; } @@ -150,12 +149,11 @@ export function ProjectFavicon(input: { ); } - const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath); return ( | undefined; readonly fallbackEmoji?: string | undefined; readonly fallbackColorClassName?: string | undefined; }) { - const [displayedSrc, setDisplayedSrc] = useState( - () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, + const [displayedSrc, setDisplayedSrc] = useState(() => + src.startsWith("data:image/") ? src : null, ); const isLoading = displayedSrc !== src; const handleLoadError = (failedSrc: string) => { - if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { - loadedProjectFaviconSrcs.delete(cacheKey); - } setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); }; @@ -256,7 +237,6 @@ function ProjectFaviconImage({ alt="" className="hidden" onLoad={() => { - loadedProjectFaviconSrcs.set(cacheKey, src); setDisplayedSrc(src); }} onError={() => handleLoadError(src)} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 9456daef72d8..2a146834012e 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({ useThreadRecentHistory: () => EMPTY_HISTORY, })); -vi.mock("~/state/session", () => ({ +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), readPreparedConnection: mocks.readPreparedConnection, })); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 0a1183d48abd..8ec2b16add76 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -33,6 +33,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; @@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext( const cacheStore = EnvironmentCacheStore.of({ loadShell: (environmentId) => readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), Effect.flatMap((raw) => { if (typeof raw !== "string") { return Effect.succeed(Option.none()); @@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext( clear: (environmentId) => Effect.all( [ + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), removeDatabaseValuesInRange( database, diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 5e31beb826b5..d1ef71f8662d 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -1,5 +1,16 @@ -import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; +import { + createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, +} from "@t3tools/client-runtime/state/assets"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { environmentSession } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); + +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c7d921393444..7409a194a2fd 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./project-favicon-cache": { + "types": "./src/projectFaviconCache.ts", + "default": "./src/projectFaviconCache.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts new file mode 100644 index 000000000000..980ccf495165 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_CACHE_MAX_BYTES, + PROJECT_FAVICON_CACHE_MAX_ENTRIES, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_MAX_SOURCE_BYTES, + type ProjectFaviconEntry, + type ProjectFaviconStorage, +} from "./projectFaviconCache.ts"; + +const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; +const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; +const image = "data:image/png;base64,aWNvbg=="; +const replacement = "data:image/png;base64,bmV3"; +const signal = () => new AbortController().signal; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture() { + const records = new Map(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + records.delete(key); + }, + }; + return { + storage, + load, + records, + cache: createProjectFaviconCache({ storage, load }), + }; +} + +describe("persistent project favicon cache", () => { + it("restores image bytes in a fresh client before any remote response", async () => { + const { cache, storage, load } = fixture(); + expect(await cache.resolve(target, url, signal())).toBe(image); + await cache.flush(); + const reloaded = createProjectFaviconCache({ storage, load }); + await reloaded.hydrate(); + expect(reloaded.peek(target)).toBe(image); + expect(await reloaded.resolve(target, null, signal())).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("reuses the image when signed URLs or connection origins change", async () => { + const { cache, load } = fixture(); + await cache.resolve(target, url, signal()); + expect( + await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), + ).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("keeps the old image during refresh and failures, then persists its replacement", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + load.mockImplementationOnce(() => next.promise); + const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + expect(cache.peek(target)).toBe(image); + next.resolve(replacement); + expect(await refreshing).toBe(replacement); + load.mockRejectedValueOnce(new Error("offline")); + expect(await cache.resolve(target, url, signal())).toBe(replacement); + await cache.flush(); + expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe( + replacement, + ); + }); + + it("persists confirmed removal and ignores an aborted older download", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const controller = new AbortController(); + const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal); + await started.promise; + controller.abort(); + expect( + await cache.resolve( + target, + "https://remote.test/api/assets/token/project-favicon-missing", + signal(), + ), + ).toBeNull(); + next.resolve(replacement); + await pending; + await cache.flush(); + expect( + await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()), + ).toBeNull(); + }); + + it("isolates environments, workspaces, and icon selections", async () => { + const { cache } = fixture(); + await cache.resolve(target, url, signal()); + expect(cache.peek({ ...target, faviconPath: null })).toBe(image); + expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/other" })).toBeNull(); + expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); + }); + + it.each([ + { + scope: "one environment", + clear: (cache: ReturnType["cache"]) => + cache.clearEnvironment(target.environmentId), + remaining: 1, + }, + { + scope: "every environment", + clear: (cache: ReturnType["cache"]) => cache.clearAll(), + remaining: 0, + }, + ])( + "does not restore images for $scope removed during a download", + async ({ clear, remaining }) => { + const { cache, load, records } = fixture(); + const other = { ...target, environmentId: EnvironmentId.make("other") }; + await cache.resolve(other, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const pending = cache.resolve(target, url, signal()); + await started.promise; + await clear(cache); + next.resolve(image); + await pending; + await cache.flush(); + expect(cache.peek(target)).toBeNull(); + expect(records.size).toBe(remaining); + }, + ); + + it("discards a download that starts while the environment is being cleared", async () => { + const records = new Map(); + const removal = deferred(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + await removal.promise; + records.delete(key); + }, + }; + const cache = createProjectFaviconCache({ storage, load }); + await cache.resolve(target, url, signal()); + await cache.flush(); + const clearing = cache.clearEnvironment(target.environmentId); + await Promise.resolve(); + const late = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + removal.resolve(); + await clearing; + expect(records.size).toBe(0); + expect(cache.peek(target)).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + expect(await late).toBe(image); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("bounds individual images, total bytes, and entry count in storage", async () => { + const { cache, load, records } = fixture(); + load.mockResolvedValueOnce( + `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + expect(await cache.resolve(target, url, signal())).toBe(url); + expect(cache.peek(target)).toBeNull(); + const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; + load.mockResolvedValue(large); + for (let i = 0; i < 40; i++) { + await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); + } + await cache.flush(); + expect( + [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0), + ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); + expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); + load.mockResolvedValue(image); + for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { + await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); + } + await cache.flush(); + expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES); + expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); + }); + + it("skips corrupt records and tolerates unavailable storage", async () => { + const corrupt = createProjectFaviconCache({ + storage: { + list: async () => [ + { ...target, faviconPath: null, revision: "r", dataUrl: image }, + { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" }, + "garbage", + ], + put: async () => {}, + remove: async () => {}, + }, + load: async () => replacement, + }); + await corrupt.hydrate(); + expect(corrupt.peek(target)).toBe(image); + expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull(); + + const unavailable = createProjectFaviconCache({ + storage: { + list: async () => { + throw new Error("storage unavailable"); + }, + put: async () => { + throw new Error("quota exceeded"); + }, + remove: async () => { + throw new Error("quota exceeded"); + }, + }, + load: async () => image, + }); + expect(await unavailable.resolve(target, url, signal())).toBe(image); + await unavailable.flush(); + expect(unavailable.peek(target)).toBe(image); + }); +}); + +describe("project favicon image loader", () => { + const svg = + ''; + const svgBase64 = btoa(svg); + + function loader(response: Response, downscale = vi.fn(async () => replacement)) { + return { + downscale, + load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }), + }; + } + + it("inlines small icons exactly as served without rasterizing", async () => { + const { load, downscale } = loader( + new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }), + ); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + expect(downscale).not.toHaveBeenCalled(); + }); + + it("falls back to the file extension when the response has no image type", async () => { + const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } })); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + }); + + it("downscales large bitmaps and refuses large vector icons", async () => { + const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH); + const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } })); + expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe( + replacement, + ); + expect(bitmap.downscale).toHaveBeenCalledWith( + expect.objectContaining({ mimeType: "image/png", bytes }), + expect.any(AbortSignal), + ); + const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } })); + await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit"); + expect(vector.downscale).not.toHaveBeenCalled(); + }); + + it("stops reading a response that exceeds the source limit", async () => { + let pulled = 0; + const chunk = new Uint8Array(1024 * 1024); + const stream = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(chunk); + }, + }); + const { load, downscale } = loader( + new Response(stream, { headers: { "content-type": "image/png" } }), + ); + await expect(load(url, signal())).rejects.toThrow("too large"); + expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); + expect(downscale).not.toHaveBeenCalled(); + const declared = loader( + new Response("x", { + headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, + }), + ); + await expect(declared.load(url, signal())).rejects.toThrow("too large"); + }); + + it("rejects failed responses and non-image payloads", async () => { + const failed = loader(new Response("nope", { status: 404 })); + await expect(failed.load(url, signal())).rejects.toThrow("404"); + const html = loader(new Response("", { headers: { "content-type": "text/html" } })); + await expect( + html.load("https://remote.test/api/assets/t/v1-favicon", signal()), + ).rejects.toThrow("no image type"); + }); +}); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts new file mode 100644 index 000000000000..53fd58372190 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -0,0 +1,263 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { mediaMimeType } from "@t3tools/shared/filePreview"; +import { + getProjectFaviconCacheKey, + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; +export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; +/** Larger sources are not worth decoding for an icon and are left to the remote URL. */ +export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; + +export interface ProjectFaviconTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +} + +const ImageDataUrl = Schema.String.check( + Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), + Schema.isPattern( + /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/, + ), +); +const Entry = Schema.Struct({ + environmentId: EnvironmentId, + cwd: Schema.String, + faviconPath: Schema.NullOr(Schema.String), + revision: Schema.String, + dataUrl: ImageDataUrl, +}); +export type ProjectFaviconEntry = typeof Entry.Type; +const decodeEntry = Schema.decodeUnknownOption(Entry); +const isImageDataUrl = Schema.is(ImageDataUrl); + +function keyFor(target: ProjectFaviconTarget) { + return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); +} + +export interface ProjectFaviconStorage { + /** Every persisted record; entries that fail validation are ignored. */ + readonly list: () => Promise>; + readonly put: (key: string, entry: ProjectFaviconEntry) => Promise; + readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise; +} + +async function readBounded(response: Response, maxBytes: number) { + const declared = Number(response.headers.get("content-length")); + if (declared > maxBytes) throw new Error("Project icon is too large to decode."); + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); + return bytes; + } + const reader = response.body.getReader(); + const chunks: Array = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw new Error("Project icon is too large to decode."); + chunks.push(value); + } + } finally { + reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** + * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs + * and small bitmaps are stored exactly as served. Larger bitmaps go through the + * platform downscaler; larger SVGs stay remote because rasterizing them without + * intrinsic dimensions is unreliable. + */ +export function createProjectFaviconImageLoader(input: { + readonly fetch?: typeof fetch; + readonly downscale: ( + image: { + readonly url: string; + readonly mimeType: string; + readonly bytes: Uint8Array; + }, + signal: AbortSignal, + ) => Promise; +}) { + const fetchImpl = input.fetch ?? globalThis.fetch; + return async (url: string, signal: AbortSignal): Promise => { + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`); + const contentType = response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); + if (!mimeType) throw new Error("Project icon has no image type."); + const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES); + signal.throwIfAborted(); + const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; + if (isImageDataUrl(dataUrl)) return dataUrl; + if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit."); + return input.downscale({ url, mimeType, bytes }, signal); + }; +} + +/** Stores small, self-contained images so startup never needs an old signed URL. */ +export function createProjectFaviconCache(input: { + readonly storage: ProjectFaviconStorage; + readonly load: (url: string, signal: AbortSignal) => Promise; +}) { + const entries = new Map(); + const environmentRevisions = new Map(); + let generation = 0; + let hydration: Promise | undefined; + let clearing: Promise | undefined; + const pending = new Set>(); + + const persist = (operation: () => Promise) => { + const task: Promise = operation() + .catch(() => { + // Keep the in-memory image if local storage is full or unavailable. + }) + .finally(() => pending.delete(task)); + pending.add(task); + }; + + const remove = (key: string) => { + const entry = entries.get(key); + if (!entry) return; + entries.delete(key); + persist(() => input.storage.remove(key, entry)); + }; + + const trim = () => { + let bytes = 0; + for (const entry of entries.values()) bytes += entry.dataUrl.length; + while ( + entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES || + bytes > PROJECT_FAVICON_CACHE_MAX_BYTES + ) { + const oldest = entries.entries().next().value; + if (!oldest) break; + bytes -= oldest[1].dataUrl.length; + remove(oldest[0]); + } + }; + + const hydrate = () => + (hydration ??= (async () => { + try { + for (const record of await input.storage.list()) { + const entry = decodeEntry(record); + if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); + } + trim(); + } catch { + // A missing, corrupt, or unavailable cache must not prevent startup. + } + })()); + + const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; + + const resolve = async ( + target: ProjectFaviconTarget, + url: string | null, + signal: AbortSignal, + ): Promise => { + await clearing; + const startGeneration = generation; + const startRevision = environmentRevisions.get(target.environmentId) ?? 0; + await hydrate(); + if (signal.aborted || url === null) return peek(target); + const key = keyFor(target); + if (isProjectFaviconFallbackUrl(url)) { + remove(key); + return null; + } + const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); + const cached = entries.get(key); + if (cached) { + entries.delete(key); + entries.set(key, cached); + if (cached.revision === revision) return cached.dataUrl; + } + try { + const dataUrl = await input.load(url, signal); + if ( + signal.aborted || + startGeneration !== generation || + startRevision !== (environmentRevisions.get(target.environmentId) ?? 0) + ) { + return peek(target); + } + if (isImageDataUrl(dataUrl)) { + const entry = { + environmentId: target.environmentId, + cwd: target.cwd, + faviconPath: target.faviconPath || null, + revision, + dataUrl, + }; + entries.set(key, entry); + persist(() => input.storage.put(key, entry)); + trim(); + return dataUrl; + } + } catch { + // An outage or failed decode leaves the last successful image visible. + } + return peek(target) ?? url; + }; + + const flush = async () => { + await Promise.all(pending); + }; + + // A download that started before the clear sees the revision change and is discarded; + // one that starts during the clear waits for it, so it cannot repopulate storage. + const clear = async (environmentId?: EnvironmentId) => { + if (environmentId === undefined) generation += 1; + else + environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); + const previous = clearing; + const task = (async () => { + await previous; + await hydrate(); + for (const [key, entry] of entries) { + if (environmentId === undefined || entry.environmentId === environmentId) remove(key); + } + await flush(); + })().finally(() => { + if (clearing === task) clearing = undefined; + }); + clearing = task; + await task; + }; + + return { + hydrate, + peek, + resolve, + clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId), + clearAll: () => clear(), + flush, + }; +} + +export type ProjectFaviconCache = ReturnType; diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index d75e82281382..1cbc970df928 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createProjectFaviconCache } from "../projectFaviconCache.ts"; import { createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, InvalidAssetCollectionKeyError, parseAssetCollectionKey, } from "./assets.ts"; @@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => { ).not.toBe(assets.createUrls({ environmentId, resources })); }); }); + +describe("project favicon URL cache", () => { + it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { + const image = "data:image/png;base64,aWNvbg=="; + const replacement = "data:image/png;base64,bmV3"; + const records = new Map(); + const storage = { + list: async () => [...records.values()], + put: async (key: string, entry: unknown) => { + records.set(key, entry); + }, + remove: async (key: string) => { + records.delete(key); + }, + }; + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const previousCache = createProjectFaviconCache({ storage, load: async () => image }); + await previousCache.resolve( + target, + "https://remote.test/api/assets/old/v1-icon.png", + new AbortController().signal, + ); + await previousCache.flush(); + const cache = createProjectFaviconCache({ storage, load: async () => replacement }); + await cache.hydrate(); + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make>(Option.none()); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + imageCache: cache, + })(target); + const unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBe(image); + let unsubscribe = () => {}; + const refreshed = new Promise((resolve) => { + unsubscribe = registry.subscribe(favicon, (value) => { + if (value === replacement) resolve(); + }); + }); + registry.set(connection, Option.some({ httpBaseUrl: "https://remote.test" })); + registry.set( + result, + AsyncResult.success({ + relativeUrl: "/api/assets/new/v2-icon.png", + expiresAt: 4_000_000_000_000, + }), + ); + expect(registry.get(favicon)).toBe(image); + await refreshed; + unsubscribe(); + expect(registry.get(favicon)).toBe(replacement); + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("offline"))); + expect(registry.get(favicon)).toBe(replacement); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); + let unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBeNull(); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-a/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + unmount(); + unmount = registry.mount(favicon); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(result, AsyncResult.initial()); + registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" })); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-b/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg"); + + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-c/project-favicon-missing", + }), + ); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + registry.set(connection, Option.none()); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("does not reuse another environment, workspace, or selected icon's cached URL", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token/icon.svg", + }), + ); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })), + }); + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const unmount = registry.mount(favicon(target)); + try { + expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg"); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect( + registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })), + ).toBeNull(); + expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe( + "https://remote.test/api/assets/token/icon.svg", + ); + } finally { + unmount(); + registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 0030cec6d0c5..b8646d911cc2 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -5,10 +5,17 @@ import { EnvironmentId, WS_METHODS, } from "@t3tools/contracts"; +import { + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; @@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms( }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), }; } + +/** + * Keeps project icons visible while their environment reconnects. Each resource + * owns its last resolved URL, including a confirmed missing-icon response. + */ +export function createProjectFaviconUrlAtomFamily(input: { + readonly imageCache?: ProjectFaviconCache; + readonly createUrl: (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Atom.Atom>; + readonly preparedConnection: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; +}) { + const decodeKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), + ); + const family = Atom.family((key: string) => { + const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); + const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; + const request = input.createUrl({ environmentId, input: { resource } }); + const resolvedUrl = Atom.make((get): string | null => { + const result = get(request); + const connection = get(input.preparedConnection(environmentId)); + const state = assetUrlStateFromResult( + result, + Option.isSome(connection) ? connection.value.httpBaseUrl : null, + ); + return state._tag === "Success" ? state.url : Option.getOrNull(get.self()); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + const cache = input.imageCache; + if (!cache) return resolvedUrl; + + const target = { environmentId, cwd, faviconPath: path }; + const image = Atom.make((get) => { + get(request); + const url = get(resolvedUrl); + return Effect.promise((signal) => cache.resolve(target, url, signal)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + + return Atom.make((get): string | null => { + const result = get(image); + if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; + return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + }); + return (target: ProjectFaviconTarget) => + family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath)); +} diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b63..b6fd9e56f3a6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,13 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconResourceKey( + environmentId: string, + workspaceRoot: string, + faviconPath?: string | null, +) { + return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); +} + export function getProjectFaviconCacheKey( environmentId: string, workspaceRoot: string, From b438447f67b6b61bbe6f564d8b78fd90702117c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 19:22:39 -0700 Subject: [PATCH 27/65] fix(mobile): use selected theme across input forms and controls (#10239) --- ...rated-uniwind-default-theme-variables.json | 6 +++ apps/mobile/generated-uniwind-themes.css | 30 +++++++++++++ apps/mobile/global.css | 10 +++++ apps/mobile/src/components/AppText.tsx | 2 + .../components/ComposerAttachmentStrip.tsx | 2 +- apps/mobile/src/components/ErrorBanner.tsx | 4 +- .../connection/CloudEnvironmentRows.tsx | 2 +- .../connection/ConnectionEnvironmentRow.tsx | 2 +- .../src/features/connection/connectionTone.ts | 20 ++++----- .../features/files/ThreadFilesRouteScreen.tsx | 6 +-- .../features/home/thread-swipe-actions.tsx | 33 ++++++++++---- .../src/features/review/ReviewSheet.tsx | 8 ++-- .../components/FontSizeSliderRow.tsx | 3 +- .../threads/GitActionProgressOverlay.tsx | 8 ++-- .../features/threads/PendingApprovalCard.tsx | 24 +++++------ .../features/threads/PendingUserInputCard.tsx | 43 ++++++++++--------- .../src/features/threads/ThreadFeed.tsx | 19 +++++--- .../features/threads/git/GitCommitSheet.tsx | 2 +- .../features/threads/thread-list-items.tsx | 4 +- .../features/threads/thread-list-v2-items.tsx | 23 ++++------ .../features/threads/thread-search-match.tsx | 2 +- .../src/features/threads/thread-work-log.tsx | 6 +-- .../features/threads/threadPresentation.ts | 24 +++++------ apps/mobile/src/lib/mobileTheme.test.ts | 8 +++- apps/mobile/src/lib/mobileTheme.ts | 3 ++ .../src/state/thread-pr-presentation.ts | 4 +- apps/mobile/src/state/use-thread-pr.test.ts | 2 +- 27 files changed, 184 insertions(+), 116 deletions(-) diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 427d370acb57..1953880fa946 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -28,6 +28,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "#fffbeb", + "--color-warning-border": "#fde68a", + "--color-warning-foreground": "#b45309", "--color-danger": "#fef2f2", "--color-danger-border": "rgba(239, 68, 68, 0.12)", "--color-danger-foreground": "#dc2626", @@ -95,6 +98,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "rgba(69, 26, 3, 0.4)", + "--color-warning-border": "rgba(120, 53, 15, 0.6)", + "--color-warning-foreground": "#fcd34d", "--color-danger": "rgba(239, 68, 68, 0.14)", "--color-danger-border": "rgba(248, 113, 113, 0.18)", "--color-danger-foreground": "#fca5a5", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 8ba542165f18..e9352e1b38b9 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -150,6 +150,9 @@ --color-switch-active-thumb: #ffffff; --color-switch-inactive-track: #f1c4e6; --color-switch-inactive-thumb: #8d1255; + --color-warning: #fcf0ea; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #b05109; --color-danger: #fde4f1; --color-danger-border: rgba(247, 8, 108, 0.32); --color-danger-foreground: #9d174d; @@ -275,6 +278,9 @@ --color-switch-active-thumb: #fbd0e8; --color-switch-inactive-track: #362d3d; --color-switch-inactive-thumb: #e7d0dd; + --color-warning: #412f20; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #fbbf24; --color-danger: #331a2b; --color-danger-border: rgba(157, 23, 77, 0.32); --color-danger-foreground: #fbd0e8; @@ -400,6 +406,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e2ede7; --color-switch-inactive-thumb: #6e696f; + --color-warning: #f4f0e1; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b64a00; --color-danger: #f4e7e5; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -525,6 +534,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #2a4b39; --color-switch-inactive-thumb: #9da5a2; + --color-warning: #3f3a1c; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3f2c28; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6668; @@ -650,6 +662,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e4ecf2; --color-switch-inactive-thumb: #6f6873; + --color-warning: #f6efe4; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b74b00; --color-danger: #f5e6e9; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -775,6 +790,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #293f52; --color-switch-inactive-thumb: #969ca6; + --color-warning: #3c3424; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3c2630; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -900,6 +918,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #f3eae5; --color-switch-inactive-thumb: #74686f; + --color-warning: #f9efe2; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f9e7e6; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1025,6 +1046,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #513728; --color-switch-inactive-thumb: #a59996; + --color-warning: #4b3215; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #4a2321; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -1150,6 +1174,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #edeaf4; --color-switch-inactive-thumb: #726874; + --color-warning: #f8efe5; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f8e6ea; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1275,6 +1302,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #362d51; --color-switch-inactive-thumb: #9690a1; + --color-warning: #412e23; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #40202e; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; diff --git a/apps/mobile/global.css b/apps/mobile/global.css index e6961eac4eea..e153107b1811 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -52,6 +52,11 @@ --color-switch-inactive-track: rgba(0, 0, 0, 0.08); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: #fffbeb; + --color-warning-border: #fde68a; + --color-warning-foreground: #b45309; + /* Danger */ --color-danger: #fef2f2; --color-danger-border: rgba(239, 68, 68, 0.12); @@ -151,6 +156,11 @@ --color-switch-inactive-track: rgba(255, 255, 255, 0.06); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: rgba(69, 26, 3, 0.4); + --color-warning-border: rgba(120, 53, 15, 0.6); + --color-warning-foreground: #fcd34d; + /* Danger */ --color-danger: rgba(239, 68, 68, 0.14); --color-danger-border: rgba(248, 113, 113, 0.18); diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 39517f0e62ee..6501d2044083 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -35,6 +35,8 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { className, )} placeholderTextColorClassName="accent-placeholder" + selectionColorClassName="accent-foreground-secondary" + cursorColorClassName="accent-foreground-secondary" {...props} /> ); diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 40465012e5da..8c86fcc38e69 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -195,7 +195,7 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { {!props.compact ? ( diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 6c12c9bdd823..38f85de195b5 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -3,8 +3,8 @@ import { View } from "react-native"; import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - - {props.message} + + {props.message} ); } diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 448889549016..806499c2273b 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -299,7 +299,7 @@ function CloudEnvironmentRowShell(props: { traceId: props.connectionErrorTraceId, }); const statusClassName = props.connectionError - ? "text-adaptive-rose-500-400" + ? "text-danger-foreground" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ readonly text: string; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 75d3e8ce7a34..5555548ff799 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -96,7 +96,7 @@ export function ConnectionEnvironmentRow(props: { {props.truncated ? ( - - + + Partial file - + Preview limited to the first 1 MB of a truncated file. diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 052ac969c10f..c5f6768c55d0 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -62,7 +62,7 @@ interface ThreadSwipeAction { } interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; } function swipeActionsWidth(hasSecondaryAction: boolean) { @@ -80,7 +80,7 @@ function resolveSecondaryAction(input: { if (input.secondaryAction === undefined) { return { accessibilityLabel: `Delete ${input.threadTitle}`, - backgroundColor: "#ff2d55", + tone: "danger", icon: "trash", label: "Delete", onPress: () => { @@ -92,7 +92,7 @@ function resolveSecondaryAction(input: { const action = input.secondaryAction; return { ...action, - backgroundColor: "#5856d6", + tone: "secondary", menu: action.menu === undefined ? undefined @@ -359,7 +359,7 @@ export function ThreadSwipeable(props: { function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly actionsWidth: number; - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; readonly compact: boolean; readonly entryRange: readonly [number, number]; readonly fullSwipeThreshold: number; @@ -462,9 +462,15 @@ function SwipeActionButton(props: { > - + - - Partial diff - - {props.notice} + + Partial diff + {props.notice} ); }); diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 9b5d8e113f85..bc8ddfc84ba5 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -176,10 +176,9 @@ export function FontSizeSliderRow(props: { /> + diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index a94f321a4ad8..4be6ff611842 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -30,22 +30,20 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( - - + + Approval needed - + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( - + {props.approval.detail} ) : null} {warning ? ( - - {warning} - + {warning} ) : null} {options.map((option) => ( @@ -53,10 +51,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { key={option.decision} className={`items-center justify-center rounded-[14px] px-3.5 py-3 ${ option.decision === "accept" - ? "bg-blue-500" + ? "bg-primary" : option.decision === "decline" - ? "bg-adaptive-rose-100-500-a18" - : "bg-adaptive-neutral-200-800" + ? "bg-danger" + : "bg-subtle-strong" }`} disabled={props.respondingApprovalId === props.approval.requestId} onPress={() => void props.onRespond(props.approval.requestId, option.decision)} @@ -64,10 +62,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {option.label} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index f821c5714950..8fe7fc186adb 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -161,7 +161,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { pointerEvents={props.collapsed ? "auto" : "none"} accessibilityElementsHidden={!props.collapsed} importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} - className="flex-row items-center gap-2 rounded-full border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 py-1.5 pl-4 pr-1.5" + className="flex-row items-center gap-2 rounded-full border border-border bg-card-alt py-1.5 pl-4 pr-1.5" > - + User input needed - + {questionCount} question{questionCount === 1 ? "" : "s"} @@ -216,7 +216,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) } layout={CARD_LAYOUT_TRANSITION} - className="overflow-hidden gap-2.5 rounded-[20px] border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 p-4" + className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" style={ EXPANDED_CARD_IS_OVERLAY ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] @@ -230,14 +230,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className="flex-row items-start gap-2" > - + User input needed - - Fill in the pending answers - + Fill in the pending answers - + - + {question.header} - + {question.question} @@ -276,9 +274,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { key={optionValue} className={cn( "min-h-12 w-full rounded-2xl border px-3.5 py-3", - selected - ? "border-adaptive-blue-300-a50-blue-400-a28 bg-adaptive-blue-50-blue-400-a14" - : "border-adaptive-neutral-200-white-a6 bg-adaptive-white-neutral-950-a70", + selected ? "border-primary bg-primary/10" : "border-border bg-input", )} onPress={() => props.onSelectOption( @@ -292,15 +288,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {option.label} {description ? ( - + {description} ) : null} @@ -318,7 +312,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" + className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> ) : null} @@ -328,14 +322,21 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { void props.onSubmit()} > - Submit answers + + Submit answers + ) : null; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d53ae65be062..b57758b50c12 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -504,7 +504,12 @@ function MessageAttachmentFile(props: { function MessageAttachmentUnknown(props: { readonly name: string }) { return ( - + {props.name} @@ -1352,7 +1357,7 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border px-2" style={{ minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), }} @@ -1406,7 +1411,7 @@ function renderFeedEntry( accessibilityLabel={label} className="mb-3 flex-row items-center gap-3 px-1 py-1" > - + {label} - + ); } @@ -1503,7 +1508,7 @@ function renderFeedEntry( })} - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1552,7 +1557,7 @@ function renderFeedEntry( attachmentId={attachment.id} name={attachment.name} mimeType={attachment.mimeType} - className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong" onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( @@ -1576,7 +1581,7 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> - + {timestampLabel} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index f263372bad22..e76672de20f1 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -85,7 +85,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {isDefaultRef ? ( - + Warning: this is the default branch. ) : null} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index fcba4626be2d..fc3898279e3e 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -297,8 +297,8 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ); const statusPill = ( - - Pending + + Pending ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 97c13de56aab..e34dce059014 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,7 +23,6 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadListV2SnoozeMenuSelection, @@ -53,10 +52,10 @@ const MONO_FONT = Platform.select({ const STATUS_LABEL_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-adaptive-amber-700-300" }, - input: { label: "Input", className: "text-adaptive-indigo-600-300" }, - working: { label: "Working", className: "text-adaptive-sky-600-400" }, - failed: { label: "Failed", className: "text-adaptive-red-700-300" }, + approval: { label: "Approval", className: "text-warning-foreground" }, + input: { label: "Input", className: "text-foreground-secondary" }, + working: { label: "Working", className: "text-foreground-secondary" }, + failed: { label: "Failed", className: "text-danger-foreground" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -107,9 +106,6 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid ); }); -const SNOOZE_ACCENT_LIGHT = "#2563eb"; -const SNOOZE_ACCENT_DARK = "#60a5fa"; - export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; readonly disabled?: boolean; @@ -117,7 +113,6 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }) { - const { themeAppearance: colorScheme } = useAppearancePreferences(); return ( ({ opacity: pressed ? 0.6 : 1 })} > - + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} - + @@ -737,7 +732,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -919,7 +914,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? "text-user-bubble-foreground-muted" : snoozedRow - ? "text-adaptive-blue-600-400" + ? "text-foreground-secondary" : "text-foreground-tertiary", )} style={{ fontFamily: MONO_FONT }} diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 48aaf80249d5..9c478f3c4504 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -65,7 +65,7 @@ export function ThreadSearchMatchExcerpt(props: { props.selected ? "text-user-bubble-foreground" : isUser - ? "text-adaptive-blue-500-400" + ? "text-foreground-secondary" : "text-adaptive-emerald-600-400", )} > diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 13a78b8f1454..9d5b40fce6dc 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -773,7 +773,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( color={props.iconSubtleColor} colorClassName={ iconIsDestructive - ? "accent-adaptive-rose-600-400" + ? "accent-danger-foreground" : failed ? "accent-danger-foreground/40" : undefined @@ -784,7 +784,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( @@ -832,7 +832,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( entering={WORK_LOG_DETAIL_ENTER_TRANSITION} exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} layout={WORK_LOG_LAYOUT_TRANSITION} - className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" + className="ml-7 border-l border-border pb-1 pl-3 pt-0.5" > {viewedImagePath ? ( diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 59cf108a01dd..bfc72d2ac9b8 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -42,8 +42,8 @@ export function resolveThreadStatus( return { kind: "pending-approval", label: "Needs Approval", - pillClassName: "bg-adaptive-amber-500-a12-a16", - textClassName: "text-adaptive-amber-700-300", + pillClassName: "bg-warning", + textClassName: "text-warning-foreground", iconColor: "#ff9f0a", iconBackground: "rgba(255,159,10,0.22)", pulse: false, @@ -54,8 +54,8 @@ export function resolveThreadStatus( return { kind: "awaiting-input", label: "Awaiting Input", - pillClassName: "bg-adaptive-indigo-500-a12-a16", - textClassName: "text-adaptive-indigo-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#5e5ce6", iconBackground: "rgba(94,92,230,0.22)", pulse: false, @@ -66,8 +66,8 @@ export function resolveThreadStatus( return { kind: "working", label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -78,8 +78,8 @@ export function resolveThreadStatus( return { kind: "connecting", label: "Connecting", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -90,8 +90,8 @@ export function resolveThreadStatus( return { kind: "error", label: "Error", - pillClassName: "bg-adaptive-rose-500-a12-a16", - textClassName: "text-adaptive-rose-700-300", + pillClassName: "bg-danger", + textClassName: "text-danger-foreground", iconColor: "#ff453a", iconBackground: "rgba(255,69,58,0.22)", pulse: false, @@ -106,8 +106,8 @@ export function resolveThreadStatus( return { kind: "plan-ready", label: "Plan Ready", - pillClassName: "bg-adaptive-violet-500-a12-a16", - textClassName: "text-adaptive-violet-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#bf5af2", iconBackground: "rgba(191,90,242,0.22)", pulse: false, diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index a3c6712abae8..652de9296f1f 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -153,10 +153,16 @@ describe("mobile themes", () => { it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); - expect(Object.keys(variables)).toHaveLength(65); + expect(Object.keys(variables)).toHaveLength(68); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); + expect(variables["--color-warning"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), + ); + expect(variables["--color-warning-foreground"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), + ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 23034511287e..10ef1b58edec 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -239,6 +239,9 @@ export function createMobileThemeVariables( "--color-switch-active-thumb": c.accentForeground, "--color-switch-inactive-track": c.secondary, "--color-switch-inactive-thumb": c.mutedForeground, + "--color-warning": c.warningSurface, + "--color-warning-border": withAlpha(c.warning, 0.32), + "--color-warning-foreground": c.warningForeground, "--color-danger": c.errorSurface, "--color-danger-border": withAlpha(c.error, 0.32), "--color-danger-foreground": c.errorForeground, diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 53d19abd95a4..fd7a171810ee 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -20,7 +20,7 @@ export interface ThreadPrPresentation { const PR_STATE_TEXT_CLASS: Record = { open: "text-adaptive-emerald-600-400", merged: "text-adaptive-violet-600-400", - closed: "text-adaptive-zinc-500-400", + closed: "text-foreground-muted", }; export function presentThreadPr( @@ -37,6 +37,6 @@ export function presentThreadPr( url: pr.url, label: String(pr.number), accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`, - textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state], + textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index ddda3b1acd96..f6fddfdc5578 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -39,7 +39,7 @@ describe("presentThreadPr", () => { presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined), ).toMatchObject({ accessibilityLabel: "#3774 pull request draft", - textClassName: "text-adaptive-zinc-500-400", + textClassName: "text-foreground-muted", }); }); }); From bfba7781681eaa03eb465ce3d9a4ec07bf952b78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 20:12:51 -0700 Subject: [PATCH 28/65] fix(mobile): keep the new-task draft when switching environment (#10247) Co-authored-by: Claude Code --- .../threads/new-task-flow-provider.tsx | 50 ++++++------- .../new-task-project-selection.test.ts | 75 ++++++++++++++++++- .../threads/new-task-project-selection.ts | 47 ++++++++++++ .../src/state/use-composer-drafts.test.ts | 43 +++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 20 ++++- 5 files changed, 202 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6d87f284ebda..c17e2c002a15 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -92,6 +92,7 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; +import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; type WorkspaceMode = "local" | "worktree"; @@ -622,51 +623,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback( + // New-task drafts are keyed per (environment, project), so retargeting the + // composer would otherwise show the target's empty draft and strand what the + // user typed under the old key. + const carryDraftContentTo = useCallback( (project: EnvironmentProject) => { - const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - const nextDraftKey = `new-task:${nextProjectKey}`; + const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`; if ( selectedProjectDraftKey?.startsWith("new-task:") && selectedProjectDraftKey !== nextDraftKey ) { void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); } - setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(nextProjectKey); }, [selectedProjectDraftKey], ); + const setProject = useCallback( + (project: EnvironmentProject) => { + carryDraftContentTo(project); + setSelectedEnvironmentId(project.environmentId); + setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); + }, + [carryDraftContentTo], + ); + const selectEnvironment = useCallback( (environmentId: EnvironmentId) => { - const projectsOnTarget = projects.filter( - (project) => project.environmentId === environmentId, + const match = resolveEnvironmentProjectMatch( + projects.filter((project) => project.environmentId === environmentId), + selectedProject, ); - const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; - // Prefer the repository identity; projects without one (e.g. not yet - // indexed) fall back to workspace basename, then title, so switching - // computers still follows the same repo instead of resetting to - // whatever project is first on the target machine. - const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const match = - (repositoryKey !== null - ? projectsOnTarget.find( - (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, - ) - : undefined) ?? - (workspaceBasename !== null - ? projectsOnTarget.find( - (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename, - ) - : undefined) ?? - (selectedProject !== null - ? projectsOnTarget.find((project) => project.title === selectedProject.title) - : undefined); + if (match) { + carryDraftContentTo(match); + } setSelectedEnvironmentId(environmentId); setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); }, - [projects, selectedProject], + [projects, selectedProject, carryDraftContentTo], ); const setWorkspaceMode = useCallback( diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 2d52ed716d50..ca59a2b9dddc 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -6,15 +6,33 @@ import type { HomeProjectScope } from "../home/homeThreadList"; import { getProjectScopeSelectionTarget, resolveDraftProjectSelection, + resolveEnvironmentProjectMatch, } from "./new-task-project-selection"; -function makeProject(id: string, environmentId = "environment"): EnvironmentProject { +function makeProject( + id: string, + environmentId = "environment", + options: { + readonly title?: string; + readonly workspaceRoot?: string; + readonly repositoryKey?: string; + } = {}, +): EnvironmentProject { return { environmentId: EnvironmentId.make(environmentId), id: ProjectId.make(id), - title: id, - workspaceRoot: `/work/${id}`, - repositoryIdentity: null, + title: options.title ?? id, + workspaceRoot: options.workspaceRoot ?? `/work/${id}`, + repositoryIdentity: options.repositoryKey + ? { + canonicalKey: options.repositoryKey, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://${options.repositoryKey}.git`, + }, + } + : null, defaultModelSelection: null, scripts: [], createdAt: "2026-07-01T00:00:00.000Z", @@ -51,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => { }); }); +describe("resolveEnvironmentProjectMatch", () => { + it("follows the same repository onto the target machine", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [ + makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }), + makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); + }); + + it("falls back to workspace basename, then title, for unindexed projects", () => { + const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" }); + const byBasename = [ + makeProject("other", "server"), + makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); + + const byTitle = [ + makeProject("other", "server"), + makeProject("srv", "server", { title: "t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]); + }); + + it("does not treat a known different repository as a basename or title match", () => { + const selected = makeProject("t3code", "mac", { + repositoryKey: "github.com/t3tools/t3code", + workspaceRoot: "/Users/me/t3code", + }); + const fork = makeProject("fork", "server", { + repositoryKey: "github.com/someone/t3code", + title: "t3code", + workspaceRoot: "/home/me/t3code", + }); + const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); + expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); + // Without any weaker match the fork is still the first-project fallback. + expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + }); + + it("falls back to the first project on the target so the draft has a key to carry over to", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]); + expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); + }); +}); + describe("resolveDraftProjectSelection", () => { it("preserves an explicit project selection", () => { const project = makeProject("t3code"); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 0528dc66687a..65dd9916f2f6 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -26,6 +26,53 @@ function getOnlySelectableProject( return onlyScope?.representative ?? null; } +/** + * Picks the project on a target environment that corresponds to the project + * currently selected in the new-task flow, so switching computers follows the + * same repo. Repository identity is preferred; projects without one (e.g. not + * yet indexed) fall back to workspace basename, then title. When nothing + * matches, the first project on the target stands in — the same fallback the + * render path applies when no key is selected — so the draft always has a + * concrete key to carry over to. + */ +export function resolveEnvironmentProjectMatch( + projectsOnTarget: ReadonlyArray, + selectedProject: EnvironmentProject | null, +): EnvironmentProject | null { + const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + // `|| null` (not `??`): a pending-task placeholder project can have an empty + // workspaceRoot, and an "" basename would match nothing meaningful. + const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; + // The weaker signals only apply where identity is unknown on at least one + // side; two known, different repositories never match on a shared basename + // or title (mirrors the environment list filter in the new-task flow). + const isKnownMismatch = (project: EnvironmentProject) => { + const projectKey = project.repositoryIdentity?.canonicalKey ?? null; + return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; + }; + return ( + (repositoryKey !== null + ? projectsOnTarget.find( + (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, + ) + : undefined) ?? + (workspaceBasename !== null + ? projectsOnTarget.find( + (project) => + !isKnownMismatch(project) && + project.workspaceRoot.split("/").at(-1) === workspaceBasename, + ) + : undefined) ?? + (selectedProject !== null + ? projectsOnTarget.find( + (project) => !isKnownMismatch(project) && project.title === selectedProject.title, + ) + : undefined) ?? + projectsOnTarget[0] ?? + null + ); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a5e85227e271..57c0ac91d147 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load, })); +import type { DraftComposerAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { @@ -1433,6 +1434,48 @@ describe("mobile composer drafts", () => { expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); }); + it("drops another environment's upload stamp when carrying attachments across machines", () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-2:project-2"; + const uploadedElsewhere: DraftComposerAttachment = { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + uploadedAttachmentId: "upload-1", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + const uploadedOnTarget: DraftComposerAttachment = { + ...uploadedElsewhere, + id: "image-2", + uploadedAttachmentId: "upload-2", + uploadEnvironmentId: EnvironmentId.make("environment-2"), + }; + + const next = copyComposerDraftContentState( + { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } }, + sourceKey, + targetKey, + ); + + expect(next[targetKey]?.attachments).toEqual([ + { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + }, + uploadedOnTarget, + ]); + expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]); + }); + it("merges shared content into a project draft without duplicating retries", () => { const draftKey = "new-task:environment-1:project-1"; const sharedAttachment = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 6b463c2d2624..bf25866b14ce 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1049,17 +1049,35 @@ export function copyComposerDraftContentState( if (!sourceHasContent || targetHasContent) { return current; } + // Pending uploads live on one server. Crossing environments keeps the local + // bytes (the upload worker re-sends them to the new key's environment) but + // drops the old stamp, so it cannot pin the source environment's pending + // upload alive from the copy. + const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []); + const attachments = source.attachments.map((attachment) => + attachment.uploadEnvironmentId !== undefined && + attachment.uploadEnvironmentId !== targetEnvironmentId + ? stripAttachmentUploadReference(attachment) + : attachment, + ); return { ...current, [targetDraftKey]: { ...target, text: source.text, - attachments: source.attachments, + attachments, ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), }, }; } +function stripAttachmentUploadReference( + attachment: DraftComposerAttachment, +): DraftComposerAttachment { + const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment; + return rest; +} + export async function copyComposerDraftContentIfEmpty( sourceDraftKey: string, targetDraftKey: string, From 2c3353578098a9e55e203a72217abe993f97987e Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 6 Sep 2026 05:52:07 +0200 Subject: [PATCH 29/65] fix(web): keep timestamp tooltip dates in English (#10256) --- apps/web/src/timestampFormat.test.ts | 19 +++++++++++++++++++ apps/web/src/timestampFormat.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index ca73095f7fd4..8c6287010d8f 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -52,6 +52,25 @@ describe("formatShortTimestamp", () => { }); }); +describe("formatChatTimestampTooltip", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it.each(["de-DE", "it-IT"])("keeps the English date label in a %s runtime", async (locale) => { + const DateTimeFormat = Intl.DateTimeFormat; + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (locales, options) { + return new DateTimeFormat(locales ?? locale, options); + }); + vi.resetModules(); + const { formatChatTimestampTooltip: format } = await import("./timestampFormat"); + const date = new Date(2026, 5, 4, 14, 4).toISOString(); + + expect(format(date, "24-hour")).toBe("14:04, 4th June 2026"); + }); +}); + describe("formatExpiresInLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 0f87204efde4..9dd463bb50fa 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -81,7 +81,7 @@ export function parseTimestampDate(isoDate: string): Date | null { // Deliberately not the host locale: the tooltip's ordinal suffix and // day-before-month order below are English, so a localized month alone would // read "4th Juni 2026". Localizing the whole label is a separate change. -const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); +const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); function ordinalSuffix(day: number): string { const lastTwo = day % 100; From 3da9399b1ac4e015f8db29c49718a74c57e62e83 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:53:03 -0700 Subject: [PATCH 30/65] fix(web): let authorized clients scrolling reach settings (#10080) --- apps/web/src/components/settings/ConnectionsSettings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5a6f3ebd70b0..da46e067d47a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3222,6 +3222,7 @@ export function ConnectionsSettings() { > From add8c3a55ac8a7d520dc2ea11a8fa05bc3cee361 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:54:18 +0300 Subject: [PATCH 31/65] fix(web): remember usage page selection (#10189) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/components/usage/UsagePage.test.tsx | 39 ++++++----- apps/web/src/components/usage/UsagePage.tsx | 33 +++++++-- .../usage/usagePagePreferences.test.ts | 70 +++++++++++++++++++ .../components/usage/usagePagePreferences.ts | 32 +++++++++ 4 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/usage/usagePagePreferences.test.ts create mode 100644 apps/web/src/components/usage/usagePagePreferences.ts diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 944987388b06..e41843e6d9cc 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), - metric: "cost" as "cost" | "tokens", + metric: "cost" as "cost" | "tokens" | "limits", breakdown: "time" as "model" | "time", })); @@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useState: vi.fn((initial: unknown) => [ - 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, + 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(), ]), }; @@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => { }); import { UsagePage } from "./UsagePage"; +import { readUsagePagePreferences } from "./usagePagePreferences"; const providerTotals = (codex: number, claude: number) => new Map([ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f0dcec49ad90..deb05f266b98 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; +import { + readUsagePagePreferences, + saveUsagePagePreferences, + type UsagePagePreferences, +} from "./usagePagePreferences"; type UsageMetric = UsageChartMetric | "limits"; const METRIC_OPTIONS = [ @@ -81,12 +86,21 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { + return WINDOW_OPTIONS.some((option) => option.days === value); +} + export function UsagePage() { + const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, - window: makeWindow(30), + days: preferences.windowDays, + window: makeWindow( + preferences.windowDays, + undefined, + preferences.windowDays === 1 ? "hour" : "day", + ), })); - const [metric, setMetric] = useState("cost"); + const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); const refreshingRef = useRef(false); @@ -134,11 +148,20 @@ export function UsagePage() { const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; const selectWindow = (days: number) => { + if (!isUsageWindowDays(days)) return; + const nextPreferences = { metric, windowDays: days }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectMetric = (nextMetric: UsageMetric) => { + const nextPreferences = { metric: nextMetric, windowDays }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); + }; const refreshWindow = () => { if (refreshingRef.current) return; @@ -210,7 +233,7 @@ export function UsagePage() { value={[metric]} onValueChange={(next) => { const value = next[0]; - if (isUsageMetric(value)) setMetric(value); + if (isUsageMetric(value)) selectMetric(value); }} > {METRIC_OPTIONS.map((option) => ( @@ -252,7 +275,7 @@ export function UsagePage() {

Node.js required. Modem optional.

System requirements
  • A modern computer
  • A supported coding agent
  • A dream, ideally a small one

Does not actually run on Windows 95.

+ + +
+

Questions

+
Wait. Is this a real product?

Yes. T3 Code is a real, free, open-source app used by {MARKETING_STATS.users} developers. The packaging is a joke. The app is not. Visit the regular website.

+
Does this replace my Claude or Codex subscription?

No. T3 Code connects to the coding agents you already use. Keep your provider accounts and subscriptions. T3 Code gives you one app to work with them.

+
Will it run on Windows 95?

Absolutely not. We brought back the look, not the driver problems. Get a build for a current version of Windows, macOS, or Linux.

+
Where do I mail my check?

Please do not mail us a check for zero dollars. Just download the app. The entire accounts department is a download button.

+
+ + + + +
Done. Internet
+ + + + + +
+
Start
+ + +
4:04 PM
+
+ +
T3 Code '95

Get T3 Code
+ + + + diff --git a/apps/marketing/src/styles/retro.css b/apps/marketing/src/styles/retro.css new file mode 100644 index 000000000000..df166eb71ded --- /dev/null +++ b/apps/marketing/src/styles/retro.css @@ -0,0 +1,1244 @@ +/* This page has its own document so the retro styles do not affect other pages. */ +:root { + color-scheme: dark; + font-family: Tahoma, Verdana, Arial, sans-serif; + color: #fff; + background: #000; + --silver: #c0c0c0; + --yellow: #eaff00; + --pink: #ff79bd; + --navy: #000080; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + height: 100dvh; + overflow: hidden; +} +button, +input { + font: inherit; +} +button, +a, +summary { + -webkit-tap-highlight-color: transparent; +} +button, +summary { + cursor: pointer; +} +button { + color: inherit; +} +a { + color: inherit; +} +button { + border-radius: 0; +} +svg { + flex-shrink: 0; +} +[hidden] { + display: none !important; +} +:focus-visible { + outline: 2px dashed var(--pink); + outline-offset: 4px; +} +section { + scroll-margin-top: 24px; +} +.skip-link { + position: fixed; + top: -80px; + left: 12px; + z-index: 100; +} +.skip-link:focus { + top: 12px; +} +.raised { + border: 2px solid; + border-color: #fff #333 #333 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; +} +.sunken { + border: 2px solid; + border-color: #808080 #fff #fff #808080; + box-shadow: inset 1px 1px #000; +} +.retro-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border: 2px solid; + border-color: #fff #000 #000 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; + padding: 8px 16px; + background: var(--silver); + color: #000; + text-decoration: none; + font-size: 12px; + font-weight: 700; +} +.retro-button:active { + border-color: #000 #fff #fff #000; + box-shadow: inset 1px 1px #808080; +} +.retro-button:hover { + background: #d7d7d7; +} +.desktop { + position: fixed; + inset: 0 0 42px; + z-index: 1; + max-width: 1280px; + margin: 0 auto; + padding: 28px 30px 40px 112px; + pointer-events: none; +} +.desktop-icons { + position: absolute; + top: 37px; + left: max(10px, calc((100vw - 1280px) / 2 + 10px)); + width: 83px; + display: grid; + gap: 29px; +} +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + border: 0; + background: none; + color: #fff; + text-align: center; + text-decoration: none; + font-size: 11px; + line-height: 1.4; + padding: 3px 0; +} +.desktop-icon:hover span, +.desktop-icon:focus-visible span { + background: var(--navy); +} +.browser-window { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 3px; + background: var(--silver); + pointer-events: auto; + transform: translate(var(--window-x, 0px), var(--window-y, 0px)); +} +.browser-window > :not(main) { + flex-shrink: 0; +} +#window-title { + cursor: grab; + touch-action: none; + user-select: none; +} +#window-title.dragging { + cursor: grabbing; +} +#window-title:focus-visible { + outline-offset: -2px; +} +.window-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 27px; + padding: 3px 4px 3px 6px; + background: linear-gradient(90deg, #000080, #2253a4); + color: #fff; + font-size: 12px; + font-weight: 700; +} +.window-name { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} +.window-name > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.window-controls { + display: flex; + gap: 3px; +} +.window-control { + width: 20px; + height: 20px; + min-width: 20px; + padding: 0; + font: + 700 18px Arial, + sans-serif; +} +.window-control:first-child { + font-size: 17px; +} +.maximize-icon { + width: 10px; + height: 10px; + border: 1px solid #000; + border-top-width: 3px; +} +.browser-menu { + display: flex; + align-items: center; + gap: 2px; + padding: 3px 4px; + color: #000; +} +.browser-menu > a, +.browser-menu > button { + padding: 5px 8px; + border: 0; + background: none; + text-decoration: none; + font-size: 11px; +} +.browser-menu > a:hover, +.browser-menu > button:hover { + color: #fff; + background: var(--navy); +} +.address-bar { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 7px 9px; + color: #000; + font-size: 11px; +} +.address-field { + display: flex; + align-items: center; + gap: 7px; + flex: 1; + padding: 4px 6px; + background: #fff; + min-width: 0; +} +.address-field > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.address-go { + align-self: stretch; + padding: 2px 9px; + font-weight: 400; +} +main { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + border: 2px solid; + border-color: #555 #fff #fff #555; + background: #000; +} +main::-webkit-scrollbar { + width: 16px; + height: 16px; +} +main::-webkit-scrollbar-track, +main::-webkit-scrollbar-corner { + background: #dfdfdf; +} +main::-webkit-scrollbar-thumb { + border: 2px solid; + border-color: #fff #333 #333 #fff; + background: var(--silver); + box-shadow: inset -1px -1px #808080; +} +.hero { + display: grid; + grid-template-columns: 1.1fr 1fr; + align-items: center; + padding: 16px 35px 20px; + gap: 8px; +} +.hero h1 { + font-size: clamp(36px, 3.4vw, 48px); + letter-spacing: -2px; +} +.hero .hero-explanation { + margin: 14px 0 0; +} +.edition-packages { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: end; +} +.hero-package { + display: block; + width: min(100%, 170px); + margin: 0 auto; + text-decoration: none; +} +.hero-package > img { + display: block; + width: 100%; + height: auto; +} +.hero-package > .edition-download { + display: flex; + justify-content: center; + padding: 6px 8px; + margin-top: 4px; + font-size: 11px; + background: var(--yellow); +} +.nightly-package > .edition-download { + background: #c9bdff; +} +h1 { + margin: 0; + font: + 900 clamp(40px, 4.7vw, 62px)/0.99 Arial, + Helvetica, + sans-serif; + letter-spacing: -3.3px; +} +h1 > span { + color: var(--yellow); +} +.hero-explanation { + max-width: 360px; + margin: 0 0 23px; + font-size: 12px; + line-height: 1.7; + color: #c0c0c0; +} +.primary-cta { + padding: 13px 17px; + gap: 12px; + background: var(--yellow); + border-color: #ffffd1 #737c00 #737c00 #ffffd1; + box-shadow: + inset -1px -1px #a3b000, + inset 1px 1px #ffffbd; + font: + 900 13px Arial, + sans-serif; + letter-spacing: 0.3px; +} +.primary-cta > span:last-child { + font-size: 22px; + margin-left: 8px; +} +.primary-cta:hover { + background: #f2ff73; +} +.platform-line { + display: flex; + margin-top: 16px; + flex-wrap: wrap; + align-items: center; + gap: 11px; + font-size: 10px; +} +.platform-line > span { + font: + 8px "Courier New", + monospace; + color: #ababab; + letter-spacing: 0.5px; +} +.platform-line > strong { + font-weight: 400; +} +.platform-line > b { + color: #656565; +} +.box-95 { + position: absolute; + right: 9px; + bottom: -6px; + font: + italic 900 75px Arial, + sans-serif; + color: var(--yellow); + letter-spacing: -6px; +} +.agents-section { + padding: 14px 35px 24px; + border-top: 1px solid #363636; + border-bottom: 1px solid #363636; +} +.agent-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 26px 20px; + margin: 10px 0 24px; +} +.agent { + min-width: 0; + margin: 0; + text-align: center; +} +.agent-box { + display: block; + width: 100%; + max-width: 200px; + height: auto; + margin: 0 auto; + object-fit: contain; +} +.agents-section > .section-heading { + margin-bottom: 0; +} +.agent > figcaption { + padding: 12px 4px 0; + border-top: 3px ridge #777; +} +.agent h3 { + margin: 0 0 6px; + font-size: 14px; +} +.agent p { + max-width: 27ch; + margin: 0 auto; + color: var(--yellow); + font: + 11px/1.5 "Courier New", + monospace; +} +.agents-note { + margin: 0; + color: #aaa; + font: + 9px/1.5 "Courier New", + monospace; +} +.features-section { + padding: 33px 35px 38px; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 20px; + margin-bottom: 21px; +} +.section-heading h2 { + font: + 900 20px/1.2 Arial, + sans-serif; + letter-spacing: -0.6px; + margin: 0; +} +.section-heading h2 > span { + color: var(--yellow); +} +.section-heading > span { + font: + 8px/1.5 "Courier New", + monospace; + color: #aaa; + text-align: right; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; +} +.feature-window { + min-width: 0; + padding: 3px; + background: var(--silver); +} +.feature-titlebar { + display: flex; + justify-content: space-between; + gap: 5px; + padding: 5px 6px; + background: #393939; + font: + 9px "Courier New", + monospace; +} +.feature-body { + background: #0b0b0b; + padding: 18px 15px 17px; + display: flex; + flex-direction: column; + align-items: flex-start; + height: calc(100% - 21px); +} +.feature-body h3 { + font: + 700 18px/1.15 Arial, + sans-serif; + margin: 16px 0 12px; + letter-spacing: -0.3px; +} +.feature-body p { + margin: 0 0 22px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; +} +.feature-tag { + display: block; + margin-top: auto; + color: var(--yellow); + font: + 700 8px/1.6 "Courier New", + monospace; + letter-spacing: 0.3px; + text-decoration: none; +} +a.feature-tag { + text-decoration: underline; + text-underline-offset: 3px; +} +.order-section { + position: relative; + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 45px; + padding: 32px 35px; + border-top: 1px solid #546124; + border-bottom: 1px solid #546124; + background: #121707; + align-items: center; +} +.order-pitch h2 { + font: + 900 30px/1.1 Arial, + sans-serif; + letter-spacing: -1px; + margin: 15px 0 12px; +} +.order-pitch h2 > span { + color: var(--yellow); +} +.order-pitch > p { + font-size: 11px; + line-height: 1.6; + margin: 0 0 23px; +} +.order-pitch .offer-note { + color: #b9bea9; + font: + 9px/1.7 "Courier New", + monospace; + margin: 13px 0 0; +} +.run-window { + padding: 3px; + background: var(--silver); + color: #000; +} +.run-window .window-title { + min-height: 23px; + font-size: 11px; +} +.run-body { + padding: 14px 13px; + font-size: 11px; +} +.run-body > p:first-child { + margin: 0 0 14px; + font-weight: 700; +} +.run-body label { + font-size: 10px; +} +.command-row { + display: flex; + gap: 7px; + margin-top: 7px; +} +.command-row input { + width: 0; + min-width: 0; + flex: 1; + border-radius: 0; + padding: 7px 8px; + background: #fff; + color: #000; + font: + 700 15px "Courier New", + monospace; +} +.command-row button { + padding: 5px 12px; +} +.command-feedback { + min-height: 27px; + margin: 8px 0 10px; + font: + 9px/1.5 "Courier New", + monospace; +} +.run-divider { + border-top: 1px solid #808080; + border-bottom: 1px solid #fff; + margin: 0 0 13px; +} +.run-body > b { + font-size: 10px; +} +.run-body ul { + padding-left: 17px; + margin: 7px 0 10px; + font-size: 10px; + line-height: 1.8; +} +.requirements-note { + font: + 8px "Courier New", + monospace; + margin-bottom: 0; +} +.faq-section { + padding: 34px 35px; +} +.faq-section > h2 { + margin: 0 0 20px; + color: var(--pink); + font: + 700 11px "Courier New", + monospace; +} +.faq-section > details { + border-top: 1px dotted #626262; +} +.faq-section > details:last-child { + border-bottom: 1px dotted #626262; +} +.faq-section summary { + padding: 13px 2px; + font-size: 12px; +} +.faq-section summary::marker { + color: var(--yellow); +} +.faq-section details p { + margin: 0; + padding: 0 20px 16px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; + max-width: 740px; +} +.faq-section a { + color: var(--yellow); + text-underline-offset: 3px; +} +.site-footer { + margin: 0 35px; + padding: 25px 0 27px; + text-align: center; + border-top: 1px solid #363636; +} +.visitor-counter { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px; + font: + 9px "Courier New", + monospace; + color: #c0c0c0; +} +.counter-digits { + display: inline-flex; + gap: 2px; + padding: 3px; + border: 2px inset #666; + background: #141414; +} +.counter-digits > span { + display: block; + padding: 2px 4px; + background: #252b1a; + color: var(--yellow); + font: + 700 15px "Courier New", + monospace; +} +.site-footer > p { + color: #b0b0b0; + font: + 9px/1.6 "Courier New", + monospace; + margin: 0 0 10px; +} +.site-footer nav { + display: flex; + flex-wrap: wrap; + gap: 18px; + justify-content: center; + font: + 9px "Courier New", + monospace; +} +.site-footer nav a { + color: var(--pink); + text-underline-offset: 3px; +} +.browser-status { + display: flex; + align-items: stretch; + gap: 4px; + height: 24px; + padding-top: 4px; + color: #000; + font-size: 10px; +} +.browser-status > span { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; +} +.browser-status > span:first-child { + flex: 1; +} +.browser-status > span:nth-child(2) { + min-width: 120px; +} +.resize-grip { + width: 13px; + background: repeating-linear-gradient(135deg, transparent 0 2px, #808080 2px 3px, #fff 3px 4px); + clip-path: polygon(100% 0, 100% 100%, 0 100%); +} +.taskbar { + position: fixed; + z-index: 10; + left: 0; + right: 0; + bottom: 0; + min-height: 40px; + padding: 3px 5px; + background: var(--silver); + color: #000; + display: flex; + align-items: center; + gap: 8px; +} +.start-menu { + position: relative; +} +.start-button { + gap: 7px; + padding: 4px 8px; + min-height: 30px; + font-size: 14px; + list-style: none; +} +.start-button::-webkit-details-marker { + display: none; +} +.start-mark { + display: grid; + grid-template-columns: 8px 8px; + gap: 2px; + transform: skewY(-8deg); +} +.start-mark i { + width: 8px; + height: 8px; + background: #f3433c; +} +.start-mark i:nth-child(2) { + background: #75b53c; +} +.start-mark i:nth-child(3) { + background: #347be3; +} +.start-mark i:nth-child(4) { + background: #ffe348; +} +.start-panel { + position: absolute; + bottom: calc(100% + 5px); + left: -1px; + display: flex; + width: 253px; + padding: 3px; + background: var(--silver); +} +.start-brand { + writing-mode: vertical-rl; + transform: rotate(180deg); + background: #808080; + color: #dedede; + padding: 12px 8px; + font: + 900 18px Arial, + sans-serif; + white-space: nowrap; +} +.start-brand b { + color: #fff; +} +.start-panel > div:last-child { + flex: 1; +} +.start-panel a, +.start-panel button { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 10px; + width: 100%; + background: none; + border: 0; + color: #000; + text-decoration: none; + font-size: 11px; + text-align: left; +} +.start-panel a:hover, +.start-panel button:hover { + background: var(--navy); + color: #fff; +} +.taskbar-divider { + align-self: stretch; + border-left: 1px solid #808080; + border-right: 1px solid #fff; +} +.task-button { + display: flex; + align-items: center; + gap: 8px; + background: #d7d7d7; + color: #000; + padding: 3px 8px; + min-height: 29px; + min-width: 170px; + font-size: 11px; + font-weight: 700; + text-align: left; +} +.taskbar-clock { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 4px 10px; + min-height: 29px; + font-size: 11px; + white-space: nowrap; +} +.taskbar-clock > span:first-child { + font-size: 16px; +} +.maximized .desktop { + max-width: none; + padding: 0; +} +.maximized .browser-window { + transform: none; +} +.maximized #window-title { + cursor: default; +} +.minimized-message { + height: 100%; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + text-align: center; +} +.minimized-message h1 { + font: + 700 25px Arial, + sans-serif; + letter-spacing: -0.5px; +} +.minimized-message p { + font: + 12px "Courier New", + monospace; + margin: 0 0 10px; +} +.retro-dialog { + width: min(440px, calc(100vw - 32px)); + padding: 3px; + background: var(--silver); + color: #000; +} +.retro-dialog::backdrop { + background: #000b; +} +.dialog-content { + display: flex; + align-items: center; + gap: 20px; + padding: 22px 20px 13px; +} +.dialog-content > p { + font-size: 12px; + line-height: 1.7; + margin: 0; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 16px 17px; +} + +@media (min-width: 1450px) { + .maximized .hero { + grid-template-columns: 1fr 1fr; + padding-left: 65px; + padding-right: 65px; + } + .maximized h1 { + font-size: 76px; + } +} + +@media (max-width: 1100px) { + .desktop { + padding-left: 98px; + padding-right: 15px; + } + .hero { + padding-left: 25px; + padding-right: 25px; + } + h1 { + font-size: 47px; + letter-spacing: -2.6px; + } + .order-section { + gap: 23px; + } + .order-pitch h2 { + font-size: 27px; + } + .feature-grid { + gap: 10px; + } + .feature-body { + padding: 15px 11px; + } +} + +@media (max-width: 820px) { + .desktop { + padding: 14px 12px; + } + .desktop-icons { + display: none; + } + .hero { + grid-template-columns: 1.1fr 0.9fr; + gap: 0; + padding-top: 29px; + } + h1 { + font-size: 44px; + } + .hero-explanation { + max-width: 295px; + } + .primary-cta { + font-size: 11px; + gap: 7px; + padding: 12px; + } + .primary-cta > span:last-child { + margin-left: 3px; + } + .section-heading { + display: block; + } + .section-heading > span { + display: block; + text-align: left; + margin-top: 8px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 25px; + padding-right: 25px; + } + .site-footer { + margin-left: 25px; + margin-right: 25px; + } + .feature-body h3 { + font-size: 16px; + } + .taskbar-clock { + margin-left: auto; + } +} + +@media (max-width: 620px) { + .browser-menu { + flex-wrap: wrap; + } + .hero-package { + width: min(100%, 160px); + } + .edition-packages { + width: 100%; + margin-top: 12px; + } + .desktop { + padding: 9px 7px; + } + .window-name { + font-size: 10px; + } + .window-controls { + gap: 2px; + } + .browser-menu { + gap: 0; + } + .browser-menu > a, + .browser-menu > button { + padding: 6px 7px; + font-size: 10px; + } + .address-bar { + padding: 3px 4px 7px; + gap: 6px; + font-size: 10px; + } + .address-go { + font-size: 10px; + } + .hero { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 29px 20px 15px; + } + h1 { + font-size: clamp(41px, 10.5vw, 63px); + letter-spacing: -2.5px; + } + .hero-explanation { + max-width: 420px; + font-size: 11px; + } + .primary-cta { + font-size: 12px; + padding: 12px 15px; + gap: 10px; + } + .platform-line { + font-size: 9px; + gap: 9px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding: 25px 20px; + } + .agent-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px 12px; + margin-top: 20px; + } + .agent h3 { + font-size: 13px; + } + .agent p { + font-size: 10px; + } + .agents-note { + font-size: 8px; + } + .section-heading h2 { + font-size: 20px; + } + .section-heading h2 > span { + display: block; + } + .feature-grid { + grid-template-columns: 1fr; + gap: 17px; + } + .feature-body { + padding: 17px; + height: auto; + } + .feature-body h3 { + font-size: 21px; + margin-top: 13px; + } + .feature-body p { + font-size: 12px; + margin-bottom: 18px; + } + .feature-tag { + font-size: 9px; + } + .feature-titlebar { + font-size: 10px; + } + .order-section { + grid-template-columns: 1fr; + gap: 25px; + } + .order-pitch h2 { + font-size: 29px; + } + .order-pitch > p { + font-size: 11px; + } + .run-body { + padding: 16px; + } + .command-feedback { + min-height: 15px; + } + .faq-section h2 { + font-size: 10px; + line-height: 1.5; + } + .faq-section summary { + font-size: 11px; + line-height: 1.5; + } + .site-footer { + margin-left: 20px; + margin-right: 20px; + } + .visitor-counter { + font-size: 8px; + } + .site-footer > p { + font-size: 8px; + } + .site-footer nav { + font-size: 8px; + gap: 15px; + } + .browser-status { + font-size: 8px; + height: 26px; + } + .browser-status > span:nth-child(2) { + min-width: 67px; + } + .browser-status > span:first-child { + white-space: nowrap; + overflow: hidden; + } + .resize-grip { + display: none !important; + } + .taskbar { + gap: 6px; + } + .task-button { + min-width: 0; + flex: 1; + max-width: 170px; + } + .taskbar-clock { + padding: 4px 7px; + gap: 5px; + font-size: 10px; + } + .dialog-content { + padding: 19px 13px 10px; + gap: 12px; + } +} + +@media (max-width: 620px) { + .hero { + padding-top: 14px; + padding-bottom: 14px; + } + .hero h1 { + font-size: 30px; + } + .hero-package { + max-width: 120px; + } + .hero .platform-line { + display: none; + } + .agents-section { + padding-top: 14px; + } + .agents-section .agent-list { + margin-top: 8px; + } +} + +@media (min-width: 821px) and (max-height: 820px) { + .hero { + padding-top: 10px; + padding-bottom: 12px; + } + .hero h1 { + font-size: 40px; + } + .hero-package { + max-width: 140px; + } + .agent-box { + max-width: 180px; + } + .agents-section { + padding-top: 8px; + } +} + +@media (max-width: 360px) { + .hero-package { + max-width: 110px; + } + .hero, + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 14px; + padding-right: 14px; + } + .primary-cta { + font-size: 10px; + } + .taskbar-clock > span:first-child { + display: none; + } +} From fdcc491e0b36245d4b4c74d1fd338d874f4ec84f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:28 -0700 Subject: [PATCH 36/65] refactor(web): remove unused runtime wrappers and exports (#10225) --- apps/web/src/assets/assetUrls.ts | 8 -- apps/web/src/assets/projectFaviconCache.ts | 2 +- apps/web/src/cloud/connectCliAuth.ts | 2 +- apps/web/src/cloud/managedRelayLayer.ts | 2 +- apps/web/src/cloud/managedRelayState.ts | 9 +- apps/web/src/cloud/primaryCloudLinkState.ts | 2 +- apps/web/src/connection/desktopLocal.ts | 2 +- apps/web/src/environments/primary/auth.ts | 91 +------------------ apps/web/src/environments/primary/context.ts | 2 +- apps/web/src/environments/primary/index.ts | 15 +-- .../src/environments/primary/sessionState.ts | 2 +- apps/web/src/lib/runtime.ts | 2 - apps/web/src/observability/clientTracing.ts | 9 -- apps/web/src/rpc/requestLatencyState.ts | 2 +- apps/web/src/rpc/transportError.ts | 5 +- apps/web/src/state/entities.ts | 2 +- apps/web/src/state/environments.ts | 5 - apps/web/src/state/queries.ts | 31 ------- apps/web/src/state/server.ts | 2 +- apps/web/src/state/shell.ts | 5 - apps/web/src/state/threads.ts | 2 +- 21 files changed, 15 insertions(+), 187 deletions(-) diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 84ff979e4e89..5a9738c9fbfa 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -32,14 +32,6 @@ export function useAssetUrlState( ); } -export function useAssetUrl( - environmentId: EnvironmentId | null, - resource: AssetResource | null, -): string | null { - const result = useAssetUrlState(environmentId, resource); - return result._tag === "Success" ? result.url : null; -} - export function useAssetUrlRefresh( environmentId: EnvironmentId | null, resource: AssetResource | null, diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts index e6fcc817b969..b44e919af4b3 100644 --- a/apps/web/src/assets/projectFaviconCache.ts +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -46,7 +46,7 @@ async function withStore( } /** Rasterizes a bitmap that is too large to inline, retrying at half size. */ -export async function downscaleProjectFavicon( +async function downscaleProjectFavicon( image: { readonly mimeType: string; readonly bytes: Uint8Array }, signal: AbortSignal, ) { diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 815715da2499..0bc65080cf8c 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -12,7 +12,7 @@ import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./ const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; -export function resolveConnectCliOAuthClientId(): string | null { +function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 52f9b6496c95..b5ce11e842f5 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -13,7 +13,7 @@ import { type BrowserDpopKey, } from "./dpop"; -export const relayDpopSignerLayer = Layer.effect( +const relayDpopSignerLayer = Layer.effect( ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 9a56bde88514..c8f33d1d9d3d 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -33,7 +33,7 @@ const managedRelayAtomRuntime = Atom.runtime( ), ); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); const managedRelayMutationScheduler = createAtomCommandScheduler(); @@ -114,10 +114,3 @@ export function useManagedRelayDevices() { refresh, }; } - -export function refreshManagedRelayEnvironments(): void { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session) { - managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); - } -} diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 34fdacd214af..c5871fa65d66 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -42,7 +42,7 @@ function targetKey(target: CloudLinkTarget): string { return JSON.stringify(target); } -export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { +function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { if (target) { appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } diff --git a/apps/web/src/connection/desktopLocal.ts b/apps/web/src/connection/desktopLocal.ts index c9d8b938771b..d27d20e5b317 100644 --- a/apps/web/src/connection/desktopLocal.ts +++ b/apps/web/src/connection/desktopLocal.ts @@ -17,7 +17,7 @@ import { * via {@link isDesktopLocalConnectionTarget}, so the convention can never drift * between the two. */ -export const DESKTOP_LOCAL_CONNECTION_ID_PREFIX = "local:"; +const DESKTOP_LOCAL_CONNECTION_ID_PREFIX = "local:"; export function desktopLocalConnectionId(backendId: string): string { return `${DESKTOP_LOCAL_CONNECTION_ID_PREFIX}${backendId}`; diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index d76002a93298..0697fa4fe65c 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -9,7 +9,6 @@ import type { } from "@t3tools/contracts"; import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClientError } from "effect/unstable/http"; @@ -66,7 +65,7 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( "PrimaryEnvironmentPairingCredentialRejectedError", @@ -96,10 +95,6 @@ export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErro } } -export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( - PrimaryEnvironmentAuthSessionTimeoutError, -); - export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( "PrimaryEnvironmentPairingCredentialRequiredError", { @@ -111,10 +106,6 @@ export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.Tag } } -export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( - PrimaryEnvironmentPairingCredentialRequiredError, -); - const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -386,44 +377,6 @@ export async function createServerPairingCredential(input?: { } } -export async function listServerPairingLinks(): Promise> { - try { - const pairingLinks = await runPrimaryHttp( - PrimaryEnvironmentHttpClient.pipe( - Effect.flatMap((client) => client.auth.pairingLinks({ headers: {} })), - ), - ); - return pairingLinks.map((pairingLink) => { - const timestamps = { - createdAt: DateTime.formatIso(pairingLink.createdAt), - expiresAt: DateTime.formatIso(pairingLink.expiresAt), - }; - if (pairingLink.label === undefined) { - return { - id: pairingLink.id, - scopes: pairingLink.scopes, - subject: pairingLink.subject, - createdAt: timestamps.createdAt, - expiresAt: timestamps.expiresAt, - }; - } - return { - id: pairingLink.id, - scopes: pairingLink.scopes, - subject: pairingLink.subject, - label: pairingLink.label, - createdAt: timestamps.createdAt, - expiresAt: timestamps.expiresAt, - }; - }); - } catch (error) { - throw PrimaryEnvironmentRequestError.fromCause({ - operation: "list-pairing-links", - cause: error, - }); - } -} - export async function revokeServerPairingLink(id: string): Promise { try { await runPrimaryHttp( @@ -440,38 +393,6 @@ export async function revokeServerPairingLink(id: string): Promise { } } -export async function listServerClientSessions(): Promise< - ReadonlyArray -> { - try { - const clientSessions = await runPrimaryHttp( - PrimaryEnvironmentHttpClient.pipe( - Effect.flatMap((client) => client.auth.clients({ headers: {} })), - ), - ); - return clientSessions.map((clientSession) => ({ - sessionId: clientSession.sessionId, - subject: clientSession.subject, - scopes: clientSession.scopes, - method: clientSession.method, - client: clientSession.client, - issuedAt: DateTime.formatIso(clientSession.issuedAt), - expiresAt: DateTime.formatIso(clientSession.expiresAt), - lastConnectedAt: - clientSession.lastConnectedAt === null - ? null - : DateTime.formatIso(clientSession.lastConnectedAt), - connected: clientSession.connected, - current: clientSession.current, - })); - } catch (error) { - throw PrimaryEnvironmentRequestError.fromCause({ - operation: "list-client-sessions", - cause: error, - }); - } -} - export async function revokeServerClientSession(sessionId: AuthSessionId): Promise { try { await runPrimaryHttp( @@ -531,16 +452,6 @@ export async function resolveInitialServerAuthGateState(): Promise { - resolvedAuthenticatedGateState = null; - bootstrapPromise = null; - return resolveInitialServerAuthGateState(); -} - export function __resetServerAuthBootstrapForTests() { bootstrapPromise = null; resolvedAuthenticatedGateState = null; diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index 48017ac29e38..4bc4cb9f6681 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -54,7 +54,7 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise; -export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); - const primaryHttpRuntime = ManagedRuntime.make( PrimaryEnvironmentHttpClient.layer.pipe(Layer.provide(primaryEnvironmentHttpLayer)), ); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 95d390b90026..81cd18e207de 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -41,15 +41,6 @@ export interface ClientTracingConfig { readonly exportIntervalMs?: number; } -export const ClientTracingLive = Layer.succeed( - Tracer.Tracer, - Tracer.make({ - span(options) { - return activeDelegate?.span(options) ?? new Tracer.NativeSpan(options); - }, - }), -); - export function configureClientTracing(config: ClientTracingConfig = {}): Promise { if (config.exportIntervalMs === undefined && activeConfigKey !== null) { return pendingConfiguration; diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 9015a3c40b00..2731efecfc85 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -112,7 +112,7 @@ export function acknowledgeRpcRequest(requestId: string): void { setSlowRpcAckRequests(slowRequests.filter((request) => request.requestId !== requestId)); } -export function clearAllTrackedRpcRequests(): void { +function clearAllTrackedRpcRequests(): void { for (const pending of pendingRpcAckRequests.values()) { clearTimeout(pending.timeoutId); } diff --git a/apps/web/src/rpc/transportError.ts b/apps/web/src/rpc/transportError.ts index 493de5f93bd2..7d0e4777a3a0 100644 --- a/apps/web/src/rpc/transportError.ts +++ b/apps/web/src/rpc/transportError.ts @@ -1,4 +1 @@ -export { - isTransportConnectionErrorMessage, - sanitizeThreadErrorMessage, -} from "@t3tools/client-runtime/errors"; +export { sanitizeThreadErrorMessage } from "@t3tools/client-runtime/errors"; diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c44c5b437b63..deb4948a0f5a 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -39,7 +39,7 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pip Atom.withLabel("web-thread-status:empty"), ); -export const activeEnvironmentIdAtom = Atom.make(null).pipe( +const activeEnvironmentIdAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("web-active-environment-id"), ); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cdc..f085075fdd7c 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -11,7 +11,6 @@ import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; -import { useEnvironmentQuery } from "./query"; import { relayEnvironmentDiscovery } from "./relay"; import { usePreparedConnection } from "./session"; @@ -85,7 +84,3 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } - -export function useEnvironmentConnectionState(environmentId: EnvironmentId) { - return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); -} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..1792c5e9e599 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -14,7 +14,6 @@ import type { OrchestrationThread, ProjectContentMatch, ProjectEntryKind, - ThreadId, VcsListRefsResult, VcsRef, } from "@t3tools/contracts"; @@ -28,7 +27,6 @@ import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; -import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; @@ -103,35 +101,6 @@ export function useThreadSearch( }; } -export function useThreadDetail( - environmentId: EnvironmentId | null, - threadId: ThreadId | null, -): ThreadDetailView { - const state = useEnvironmentThread(environmentId, threadId); - return { - data: Option.getOrNull(state.data), - error: Option.getOrNull(state.error), - isPending: state.status === "synchronizing", - isDeleted: state.status === "deleted", - }; -} - -export function useBranches(target: VcsRefTarget) { - const query = target.query?.trim() ?? ""; - return useEnvironmentQuery( - target.environmentId !== null && target.cwd !== null - ? vcsEnvironment.listRefs({ - environmentId: target.environmentId, - input: { - cwd: target.cwd, - ...(query.length > 0 ? { query } : {}), - limit: VCS_REF_LIST_LIMIT, - }, - }) - : null, - ); -} - export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; const targetKey = diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 31b9436621c9..f13965e5b4d8 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -50,7 +50,7 @@ const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { welcome: null, }; -export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { +const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { const environmentId = get(primaryEnvironmentIdAtom); if (environmentId === null) { return EMPTY_PRIMARY_SERVER_STATE; diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index 1f88da2f971d..b1719819da9d 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -4,7 +4,6 @@ import { } from "@t3tools/client-runtime/connection"; import { createEnvironmentShellAtoms, - createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, type EnvironmentShellState, @@ -21,10 +20,6 @@ import { isHostedStaticApp } from "../hostedPairing"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); -export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ - catalogValueAtom: environmentCatalog.catalogValueAtom, - shellStateValueAtom: environmentShell.stateValueAtom, -}); export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index fd936f99ff23..c7caaa6a35a7 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -16,7 +16,7 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); -export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, ); From 226abe5f916ff828d8be2c19f1148b10feebfe43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:28 -0700 Subject: [PATCH 37/65] refactor(web): keep feature component helpers private (#10226) --- .../BranchToolbarEnvModeSelector.tsx | 2 +- apps/web/src/components/ConfirmDialogHost.tsx | 2 +- apps/web/src/components/DiffPanel.tsx | 2 -- .../src/components/EnvironmentMachineIcon.tsx | 4 ++-- apps/web/src/components/LegacySidebar.tsx | 2 +- .../ProviderUpdateLaunchNotification.logic.ts | 4 ++-- apps/web/src/components/Sidebar.logic.ts | 15 ++++--------- .../src/components/ThreadCommandSubtitle.tsx | 3 +-- .../chat/ComposerPendingElementContexts.tsx | 2 +- .../chat/ComposerPendingTerminalContexts.tsx | 22 ------------------- .../chat/ContextWindowMeter.logic.ts | 4 ++-- .../components/chat/MessagesTimeline.logic.ts | 17 +++++++------- .../chat/externalLinkContextMenu.ts | 2 +- .../cloud/CloudEnvironmentConnectList.tsx | 2 +- .../src/components/composerFooterLayout.ts | 2 +- .../files/projectFilesQueryState.ts | 2 +- .../web/src/components/media/MediaActions.tsx | 2 +- .../preview/previewAutomationErrors.ts | 2 +- .../preview/previewMiniPlayerLayout.ts | 2 +- .../src/components/projectScriptEditor.tsx | 2 +- .../pullRequest/PullRequestCodeTab.tsx | 2 +- .../pullRequest/pullRequestLinkContextMenu.ts | 2 +- .../pullRequest/pullRequestList.logic.ts | 2 +- .../pullRequest/pullRequestListPreferences.ts | 2 +- .../settings/KeybindingsSettings.logic.ts | 2 +- .../settings/ProjectSettingsPanel.tsx | 6 ++--- .../settings/SettingsSidebarNav.tsx | 2 +- .../components/settings/ThemeWireframe.tsx | 2 +- .../settings/customModelEditor.logic.ts | 4 ++-- .../components/settings/providerDriverMeta.ts | 4 ++-- .../src/components/settings/themeInspector.ts | 2 +- 31 files changed, 46 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index c0fa463fe72a..611bd4234a0f 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -18,7 +18,7 @@ import { SelectValue, } from "./ui/select"; -export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; +const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index c169a1eff7fc..7babc62fd2ea 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -23,7 +23,7 @@ type ConfirmationCopy = { readonly description: string | null; }; -export function resolveConfirmDialogCopy(message: string): ConfirmationCopy { +function resolveConfirmDialogCopy(message: string): ConfirmationCopy { const normalizedMessage = message.trim(); const lines = normalizedMessage.split("\n"); const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?")); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index af6d95dba0b0..d764b9c6a9e2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -104,8 +104,6 @@ interface DiffPanelProps { workspaceMutationId: string | null; } -export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; - export default function DiffPanel({ mode = "inline", composerDraftTarget, diff --git a/apps/web/src/components/EnvironmentMachineIcon.tsx b/apps/web/src/components/EnvironmentMachineIcon.tsx index 31b7a953a3a4..b42bf41266c9 100644 --- a/apps/web/src/components/EnvironmentMachineIcon.tsx +++ b/apps/web/src/components/EnvironmentMachineIcon.tsx @@ -23,7 +23,7 @@ function LucideLike(props: SVGProps) { } /** A Mac mini: squat rounded slab with a front-edge LED. */ -export function MacMiniIcon(props: SVGProps) { +function MacMiniIcon(props: SVGProps) { return ( @@ -33,7 +33,7 @@ export function MacMiniIcon(props: SVGProps) { } /** A Mac Studio: the same slab twice as tall, ports along the front foot. */ -export function MacStudioIcon(props: SVGProps) { +function MacStudioIcon(props: SVGProps) { return ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index bacdfa62118c..0eb6a74ebe80 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -354,7 +354,7 @@ interface SidebarThreadRowProps { ) => boolean; } -export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 16184ac070bc..6da4eaac6dde 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -220,7 +220,7 @@ export function providerUpdateNotificationKey( return parts.length > 0 ? parts.join("|") : null; } -export function formatProviderList(providers: ReadonlyArray>) { +function formatProviderList(providers: ReadonlyArray>) { const names = providers.map( (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, ); @@ -249,7 +249,7 @@ export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastVi return view.phase !== "running"; } -export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { +function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { return { phase: "running", type: "loading", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index d5d7d1f23a44..194db3130011 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,7 +15,7 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; -export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed @@ -23,10 +23,10 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // activities, growing as agents work) for as long as the row stays visible, // so this limit is a direct renderer-heap and server-load multiplier — keep // it small; cold opens still render instantly from the cached snapshot. -export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; +const SIDEBAR_THREAD_PREWARM_LIMIT = 3; // A small buffer keeps the next few rows warm without leasing every row that // content-visibility leaves mounted below the scroll viewport. -export const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; +const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): { readonly leaseLiveStatus: boolean; @@ -544,13 +544,6 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } -/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not - poison the whole ordering, so it sinks to the epoch instead. */ -export function parseTimestampMs(isoDate: string): number { - const parsed = Date.parse(isoDate); - return Number.isNaN(parsed) ? 0 : parsed; -} - /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- yet-malformed string must also fall through to the next candidate rather than sink the row to the epoch. */ @@ -567,7 +560,7 @@ export function firstValidTimestampMs( /** String twin of firstValidTimestampMs for callers that need the ISO string (display labels, tick anchors) rather than epoch ms. */ -export function firstValidTimestamp( +function firstValidTimestamp( ...candidates: ReadonlyArray ): string | null { for (const candidate of candidates) { diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index cd5074518719..5890e99d2010 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -15,8 +15,7 @@ export type ThreadCommandSubtitleVariant = | "favicon-workspace" | "favicon-branch-harness"; -export const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = - "favicon-workspace-harness"; +const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = "favicon-workspace-harness"; export const COMMAND_PALETTE_META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/70"; diff --git a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx index 7373403a7c39..8d59485b7d15 100644 --- a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx +++ b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx @@ -39,7 +39,7 @@ function buildTooltipContent(context: ElementContextDraft): string { return lines.join("\n"); } -export function ComposerPendingElementContextChip({ +function ComposerPendingElementContextChip({ context, onRemove, }: ComposerPendingElementContextChipProps) { diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx index 37c05eab2d0d..e2b3109f17a5 100644 --- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx +++ b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx @@ -1,4 +1,3 @@ -import { cn } from "~/lib/utils"; import { type TerminalContextDraft, formatTerminalContextLabel, @@ -6,11 +5,6 @@ import { } from "~/lib/terminalContext"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -interface ComposerPendingTerminalContextsProps { - contexts: ReadonlyArray; - className?: string; -} - interface ComposerPendingTerminalContextChipProps { context: TerminalContextDraft; } @@ -26,19 +20,3 @@ export function ComposerPendingTerminalContextChip({ return ; } - -export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) { - const { contexts, className } = props; - - if (contexts.length === 0) { - return null; - } - - return ( -
- {contexts.map((context) => ( - - ))} -
- ); -} diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index be3dacb05e92..6ff2b6e0a660 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -9,8 +9,8 @@ import { } from "../../providerInstances"; import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; -export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; -export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; +const CLAUDE_RESUME_COMPACTION_MINUTES = 70; +const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; export function providerSupportsManualCompaction( provider: ProviderInstanceEntry | null | undefined, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 03d1e46faf32..1288cd6fad8b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -14,7 +14,6 @@ import { } from "@t3tools/client-runtime/work-log/presentation"; export { normalizeCompactToolLabel, - summarizeToolGroup, toolGroupAction, } from "@t3tools/client-runtime/work-log/presentation"; import { @@ -31,11 +30,11 @@ import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../.. import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; -export const TIMELINE_MINIMAP_ITEM_SPACING = 8; +const TIMELINE_MINIMAP_ITEM_SPACING = 8; export const TIMELINE_MINIMAP_MIN_ITEMS = 2; -export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; -export const TIMELINE_CONTENT_MAX_WIDTH = 768; -export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; +const TIMELINE_CONTENT_MAX_WIDTH = 768; +const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; function singleToolCallLabel(entry: WorkLogEntry): string { const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); @@ -145,7 +144,7 @@ export interface TimelineEndState { * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming * reliable while streaming content is still growing under the viewport. */ -export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; +const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { if (!state) { @@ -207,9 +206,9 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number) return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } -export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; -export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; -export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; +const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; +const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; +const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; /** * The minimap overlays the viewport's left edge while the content column is diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index d0f37f97d800..f836f069006d 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -35,7 +35,7 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ * whole menu with the one item that cannot be honoured is what left a right-click on a link * showing the platform's cut-and-paste menu instead of a way to copy the link. */ -export function externalLinkContextMenuItems(options: { +function externalLinkContextMenuItems(options: { readonly canOpenInPreview: boolean; readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined; }): readonly ContextMenuItem[] { diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 7f29c69c3208..747b82d1e552 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -32,7 +32,7 @@ export interface SavedCloudEnvironmentConnection { readonly connection: EnvironmentConnectionPresentation; } -export function RemoteEnvironmentRowsSkeleton() { +function RemoteEnvironmentRowsSkeleton() { return (
diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 2747e72bb029..5dd6000dbc75 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,6 +1,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; -export const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; +const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; export function getRestingComposerImagePreviewCounts(imageCount: number): { visibleCount: number; diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index d02ec99605ba..a12772920956 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -33,7 +33,7 @@ interface ProjectQueryState { readonly refresh: () => void; } -export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { +function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { return projectEnvironment.listEntries({ environmentId, input: { cwd } }); } diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index cf79c81b9f60..d6c5cca70153 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -33,7 +33,7 @@ function mediaFileName(source: MediaActionSource): string { } /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ -export function useMediaActions(source: MediaActionSource) { +function useMediaActions(source: MediaActionSource) { const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index dcf35de53f2d..97a099ec72eb 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -216,7 +216,7 @@ export const PreviewAutomationHostError = Schema.Union([ ]); export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type; -export const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); +const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); export function serializePreviewAutomationHostError( error: PreviewAutomationHostError, diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 3aa2e141af07..10723cedaa8a 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -4,7 +4,7 @@ export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; -export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; +const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; export function clampPreviewMiniPlayerSize( size: PreviewMiniPlayerSize, diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4b728c2e07eb..4ffd453955e9 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -49,7 +49,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Switch } from "./ui/switch"; import { Textarea } from "./ui/textarea"; -export const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ +const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ { id: "play", label: "Play" }, { id: "test", label: "Test" }, { id: "lint", label: "Lint" }, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index aa2d278ce249..b77a3711d90f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -187,7 +187,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): { * host sit under the line they were written on, and a new comment joins the review being * drafted rather than being posted as it is typed. */ -export function PullRequestCodeTab({ +function PullRequestCodeTab({ environmentId, reference, detail, diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index ef554d0eb3ee..1ccdb64b73f4 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -8,7 +8,7 @@ import { toastManager } from "../ui/toast"; export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; /** Named for the host rather than "externally": the point is where you will land. */ -export const OPEN_ON_HOST_LABELS: Partial> = { +const OPEN_ON_HOST_LABELS: Partial> = { github: "Open on GitHub", gitlab: "Open on GitLab", bitbucket: "Open on Bitbucket", diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index cee672489389..c2fdde3d011f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -67,7 +67,7 @@ export type PullRequestViewers = PullRequestListResult["viewers"]; /** A row plus the environment that read it, where the caller has one to give. */ type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string }; -export const pullRequestViewerKey = (entry: ScopedEntry): string => +const pullRequestViewerKey = (entry: ScopedEntry): string => `${entry.environmentId ?? ""} ${entry.host}`; const GROUP_LABELS: Record = { diff --git a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts index bc2bc6f272cd..95c4bf02e743 100644 --- a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts +++ b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts @@ -37,7 +37,7 @@ export type PullRequestListPreferencePatch = { [Key in keyof PullRequestListPreferences]?: PullRequestListPreferences[Key] | undefined; }; -export const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { +const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { involvement: "all", state: "open", } as const satisfies PullRequestListPreferences; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index d987bc7a83dd..c366a87e7efd 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -291,7 +291,7 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } -export function normalizeShortcutKeyToken(key: string): string | null { +function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( normalized === "meta" || diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 7be0f15cd5c2..0181041ec6b1 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -127,14 +127,14 @@ const ProjectIconPickerDialog = lazy(() => })), ); -export const PROJECT_GROUPING_MODE_LABELS: Record = { +const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; /** Logical project groups for the settings page, sorted by display name. */ -export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { +function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -253,7 +253,7 @@ function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { ); } -export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index f8f0254cda61..0e1ff2076e99 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -80,7 +80,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/archived": ArchiveIcon, }; -export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ +const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; to: SettingsPath; icon: ComponentType<{ className?: string }>; diff --git a/apps/web/src/components/settings/ThemeWireframe.tsx b/apps/web/src/components/settings/ThemeWireframe.tsx index ce4f13f208e5..895d8d1eecb7 100644 --- a/apps/web/src/components/settings/ThemeWireframe.tsx +++ b/apps/web/src/components/settings/ThemeWireframe.tsx @@ -4,7 +4,7 @@ import type { ThemeCardPreviewColors } from "./ThemePreviewCircles"; // A simple miniature of the app: sidebar, a short conversation, the // composer, and the orchestrator panel floating over the interface as an // island with horizontal agent rows. -export function ThemeWireframePane({ +function ThemeWireframePane({ colors, clip, }: { diff --git a/apps/web/src/components/settings/customModelEditor.logic.ts b/apps/web/src/components/settings/customModelEditor.logic.ts index 0d48057206df..15de96e2f182 100644 --- a/apps/web/src/components/settings/customModelEditor.logic.ts +++ b/apps/web/src/components/settings/customModelEditor.logic.ts @@ -104,7 +104,7 @@ export const DESCRIPTOR_PRESETS_BY_KIND: Partial< }; let nextKey = 0; -export function newEditorKey(): string { +function newEditorKey(): string { nextKey += 1; return `k${nextKey}`; } @@ -140,7 +140,7 @@ export function emptyEditorChoice(): EditorChoice { * by built-in runtime profiles a custom entry does not have, so they are * dropped rather than stored as a plain option value. */ -export function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { +function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { const promptInjected = new Set( descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [], ); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index a782632b10c8..4bf4da3919ba 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -43,7 +43,7 @@ export interface ProviderClientDefinition { readonly badgeLabel?: string; } -export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ +const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ { value: ProviderDriverKind.make("codex"), label: "Codex", @@ -84,7 +84,7 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = }, ]; -export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< +const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< Record > = Object.fromEntries( PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]), diff --git a/apps/web/src/components/settings/themeInspector.ts b/apps/web/src/components/settings/themeInspector.ts index 9306226b8c86..b790c307a3d9 100644 --- a/apps/web/src/components/settings/themeInspector.ts +++ b/apps/web/src/components/settings/themeInspector.ts @@ -14,7 +14,7 @@ const THEME_PAINT_KIND_ORDER: ReadonlyArray = [ "foreground", ]; -export const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; +const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe"; const THEME_TOKEN_PROBE_COLOR = "#01fea7"; From 1200f530bdc8be25ca1c85affa42b6d9c259601c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:29 -0700 Subject: [PATCH 38/65] refactor(web): keep app utilities private and remove dead helpers (#10227) --- apps/web/src/browser/browserViewportActions.ts | 2 +- apps/web/src/browser/webviewCrashRecovery.ts | 4 ++-- apps/web/src/browserFaviconLogic.ts | 2 +- apps/web/src/browserHistoryStore.ts | 4 ++-- apps/web/src/clientPersistenceStorage.ts | 2 +- apps/web/src/composerDraftStore.ts | 6 ++---- apps/web/src/desktopAppActivation.ts | 2 +- apps/web/src/keybindings.ts | 8 -------- apps/web/src/lib/attachmentUploadQueue.ts | 2 +- apps/web/src/lib/diffRendering.ts | 4 ++-- apps/web/src/lib/previewAnnotation.ts | 2 +- apps/web/src/lib/storage.ts | 2 +- apps/web/src/lib/terminalContext.ts | 7 ++----- apps/web/src/lib/windowControlsOverlay.ts | 2 +- apps/web/src/logicalProject.ts | 1 - apps/web/src/portDiscoveryState.ts | 2 +- apps/web/src/projectIconOptions.ts | 2 +- apps/web/src/reviewCommentContext.ts | 6 ------ apps/web/src/rightPanelStore.ts | 4 ++-- apps/web/src/test/reactHookHarness.ts | 2 +- apps/web/src/themePalette.ts | 11 +++-------- apps/web/src/versionSkew.ts | 2 +- 22 files changed, 27 insertions(+), 52 deletions(-) diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index b80f68af3f00..64a4345dfb0d 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -4,7 +4,7 @@ type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; -export class BrowserViewportCommitTimeoutError extends Error { +class BrowserViewportCommitTimeoutError extends Error { override readonly name = "BrowserViewportCommitTimeoutError"; constructor(readonly tabId: string) { diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts index 2267f4a812dc..606244d43643 100644 --- a/apps/web/src/browser/webviewCrashRecovery.ts +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -1,6 +1,6 @@ export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; -export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; -export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; +const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; +const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; export interface WebviewCrashRecoveryState { readonly attempts: number; diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts index 695bcff20e95..55adc129c3b2 100644 --- a/apps/web/src/browserFaviconLogic.ts +++ b/apps/web/src/browserFaviconLogic.ts @@ -9,7 +9,7 @@ export type BrowserFaviconEntry = { }; export const BROWSER_FAVICON_MAX_ENTRIES = 40; -export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; +const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4; const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255; diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index 4c0a560817bb..7909fef95700 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -14,7 +14,7 @@ export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; export const BROWSER_HISTORY_MAX_PROJECTS = 20; -export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; const MAX_VALID_DATE_MS = 8_640_000_000_000_000; @@ -35,7 +35,7 @@ export function normalizeHistoryUrl(raw: string): string | null { return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; } -export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { +function titleLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(visitLookupKey(normalized, environmentHostname)); if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index f39ea63c5a7c..e1c1459facb3 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -2,7 +2,7 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; -export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; function hasWindow(): boolean { return typeof window !== "undefined"; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3adb6e45c2ae..9dbc7b9b5c21 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -769,7 +769,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ * slice — adding a new field to the interface (e.g. `elementContexts`) only * has to be reflected here, not in every stub. */ -export function createEmptyThreadDraft(): ComposerThreadDraftState { +function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], @@ -4050,9 +4050,7 @@ export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean { ); } -export function useComposerDraftModelState( - threadRef: ComposerThreadTarget, -): ComposerDraftModelState { +function useComposerDraftModelState(threadRef: ComposerThreadTarget): ComposerDraftModelState { return useComposerDraftStore( useShallow((state) => { const draft = getComposerDraftState(state, threadRef); diff --git a/apps/web/src/desktopAppActivation.ts b/apps/web/src/desktopAppActivation.ts index e9d3a4d1d0f7..d291e7ed54e2 100644 --- a/apps/web/src/desktopAppActivation.ts +++ b/apps/web/src/desktopAppActivation.ts @@ -44,7 +44,7 @@ function failure( return { version: 1, requestId, ok: false, code, message }; } -export function desktopPlatformToEnvironmentOs( +function desktopPlatformToEnvironmentOs( platform: DesktopAppActivationRequest["platform"], ): ExecutionEnvironmentPlatformOs { return platform === "win32" ? "windows" : platform; diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 1b4fa072d5a1..844984d4a02e 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -377,14 +377,6 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } -export function isPreviewRefreshShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "preview.refresh", options); -} - export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 79c1092f94d0..d62aee512887 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -445,7 +445,7 @@ export function startAttachmentUpload(input: { * persisted draft upload survives cancellation (an environment switch cancels * the old job, and the draft still references that server copy). */ -export function cancelAttachmentUpload(imageId: string): void { +function cancelAttachmentUpload(imageId: string): void { const job = jobsByImageId.get(imageId); if (!job) { return; diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 7d031e537c65..2866e88f45f6 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,7 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { FileDiffMetadata } from "@pierre/diffs/types"; -export const DIFF_THEME_NAMES = { +const DIFF_THEME_NAMES = { light: "pierre-light", dark: "pierre-dark", } as const; @@ -81,7 +81,7 @@ interface RenderablePatchOptions { compactPartialHunkOffsets?: boolean; } -export function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { +function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { if (!file.isPartial) return file; let splitLineStart = 0; diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 464c8c8a94d4..beabc856f60e 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -111,7 +111,7 @@ async function previewAnnotationScreenshotFile( } /** Upper bound on turning a picked element's crop into a composer attachment. */ -export const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 87b9b12ea8bc..4c409a3b1ca1 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -26,7 +26,7 @@ export function createMemoryStorage(): StateStorage { }; } -export function isStateStorage( +function isStateStorage( storage: Partial | null | undefined, ): storage is StateStorage { return ( diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 4cdbc019255d..68d85f08d3b1 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -65,7 +65,7 @@ export function filterTerminalContextsWithText( return contexts.filter((context) => hasTerminalContextText(context)); } -export function normalizeTerminalContextSelection( +function normalizeTerminalContextSelection( selection: TerminalContextSelection, ): TerminalContextSelection | null { const text = normalizeTerminalContextText(selection.text); @@ -85,10 +85,7 @@ export function normalizeTerminalContextSelection( }; } -export function formatTerminalContextRange(selection: { - lineStart: number; - lineEnd: number; -}): string { +function formatTerminalContextRange(selection: { lineStart: number; lineEnd: number }): string { return selection.lineStart === selection.lineEnd ? `line ${selection.lineStart}` : `lines ${selection.lineStart}-${selection.lineEnd}`; diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts index 42f9f13c7cda..7c9e8e8553b3 100644 --- a/apps/web/src/lib/windowControlsOverlay.ts +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -43,7 +43,7 @@ export function syncDocumentWindowControlsOverlayClass(): () => void { }; } -export function getElectronPlatformClassNames( +function getElectronPlatformClassNames( platform: string, ): | readonly [typeof ELECTRON_CLASS_NAME] diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 41df8c2013c4..d75c4c2de902 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -4,7 +4,6 @@ export { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, derivePhysicalProjectKeyFromPath, - deriveProjectGroupLabel, deriveProjectGroupingOverrideKey, getProjectOrderKey, resolveProjectGroupingMode, diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index a5623be4d0fe..206dea56d468 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -43,7 +43,7 @@ export function boundConfiguredLocalServerUrls( return bounded; } -export function useDiscoveredPorts( +function useDiscoveredPorts( environmentId: EnvironmentId | null, configuredUrls?: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/projectIconOptions.ts b/apps/web/src/projectIconOptions.ts index 9f2fc6c028be..a2213fdd4bd2 100644 --- a/apps/web/src/projectIconOptions.ts +++ b/apps/web/src/projectIconOptions.ts @@ -1,7 +1,7 @@ import { iconNames, type IconName } from "lucide-react/dynamic"; export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors"; -export const POPULAR_PROJECT_ICONS = [ +const POPULAR_PROJECT_ICONS = [ "folder-code", "code-2", "terminal", diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 41f75eb384f1..d66ca4789a48 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -180,12 +180,6 @@ export function parseReviewCommentMessageSegments( return segments; } -export function hasReviewCommentMessageSegments(value: string): boolean { - return parseReviewCommentMessageSegments(value).some( - (segment) => segment.kind === "review-comment", - ); -} - export function formatReviewCommentFence(language: string, contents: string): string { const longestBacktickRun = Math.max( 0, diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index bc7da2d5a9e7..1173cae3ef38 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -14,7 +14,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = [ +const RIGHT_PANEL_KINDS = [ "diff", "files", "file", @@ -193,7 +193,7 @@ export function pullRequestSurfaceId(target: { return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`; } -export function pullRequestSurface(target: { +function pullRequestSurface(target: { environmentId?: string; projectId: string; repository: string; diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts index 1b4b26fb6988..3a9bf9484ea1 100644 --- a/apps/web/src/test/reactHookHarness.ts +++ b/apps/web/src/test/reactHookHarness.ts @@ -34,7 +34,7 @@ import type { Dispatch, SetStateAction } from "react"; * Call `beginRender()` before each component invocation and `reset()` in * `beforeEach` to drop persisted state between tests. */ -export function createReactHookHarness() { +function createReactHookHarness() { let cursor = 0; let slots: unknown[] = []; const nextIndex = () => cursor++; diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index f4b5c83df395..ef776dced9b7 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -22,15 +22,10 @@ export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; export const T3_CHAT_THEME_ID = "t3-chat" as const; -export const T3_CHAT_THEME_LABEL = "T3 Chat"; -export const GROVE_THEME_ID = "grove" as const; -export const GROVE_THEME_LABEL = "Grove"; +const GROVE_THEME_ID = "grove" as const; export const OCEAN_THEME_ID = "ocean" as const; -export const OCEAN_THEME_LABEL = "Ocean"; -export const EMBER_THEME_ID = "ember" as const; -export const EMBER_THEME_LABEL = "Ember"; -export const IRIS_THEME_ID = "iris" as const; -export const IRIS_THEME_LABEL = "Iris"; +const EMBER_THEME_ID = "ember" as const; +const IRIS_THEME_ID = "iris" as const; export const THEME_FILE_VERSION = 1 as const; export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index d595b12b618e..0c889f17c535 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -12,7 +12,7 @@ export interface VersionMismatch { readonly hint: string; } -export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; +const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; // Runtime failures retain their identity until the next attempt. Dismiss only // that attempt, across chat remounts, without clearing the error in Settings. From da2ba5b81f8be6afc6d54e728bf4ac8781f3ab1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:29 -0700 Subject: [PATCH 39/65] ci: enforce unused runtime exports in the web app (#10228) --- docs/operations/development.md | 5 ++++- knip.jsonc | 4 ++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index 8016a777b223..e07758caa657 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -72,10 +72,13 @@ Windows investigation while that suite is not a required gate. ### Unused code `vp run knip:check` checks unused files and dependencies across the repo, then -unused runtime exports in every internal package under `packages/`. CI enforces both checks. +unused runtime exports in `apps/web` and every internal package under `packages/`. +CI enforces both checks. Exported types and Effect schemas are allowed without consumers. The schema preprocessor recognizes schema types, including aliases and schema classes; functions that create or decode schemas remain checked. Completely unused files remain checked too. +Named exports in web UI component modules are kept as complete component sets. Knip ignores +unused exports in `apps/web/src/components/ui/*.tsx`, while still reporting an entire unused file. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. The full export audit still has findings and is not a repo-wide CI gate. Extend the diff --git a/knip.jsonc b/knip.jsonc index aa665d09602c..c6ba319da5b1 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -43,6 +43,10 @@ "apps/web": { // Worktree setup invokes this directly from t3.json. "entry": ["scripts/warm-dep-cache.ts"], + // UI component modules are copied and adapted as cohesive sets. Keep their + // named subcomponents even before they have callers; the file audit still + // reports an entire component module when nothing imports it. + "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] }, }, "apps/mobile": { // Expo loads local config plugins by string; Metro handles platform variants. diff --git a/package.json b/package.json index 5e72b463a10a..4e5aca36d135 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From 76f686d03456539f41ef295f0071fecb5df08921 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:11:50 -0700 Subject: [PATCH 40/65] test(desktop): cover Clerk setup through the service (#10284) --- apps/desktop/src/app/DesktopClerk.test.ts | 34 ----------------------- apps/desktop/src/app/DesktopClerk.ts | 4 +-- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..1641149e9e35 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -63,17 +63,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { - const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; - - assert.equal( - DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), - "clerk.t3.codes", - ); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); - }); - it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); const events: string[] = []; @@ -208,27 +197,4 @@ describe("DesktopClerk", () => { Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), ); }); - - it.each([ - { isDevelopment: true, scheme: "t3code-dev" }, - { isDevelopment: false, scheme: "t3code" }, - ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue(bridge); - - assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); - assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - passkeys: true, - renderer: { scheme, host: "app" }, - }, - ], - ]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..d3c99e5e1d24 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -53,7 +53,7 @@ export class DesktopClerk extends Context.Service< } >()("@t3tools/desktop/app/DesktopClerk") {} -export function resolveDesktopClerkFrontendApiHostname( +function resolveDesktopClerkFrontendApiHostname( publishableKey: string | undefined, ): string | undefined { const normalizedKey = publishableKey?.trim(); @@ -72,7 +72,7 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { +function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), passkeys: true, From cabac780f5d9566ea26d340c9f126a46664b698b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:12:12 -0700 Subject: [PATCH 41/65] test(desktop): cover WSL hashes through runtime resolution (#10285) --- .../src/backend/DesktopBackendConfiguration.test.ts | 8 -------- apps/desktop/src/backend/DesktopBackendConfiguration.ts | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index accfdf70b3a3..747663b80ac0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -218,14 +218,6 @@ const withPackagedWslHarness = ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("DesktopBackendConfiguration", () => { - it("accepts only normalized SHA-256 archive identities", () => { - assert.equal( - DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`), - "a".repeat(64), - ); - assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123")); - }); - it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..7a8dc8334cf1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -243,7 +243,7 @@ const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; -export const parseWslRuntimeArchiveHash = (value: string): string | null => { +const parseWslRuntimeArchiveHash = (value: string): string | null => { const trimmed = value.trim(); return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; }; From 181e45110f2d14950a5c1ec415faf3a049cb9b2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:12:27 -0700 Subject: [PATCH 42/65] test(desktop): cover password store through startup (#10287) --- .../src/app/DesktopPreReadyPlatform.test.ts | 53 ++++++------------- .../src/app/DesktopPreReadyPlatform.ts | 2 +- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a29e0fd3baf6..a180f45937d8 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -37,45 +37,22 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); }); - it("reads an explicit Electron command-line switch value", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: (switchName) => switchName === "password-store", - getSwitchValue: (switchName) => { - assert.equal(switchName, "password-store"); - return "basic"; - }, - }, - "password-store", + it.effect("preserves an explicit Linux password-store switch", () => { + hasSwitchMock.mockImplementation((switchName) => switchName === "password-store"); + getSwitchValueMock.mockReturnValue(" basic "); + + return Effect.gen(function* () { + const options = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + + assert.equal(options.linuxPasswordStoreCommandLine, "basic"); + assert.isFalse(appendSwitchMock.mock.calls.some(([name]) => name === "password-store")); + }).pipe( + Effect.provide( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ), ); - - assert.equal(value, "basic"); - }); - - it("treats valueless Electron command-line switches as absent", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => true, - getSwitchValue: () => "", - }, - "password-store", - ); - - assert.isNull(value); - }); - - it("returns null for missing Electron command-line switches", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => false, - getSwitchValue: () => { - throw new Error("Unexpected switch value read."); - }, - }, - "password-store", - ); - - assert.isNull(value); }); it.effect( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 7d145632d0bb..718f54115065 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -17,7 +17,7 @@ export interface DesktopPreReadyCommandLineReader { readonly getSwitchValue: (switchName: string) => string; } -export function readCommandLineSwitchValue( +function readCommandLineSwitchValue( commandLine: DesktopPreReadyCommandLineReader, switchName: string, ): string | null { From 0c200c5f83c039f85da0cb1b8d1611db3e708c92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:13:24 -0700 Subject: [PATCH 43/65] test(desktop): cover WSL paths through public behavior (#10289) --- apps/desktop/src/wsl/wslPathParsing.test.ts | 32 ++------------------- apps/desktop/src/wsl/wslPathParsing.ts | 7 ++--- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index 41e358227e1e..dd750164b381 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect } from "vite-plus/test"; import { - DISTRO_NAME_PATTERN, extractDistroFromUncPath, isValidDistroName, parseWslDistroList, - resolveWslHomeUncPath, resolveWslPickFolderDefaultPath, wslUncPathToLinuxPath, } from "./wslPathParsing.ts"; @@ -116,29 +114,6 @@ describe("wslUncPathToLinuxPath", () => { }); }); -describe("resolveWslHomeUncPath", () => { - const distros = [ - { name: "Debian", isDefault: true, version: 2 as const }, - { name: "Ubuntu", isDefault: false, version: 2 as const }, - ]; - - it("uses the configured distro when one is selected", () => { - expect(resolveWslHomeUncPath({ distro: "Ubuntu" }, distros)).toBe( - "\\\\wsl.localhost\\Ubuntu\\home", - ); - }); - - it("uses the actual default distro when config uses the WSL default", () => { - expect(resolveWslHomeUncPath({ distro: null }, distros)).toBe( - "\\\\wsl.localhost\\Debian\\home", - ); - }); - - it("omits the default path when no default distro is known", () => { - expect(resolveWslHomeUncPath({ distro: null }, [])).toBeNull(); - }); -}); - describe("resolveWslPickFolderDefaultPath", () => { const config = { distro: null }; const distros = [{ name: "Debian", isDefault: true, version: 2 as const }]; @@ -184,23 +159,22 @@ describe("resolveWslPickFolderDefaultPath", () => { }); }); -describe("DISTRO_NAME_PATTERN / isValidDistroName", () => { +describe("isValidDistroName", () => { it("accepts common distro names", () => { for (const name of ["Ubuntu", "Ubuntu-22.04", "kali-linux", "Debian", "Ubuntu 22.04"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(true); expect(isValidDistroName(name)).toBe(true); } }); it("rejects names with trailing whitespace, hyphen, or dot", () => { for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); it("rejects names containing control or shell-meta characters", () => { for (const name of ["bad\nname", "bad\tname", "bad/name", "bad!name", "bad;name"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); }); diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index edbab81f6dc2..baae217c823d 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -10,7 +10,7 @@ export interface WslConfig { // Literal space — \s would also match \n/\t/\r and corrupt UNC paths like \\wsl.localhost\\... // Trailing char must also be \w so hand-edited config like "Ubuntu " / "Ubuntu-" / "Ubuntu." rejects. -export const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; +const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; @@ -61,10 +61,7 @@ export function wslUncPathToLinuxPath(windowsPath: string): string | null { return `/${rest.split("\\").filter(Boolean).join("/")}`; } -export function resolveWslHomeUncPath( - config: WslConfig, - distros: readonly WslDistro[], -): string | null { +function resolveWslHomeUncPath(config: WslConfig, distros: readonly WslDistro[]): string | null { const distroName = config.distro ?? distros.find((distro) => distro.isDefault)?.name ?? null; return distroName ? `\\\\wsl.localhost\\${distroName}\\home` : null; } From b4040d9bf38d57cce715c9e602de8de4b0f4a523 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:30:43 -0700 Subject: [PATCH 44/65] test(desktop): exercise WSL cache safety through public scripts (#10301) --- .../src/wsl/DesktopWslEnvironment.test.ts | 55 ++++--------------- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 9 ++- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 9dbe43b9650d..e1188e1a3387 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -12,21 +12,17 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - buildWslNodeEnvPreamble, buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, DesktopWslDistroListError, formatMissingToolsReason, - formatNodePtyProbeFailureReason, - formatWslShellTransportFailureReason, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, parseWslRuntimeRoot, probeWslDistros, - sanitizeWslRuntimeId, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -144,46 +140,19 @@ describe("probeWslDistros", () => { }); }); -describe("formatNodePtyProbeFailureReason", () => { - it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { - const reason = formatNodePtyProbeFailureReason(4); - - expect(reason).toContain("packaged Linux node-pty binary was not included"); - expect(reason).toContain("--wsl-prebuild"); - }); - - it("leaves other node-pty load failures to the compatibility diagnostic", () => { - expect(formatNodePtyProbeFailureReason(1)).toBeNull(); - }); -}); - -describe("formatWslShellTransportFailureReason", () => { - it("distinguishes timeouts and spawn failures from normal shell exit codes", () => { - expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out"); - expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe"); - expect(formatWslShellTransportFailureReason("process")).toContain("lost communication"); - expect(formatWslShellTransportFailureReason(null)).toBeNull(); - }); -}); - -describe("buildWslNodeEnvPreamble", () => { - it("passes the required Node engine range into the shared resolver", () => { - const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); - - expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); - expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( - preamble.lastIndexOf("ensure_remote_node_path || true"), - ); - }); - - it("keeps the shared resolver permissive when no Node engine range is provided", () => { - expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); - }); -}); - describe("WSL runtime cache", () => { - it("sanitizes cache ids before interpolating them into Linux paths", () => { - expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope"); + it.each([ + [ + "install", + (id: string) => buildWslRuntimeInstallScript("/runtime.tar.gz", id, "b".repeat(64)), + ], + ["prune", buildWslRuntimePruneScript], + ["invalidate", buildWslRuntimeInvalidateScript], + ] as const)("sanitizes cache ids in the %s script", (_, buildScript) => { + const runtimeId = "1.2.3/x64; touch /tmp/nope"; + const script = buildScript(runtimeId); + expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); + expect(script).not.toContain(runtimeId); }); it("installs through a temporary directory and only reuses valid completed caches", () => { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index b0c9f5ffe44b..d49d95676e64 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -147,7 +147,7 @@ const TIMEOUT_RESULT: ShellResult = { transportFailure: "timeout", }; -export const formatWslShellTransportFailureReason = ( +const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], ): string | null => { switch (failure) { @@ -165,7 +165,7 @@ export const formatWslShellTransportFailureReason = ( // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node // the same way. Passing the engine range lets the resolver fall through to // version managers like nvm when a system node exists but is too old. -export const buildWslNodeEnvPreamble = ( +const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} ensure_remote_node_path || true @@ -263,8 +263,7 @@ const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; -export const sanitizeWslRuntimeId = (value: string): string => - value.replace(/[^A-Za-z0-9._-]/g, "_"); +const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_"); // `archiveSha256` is the digest the build recorded alongside the archive. The // install verifies the bytes before extracting, so an archive can never be @@ -491,7 +490,7 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; -export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => +const formatNodePtyProbeFailureReason = (exitCode: number): string | null => exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild ` or install a build that includes WSL support." : null; From f93aafcc2c166128070b32259e827f15bc2a1d82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:30:47 -0700 Subject: [PATCH 45/65] test(web): cover file classification through diff ordering (#10304) --- .../pullRequestFileOrder.logic.test.ts | 51 +++++++++---------- .../pullRequest/pullRequestFileOrder.logic.ts | 2 +- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index 720a5669178f..d3d5d958a49f 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -1,7 +1,7 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { diffFileTier, orderDiffFiles } from "./pullRequestFileOrder.logic"; +import { orderDiffFiles } from "./pullRequestFileOrder.logic"; /** Only the path and the patch's own lines matter here; the viewer fills the rest in. */ function file(name: string, additionLines: ReadonlyArray = []): FileDiffMetadata { @@ -12,34 +12,31 @@ function order(files: ReadonlyArray): Array { return orderDiffFiles(files).map((entry) => entry.name); } -describe("diffFileTier", () => { - it("puts lockfiles, snapshots and build output last", () => { - expect(diffFileTier("pnpm-lock.yaml")).toBe("generated"); - expect(diffFileTier("apps/web/package-lock.json")).toBe("generated"); - expect(diffFileTier("src/__snapshots__/app.ts")).toBe("generated"); - expect(diffFileTier("src/app.test.ts.snap")).toBe("generated"); - expect(diffFileTier("src/api.generated.ts")).toBe("generated"); - expect(diffFileTier("public/app.min.js")).toBe("generated"); - expect(diffFileTier("dist/app.js")).toBe("generated"); - expect(diffFileTier("packages/core/vendor/lib.js")).toBe("generated"); - }); - - it("recognises a test by its name or by the directory holding it", () => { - expect(diffFileTier("src/app.test.ts")).toBe("test"); - expect(diffFileTier("src/app.spec.tsx")).toBe("test"); - expect(diffFileTier("src/__tests__/app.ts")).toBe("test"); - expect(diffFileTier("test/app.ts")).toBe("test"); - expect(diffFileTier("tests/helpers/app.ts")).toBe("test"); - }); - - it("treats everything else as source, including files merely named like a directory", () => { - expect(diffFileTier("src/app.ts")).toBe("source"); - expect(diffFileTier("src/testing.ts")).toBe("source"); - expect(diffFileTier("src/dist.ts")).toBe("source"); +describe("orderDiffFiles", () => { + it("places source before tests and generated files across path conventions", () => { + const source = ["src/app.ts", "src/dist.ts", "src/testing.ts"]; + const tests = [ + "src/__tests__/app.ts", + "src/app.spec.tsx", + "src/app.test.ts", + "test/app.ts", + "tests/helpers/app.ts", + ]; + const generated = [ + "apps/web/package-lock.json", + "dist/app.js", + "packages/core/vendor/lib.js", + "pnpm-lock.yaml", + "public/app.min.js", + "src/__snapshots__/app.ts", + "src/api.generated.ts", + "src/app.test.ts.snap", + ]; + expect( + order([...generated, ...tests, ...source].toReversed().map((path) => file(path))), + ).toEqual([...source, ...tests, ...generated]); }); -}); -describe("orderDiffFiles", () => { it("answers an empty diff with an empty order", () => { expect(order([])).toEqual([]); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts index b46ed88539c2..1a0df9d9beda 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts @@ -31,7 +31,7 @@ const GENERATED_DIRECTORIES = new Set([ const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]); const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; -export function diffFileTier(path: string): DiffFileTier { +function diffFileTier(path: string): DiffFileTier { const segments = path.split("/"); const name = segments.at(-1) ?? ""; if ( From a9fc4dc2b010db7979dee9d11c94580fee62b44d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:31:33 -0700 Subject: [PATCH 46/65] test(web): focus command palette tests on search behavior (#10302) --- .../components/CommandPalette.logic.test.ts | 60 ++++++++----------- .../src/components/CommandPalette.logic.ts | 2 - 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 65f183940018..b11d88b764b1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,46 +2,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { - browseInputEndPaddingClass, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, - normalizeSearchText, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; -describe("browseInputEndPaddingClass", () => { - it("reserves the widest space for the create action", () => { - expect( - browseInputEndPaddingClass({ - willCreateProjectPath: true, - hasHighlightedBrowseItem: false, - }), - ).toContain("pe-38"); - }); - - it("reserves space for the wider highlighted-item shortcut", () => { - expect( - browseInputEndPaddingClass({ - willCreateProjectPath: false, - hasHighlightedBrowseItem: true, - }), - ).toContain("pe-30"); - }); - - it("keeps the compact reserve for the normal add action", () => { - expect( - browseInputEndPaddingClass({ - willCreateProjectPath: false, - hasHighlightedBrowseItem: false, - }), - ).toContain("pe-24"); - }); -}); - describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; @@ -333,10 +302,33 @@ describe("buildThreadActionItems", () => { }); it("normalizes case independently of the host locale", () => { - const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); + const toLocaleLowerCase = String.prototype.toLocaleLowerCase; + const localeLowerCase = vi + .spyOn(String.prototype, "toLocaleLowerCase") + .mockImplementation(function (this: string) { + return toLocaleLowerCase.call(this, "tr"); + }); try { - expect(normalizeSearchText("GIT")).toBe("git"); - expect(localeLowerCase).not.toHaveBeenCalled(); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "GIT", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + settingsSearchItems: [ + { + kind: "action", + value: "setting:version-control", + title: "Version control", + searchTerms: ["git"], + icon: null, + run: async () => undefined, + }, + ], + }); + expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([ + "setting:version-control", + ]); } finally { localeLowerCase.mockRestore(); } diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index a0af1be0450b..2492ca0cf986 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -13,8 +13,6 @@ import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; -export { normalizeSearchText } from "../lib/utils"; - export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; From fc7ad2edaeedabebcbe1ad6d2f1a4780f0516310 Mon Sep 17 00:00:00 2001 From: Hwanseo Choi Date: Sun, 6 Sep 2026 14:32:00 +0900 Subject: [PATCH 47/65] fix(web): add project settings to legacy sidebar project menu (#10021) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/LegacySidebar.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 0eb6a74ebe80..1093157710be 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1725,11 +1725,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; }; + actionHandlers.set("project-settings", () => { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + }); + const clicked = await api.contextMenu.show( [ buildTargetedItem("rename", "Rename"), buildTargetedItem("grouping", "Group into..."), buildTargetedItem("copy-path", "Copy Path"), + { id: "project-settings", label: "Project settings", icon: "settings" }, buildTargetedItem("delete", "Remove", { destructive: true, }), @@ -1750,10 +1759,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, + isMobile, openProjectGroupingDialog, openProjectRenameDialog, project.groupedProjectCount, project.memberProjects, + project.projectKey, + router, + setOpenMobile, suppressProjectClickForContextMenuRef, ], ); From b972f1c1dfc827c2fb074af92f52a9f8fc1fcda5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:32:35 -0700 Subject: [PATCH 48/65] test(web): keep Markdown gutter styling private (#10306) --- apps/web/src/components/ChatMarkdown.test.tsx | 42 ------------------- apps/web/src/components/ChatMarkdown.tsx | 2 +- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index f4551a93088e..3243bf3c2788 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -60,7 +60,6 @@ vi.mock("~/lib/openPullRequestLink", () => ({ import ChatMarkdown, { canUseMarkdownFileShellActions, hasMarkdownFilePrimaryAction, - orderedListGutterStyle, shouldUseMarkdownFileBrowserPrimaryAction, } from "./ChatMarkdown"; @@ -640,47 +639,6 @@ describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { }); }); -describe("orderedListGutterStyle", () => { - it("leaves the default gutter alone for single-digit lists", () => { - expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); - }); - - it("widens the gutter for two-digit lists", () => { - expect(orderedListGutterStyle(99, undefined)).toEqual({ "--list-gutter": "3ch" }); - }); - - it("widens the gutter for a two-digit list that starts above 1", () => { - // start=50 + 49 items => last marker is "98", still two digits. - expect(orderedListGutterStyle(49, 50)).toEqual({ "--list-gutter": "3ch" }); - }); - - it("widens the gutter once the last marker reaches three digits", () => { - // item 100 is the bug from #6512: a 100-item list starting at 1. - expect(orderedListGutterStyle(100, undefined)).toEqual({ "--list-gutter": "4ch" }); - }); - - it("accounts for a non-default start attribute", () => { - // start=95 + 9 items => last marker is "103", three digits. - expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); - expect(orderedListGutterStyle(5, "999995")).toEqual({ "--list-gutter": "7ch" }); - }); - - it("scales further for four-digit markers", () => { - expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); - }); - - it("uses the widest marker and includes a negative start's minus sign", () => { - expect(orderedListGutterStyle(1001, -1000)).toEqual({ "--list-gutter": "6ch" }); - expect(orderedListGutterStyle(3, -15)).toEqual({ "--list-gutter": "4ch" }); - expect(orderedListGutterStyle(3, -5)).toEqual({ "--list-gutter": "3ch" }); - }); - - it("treats a missing/zero item count as a single item", () => { - expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); - expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); - }); -}); - describe("ChatMarkdown Windows file links", () => { const environmentId = EnvironmentId.make("env-windows"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4e592c406b1d..89d886ec7d1b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -340,7 +340,7 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb * message's overflow. Widen the gutter to fit the widest marker, including a * negative marker's minus sign. */ -export function orderedListGutterStyle( +function orderedListGutterStyle( itemCount: number, start: unknown, ): { "--list-gutter": string } | undefined { From 9f40b2f563c662b43887b11ff99c466fe871c1af Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 02:32:48 -0300 Subject: [PATCH 49/65] feat(settings): add shared project defaults and scoped overrides (#9754) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/ThreadRouteScreen.tsx | 21 +- .../threads/new-task-flow-provider.tsx | 4 +- .../project/ProjectSetupScriptRunner.test.ts | 61 + .../src/project/ProjectSetupScriptRunner.ts | 22 +- .../provider/Layers/ProviderService.test.ts | 82 +- .../src/provider/Layers/ProviderService.ts | 22 +- apps/server/src/server.ts | 6 +- apps/server/src/serverRuntimeStartup.test.ts | 64 +- apps/server/src/serverRuntimeStartup.ts | 21 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 17 +- apps/web/src/components/ChatView.tsx | 89 +- apps/web/src/components/CommandPalette.tsx | 2 + .../src/components/chat/DraftHeroHeadline.tsx | 9 +- .../DesktopAppActivationCoordinator.tsx | 7 +- .../components/onboarding/WelcomeWizard.tsx | 7 +- .../settings/IntegrationsSettings.test.tsx | 14 +- .../settings/IntegrationsSettings.tsx | 40 +- .../settings/ProjectActionsList.tsx | 69 ++ .../ProjectDefaultActionsSettings.tsx | 114 ++ .../settings/ProjectDefaultsSettings.tsx | 474 ++++++++ .../settings/ProjectSettingsPanel.tsx | 1058 ++++++++++------- .../components/settings/ProjectsSettings.tsx | 160 +++ .../components/settings/SettingsPanels.tsx | 51 +- .../settings/SettingsSidebarNav.tsx | 10 +- .../components/settings/settingsLayout.tsx | 10 +- .../src/components/settings/settingsSearch.ts | 15 +- apps/web/src/hooks/useHandleNewThread.test.ts | 26 +- apps/web/src/hooks/useHandleNewThread.ts | 30 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/projects.$projectKey.tsx | 10 +- apps/web/src/routes/settings.projects.tsx | 27 + docs/user/project-settings.md | 21 +- .../src/state/sharedSettings.test.ts | 16 +- .../src/state/sharedSettings.ts | 1 - packages/contracts/src/settings.ts | 37 +- packages/shared/src/projectScripts.ts | 22 +- packages/shared/src/serverSettings.test.ts | 168 +++ packages/shared/src/serverSettings.ts | 54 + 38 files changed, 2236 insertions(+), 646 deletions(-) create mode 100644 apps/web/src/components/settings/ProjectActionsList.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectsSettings.tsx create mode 100644 apps/web/src/routes/settings.projects.tsx diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index df9486e8556a..f63a206e7ef7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -7,12 +7,21 @@ import { } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ThreadId, + type ProjectScript, +} from "@t3tools/contracts"; import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; @@ -627,7 +636,12 @@ function ThreadRouteContent( gitOperationLabel: gitState.gitOperationLabel, canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), - projectScripts: selectedThreadProject?.scripts ?? [], + projectScripts: selectedThreadProject + ? resolveProjectScripts( + routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedThreadProject, + ) + : [], terminalSessions: terminalMenuSessions, showDirectFileControl: layout.usesSplitView, onOpenTerminal: handleOpenTerminal, @@ -819,6 +833,7 @@ function ThreadRouteContent( <> {activeInspectorRenderer ? : null} , + settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), + Layer.provide(settings), ); describe("ProjectSetupScriptRunner", () => { + it.effect("runs the inherited machine setup action in the checkout's worktree", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-default-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toMatchObject({ status: "started", scriptId: "default-setup" }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a" }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + data: "npm install\r", + }); + }).pipe( + Effect.provide( + testLayer( + makeProject([]), + { open, write }, + ServerSettings.layerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "npm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), + ), + ), + ); + }); + it.effect("returns no-script when no setup script exists", () => { const open = vi.fn(() => Effect.die("unexpected open")); const write = vi.fn(() => Effect.die("unexpected write")); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf489..6a79c853dc99 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,9 @@ import { ProjectId } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,6 +11,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; export interface ProjectSetupScriptRunnerResultNoScript { @@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); + const script = setupProjectScript(resolveProjectScripts(settings, project)); if (!script) { return { status: "no-script", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 238265ec5a45..fecd7fca9096 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -19,6 +19,8 @@ import { EnvironmentId, EventId, MessageId, + OrchestrationThreadShell, + ProjectId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderDriverKind, ProviderInstanceId, @@ -75,6 +77,7 @@ import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( @@ -4302,10 +4305,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { ); }); +const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); + describe("agent browser access", () => { const revokedThreads: Array = []; + const projectId = ProjectId.make("project-browser-access"); - const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) => + const startSessionWith = ( + enableAgentBrowserAccess: boolean, + threadId: ThreadId, + projectOverride?: boolean, + ) => Effect.gen(function* () { const issued: Array = []; const codex = makeFakeCodexAdapter(); @@ -4319,6 +4329,49 @@ describe("agent browser access", () => { const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); + const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getImportedAgentSessionSources: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: (requestedThreadId) => + Effect.gen(function* () { + assert.equal(requestedThreadId, threadId); + return Option.some( + yield* decodeBrowserAccessThreadShell({ + id: threadId, + projectId, + title: "Browser access test", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"), + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }), + ); + }).pipe(Effect.orDie), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { @@ -4329,7 +4382,14 @@ describe("agent browser access", () => { }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })), + Layer.provide(projectionLayer), + Layer.provide( + ServerSettings.ServerSettingsService.layerTest({ + enableAgentBrowserAccess, + projectAgentBrowserAccessOverrides: + projectOverride === undefined ? {} : { [projectId]: projectOverride }, + }), + ), Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( @@ -4387,4 +4447,22 @@ describe("agent browser access", () => { assert.deepEqual(issued, [threadId]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("withholds and revokes MCP credentials when the project disables browser access", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-off"); + revokedThreads.length = 0; + const issued = yield* startSessionWith(true, threadId, false); + assert.deepEqual(issued, []); + assert.deepEqual(revokedThreads, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("requests an MCP credential when the project overrides browser access to on", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-on"); + const issued = yield* startSessionWith(false, threadId, true); + assert.deepEqual(issued, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b853d779763c..d9cac46ec4d9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -32,6 +32,7 @@ import { import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -72,6 +73,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const isModelSelection = Schema.is(ModelSelection); interface PendingCompaction { @@ -323,6 +325,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = @@ -714,8 +719,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentBrowserAccess), + const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + function* (threadId: ThreadId) { + const settings = yield* serverSettings.getSettings; + if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { + return settings.enableAgentBrowserAccess; + } + // Provider-only runtimes may omit orchestration. An unresolved project + // must not bypass an explicit browser override. + if (Option.isNone(projectionQuery)) return false; + const thread = yield* projectionQuery.value.getThreadShellById(threadId); + if (Option.isNone(thread)) return false; + return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + }, Effect.catch((cause) => Effect.logWarning( "Could not read server settings; withholding agent browser access for this session.", @@ -726,7 +742,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled)) { + if (!(yield* agentBrowserAccessEnabled(threadId))) { // Revoke as well as clear. Every other prepare path reaches // `issueActiveMcpCredential`, which revokes the thread first, so // skipping it here would leave a previously issued bearer token valid diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c0be0c444573..4abe43d8a631 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -317,7 +317,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( ); const GitManagerLayerLive = GitManager.layer.pipe( - Layer.provideMerge(ProjectSetupScriptRunner.layer), + Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -353,7 +353,9 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), - Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe(Layer.provide(ServerSettingsLayerLive)), + ), ), ), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 8cba52552270..88e3c2e88588 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -14,6 +14,7 @@ import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => @@ -40,7 +41,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; const project = (workspaceRoot: string, autoPull = true) => - ({ workspaceRoot, autoPull }) as never; + ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; yield* ServerRuntimeStartup.autoPullProjects([ project("/clean"), @@ -52,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); + + pulled.length = 0; + yield* ServerRuntimeStartup.autoPullProjects( + [project("/inherited", false), project("/opted-out"), project("/dirty", false)], + { + defaultAutoPull: true, + projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, + }, + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + assert.deepStrictEqual(pulled, ["/inherited"]); }), ); @@ -124,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -189,8 +201,19 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }); }); -it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => +it.effect.each([ + { existing: false, machineModel: null, projectModel: null }, + { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" }, +])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) => Effect.gen(function* () { + const machineSelection = machineModel + ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } + : null; + const projectSelection = projectModel + ? { instanceId: ProviderInstanceId.make("codex"), model: projectModel } + : null; const dispatchCalls = yield* Ref.make< ReadonlyArray<{ readonly type: string; @@ -199,6 +222,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -212,7 +236,21 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + existing + ? Option.some({ + id: ProjectId.make("existing-project"), + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: projectSelection, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }) + : Option.none(), + ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -241,18 +279,22 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); - assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapProjectCreated, !existing); assert.equal(targets.bootstrapThreadCreated, true); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), - ["project.create", "thread.create"], + existing ? ["thread.create"] : ["project.create", "thread.create"], + ); + if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); + assert.deepStrictEqual( + commands.at(-1)?.modelSelection, + projectSelection ?? + machineSelection ?? { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, ); - assert.equal("defaultModelSelection" in commands[0]!, false); - assert.deepStrictEqual(commands[1]?.modelSelection, { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); }), ); @@ -262,6 +304,7 @@ it.effect( Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -322,6 +365,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa const dispatchCalls = yield* Ref.make>([]); const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 2a12dbb3637c..6f8bbc053b0c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_SERVER_SETTINGS, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -9,6 +10,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -198,6 +200,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + const defaultModelSelection = + settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); yield* Effect.gen(function* () { const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( serverConfig.cwd, @@ -209,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const createdAt = DateTime.formatIso(yield* DateTime.now); nextProjectId = ProjectId.make(yield* randomUUID); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextThreadModelSelection = getAutoBootstrapThreadModelSelection(); + nextThreadModelSelection = defaultModelSelection; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.make(yield* randomUUID), @@ -224,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); + existingProject.value.defaultModelSelection ?? defaultModelSelection; } yield* Effect.gen(function* () { @@ -737,12 +742,16 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, + settings: Pick< + typeof DEFAULT_SERVER_SETTINGS, + "defaultAutoPull" | "projectAutoPullOverrides" + > = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => project.autoPull === true) + .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) .map((project) => project.workspaceRoot), ), ]; @@ -813,7 +822,11 @@ export const make = (options?: StartupOptions) => const reactorScope = yield* Scope.make("sequential"); const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe( - Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)), + Effect.flatMap((snapshot) => + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => autoPullProjects(snapshot.projects, settings)), + ), + ), Effect.catch((cause) => Effect.logWarning("Failed to load projects for automatic pull", { cause }), ), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 04d320c03bf9..c00a07f2a7a9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,10 +22,12 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect( VcsAutoPullPolicy, Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; return { - isEnabled: (cwd: string) => - snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( - Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), - Effect.orElseSucceed(() => false), - ), + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); + if (project._tag === "None") return false; + const settings = yield* serverSettings.getSettings; + return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + }, + Effect.orElseSucceed(() => false), + ), }; }), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c9c2c60badb5..d9d66df9cd51 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,7 +53,11 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -276,7 +280,6 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -1386,7 +1389,9 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); - const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); @@ -1474,9 +1479,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - // New-thread defaults live in the primary environment's settings.json (the - // settings UI never writes to remote environments), so read them from the - // primary server rather than the thread's environment. const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -1758,10 +1760,17 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + fallbackDraftProject?.defaultModelSelection ?? + settings.defaultModelSelection ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], + [ + draftThread, + fallbackDraftProject?.defaultModelSelection, + settings.defaultModelSelection, + threadId, + ], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not @@ -1987,6 +1996,12 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + const activeProjectScripts = useMemo( + () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), + [activeProject, settings], + ); + const activeProjectDefaultModelSelection = + activeProject?.defaultModelSelection ?? settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2035,8 +2050,8 @@ export default function ChatView(props: ChatViewProps) { [activeProjectKey], ); const configuredPreviewUrls = useMemo( - () => getConfiguredPreviewUrls(activeProject?.scripts), - [activeProject?.scripts], + () => getConfiguredPreviewUrls(activeProjectScripts), + [activeProjectScripts], ); useEffect(() => { @@ -2280,7 +2295,7 @@ export default function ChatView(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? + activeProjectDefaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, @@ -2514,14 +2529,14 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId, activeThread?.session?.providerInstanceId, activeThread?.modelSelection.instanceId, - activeProject?.defaultModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, ], lockedProvider, lockedInstanceId: activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId, }), [ - activeProject?.defaultModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, activeThread?.modelSelection.instanceId, activeThread?.session?.providerInstanceId, lockedProvider, @@ -3652,11 +3667,14 @@ export default function ChatView(props: ChatViewProps) { keybindingCommand: KeybindingCommand; }): Promise> => { const updateResult = mapAtomCommandResult( - await updateProject({ + await updateProjectScriptSettings({ environmentId, input: { - projectId: input.projectId, - scripts: input.nextScripts, + patch: { + projectScriptOverrides: { + [input.projectId]: input.nextScripts, + }, + }, }, }), () => undefined, @@ -3681,7 +3699,7 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, updateProjectScriptSettings, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -3690,28 +3708,28 @@ export default function ChatView(props: ChatViewProps) { } const nextId = nextProjectScriptId( input.name, - activeProject.scripts.map((script) => script.id), + activeProjectScripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ - ...activeProject.scripts.map((script) => + ...activeProjectScripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] - : [...activeProject.scripts, nextScript]; + : [...activeProjectScripts, nextScript]; return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const updateProjectScript = useCallback( async ( @@ -3721,13 +3739,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeProject) { return AsyncResult.success(undefined); } - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + const existingScript = activeProjectScripts.find((script) => script.id === scriptId); if (!existingScript) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } const updatedScript = buildProjectScript(existingScript.id, input); - const nextScripts = activeProject.scripts.map((script) => + const nextScripts = activeProjectScripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate @@ -3738,27 +3756,27 @@ export default function ChatView(props: ChatViewProps) { return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const deleteProjectScript = useCallback( async (scriptId: string): Promise> => { if (!activeProject) { return AsyncResult.success(undefined); } - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId); - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; + const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name; const result = await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), @@ -3780,7 +3798,7 @@ export default function ChatView(props: ChatViewProps) { } return result; }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const handleRuntimeModeChange = useCallback( @@ -6061,7 +6079,7 @@ export default function ChatView(props: ChatViewProps) { const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); + const script = activeProjectScripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); event.stopPropagation(); @@ -6072,6 +6090,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, activeRightPanelSurface, + activeProjectScripts, addTerminalSurface, activeThreadRef, activeThreadPinned, @@ -6747,7 +6766,7 @@ export default function ChatView(props: ChatViewProps) { const title = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, - ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, + ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); @@ -7885,7 +7904,7 @@ export default function ChatView(props: ChatViewProps) { activeProjectFaviconPath={activeProject?.faviconPath ?? null} activeProjectIcon={activeProject?.projectIcon ?? null} openInCwd={gitCwd} - activeProjectScripts={activeProject?.scripts} + activeProjectScripts={activeProjectScripts} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } @@ -8124,9 +8143,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} - activeProjectDefaultModelSelection={ - activeProject?.defaultModelSelection - } + activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} compactThreadUnavailable={compactThreadUnavailable} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b0dd3febebcb..e54671f8e2fa 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1791,6 +1791,8 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 04bbeb6ce49b..98f9caa2300a 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -149,8 +149,13 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - if (project.defaultModelSelection) { - setModelSelection(draftId, project.defaultModelSelection, { + const defaultModelSelection = + project.defaultModelSelection ?? + environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, }); } diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index e97a46a2a258..3e941e69dd53 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; import { projectEnvironment } from "../../state/projects"; @@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() { ) ?? null, createProject: async (environmentId, workspaceRoot) => { const projectId = newProjectId(); - const providers = - primaryEnvironment?.environmentId === environmentId - ? (primaryEnvironment.serverConfig?.providers ?? []) - : []; const result = await createProject({ environmentId, input: { @@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() { title: inferProjectTitleFromPath(workspaceRoot), workspaceRoot, createWorkspaceRootIfMissing: false, - defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + defaultModelSelection: null, }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index cb31ef259667..62665aa86ebf 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -51,7 +51,6 @@ import { } from "../../onboarding/targetEnvironment.logic"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { newProjectId, randomUUID } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; import { readProjects, useProjects } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; @@ -1029,9 +1028,6 @@ function ImportStep({ const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); const environmentId = targetEnvironment?.environmentId ?? null; const machineLabel = targetEnvironment?.label ?? "this machine"; - const providers = useAtomValue( - serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)), - ); const scan = useEnvironmentQuery( environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), ); @@ -1123,7 +1119,6 @@ function ImportStep({ const importGeneration = importGenerationRef.current; const importedProjects = importedProjectsRef.current; const projectAttempts = projectAttemptsRef.current; - const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null); // Interrupted imports are neither failures nor successes — the command was // superseded or the environment dropped — but they still didn't land, so // they must not read as "imported everything". Retries skip paths that @@ -1164,7 +1159,7 @@ function ImportStep({ title: candidate.title, workspaceRoot: candidate.path, createWorkspaceRootIfMissing: false, - defaultModelSelection, + defaultModelSelection: null, }, }); if ( diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 1a8dc7d6aae8..5d185fee5824 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -1,4 +1,10 @@ import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; import { act, StrictMode, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -44,13 +50,19 @@ afterEach(async () => { }); async function openSettings() { + const router = createRouter({ + routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), + history: createMemoryHistory(), + }); + await router.load(); await act(() => { renderer = create( - + , ); }); + expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined(); } describe("Integrations browser discovery", () => { diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index e950bab31cbe..514c241a3c57 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -20,7 +20,6 @@ import { DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, - DEFAULT_UNIFIED_SETTINGS, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, @@ -35,6 +34,7 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; +import { Link } from "@tanstack/react-router"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useCallback, useRef, useState, type ReactNode } from "react"; @@ -86,7 +86,6 @@ import { persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, - usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) } function AgentBrowserAccessSetting() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - return ( - updateSettings({ - enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, - }) - } - /> - ) : null - } + description="Choose whether agents can use the preview browser for all projects or a specific project." control={ - - updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) + } /> ); diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx new file mode 100644 index 000000000000..1794a5fdaa2e --- /dev/null +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -0,0 +1,69 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { SettingsIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { commandForProjectScript } from "../../projectScripts"; +import { ScriptIcon } from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { SettingsRow } from "./settingsLayout"; + +export function ProjectActionsList({ + scripts, + keybindings, + disabled, + onEdit, +}: { + scripts: readonly ProjectScript[]; + keybindings: ResolvedKeybindingsConfig; + disabled: boolean; + onEdit: (script: ProjectScript) => void; +}) { + if (scripts.length === 0) + return ( +

+ No actions configured. +

+ ); + return scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, commandForProjectScript(script.id)); + return ( + + + {script.name} + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} + + } + description={{script.command}} + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> + ); + }); +} diff --git a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx new file mode 100644 index 000000000000..4385a5901b5e --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx @@ -0,0 +1,114 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useEnvironments } from "../../state/environments"; +import { + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + type ProjectScriptEditorRequest, +} from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { useProjectScriptSettings } from "./ProjectSettingsPanel"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +export function ProjectDefaultActionsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const targets = environments.filter( + (environment) => + (environmentId === null || environment.environmentId === environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig !== null, + ); + const representative = targets[0]?.serverConfig; + const scripts = representative?.settings.defaultProjectScripts ?? []; + const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const mixed = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !== + JSON.stringify(scripts), + ); + const [request, setRequest] = useState(null); + const { saving, persist, submit } = useProjectScriptSettings( + targets.flatMap(({ environmentId, serverConfig }) => + serverConfig + ? [ + { + environmentId, + settings: serverConfig.settings, + keybindings: serverConfig.keybindings, + }, + ] + : [], + ), + ); + + return ( + + + Import scripts + + } + /> + (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0, + ) ? ( + void persist(() => [])} + /> + ) : null + } + control={ + + } + /> + {mixed ? ( + + ) : ( + setRequest(editorRequestForScript(script, keybindings))} + /> + )} + + void persist((current) => current.filter((script) => script.id !== id), id, null) + } + onClose={() => setRequest(null)} + /> + + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx new file mode 100644 index 000000000000..938000e01002 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -0,0 +1,474 @@ +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProviderInstanceId, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useNavigate } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { Trash2Icon } from "lucide-react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { Switch } from "../ui/switch"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; +import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; +import { searchableSetting } from "./settingsSearch"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; + +/** Defaults are written only to the machines selected on the projects settings page. */ +export function ProjectDefaultsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clientSettings = useClientSettings(); + const updateClientSettings = useUpdateClientSettings(); + const navigate = useNavigate(); + const updateSettings = useAtomCommand( + serverEnvironment.updateSettings, + "project defaults update", + ); + const savingRef = useRef(new Set()); + const [saving, setSaving] = useState>(new Set()); + const scoped = environments.filter( + (environment) => environmentId === null || environment.environmentId === environmentId, + ); + const targets = scoped.filter( + (environment) => + environment.connection.phase === "connected" && environment.serverConfig !== null, + ); + const representative = + targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; + const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; + const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const settings = { ...serverSettings, ...clientSettings }; + const storedSelection = serverSettings.defaultModelSelection; + const selection = resolveDefaultProviderModelSelection(providers, storedSelection); + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), + ); + const modelOptions = getCustomModelOptionsByInstance( + settings, + providers, + selection?.instanceId, + selection?.model, + ); + const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); + const mixedModel = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !== + JSON.stringify(storedSelection), + ); + const mixedWorkspace = targets.some( + (target) => + target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, + ); + const mixedBrowser = targets.some( + (target) => + target.serverConfig?.settings.enableAgentBrowserAccess !== + serverSettings.enableAgentBrowserAccess, + ); + const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key); + const mixedAutoPull = targets.some( + (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, + ); + + function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { + const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); + for (const target of targets) { + const config = target.serverConfig; + if (!config) continue; + const entry = applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === instanceId); + const options = getCustomModelOptionsByInstance( + { ...config.settings, ...clientSettings }, + config.providers, + ).get(instanceId); + if ( + !entry?.enabled || + !entry.isAvailable || + entry.driverKind !== sourceEntry?.driverKind || + !options?.some((option) => option.slug === model && !option.isUnavailable) + ) { + return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`; + } + } + return null; + } + + async function save(patch: ServerSettingsPatch) { + const keys = Object.keys(patch); + if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return; + const nextModel = patch.defaultModelSelection; + const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null; + if (reason) { + toastManager.add({ type: "error", title: "Default model not saved", description: reason }); + return; + } + for (const key of keys) savingRef.current.add(key); + setSaving(new Set(savingRef.current)); + try { + const results = await Promise.all( + targets.map((target) => + updateSettings({ environmentId: target.environmentId, input: { patch } }), + ), + ); + const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure"); + if (failedTargets.length > 0) { + toastManager.add({ + type: "error", + title: "Project defaults not saved on every machine", + description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`, + }); + } + } finally { + for (const key of keys) savingRef.current.delete(key); + setSaving(new Set(savingRef.current)); + } + } + + const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); + return ( + + + + } + /> + + + +
+ } + /> + {scoped.length > targets.length || targets.length === 0 ? ( +

+ {targets.length === 0 + ? "Connect a machine to change its project defaults." + : "Changes apply to connected machines only. Offline machines keep their current defaults."} +

+ ) : null} + 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 + ) + } + /> + + void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode }) + } + /> + ) : null + } + control={ + + } + /> + void save({ defaultAutoPull: false })} + /> + ) : null + } + control={ + void save({ defaultAutoPull: enabled })} + /> + } + /> + + void save({ + enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, + }) + } + /> + ) : null + } + control={ + + } + /> + + + + + + + + } + /> + + void updateClientSettings({ + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + Remove checkout + + } + /> + + + + + + Remove project + + } + /> + + + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 0181041ec6b1..f4ffe699466e 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,52 +12,53 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; -import type { - ContextMenuItem, - ModelSelection, - ProjectIconOverride, - ProviderDriverKind, - SidebarProjectGroupingMode, - T3ProjectFileScript, - ThreadEnvMode, +import { + type EnvironmentId, + type ModelSelection, + type ProjectIconOverride, + type ProjectId, + type ProjectScript, + type ResolvedKeybindingsConfig, + type ServerSettings, + type ProviderDriverKind, + type SidebarProjectGroupingMode, + type T3ProjectFileScript, + type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { + projectScriptsInheritDefaults, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; +import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; -import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; -import { isElectron } from "../../env"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, - usePrimarySettings, } from "../../hooks/useSettings"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; -import { shortcutLabelForCommand } from "../../keybindings"; -import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; -import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; -import { readLocalApi } from "../../localApi"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { isElectron } from "../../env"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "../../lib/projectScriptKeybindings"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; -import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; +import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; +import { readLocalApi } from "../../localApi"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -98,16 +99,8 @@ import { MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -127,14 +120,14 @@ const ProjectIconPickerDialog = lazy(() => })), ); -const PROJECT_GROUPING_MODE_LABELS: Record = { +export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; /** Logical project groups for the settings page, sorted by display name. */ -function useSettingsProjectGroups(): SidebarProjectSnapshot[] { +export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { - const navigate = useNavigate(); - const canGoBack = useCanGoBack(); - const navigateBackWithinApp = useCallback(() => { - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, navigate]); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - event.preventDefault(); - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - activeElement.blur(); - } - navigateBackWithinApp(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateBackWithinApp]); - - return ( - -
- - - - -
-
- ); -} - -function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { - const groups = useSettingsProjectGroups(); - const navigate = useNavigate(); - const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const openProjectMenu = (event: ReactMouseEvent) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const items: ContextMenuItem[] = groups.map((group) => ({ - id: group.projectKey, - label: group.displayName, - })); - void settlePromise(() => - api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), - ).then((clicked) => { - if (clicked._tag === "Failure" || clicked.value === null) return; - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: clicked.value }, - replace: true, - hashScrollIntoView: false, - }); - }); - }; - - return ( - - Projects - - - {selected ? ( - - ) : ( - Unavailable project - )} - - - ); -} - -function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +export function ProjectSettingsPanel({ + projectKey, + environmentId = null, +}: { + projectKey: string; + environmentId?: EnvironmentId | null; +}) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; + const members = useMemo( + () => + selected?.memberProjects.filter( + (member) => environmentId === null || member.environmentId === environmentId, + ) ?? [], + [selected, environmentId], + ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. - const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); + const lastSelectionRef = useRef<{ + key: string; + environmentId: EnvironmentId | null; + memberKeys: string[]; + } | null>(null); useEffect(() => { - if (!selected) return; + if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, - memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), + environmentId, + memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected]); + }, [selected, members, environmentId]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { - if (selected !== null) return; + if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey) return; + if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ - to: "/projects/$projectKey", - params: { projectKey: successor.projectKey }, + to: "/settings/projects", + search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, selected]); + }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( @@ -298,17 +218,185 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
); } - return ; + if (members.length === 0) + return ( +

+ This project has no checkout on this machine. +

+ ); + const scopedGroup = { + ...selected, + memberProjects: members, + environmentId: members[0]!.environmentId, + id: members[0]!.id, + }; + return ( + + ); +} + +function reportScriptFailure(result: AtomCommandResult) { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to save project actions", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return mapAtomCommandResult(result, () => undefined); +} + +export function useProjectScriptSettings( + targets: readonly { + environmentId: EnvironmentId; + settings: ServerSettings; + keybindings: ResolvedKeybindingsConfig; + project?: { id: ProjectId; scripts: readonly ProjectScript[] }; + }[], +) { + const projects = useProjects(); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); + const upsertKeybinding = useAtomCommand( + serverEnvironment.upsertKeybinding, + "action shortcut update", + ); + const removeKeybinding = useAtomCommand( + serverEnvironment.removeKeybinding, + "action shortcut removal", + ); + + async function persist( + transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, + scriptId?: string, + keybinding?: string | null, + ): Promise> { + if (savingRef.current || targets.length === 0) { + const message = "No available machine, or another action change is saving."; + toastManager.add({ type: "error", title: "Actions not saved", description: message }); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + savingRef.current = true; + setSaving(true); + try { + for (const { environmentId, settings, keybindings, project } of targets) { + const current = project + ? resolveProjectScripts(settings, project) + : settings.defaultProjectScripts; + const nextScripts = transform(current); + const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; + const result = await updateSettings({ + environmentId, + input: { + patch: project + ? { projectScriptOverrides: { [project.id]: nextScripts } } + : { defaultProjectScripts: nextScripts ?? [] }, + }, + }); + if (result._tag === "Failure") return reportScriptFailure(result); + if (!isElectron) continue; + const changedIds = scriptId + ? [scriptId] + : current + .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) + .map((script) => script.id); + for (const id of changedIds) { + const command = commandForProjectScript(id); + const previousValue = keybindingValueForCommand(keybindings, command); + const previous = previousValue + ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) + : null; + const next = decodeProjectScriptKeybindingRule({ keybinding, command }); + const retainedElsewhere = + !nextScripts?.some((script) => script.id === id) && + ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || + Object.entries(settings.projectScriptOverrides).some( + ([projectId, scripts]) => + projectId !== project?.id && scripts?.some((script) => script.id === id), + ) || + projects.some( + (other) => + other.environmentId === environmentId && + other.id !== project?.id && + (project ? resolveProjectScripts(settings, other) : other.scripts).some( + (script) => script.id === id, + ), + )); + const bindingResult = next + ? await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== next.key ? { ...next, replace: previous } : next, + }) + : previous && !retainedElsewhere + ? await removeKeybinding({ environmentId, input: previous }) + : null; + if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); + } + } + return AsyncResult.success(undefined); + } finally { + savingRef.current = false; + setSaving(false); + } + } + + function submit(scriptId: string | null, input: NewProjectScriptInput) { + const existingIds = [ + ...projects.flatMap((project) => project.scripts.map((script) => script.id)), + ...targets.flatMap(({ settings, project }) => + [ + ...settings.defaultProjectScripts, + ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), + ...(project?.scripts ?? []), + ].map((script) => script.id), + ), + ]; + const id = scriptId ?? nextProjectScriptId(input.name, existingIds); + const next = buildProjectScript(id, input); + return persist( + (current) => { + const updated = current.map((script) => + script.id === id + ? next + : input.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ); + return scriptId === null ? [...updated, next] : updated; + }, + id, + input.keybinding, + ); + } + + return { saving, persist, submit }; } -function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { +function ProjectDetail({ + group, + hasOtherMembers, +}: { + group: SidebarProjectSnapshot; + hasOtherMembers: boolean; +}) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const representative = group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, + (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; - const settings = usePrimarySettings(); // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. @@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); - const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { - reportFailure: false, - }); - const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { - reportFailure: false, + const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); + const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); + const savingBrowserAccessRef = useRef(false); + const browserOverrides = group.memberProjects.map( + (member) => + environmentById.get(member.environmentId)?.serverConfig?.settings + .projectAgentBrowserAccessOverrides[member.id], + ); + const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; + const browserMixed = group.memberProjects.some((member, index) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; + return ( + browserOverrides[index] !== browserOverride || + (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== + (browserOverride ?? projectSettings.enableAgentBrowserAccess) + ); }); + const setBooleanOverride = async ( + key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", + enabled: boolean | undefined, + ) => { + if (savingBrowserAccessRef.current) return; + savingBrowserAccessRef.current = true; + setSavingBrowserAccess(true); + try { + const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); + for (const environmentId of environmentIds) { + const environment = environmentById.get(environmentId); + if (!environment?.serverConfig || environment.connection.phase !== "connected") { + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: `Connect ${environment?.label ?? "this machine"} and try again.`, + }); + return; + } + } + if (key === "projectAutoPullOverrides" && enabled === undefined) { + const result = await updateAllMembers( + { autoPull: false }, + "Failed to reset automatic pull", + ); + if (result._tag === "Failure") return; + } + for (const environmentId of environmentIds) { + const overrides = Object.fromEntries( + group.memberProjects + .filter((member) => member.environmentId === environmentId) + .map((member) => [member.id, enabled ?? null]), + ); + const result = await updateServerSettings({ + environmentId, + input: { patch: { [key]: overrides } }, + }); + if (result._tag === "Failure") { + reportFailure( + `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, + mapAtomCommandResult(result, () => undefined), + ); + return; + } + } + } finally { + savingBrowserAccessRef.current = false; + setSavingBrowserAccess(false); + } + }; + const setBrowserAccess = (enabled: boolean | undefined) => + setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ type: "success", title: "Path copied", description: path }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ? window.desktopBridge?.pickProjectFavicon : undefined; - const threadCountByMember = useMemo(() => { - const counts = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- default model ----- const storedSelection = representative.defaultModelSelection; - const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); + const resolvedSelection = resolveDefaultProviderModelSelection( + serverProviders, + storedSelection ?? projectSettings.defaultModelSelection, + ); + const mixedModel = group.memberProjects.some((member) => { + const config = environmentById.get(member.environmentId)?.serverConfig; + return ( + !Equal.equals(member.defaultModelSelection, storedSelection) || + (config !== null && + config !== undefined && + environmentById.get(representative.environmentId)?.serverConfig != null && + JSON.stringify( + resolveDefaultProviderModelSelection( + config.providers, + member.defaultModelSelection ?? config.settings.defaultModelSelection, + ), + ) !== JSON.stringify(resolvedSelection)) + ); + }); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( @@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); - const setDefaultModel = useCallback( - (selection: ModelSelection | null) => - void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), - [updateAllMembers], - ); + const setDefaultModel = (selection: ModelSelection | null) => { + if (selection !== null) { + for (const member of group.memberProjects) { + const environment = environmentById.get(member.environmentId); + const config = environment?.serverConfig; + const entry = config + ? applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === selection.instanceId) + : undefined; + const options = config + ? getCustomModelOptionsByInstance( + { ...projectSettings, ...config.settings }, + config.providers, + ).get(selection.instanceId) + : undefined; + if ( + !entry?.enabled || + !entry.isAvailable || + !options?.some((model) => model.slug === selection.model && !model.isUnavailable) + ) { + toastManager.add({ + type: "warning", + title: "Project model not saved", + description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, + }); + return; + } + } + } + void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); + }; // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; + const mixedWorkspace = group.memberProjects.some( + (member) => member.defaultThreadEnvMode !== storedEnvMode, + ); const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( @@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - const autoPull = representative.autoPull ?? false; - const setAutoPull = useCallback( - (enabled: boolean) => - void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), - [updateAllMembers], + const autoPull = resolveProjectAutoPull( + projectSettings, + representative.id, + representative.autoPull, ); + const autoPullOverridden = group.memberProjects.some( + (member) => + member.autoPull || + environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[ + member.id + ] !== undefined, + ); + const mixedAutoPull = group.memberProjects.some((member) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull; + }); + const setAutoPull = (enabled: boolean | undefined) => + setBooleanOverride("projectAutoPullOverrides", enabled); // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); @@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); // ----- checkout selection and scripts ----- - const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); - const selectedCheckout = - group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? - representative; + const hasMultipleCheckouts = group.memberProjects.length > 1; + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); + const selectedCheckoutMatch = group.memberProjects.find( + (member) => member.physicalProjectKey === selectedCheckoutKey, + ); + const selectedCheckout = selectedCheckoutMatch ?? representative; const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const scripts = selectedCheckout.scripts; + const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); + const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); + const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); const [editorRequest, setEditorRequest] = useState(null); - // Script writes replace the whole array, so two overlapping writes computed - // from the same snapshot would drop each other's changes. One at a time. - const [isSavingScripts, setIsSavingScripts] = useState(false); - const savingScriptsRef = useRef(false); + const { + saving: isSavingScripts, + persist: persistScripts, + submit: submitScript, + } = useProjectScriptSettings([ + { + environmentId: selectedCheckout.environmentId, + settings: scriptSettings, + keybindings, + project: selectedCheckout, + }, + ]); const t3File = useT3ProjectFileState( selectedCheckout.environmentId, selectedCheckout.workspaceRoot, ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. - const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; + const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => @@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [scripts, t3File.scripts], ); - const persistScripts = useCallback( - async ( - nextScripts: ReadonlyArray>, - keybinding: string | null | undefined, - keybindingCommand: ReturnType, - ): Promise> => { - if (savingScriptsRef.current) { - return AsyncResult.failure( - Cause.fail(new Error("Another script change is still saving. Try again.")), - ); - } - savingScriptsRef.current = true; - setIsSavingScripts(true); - try { - // Captured before the write so a cleared or deleted binding can be - // removed from the keybindings config afterwards. - const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); - const updateResult = mapAtomCommandResult( - await updateProject({ - environmentId: selectedCheckout.environmentId, - input: { projectId: selectedCheckout.id, scripts: nextScripts }, - }), - () => undefined, - ); - if (updateResult._tag === "Failure") { - reportFailure("Failed to save scripts", updateResult); - return updateResult; - } - - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: keybindingCommand, - }); - if (!isElectron) return updateResult; - const environmentIds = [selectedCheckout.environmentId]; - const previousTarget = previousKeybinding - ? decodeProjectScriptKeybindingRule({ - keybinding: previousKeybinding, - command: keybindingCommand, - }) - : null; - if (keybindingRule) { - // `replace` swaps the command's previous rule instead of appending a - // second one that would keep the old shortcut alive. - const input = - previousTarget && previousTarget.key !== keybindingRule.key - ? { ...keybindingRule, replace: previousTarget } - : keybindingRule; - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await upsertKeybinding({ environmentId, input }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to save keybinding", result); - return result; - } - } - } else if (previousTarget) { - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await removeKeybinding({ environmentId, input: previousTarget }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to remove keybinding", result); - return result; - } - } - } - return updateResult; - } finally { - savingScriptsRef.current = false; - setIsSavingScripts(false); - } - }, - [ - keybindings, - removeKeybinding, - reportFailure, - selectedCheckout.environmentId, - selectedCheckout.id, - updateProject, - upsertKeybinding, - ], - ); - - const submitScript = useCallback( - async ( - scriptId: string | null, - input: NewProjectScriptInput, - ): Promise> => { - if (scriptId === null) { - const nextId = nextProjectScriptId( - input.name, - scripts.map((script) => script.id), - ); - const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...scripts, nextScript]; - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId)); - } - - const updatedScript = buildProjectScript(scriptId, input); - const nextScripts = scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); - - const deleteScript = useCallback( - (scriptId: string) => { - const nextScripts = scripts.filter((script) => script.id !== scriptId); - void persistScripts(nextScripts, null, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); + const deleteScript = (scriptId: string) => + void persistScripts( + (current) => current.filter((script) => script.id !== scriptId), + scriptId, + null, + ); const importFileScript = useCallback( async (fileScript: T3ProjectFileScript) => { @@ -692,7 +772,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); } }, - [submitScript], + [submitScript, setEditorRequest], ); // ----- checkouts ----- @@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; + const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? group.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( [ projectThreads.length > 0 - ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove project "${targetLabel}"?`, + ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` + : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, @@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { "This permanently clears conversation history for those threads and any archived threads.", ] : ["This permanently clears any archived conversation history."]), - isWholeGroup + isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", @@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { draftStore.clearProjectDraftThreadId(projectRef); } - // The project's settings page just deleted itself; there is no projects - // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/", replace: true }); + if (hasOtherMembers) { + void navigate({ + to: "/settings/projects", + search: { project: group.projectKey, machine: undefined }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } } }, [ deleteProject, group.displayName, group.memberProjects.length, + group.projectKey, + hasOtherMembers, navigate, reportFailure, threads, ], ); - const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; const selectedCheckoutGrouping = projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) ] ?? "inherit"; - const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; + const checkoutLabel = (member: SidebarProjectGroupMember) => { + const label = member.environmentLabel ?? "This machine"; + return group.memberProjects.some( + (other) => + other.physicalProjectKey !== member.physicalProjectKey && + (other.environmentLabel ?? "This machine") === label, + ) + ? `${label} · ${member.workspaceRoot}` + : label; + }; + const selectedCheckoutLabel = checkoutLabel(selectedCheckout); return ( <> - - + + member.defaultModelSelection !== null) ? ( setDefaultModel(null)} /> ) : null @@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> member.defaultThreadEnvMode !== null) ? ( setDefaultThreadEnvMode(null)} /> ) : null @@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setAutoPull(false)} /> + autoPullOverridden ? ( + void setAutoPull(undefined)} + /> ) : null } control={ void setAutoPull(enabled)} /> } /> + value !== undefined) ? ( + void setBrowserAccess(undefined)} + /> + ) : null + } + control={ + + } + /> - setSelectedCheckoutKey(String(value))} - > - - {selectedCheckoutLabel} - - - {group.memberProjects.map((member) => ( - - {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} - - ))} - - - } - > -
-
- - - copyPathToClipboard(selectedCheckout.workspaceRoot, { - path: selectedCheckout.workspaceRoot, - }) - } - > - - {selectedCheckout.workspaceRoot} - - - - } - /> - Copy path - -
- {selectedCheckoutThreadCount === 1 - ? "1 thread" - : `${selectedCheckoutThreadCount} threads`} -
-
-
+ + {hasMultipleCheckouts ? ( + { + if (value) setSelectedCheckoutKey(value); + }} + > + + {selectedCheckoutLabel} + + + {group.memberProjects.map((member) => ( + + + {checkoutLabel(member)} + + + ))} + + + } + /> + ) : null} updateGroupingPreference(selectedCheckout, "inherit")} + /> + ) : null + } control={ { + if (next) onChange(next === "all" ? null : next); + }} + > + + + {value === null ? allIcon : selected?.icon} + + {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} + + + + + + + {allIcon}All {label}s + + + {options.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + + ); +} + +export function ProjectsSettings({ + projectKey, + machineId, + onScopeChange, +}: { + projectKey: string | null; + machineId: string | null; + onScopeChange: (project: string | null, machine: string | null) => void; +}) { + const groups = useSettingsProjectGroups(); + const { environments } = useEnvironments(); + const machine = environments.find((environment) => environment.environmentId === machineId); + const machineOptions = environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + icon: ( + + ), + })); + return ( +
+
+ +
+ {environments.length > 3 ? ( + onScopeChange(projectKey, value)} + /> + ) : ( + { + const value = next[0]; + if (value) onScopeChange(projectKey, value === "all" ? null : value); + }} + > + All machines + {machineOptions.map((option) => ( + + {option.icon} + {option.label} + + ))} + + )} +
+ ({ + value: group.projectKey, + label: group.displayName, + icon: ( + + ), + }))} + onChange={(value) => onScopeChange(value, machineId)} + /> +
+
+
+
+ {machineId !== null && !machine ? ( +

This machine is no longer available.

+ ) : projectKey === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fe782c5757de..c3d5d7ba812f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2557,55 +2557,24 @@ export function GeneralSettingsPanel() { - updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } + description="Choose the default model and workspace for all projects or a specific project." control={ - + Project settings + } /> = { "/settings/general": Settings2Icon, "/settings/appearance": PaletteIcon, + "/settings/projects": PanelsTopLeftIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, "/settings/integrations": BlocksIcon, @@ -274,12 +276,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setOpenMobile(false); } const targetId = item.targetId ?? item.id; - if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { + if ( + item.to !== "/settings/projects" && + pathname === item.to && + currentHash.replace(/^#/, "") === targetId + ) { scrollToSettingsTarget(targetId); return; } void navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: targetId, replace: true, hashScrollIntoView: false, diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 82fbed96459f..1bfa8c87146e 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -284,7 +284,11 @@ export function SettingsRow({ ref={targetRef} tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" - className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} + className={cn( + "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", + children ? "pt-3 pb-1" : "py-3", + className, + )} >
@@ -320,10 +324,12 @@ export function SettingsRow({ export function SettingResetButton({ label, + tooltip = "Reset to default", disabled = false, onClick, }: { label: string; + tooltip?: string; disabled?: boolean; onClick: () => void; }) { @@ -345,7 +351,7 @@ export function SettingResetButton({ } /> - Reset to default + {tooltip} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7358ed8f9a17..f715f6ca4e6d 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -2,6 +2,7 @@ import { isElectron } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; export type SettingsPath = + | "/settings/projects" | "/settings/general" | "/settings/appearance" | "/settings/keybindings" @@ -49,6 +50,7 @@ export interface SettingsSearchAvailability { export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", "/settings/appearance": "Appearance", + "/settings/projects": "Projects", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", "/settings/integrations": "Integrations", @@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "project-defaults", + title: "Project defaults and overrides", + to: "/settings/projects", + searchTerms: [ + "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts", + ], + }, { id: "color-scheme", title: "Color scheme", @@ -235,14 +245,13 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "new-threads", title: "New threads", - to: "/settings/general", + to: "/settings/projects", searchTerms: ["default workspace mode draft local worktree"], }, { id: "start-from-origin", title: "Start from origin", to: "/settings/general", - targetId: "new-threads", searchTerms: ["new worktrees latest matching remote branch local"], }, { @@ -345,7 +354,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "agent-browser-access", title: "Agent browser access", - to: "/settings/integrations", + to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, { diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 91b757f51e0d..afd503e63c25 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -49,14 +49,31 @@ const testState = vi.hoisted(() => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), + useAtomValue: (atom: unknown) => + atom === "primary-settings" + ? { newWorktreesStartFromOrigin: false } + : new Map([ + [ + "environment-ssh", + { + settings: { + defaultThreadEnvMode: "local", + newWorktreesStartFromOrigin: false, + defaultModelSelection: null, + }, + }, + ], + ]), })); vi.mock("@t3tools/client-runtime/environment", () => ({ scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" })); +vi.mock("@t3tools/contracts", () => ({ + DEFAULT_RUNTIME_MODE: "default", + DEFAULT_SERVER_SETTINGS: {}, +})); vi.mock("@t3tools/shared/threadEnvMode", () => ({ resolveDefaultThreadEnvMode: (input: { readonly projectFile: "local" | "worktree" | null; @@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useThread: () => null, })); -vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../state/server", () => ({ + environmentServerConfigsAtom: {}, + primaryServerSettingsAtom: "primary-settings", +})); vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index c26b25d1316b..78dfc1b13fe6 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,7 +4,12 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, + type ThreadId, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -30,7 +35,7 @@ import { resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; -import { primaryServerSettingsAtom } from "../state/server"; +import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef } export function useNewThreadHandler() { - // New-thread defaults are a user preference, and the settings UI only ever - // edits the primary environment's settings.json. Reading the target - // environment's own settings here would silently reset remote projects to - // the decoded defaults ("local" mode, current branch), since nothing can - // set those values on a remote server. + const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); @@ -83,6 +84,8 @@ export function useNewThreadHandler() { // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); + const targetServerSettings = + environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -138,7 +141,8 @@ export function useNewThreadHandler() { ); const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: project?.defaultModelSelection ?? null, + projectDefaultSelection: + project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null, carrySelection: carryModelSelection, carrySourceDraftId: currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, @@ -157,7 +161,7 @@ export function useNewThreadHandler() { project.workspaceRoot, ) : null, - globalDefault: primaryServerSettings.defaultThreadEnvMode, + globalDefault: targetServerSettings.defaultThreadEnvMode, }); }; const logicalProjectKey = project @@ -429,7 +433,13 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], + [ + environmentServerConfigs, + getCurrentRouteTarget, + primaryServerSettings.newWorktreesStartFromOrigin, + projectGroupingSettings, + router, + ], ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 5c796f3ab6c8..b1a9d0b9e04b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -75,6 +76,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -154,6 +160,7 @@ export interface FileRoutesByFullPath { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -175,6 +182,7 @@ export interface FileRoutesByTo { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -199,6 +207,7 @@ export interface FileRoutesById { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -224,6 +233,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -245,6 +255,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/' @@ -268,6 +279,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -351,6 +363,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/projects': { + id: '/settings/projects' + path: '/projects' + fullPath: '/settings/projects' + preLoaderRoute: typeof SettingsProjectsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -462,6 +481,7 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -474,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGeneralRoute: SettingsGeneralRoute, SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx index 6ae03719c042..d636c0a953ef 100644 --- a/apps/web/src/routes/projects.$projectKey.tsx +++ b/apps/web/src/routes/projects.$projectKey.tsx @@ -1,15 +1,17 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; - export const Route = createFileRoute("/projects/$projectKey")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, params }) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" ) { throw redirect({ to: "/pair", replace: true }); } + throw redirect({ + to: "/settings/projects", + search: { project: params.projectKey, machine: undefined }, + replace: true, + }); }, - component: () => , }); diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx new file mode 100644 index 000000000000..fa79f46fbb2c --- /dev/null +++ b/apps/web/src/routes/settings.projects.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ProjectsSettings } from "../components/settings/ProjectsSettings"; + +export const Route = createFileRoute("/settings/projects")({ + validateSearch: (search: Record) => ({ + project: typeof search.project === "string" ? search.project : undefined, + machine: typeof search.machine === "string" ? search.machine : undefined, + }), + component: ProjectsRoute, +}); + +function ProjectsRoute() { + const { project, machine } = Route.useSearch(); + const navigate = Route.useNavigate(); + return ( + { + void navigate({ + search: { project: project ?? undefined, machine: machine ?? undefined }, + replace: true, + }); + }} + /> + ); +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 747bc52c07ec..c76c18544df2 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,11 +1,28 @@ # Project settings -Open **Settings → Projects** and select a project to change its preferences. +Open **Settings → Projects**. The project and machine pickers start at **All projects** and +**All machines**. + +Change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values. +Select an individual project to override a default. Reset its row to inherit again. Changing a +default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence +over machine defaults when the project has no explicit workspace override. + +Select a machine to limit edits to it. **All machines** writes defaults to connected machines; +offline machines keep their previous values. Mixed values are indicated when selected machines +or checkouts disagree. Browser access changes apply when an agent session next starts. + +Project grouping has a client-wide default across machines, with individual checkout overrides. +Shared actions apply to inheriting projects; editing a project's actions creates an independent list. +Reset that list to use shared actions again. Existing project actions are preserved. + +Project names, icons, removal, and importing actions from a checkout remain project-specific. +When there are several checkouts, the checkout picker selects which actions and grouping to edit. ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies -to every checkout in the project group and appears on connected clients. Choose **Automatic** to +to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to let T3 Code detect an icon again. ## Keep the default branch current diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 8cf0e3bc7f08..712138aab4c9 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -44,13 +44,19 @@ describe("splitSharedServerPatch", () => { sidebarAutoSettleOnMerge: false, continueThreadsAfterServerUpdate: true, enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", + newWorktreesStartFromOrigin: true, }); expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false, continueThreadsAfterServerUpdate: true, + newWorktreesStartFromOrigin: true, + }); + expect(localPatch).toEqual({ + enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", }); - expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); }); }); @@ -60,7 +66,6 @@ describe("pickSharedServerSettings", () => { Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), ).toEqual([ "continueThreadsAfterServerUpdate", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", @@ -206,7 +211,12 @@ describe("findSharedSettingsMismatches", () => { environmentId: boxId, label: "Remote Box", syncEligible: true, - settings: { ...primarySettings, enableAgentBrowserAccess: false }, + settings: { + ...primarySettings, + enableAgentBrowserAccess: false, + defaultThreadEnvMode: + primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local", + }, }, ], }); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 128d5c25464b..0fd691a64bcd 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [ "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", ] as const satisfies ReadonlyArray; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ce9082477372..983e17b54370 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,12 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { + ForwardCompatibleNullable, + ProjectId, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { @@ -11,7 +16,7 @@ import { DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, } from "./model.ts"; -import { ModelSelection } from "./orchestration.ts"; +import { ModelSelection, ProjectScript } from "./orchestration.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, @@ -856,6 +861,22 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + defaultProjectScripts: Schema.Array(ProjectScript).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1116,6 +1137,18 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + projectAgentBrowserAccessOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultAutoPull: Schema.optionalKey(Schema.Boolean), + defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), + projectScriptOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))), + ), + projectAutoPullOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 199a55bf3cbf..4d98e36b4d70 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,4 +1,24 @@ -import type { ProjectScript } from "@t3tools/contracts"; +import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; + +/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ +export function resolveProjectScripts( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): readonly ProjectScript[] { + const override = settings.projectScriptOverrides[project.id]; + if (override === null) return settings.defaultProjectScripts; + return ( + override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts) + ); +} + +export function projectScriptsInheritDefaults( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): boolean { + const override = settings.projectScriptOverrides[project.id]; + return override === null || (override === undefined && project.scripts.length === 0); +} interface ProjectScriptRuntimeEnvInput { project: { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 31f056c211e9..a5e428fcdaac 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, UsageLimitSourceId, @@ -9,14 +10,181 @@ import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; +import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, + resolveProjectAgentBrowserAccess, + resolveProjectAutoPull, } from "./serverSettings.ts"; describe("serverSettings helpers", () => { + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { + const project = { id: ProjectId.make("project-actions"), scripts: [] }; + const action = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [action], + }); + expect(resolveProjectScripts(defaults, project)).toEqual([action]); + expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); + const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; + expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts); + expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false); + const disabled = applyServerSettingsPatch(defaults, { + projectScriptOverrides: { [project.id]: [] }, + }); + expect(resolveProjectScripts(disabled, project)).toEqual([]); + expect(projectScriptsInheritDefaults(disabled, project)).toBe(false); + const changedDefault = applyServerSettingsPatch(disabled, { + defaultProjectScripts: [{ ...action, command: "npm run build" }], + }); + expect(resolveProjectScripts(changedDefault, project)).toEqual([]); + const reset = applyServerSettingsPatch(changedDefault, { + projectScriptOverrides: { [project.id]: null }, + }); + expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts); + expect(projectScriptsInheritDefaults(reset, existing)).toBe(true); + expect( + resolveProjectScripts( + applyServerSettingsPatch(reset, { defaultProjectScripts: [] }), + existing, + ), + ).toEqual([]); + }); + + it("preserves other projects' actions when overriding, clearing, or resetting one project", () => { + const firstProject = { id: ProjectId.make("first-project"), scripts: [] }; + const secondProject = { id: ProjectId.make("second-project"), scripts: [] }; + const defaultAction = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const firstAction = { ...defaultAction, command: "npm run lint" }; + const secondAction = { ...defaultAction, command: "npm run build" }; + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [defaultAction], + projectScriptOverrides: { [firstProject.id]: [firstAction] }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectScriptOverrides: { [secondProject.id]: [secondAction] }, + }); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]); + + const cleared = applyServerSettingsPatch(secondUpdate, { + projectScriptOverrides: { [firstProject.id]: [] }, + }); + expect(resolveProjectScripts(cleared, firstProject)).toEqual([]); + expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]); + + const reset = applyServerSettingsPatch(cleared, { + projectScriptOverrides: { [firstProject.id]: null }, + }); + expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([ + defaultAction, + ]); + expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + }); + + it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => { + const projectId = ProjectId.make("project-pull"); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true); + const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true }); + expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true); + const overridden = applyServerSettingsPatch(enabled, { + projectAutoPullOverrides: { [projectId]: false }, + }); + expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false); + const reset = applyServerSettingsPatch(overridden, { + projectAutoPullOverrides: { [projectId]: null }, + }); + expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true); + const disabled = applyServerSettingsPatch(reset, { + defaultAutoPull: false, + projectAutoPullOverrides: { [projectId]: true }, + }); + expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true); + expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false); + }); + + it("inherits browser access and restores inheritance when a project override is removed", () => { + const projectId = ProjectId.make("project-browser"); + const otherProjectId = ProjectId.make("other-project"); + const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectAgentBrowserAccessOverrides: { [projectId]: false }, + }); + expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false); + expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true); + const reset = applyServerSettingsPatch(overridden, { + projectAgentBrowserAccessOverrides: { [projectId]: null }, + }); + expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true); + const enabled = applyServerSettingsPatch(reset, { + enableAgentBrowserAccess: false, + projectAgentBrowserAccessOverrides: { [projectId]: true }, + }); + expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true); + expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false); + }); + + it("preserves other projects' boolean overrides across separate updates and resets", () => { + const firstProjectId = ProjectId.make("first-project"); + const secondProjectId = ProjectId.make("second-project"); + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultAutoPull: true, + projectAutoPullOverrides: { [firstProjectId]: false }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: false }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectAutoPullOverrides: { [secondProjectId]: false }, + projectAgentBrowserAccessOverrides: { [secondProjectId]: false }, + }); + for (const projectId of [firstProjectId, secondProjectId]) { + expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false); + } + + const reset = applyServerSettingsPatch(secondUpdate, { + projectAutoPullOverrides: { [firstProjectId]: null }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: null }, + }); + expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true); + expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true); + expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false); + expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined(); + expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined(); + expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false); + }); + + it("replaces and clears conversation model defaults without retaining old options", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet"); + const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection }); + expect(updated.defaultModelSelection).toEqual(selection); + expect( + applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection, + ).toBeNull(); + }); + it("ignores missing and blank persisted observability URLs", () => { expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ otlpTracesUrl: undefined, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index dc50da2d7627..f969e4412c30 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -3,6 +3,7 @@ import { isProviderAvailable, resolveProviderInstanceEnabled, type ModelSelection, + type ProjectId, type ProviderDriverKind, type ServerProvider, ServerSettings, @@ -23,6 +24,27 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +export function resolveProjectAgentBrowserAccess( + settings: Pick, + projectId: ProjectId, +): boolean { + return ( + settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess + ); +} + +export function resolveProjectAutoPull( + settings: Pick, + projectId: ProjectId, + legacyAutoPull: boolean | undefined, +): boolean { + // Existing opt-ins stay enabled until explicitly overridden or reset. + return ( + settings.projectAutoPullOverrides[projectId] ?? + (legacyAutoPull === true || settings.defaultAutoPull) + ); +} + type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; const getLegacyProviderSettings = ( @@ -151,6 +173,8 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, + projectAutoPullOverrides: projectAutoPullOverridesPatch, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -207,6 +231,36 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(projectAgentBrowserAccessOverridesPatch !== undefined + ? { + projectAgentBrowserAccessOverrides: mergeSettingsEntries( + current.projectAgentBrowserAccessOverrides, + projectAgentBrowserAccessOverridesPatch, + ), + } + : {}), + ...(projectAutoPullOverridesPatch !== undefined + ? { + projectAutoPullOverrides: mergeSettingsEntries( + current.projectAutoPullOverrides, + projectAutoPullOverridesPatch, + ), + } + : {}), + ...(patch.defaultModelSelection !== undefined + ? { defaultModelSelection: patch.defaultModelSelection } + : {}), + ...(patch.defaultProjectScripts !== undefined + ? { defaultProjectScripts: patch.defaultProjectScripts } + : {}), + ...(patch.projectScriptOverrides !== undefined + ? { + projectScriptOverrides: { + ...current.projectScriptOverrides, + ...patch.projectScriptOverrides, + }, + } + : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( From 420fd76f60433fe05b8d2c76f4fbde430dc49968 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 02:33:13 -0300 Subject: [PATCH 50/65] feat(connections): balance new threads across connected machines (#9895) --- .../settings/DesktopClientSettings.test.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/resourceTelemetry/HostResources.ts | 93 +++++++++++++ apps/server/src/server.test.ts | 94 ++++++++++++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 6 + apps/web/src/components/BranchToolbar.tsx | 52 ++++++- .../BranchToolbarBranchSelector.tsx | 6 +- .../BranchToolbarEnvironmentSelector.tsx | 46 ++++-- apps/web/src/components/ChatView.tsx | 131 ++++++++++++++++++ apps/web/src/components/GitActionsControl.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 2 + .../settings/LoadBalancingSettings.tsx | 94 +++++++++++++ .../src/components/settings/settingsSearch.ts | 8 ++ apps/web/src/composerDraftStore.test.ts | 57 ++++++++ apps/web/src/composerDraftStore.ts | 59 ++++++++ .../src/hooks/useLoadBalancedEnvironment.ts | 50 +++++++ docs/user/remote-access.md | 17 +++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/load-balancing.ts | 40 ++++++ .../src/state/projectGrouping.test.ts | 65 +++++++++ packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/resourceTelemetry.ts | 10 ++ packages/contracts/src/rpc.ts | 9 ++ packages/contracts/src/settings.test.ts | 15 ++ packages/contracts/src/settings.ts | 9 ++ 26 files changed, 865 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/resourceTelemetry/HostResources.ts create mode 100644 apps/web/src/components/settings/LoadBalancingSettings.tsx create mode 100644 apps/web/src/hooks/useLoadBalancedEnvironment.ts create mode 100644 packages/client-runtime/src/load-balancing.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 9fbacc832a90..89e1a7fb19e3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -58,6 +58,8 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + loadBalancingEnabled: false, + loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7bd1ed6c45f1..a069322aa8bf 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts new file mode 100644 index 000000000000..032832dd4869 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -0,0 +1,93 @@ +import * as NodeOS from "node:os"; +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class HostResources extends Context.Service< + HostResources, + { readonly read: Effect.Effect } +>()("t3/resourceTelemetry/HostResources") {} + +function readCpu() { + const cpus = NodeOS.cpus(); + const cpu = cpus.reduce( + (sum, { times }) => ({ + idle: sum.idle + times.idle, + total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, + }), + { idle: 0, total: 0 }, + ); + return { ...cpu, count: cpus.length }; +} + +function darwinAvailableMemory(output: string): number | null { + const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; + const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; + const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; + const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; + if (!pageSize || !free || !inactive || !speculative) return null; + // vm_stat subtracts speculative pages from its printed "Pages free" count. + // Adding them here counts each reclaimable page once; purgeable pages overlap. + const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); + return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; +} + +export const make = Effect.fn("makeHostResources")(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const sample = Effect.fn("HostResources.sample")(function* () { + const previousCpu = readCpu(); + // CPU counters need two readings; idle servers do no polling or process scans. + yield* Effect.sleep("200 millis"); + const cpu = readCpu(); + const totalDelta = cpu.total - previousCpu.total; + const idleDelta = cpu.idle - previousCpu.idle; + const cpuUtilization = + previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 + ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) + : null; + const totalMemoryBytes = NodeOS.totalmem(); + // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. + let availableMemoryBytes = NodeOS.freemem(); + if (platform === "linux") { + const meminfo = yield* fs + .readFileString("/proc/meminfo") + .pipe(Effect.catch(() => Effect.succeed(""))); + const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; + if (available) availableMemoryBytes = Number(available) * 1024; + } else if (platform === "darwin") { + const output = yield* spawner + .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed("")), + ); + availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; + } + return { + sampledAt: DateTime.toEpochMillis(yield* DateTime.now), + cpuUtilization, + cpuCount: cpu.count, + availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), + totalMemoryBytes, + }; + }); + + // One server-lifetime cache deduplicates simultaneous requests from all sockets. + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_key: "host") => sample(), + timeToLive: "5 seconds", + }); + return HostResources.of({ read: Cache.get(cache, "host") }); +}); + +export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 56889c1e63b2..64bfea1b9805 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -165,6 +165,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -845,7 +846,8 @@ const buildAppUnderTest = (options?: { }), }), ), - Layer.provide( + Layer.provide([ + HostResources.layer, Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ readHistory: (input) => Effect.succeed({ @@ -860,7 +862,7 @@ const buildAppUnderTest = (options?: { error: Option.none(), }), }), - ), + ]), Layer.provide( Layer.mock(TraceDiagnostics.TraceDiagnostics)({ read: () => @@ -6154,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns cached whole-host resources over websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const [first, second] = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all( + [ + client[WS_METHODS.serverGetHostResources]({}), + client[WS_METHODS.serverGetHostResources]({}), + ], + { concurrency: "unbounded" }, + ), + ), + ); + assert.deepEqual(first, second); + assert.isAtLeast(first.sampledAt, 0); + assert.isAbove(first.cpuCount, 0); + assert.isAbove(first.totalMemoryBytes, 0); + assert.isAtLeast(first.availableMemoryBytes, 0); + assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes); + if (first.cpuUtilization !== null) { + assert.isAtLeast(first.cpuUtilization, 0); + assert.isAtMost(first.cpuUtilization, 1); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("counts macOS reclaimable memory once and shares concurrent samples", () => + Effect.gen(function* () { + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Ref.update(commandCalls, (count) => count + 1).pipe( + Effect.as( + "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + + "Pages purgeable: 999.\n", + ), + ), + }), + ), + ); + const [first, second] = yield* Effect.all([hostResources.read, hostResources.read], { + concurrency: "unbounded", + }); + assert.equal(first.availableMemoryBytes, 35 * 16384); + assert.deepEqual(first, second); + assert.deepEqual(yield* hostResources.read, first); + assert.equal(yield* Ref.get(commandCalls), 1); + }).pipe(TestClock.withLive), + ); + + it.effect("retries host sampling immediately after its caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(commandCalls, (count) => count + 1); + if (call === 1) { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + } + return ( + "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + ); + }), + }), + ), + ); + const firstRead = yield* hostResources.read.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(firstRead); + const recovered = yield* hostResources.read; + assert.equal(recovered.availableMemoryBytes, 35 * 4096); + assert.equal(yield* Ref.get(commandCalls), 2); + }).pipe(TestClock.withLive), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4abe43d8a631..ce39ee64f51a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -115,6 +115,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); const ResourceDiagnosticsLayerLive = Layer.mergeAll( + HostResources.layer, ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fa29d5bd9847..b4255c647816 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -135,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; @@ -605,6 +606,7 @@ const makeWsRpcLayer = ( const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; @@ -2020,6 +2022,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostResources]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 07407bcf21b3..7ad86106ae08 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,6 +6,7 @@ import { FolderGitIcon, FolderIcon, HistoryIcon, + ScaleIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -56,6 +57,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -66,6 +69,8 @@ interface BranchToolbarProps { } interface MobileRunContextSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; @@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, envModeLocked, environmentId, @@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + {autoEnvironmentLabel ? ( + ) : ( @@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ data-composer-label-motion className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" > - {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {autoEnvironmentLabel ?? + (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} @@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Run on onEnvironmentChange(value as EnvironmentId)} + value={autoEnvironmentLabel ? "auto" : environmentId} + onValueChange={(value) => + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange(value as EnvironmentId) + } > + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + + + )} {availableEnvironments.map((env) => ( { + (branch: string | null, worktreePath: string | null, automatic = false) => { if (!activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ @@ -186,6 +186,7 @@ export function BranchToolbarBranchSelector({ branch, worktreePath, envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, @@ -201,6 +202,7 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, ], @@ -507,7 +509,7 @@ export function BranchToolbarBranchSelector({ ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null); + setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 431805f2174b..863cc7312fe6 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import { ScaleIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; @@ -15,6 +16,8 @@ import { } from "./ui/select"; interface BranchToolbarEnvironmentSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, environmentId, availableEnvironments, @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir }, [availableEnvironments, environmentId]); const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ + () => [ + ...(onAutoEnvironment + ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + : []), + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label, })), - [availableEnvironments], + ], + [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], ); // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -75,8 +84,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir return ( + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f715f6ca4e6d..dd4e357eb9d7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -514,6 +514,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, + { + id: "load-balancing", + title: "Load balancing", + to: "/settings/connections", + searchTerms: [ + "automatic machine environment resources cpu memory capacity preference weight shared projects", + ], + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 53d07aab21d9..4a682b97910d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1623,6 +1623,63 @@ describe("composerDraftStore project draft thread mapping", () => { expect(file && composerFileNeedsReattach(file)).toBe(true); }); + it("rechecks balancing when an empty draft is remapped to another project member", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + environmentSelection: "auto", + loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID, + }); + store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID }); + store.setDraftThreadContext(draftId, { projectRef }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + }); + + it("does not opt a legacy branch choice into balancing when runtime mode changes", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" }); + store.setDraftThreadContext(draftId, { runtimeMode: "full-access" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined(); + expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned"); + }); + + it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep this prompt"); + store.setDraftThreadContext(draftId, { + projectRef: remoteProjectRef, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentId: OTHER_TEST_ENVIRONMENT_ID, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + store.setDraftThreadContext(draftId, { branch: "feature/pinned" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual"); + store.setDraftThreadContext(draftId, { + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }); + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9dbc7b9b5c21..5bdd823b4d88 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -315,6 +315,8 @@ const PersistedDraftThreadState = Schema.Struct({ environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), + environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), + loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -427,6 +429,8 @@ export interface DraftSessionState { environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; @@ -503,6 +507,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -518,6 +524,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -532,6 +540,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1534,6 +1544,8 @@ function createDraftThreadState( startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ): DraftThreadState { // A project change (including switching environments within a logical @@ -1560,11 +1572,23 @@ function createDraftThreadState( options?.startFromOrigin === undefined ? (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; + const environmentSelection = + options?.environmentSelection ?? existingThread?.environmentSelection; return { threadId, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + ...(options?.loadBalancedEnvironmentId !== undefined + ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId } + : existingThread?.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: projectChanged + ? null + : existingThread.loadBalancedEnvironmentId, + } + : {}), createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: @@ -1599,6 +1623,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.environmentId === right.environmentId && left.projectId === right.projectId && left.logicalProjectKey === right.logicalProjectKey && + left.environmentSelection === right.environmentSelection && + left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId && left.createdAt === right.createdAt && left.runtimeMode === right.runtimeMode && left.interactionMode === right.interactionMode && @@ -1754,6 +1780,16 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + ...(candidateDraftThread.environmentSelection === "manual" || + candidateDraftThread.environmentSelection === "auto" + ? { environmentSelection: candidateDraftThread.environmentSelection } + : {}), + ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" && + candidateDraftThread.loadBalancedEnvironmentId.length > 0 + ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId } + : candidateDraftThread.loadBalancedEnvironmentId === null + ? { loadBalancedEnvironmentId: null } + : {}), promotedTo, }; } @@ -2453,6 +2489,15 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + ...(persistedDraftThread.environmentSelection + ? { environmentSelection: persistedDraftThread.environmentSelection } + : {}), + ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: + persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null, + } + : {}), promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2707,11 +2752,23 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const environmentSelection = + options.environmentSelection ?? + (options.branch != null || options.worktreePath != null + ? "manual" + : existing.environmentSelection); const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + loadBalancedEnvironmentId: + options.loadBalancedEnvironmentId === undefined + ? projectChanged + ? null + : (existing.loadBalancedEnvironmentId ?? null) + : options.loadBalancedEnvironmentId, createdAt: options.createdAt === undefined ? existing.createdAt @@ -2729,6 +2786,8 @@ const composerDraftStore = create()( nextDraftThread.environmentId === existing.environmentId && nextDraftThread.projectId === existing.projectId && nextDraftThread.logicalProjectKey === existing.logicalProjectKey && + nextDraftThread.environmentSelection === existing.environmentSelection && + nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId && nextDraftThread.createdAt === existing.createdAt && nextDraftThread.runtimeMode === existing.runtimeMode && nextDraftThread.interactionMode === existing.interactionMode && diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts new file mode 100644 index 000000000000..5d3ddfb8b1ef --- /dev/null +++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts @@ -0,0 +1,50 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext, useMemo } from "react"; + +import { serverEnvironment } from "../state/server"; + +/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */ +export function useLoadBalancedEnvironment( + environmentIds: readonly EnvironmentId[], + weights: Readonly>, +) { + const registry = useContext(RegistryContext); + const refresh = useCallback( + (ids: readonly EnvironmentId[]) => { + for (const environmentId of ids) { + registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} })); + } + }, + [registry], + ); + const resourcesAtom = useMemo( + () => + Atom.make((get) => + environmentIds.map((environmentId) => { + const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); + return { + environmentId, + resources: result._tag === "Success" ? result.value : null, + receivedAt: result._tag === "Success" ? result.timestamp : 0, + pending: result._tag === "Initial" || result.waiting, + }; + }), + ), + [environmentIds], + ); + const resources = useAtomValue(resourcesAtom); + return { + refresh, + pending: resources.some((resource) => resource.pending), + environmentId: chooseLoadBalancedEnvironment( + resources.map((resource) => ({ + ...resource, + weight: weights[resource.environmentId] ?? 50, + })), + Date.now(), + ) as EnvironmentId | null, + }; +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index ec2724b4b04e..b10123ab6223 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -61,6 +61,23 @@ created in Settings can only be copied from the client that created them while its Connections page stays open. If you leave or reload that page, create another link to share. +### Balance new threads across machines + +Auto balance is off by default. On web and desktop, enable it in +**Settings → Connections → Load balancing** to automatically choose a machine for +new threads in projects grouped across connected environments. +Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and +memory available, **Less often** to reduce its share, or **Manual only** to exclude +it from automatic selection. These are preferences, not fixed traffic percentages. +Preferences are saved separately in each client. + +The composer checks eligible machines when choosing a draft's environment, then keeps +that choice stable. Choose **Auto balance** again to check current resources, or choose +a specific machine to override it. Choosing a branch or worktree also keeps the draft +on that machine. Existing threads stay where they started. If resource checks are +unavailable or all eligible machines are full, choose a machine manually to continue. +Mobile keeps its manual environment selection. + ### Tailscale HTTPS Join both devices to the same tailnet. In the desktop app, enable **Tailscale diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7409a194a2fd..775a4a898f8f 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./load-balancing": { + "types": "./src/load-balancing.ts", + "default": "./src/load-balancing.ts" + }, "./project-favicon-cache": { "types": "./src/projectFaviconCache.ts", "default": "./src/projectFaviconCache.ts" diff --git a/packages/client-runtime/src/load-balancing.ts b/packages/client-runtime/src/load-balancing.ts new file mode 100644 index 000000000000..0b938c6c090e --- /dev/null +++ b/packages/client-runtime/src/load-balancing.ts @@ -0,0 +1,40 @@ +import type { HostResourcesSnapshot } from "@t3tools/contracts"; + +/** Callers supply only connected machines hosting the project and selected provider. */ +export function chooseLoadBalancedEnvironment( + candidates: ReadonlyArray<{ + environmentId: string; + resources: HostResourcesSnapshot | null; + /** Client receipt time avoids comparing clocks on different machines. */ + receivedAt?: number; + weight: number; + }>, + now: number, +): string | null { + let selected: string | null = null; + let bestScore = 0; + for (const { environmentId, resources, receivedAt, weight } of candidates) { + const sampledAt = receivedAt ?? resources?.sampledAt ?? 0; + if ( + !resources || + !Number.isFinite(weight) || + weight <= 0 || + now - sampledAt > 15_000 || + sampledAt > now + 5_000 || + resources.cpuUtilization === null || + resources.cpuUtilization >= 0.95 || + resources.totalMemoryBytes <= 0 || + resources.cpuCount <= 0 + ) { + continue; + } + const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes; + if (memoryAvailable <= 0.05) continue; + const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable; + if (score > bestScore) { + selected = environmentId; + bestScore = score; + } + } + return selected; +} diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 94d213b257b6..4884c3b99bbc 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "./models.ts"; +import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -9,6 +10,70 @@ import { } from "./projectGrouping.ts"; const environmentId = EnvironmentId.make("environment"); + +describe("load balancing shared project machines", () => { + const now = 100_000; + const resources = { + sampledAt: now, + cpuUtilization: 0.2, + cpuCount: 8, + availableMemoryBytes: 8_000, + totalMemoryBytes: 16_000, + }; + + it("compares three machines using free capacity and preference", () => { + const candidates = [ + { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 }, + { environmentId: "idle", resources, weight: 1 }, + { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 }, + ]; + expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred"); + expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle"); + }); + + it("rejects stale, unknown, excluded and saturated machines", () => { + expect( + chooseLoadBalancedEnvironment( + [ + { + environmentId: "stale", + resources: { ...resources, sampledAt: now - 15_001 }, + weight: 1, + }, + { environmentId: "unknown", resources: null, weight: 1 }, + { + environmentId: "no-cpu-sample", + resources: { ...resources, cpuUtilization: null }, + weight: 1, + }, + { environmentId: "excluded", resources, weight: 0 }, + { + environmentId: "cpu-full", + resources: { ...resources, cpuUtilization: 0.95 }, + weight: 1, + }, + { + environmentId: "memory-full", + resources: { ...resources, availableMemoryBytes: 100 }, + weight: 1, + }, + ], + now, + ), + ).toBeNull(); + }); + + it("uses client receipt time when host clocks differ", () => { + const candidate = { + environmentId: "different-clock", + resources: { ...resources, sampledAt: now + 60_000 }, + receivedAt: now, + weight: 1, + }; + expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock"); + expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull(); + }); +}); const repositoryIdentity = { canonicalKey: "github.com/t3tools/t3code", locator: { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5df29a8629d4..911ee1bd85c6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, + createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createRuntimeCommand, @@ -1018,6 +1019,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostResources: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-resources", + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), + }), processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 3ec1e4de3ef4..87b52993a56b 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; +/** Whole-host capacity, independent of T3's process diagnostics. */ +export const HostResourcesSnapshot = Schema.Struct({ + sampledAt: NonNegativeInt, + cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + cpuCount: NonNegativeInt, + availableMemoryBytes: NonNegativeInt, + totalMemoryBytes: NonNegativeInt, +}); +export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; + export const ResourceTelemetryIoSemantics = Schema.Literals([ "storage", "logical", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 12c653f70cc4..fb077193c202 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -210,6 +210,7 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { + HostResourcesSnapshot, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -323,6 +324,7 @@ export const WS_METHODS = { serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", + serverGetHostResources: "server.getHostResources", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -538,6 +540,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia error: EnvironmentAuthorizationError, }); +const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, { + payload: Schema.Struct({}), + success: HostResourcesSnapshot, + error: EnvironmentAuthorizationError, +}); + const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { payload: ServerProcessResourceHistoryInput, success: ServerProcessResourceHistoryResult, @@ -1197,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, + WsServerGetHostResourcesRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1673f4ad159b..7c497bacfa89 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -135,6 +135,21 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings load balancing", () => { + it("requires opt-in when settings are new or omit load balancing", () => { + expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); + expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false); + }); + + it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => { + const settings = decodeClientSettings({ loadBalancingEnabled }); + expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled); + expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe( + loadBalancingEnabled, + ); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 983e17b54370..1ddf66cde63c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -204,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; +export const LoadBalancingWeights = Schema.Record( + TrimmedNonEmptyString, + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), +); + export const ClientSettingsSchema = Schema.Struct({ + loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), ), @@ -1210,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), + loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), From f1e84c28fe5982ecb507f4c934acf7a1a7b253b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:33:29 -0700 Subject: [PATCH 51/65] test(web): keep settings viewport comparison private (#10307) --- .../settings/SettingsPanels.logic.test.ts | 23 ------------------- .../settings/SettingsPanels.logic.ts | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index b99c69ee331f..d93db8d970b1 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -14,7 +14,6 @@ import { formatDiagnosticsDescription, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, - isSamePreviewViewport, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, @@ -282,25 +281,3 @@ describe("getChangedBrowserSettingLabels", () => { ]); }); }); - -describe("isSamePreviewViewport", () => { - it("separates presets that share a size", () => { - // Two presets can agree on width and height and still be different - // entries in the picker, so the id has to take part in the comparison. - expect( - isSamePreviewViewport( - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - { _tag: "preset", width: 390, height: 844, presetId: "ipad-mini" }, - ), - ).toBe(false); - }); - - it("separates a freeform viewport from a preset of the same size", () => { - expect( - isSamePreviewViewport( - { _tag: "freeform", width: 390, height: 844 }, - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 3ac6bbaa0017..5cbcb190a97b 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -126,7 +126,7 @@ export type BrowserDefaultSettings = Pick< * reports every stored viewport as changed — including one that matches the * default. */ -export function isSamePreviewViewport( +function isSamePreviewViewport( left: PreviewViewportSetting, right: PreviewViewportSetting, ): boolean { From 4f782bedaf49b914903eb08501f35c0940845c51 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:37:07 +0100 Subject: [PATCH 52/65] fix(web): prevent file tree search focus ring clipping (#10175) --- apps/web/src/components/files/FileBrowserPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 7ed900963b49..49894db3c8cf 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -373,7 +373,7 @@ export default function FileBrowserPanel({ data-file-browser-panel={`${environmentId}:${cwd}`} >
From b7465a3bc993e7f10f4ec7a469759b95eb4c2f2a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:04:16 -0700 Subject: [PATCH 53/65] fix(mobile): stop the work log flickering during subagent runs and failing calls (#10273) --- .../src/features/threads/ThreadFeed.tsx | 33 +- .../src/features/threads/thread-work-log.tsx | 195 ++++++++-- apps/mobile/src/lib/threadActivity.test.ts | 334 +++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 214 ++++++++++- 4 files changed, 724 insertions(+), 52 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b57758b50c12..9b09ef6d903d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -153,6 +153,7 @@ import { } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, + ThreadAgentSpawnCard, ThreadDisclosureChevron, ThreadWorkGroupToggle, ThreadThinkingRow, @@ -504,12 +505,7 @@ function MessageAttachmentFile(props: { function MessageAttachmentUnknown(props: { readonly name: string }) { return ( - + {props.name} @@ -1357,7 +1353,7 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border px-2" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" style={{ minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), }} @@ -1382,6 +1378,19 @@ function renderFeedEntry( return ; } + if (entry.type === "agent-spawn") { + return ( + props.onToggleWorkGroup(entry.id, entry.id)} + onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())} + /> + ); + } + if (entry.type === "work-toggle") { return ( - + {label} - + ); } @@ -1508,7 +1517,7 @@ function renderFeedEntry( })} - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1557,7 +1566,7 @@ function renderFeedEntry( attachmentId={attachment.id} name={attachment.name} mimeType={attachment.mimeType} - className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong" + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( @@ -1581,7 +1590,7 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> - + {timestampLabel} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 9d5b40fce6dc..e812e77ef088 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; -import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity"; +import { + type AgentSpawnSummary, + type ThreadFeedActivity, + workEntryRowLabel, +} from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: { } function ShimmerWorkContent(props: { + readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; readonly icon: WorkContentIcon; @@ -147,26 +152,29 @@ function ShimmerWorkContent(props: { }) { return ( - - {props.showIcon && props.toolIcon && props.environmentId ? ( - - ) : props.showIcon ? ( - - ) : null} - + {props.showIcon ? ( + + {props.toolIcon && props.environmentId ? ( + + ) : ( + + )} + + ) : null} { const subscription = AppState.addEventListener("change", (state) => { @@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: { onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} > @@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( entering={WORK_LOG_DETAIL_ENTER_TRANSITION} exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} layout={WORK_LOG_LAYOUT_TRANSITION} - className="ml-7 border-l border-border pb-1 pl-3 pt-0.5" + className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" > {viewedImagePath ? ( @@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: { ); } +const AGENT_SPAWN_TONE_DOT_CLASS = { + working: "bg-adaptive-sky-600-400", + completed: "bg-adaptive-emerald-600-400", + failed: "bg-adaptive-rose-600-400", + stopped: "bg-foreground-muted", +} as const satisfies Record; + +/** + * A batch of spawned subagents. The status line updates in place as members + * report progress; expanding lists each member. Text nodes carry keys tied to + * the row identity only, so a progress tick re-renders the labels without + * remounting the card (see the batch key in appendActivityGroupRows). + */ +export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: { + readonly summary: AgentSpawnSummary; + readonly expanded: boolean; + readonly iconSubtleColor: ColorValue; + readonly rowSizing: ReturnType; + readonly onToggle: () => void; + readonly onCopy: () => void; +}) { + const { summary, expanded } = props; + const working = summary.tone === "working"; + const memberCount = summary.members.length; + const canExpand = memberCount > 0; + return ( + + { + if (!canExpand) return; + void Haptics.selectionAsync(); + props.onToggle(); + }} + onLongPress={props.onCopy} + className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle" + > + + + + + + + {summary.title} + + + + {working ? ( + + ) : ( + + {summary.status} + + )} + + + {canExpand ? ( + + ) : null} + + {expanded && canExpand ? ( + + {summary.members.map((member) => ( + + + + + {member.title} + + {member.status} + + {member.detail ? ( + + {member.detail} + + ) : null} + + ))} + + ) : null} + + + ); +}); + export function ThreadThinkingRow(props: { readonly rowSizing: ReturnType; readonly iconSubtleColor: ColorValue; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 6131dc9a1b31..7d6cc39ea616 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/contracts"; import { + agentSpawnSummary, buildPendingUserInputAnswers, buildThreadFeed, derivePendingApprovals, @@ -24,6 +25,7 @@ import { workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, + type WorkLogEntry, } from "./threadActivity"; describe("Codex feedback pseudo-messages", () => { @@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => { new Set(), latestTurn.startedAt, ); + // The shimmering row is the turn's live slot; once it stops shimmering + // the slot belongs to "Thinking" and the group keeps its own identity. expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ ["work-toggle:work-group:activity-1", "work-toggle"], ["activity-2", "activity-group"], - ["work-live:work-group:activity-3", "work-toggle"], + [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"], ]); expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ false, @@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => { const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); - expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId }); + expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId }); // The row identity is stable across re-derivations so the list can reuse it. expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( rows[1], @@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => { ).toEqual(["message"]); }); + it("keeps one live slot while calls fail and restart", () => { + // Recorded from a Claude session whose Bash was broken: every call went + // inProgress → failed within two seconds. Each transition used to insert + // or remove a Thinking row under the group; now the same row id holds + // the live call and then "Thinking", so the list updates it in place. + const turnId = TurnId.make("turn-failing-calls"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const call = (n: number, status: "inProgress" | "failed") => + makeActivity({ + id: EventId.make(`call-${n}-${status}`), + kind: status === "failed" ? "tool.completed" : "tool.updated", + tone: "tool", + summary: "Command run", + createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`, + turnId, + payload: { + itemType: "command_execution", + toolCallId: `call-${n}`, + title: "Command run", + status, + detail: `Bash: ls ${n}`, + }, + }); + const liveIds = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-failing-calls"), + projectId: ProjectId.make("project-1"), + title: "Failing calls", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ).map((row) => `${row.type}:${row.id}`); + + expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]); + expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "thinking:live-activity-row", + ]); + expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([ + "work-toggle:live-activity-row", + ]); + // A call whose end was never reported, in a run before an error row, + // keeps its own identity: only the trailing run can hold the live slot. + const errorRow = makeActivity({ + id: EventId.make("runtime-error"), + kind: "runtime.error", + tone: "error", + summary: "Provider error", + createdAt: "2026-04-01T00:00:02.500Z", + turnId, + payload: { message: "boom" }, + }); + expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "activity-group:runtime-error", + "work-toggle:live-activity-row", + ]); + }); + it("hands a settled tool run off to Thinking once assistant text streams after it", () => { const turnId = TurnId.make("turn-streaming-tail"); const latestTurn = { @@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => { }), ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + // The batch anchors on the first task.started: a fixed id and timestamp, + // unlike progress ticks (which the server rewrites in place). const running = rowsFor([]); expect(running.map((row) => [row.id, row.summary])).toEqual([ - ["a-progress", "Kicked off 2 subagents · 2 working"], + ["a-start", "Kicked off 2 subagents · 2 working"], ["shell-1", "Run tests"], ]); expect(running[0]).toMatchObject({ + createdAt: "2026-04-01T00:00:01.000Z", lifecycleStatus: "inProgress", workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } }, }); const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]); expect(oneDone[0]).toMatchObject({ - id: "a-progress", + id: "a-start", summary: "Kicked off 2 subagents · 1 working", lifecycleStatus: "inProgress", }); @@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => { agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }), ]); expect(allDone[0]).toMatchObject({ - id: "a-progress", + id: "a-start", summary: "Ran 2 subagents · 1 failed", lifecycleStatus: "failed", status: "failure", @@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => { expect(allDone).toHaveLength(2); }); + it("folds the tool call that launched an agent into its spawn card", () => { + const turnId = TurnId.make("turn-agent-tool"); + const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-agent-tool"), + projectId: ProjectId.make("project-1"), + title: "Agent tool", + activities: [ + makeActivity({ + id: EventId.make("agent-call-updated"), + kind: "tool.updated", + tone: "tool", + summary: "Subagent task", + createdAt: at(1), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "inProgress", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + makeActivity({ + id: EventId.make("agent-started"), + kind: "task.started", + summary: "Locate code", + createdAt: at(2), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + }, + }), + makeActivity({ + id: EventId.make("agent-done"), + kind: "task.completed", + summary: "Locate code", + createdAt: at(3), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + status: "completed", + }, + }), + makeActivity({ + id: EventId.make("agent-call-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Subagent task", + createdAt: at(4), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "completed", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + ], + }), + ); + const rows = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [], + ); + expect(rows).toEqual(["agent-started"]); + expect( + deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type), + ).toEqual(["turn-fold", "agent-spawn"]); + }); + + it("presents a spawn batch as one card whose status line follows the newest member activity", () => { + const turnId = TurnId.make("turn-spawn-card"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const agent = ( + id: string, + kind: "task.started" | "task.progress" | "task.completed", + taskId: string, + seconds: number, + extra: Record = {}, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `Agent ${taskId}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId, + agentKind: "agent", + taskType: "local_agent", + title: `Agent ${taskId}`, + ...extra, + }, + }); + const presentFor = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-spawn-card"), + projectId: ProjectId.make("project-1"), + title: "Spawn card", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + + // A working card is the live activity; no Thinking row sits under it. + const single = presentFor([agent("a-start", "task.started", "a", 1)]); + expect(single.map((row) => row.type)).toEqual(["agent-spawn"]); + expect(single[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { title: "Agent a", status: "Working", tone: "working" }, + }); + + // The server upserts the progress row with a new createdAt each tick; + // the card keeps its identity and only the status line changes. + const tick = (seconds: number, detail: string) => + presentFor([ + agent("a-start", "task.started", "a", 1), + agent("task-progress:a", "task.progress", "a", seconds, { detail }), + ]); + expect(tick(2, "Reading a.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { title: "Agent a", status: "Reading a.ts", tone: "working" }, + }); + expect(tick(3, "Reading b.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { status: "Reading b.ts" }, + }); + + const batch = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + ]); + expect(batch[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { + title: "2 subagents", + status: "Grepping", + tone: "working", + members: [ + { title: "Agent a", status: "completed", tone: "completed" }, + { title: "Agent b", status: "working", tone: "working", detail: "Grepping" }, + ], + }, + }); + + const settled = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }), + ]); + expect(settled[0]).toMatchObject({ + type: "agent-spawn", + summary: { title: "2 subagents", status: "1 failed", tone: "failed" }, + }); + expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]); + }); + it.each(["cancelled", "failed", "interrupted", "idle"] as const)( "replaces Antigravity batch progress with %s", (status) => { @@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => { expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed"); }); + it("summarizes a spawn card from the newest member report and the batch outcome", () => { + type Member = NonNullable["agents"][number]; + const member = (title: string, status: Member["status"], detail: string, seconds: number) => + ({ + title, + status, + detail, + updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + }) satisfies Member; + const direct = (agents: ReadonlyArray) => ({ + workflowId: null, + agentTaskIds: agents.map((_, index) => `a${index}`), + agents, + }); + + // The newest report wins regardless of member order. + expect( + agentSpawnSummary( + direct([ + member("Agent 0", "inProgress", "Reading b.ts", 5), + member("Agent 1", "inProgress", "Reading a.ts", 2), + ]), + "inProgress", + ), + ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" }); + + // A declined request is a failed batch, not a completed one. + expect( + agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"), + ).toMatchObject({ status: "failed", tone: "failed" }); + + // A coordinator that failed on its own reports the failure even when every + // member succeeded; before any member reports, the card has a neutral title. + const workflow = (agents: ReadonlyArray) => ({ + workflowId: "wf", + agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)], + agents: [member("review", "failed", "", 9), ...agents], + }); + expect( + agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"), + ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" }); + expect( + agentSpawnSummary( + { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] }, + "inProgress", + ), + ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] }); + }); + it("treats a Codex child's idle turn end as a finished batch member", () => { const turnId = TurnId.make("turn-codex"); const child = ( @@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => { expect(ids).toContain("nested-done"); expect(ids).not.toContain("shell-done"); expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([ - { type: "activity-group", id: "nested-done" }, + { + type: "agent-spawn", + id: "agent-spawn:n-1", + activity: { id: "nested-done" }, + summary: { title: "Task completed", status: "completed", tone: "completed" }, + }, ]); }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 1d6fb6c0e252..7286446fb2e8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -122,6 +122,8 @@ export interface WorkLogEntry { readonly title: string; readonly status: WorkLogToolLifecycleStatus | undefined; readonly detail: string | undefined; + /** When this member last reported, so the card can show the newest activity. */ + readonly updatedAt: string; }>; }; toolData?: unknown; @@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry { collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; + /** The tool call that launched this agent, when the provider reports one. */ + agentSpawnToolCallId?: string; isWorkflowCoordinator?: boolean; /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ isBackgroundTask?: boolean; @@ -187,12 +191,47 @@ export type ThreadFeedEntry = readonly expanded: boolean; } | { + /** + * The turn's single live slot. Web keys its live tool row and its + * "Thinking" row identically so the slot updates in place; here the + * slot holds "Thinking" whenever no tool row is shimmering, so a tool + * failing does not insert a row under the group it lives in. + */ readonly type: "thinking"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; + } + | { + /** + * One batch of spawned subagents. Rendered as its own card because a + * single-line tool row has no room for what the agents are doing now, + * which on a phone is the one thing worth showing. + */ + readonly type: "agent-spawn"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; + readonly activity: ThreadFeedActivity; + readonly expanded: boolean; + readonly summary: AgentSpawnSummary; }; +export interface AgentSpawnSummary { + /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */ + readonly title: string; + /** Latest member activity while working, else the batch outcome. */ + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly members: ReadonlyArray<{ + readonly title: string; + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly detail: string | undefined; + readonly updatedAt: string; + }>; +} + export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" @@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean return false; } const isTaskRow = + activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.updated" || activity.kind === "task.completed"; @@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean return payload.timelineBypass === true || ownedByAgent; } +/** Agent (non-background) task.started rows seed spawn batches. */ +function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + return typeof payload?.taskId === "string" && payload.agentKind === "agent"; +} + function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -449,7 +498,11 @@ function deriveWorkLogEntries( for (const activity of ordered) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; - if (activity.kind === "task.started") continue; + // Like web: an agent's task.started row anchors its batch. It has a fixed + // id and timestamp, unlike progress ticks, whose stable per-task id is + // rewritten with a new createdAt on every update (and would otherwise + // make the batch row a "fresh" row again on each tick). + if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; @@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const toolPresentation = extractToolActivityPresentation(payload); // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = + activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.completed" || activity.kind === "task.updated"; @@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (payload.agentKind !== "agent") { entry.isBackgroundTask = true; } + const spawnToolCallId = asTrimmedString(payload.toolUseId); + if (spawnToolCallId) { + entry.agentSpawnToolCallId = spawnToolCallId; + } if ( payload.taskType === "local_workflow" || (typeof payload.workflowName === "string" && payload.workflowName.length > 0) @@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.requestKind = requestKind; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); - if (!toolLifecycleStatus && activity.kind === "tool.completed") { - toolLifecycleStatus = "completed"; + if ( + !toolLifecycleStatus && + (activity.kind === "tool.completed" || activity.kind === "task.completed") + ) { + toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed"; } // A Codex child that finishes its turn reports "idle" (resumable, not // terminal). For the batch row that is a finished member. @@ -681,6 +742,7 @@ function agentSpawnMember( title: entry.toolTitle ?? previous?.title ?? entry.label, status: entry.toolLifecycleStatus ?? previous?.status, detail: entry.detail ?? previous?.detail, + updatedAt: entry.createdAt, }; } @@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus( return "inProgress"; } if (statuses.includes("failed")) return "failed"; + if (statuses.includes("declined")) return "declined"; if (statuses.includes("stopped")) return "stopped"; return "completed"; } @@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries( const spawnRowIndex = new Map(); const spawnGroupByTaskId = new Map(); const toolLifecycleRowIndex = new Map(); + // Tool calls that launched an agent (Claude's Agent tool, ACP subagent + // calls). The batch card is the whole story of that call, so its own + // lifecycle row is dropped. + const spawnToolCallIds = new Set( + entries.flatMap((entry) => + entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [], + ), + ); for (const entry of entries) { + if ( + entry.toolCallId !== undefined && + entry.taskId === undefined && + spawnToolCallIds.has(entry.toolCallId) + ) { + continue; + } const isTaskRow = entry.taskId !== undefined && - (entry.sourceActivityKind === "task.progress" || + (entry.sourceActivityKind === "task.started" || + entry.sourceActivityKind === "task.progress" || entry.sourceActivityKind === "task.completed" || entry.sourceActivityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { @@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable) { return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); } +function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] { + switch (status) { + case undefined: + case "inProgress": + return "working"; + case "completed": + return "completed"; + case "failed": + case "declined": + return "failed"; + case "stopped": + return "stopped"; + } +} + +/** + * What the spawn card shows. While members work, the status line is the + * newest member activity (its progress detail), so the card reads like the + * live tool row does for a single call. Once every member settles, it is the + * batch outcome in web's CTA wording. + */ +export function agentSpawnSummary( + spawn: NonNullable, + batchStatus: WorkLogToolLifecycleStatus | undefined, +): AgentSpawnSummary { + const members = agentSpawnMembers(spawn).map((agent) => { + const tone = agentSpawnTone(agent.status); + return { + title: agent.title, + status: tone === "working" ? "working" : (agent.status ?? tone), + tone, + detail: agent.detail, + updatedAt: agent.updatedAt, + }; + }); + const tone = agentSpawnTone(batchStatus); + // A workflow's coordinator is not a member; before any member reports the + // batch has none. + const title = + members.length === 0 + ? "Subagents" + : members.length === 1 + ? members[0]!.title + : `${members.length} subagents`; + if (tone === "working") { + const working = members.filter((member) => member.tone === "working"); + const latest = working + .filter((member) => member.detail !== undefined) + .reduce<(typeof working)[number] | undefined>( + (newest, member) => + newest === undefined || member.updatedAt > newest.updatedAt ? member : newest, + undefined, + ); + const status = + latest?.detail ?? + (members.length > 1 ? `${working.length} of ${members.length} working` : "Working"); + return { title, status, tone, members }; + } + // The batch tone covers a coordinator that failed or stopped on its own. + const failed = members.filter((member) => member.tone === "failed").length; + const stopped = members.filter((member) => member.tone === "stopped").length; + const outcome = + tone === "failed" || failed > 0 + ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed` + : tone === "stopped" || stopped > 0 + ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped` + : "completed"; + return { title, status: outcome, tone, members }; +} + function agentSpawnExpandedBody(spawn: NonNullable): string | null { const lines = agentSpawnMembers(spawn).map((agent) => { const status = @@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation( ): ThreadFeedEntry[] { const sourceFeed = feed.filter( (entry) => - entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", + entry.type !== "turn-fold" && + entry.type !== "work-toggle" && + entry.type !== "thinking" && + entry.type !== "agent-spawn", ); const activeTailGroup = sourceFeed.findLast( (entry) => entry.type !== "message" || !isEmptyMessage(entry), @@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation( } // A working turn always shows one live activity. When no tool row is // shimmering (no tools yet, or the latest failed), that row is "Thinking". + // The trailing group's live row and this row share LIVE_ACTIVITY_ROW_ID, so + // the handoff between them happens in place (one row, new content) instead + // of a row being inserted below the group every time a call fails. if ( activeWorkStartedAt !== null && - !result.some((row) => row.type === "work-toggle" && row.shimmer) + !result.some( + (row) => + (row.type === "work-toggle" && row.shimmer) || + // A working spawn card is the live activity: its status line shows + // what the agents are doing, so a Thinking row under it would lie. + (row.type === "agent-spawn" && + row.summary.tone === "working" && + row.turnId === unsettledTurnId), + ) ) { result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); } return result; } +/** + * Shared by the trailing tool group's live row and the "Thinking" row so the + * list keeps one mounted row for the turn's live slot (mirrors web's + * LIVE_ACTIVITY_ROW_ID). Anything keyed by row id must not distinguish them. + */ +export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + function thinkingRow(createdAt: string, turnId: TurnId | null) { if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) { - cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId }; + cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, turnId }; } return cachedThinkingRow; } @@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry( cached.isWorking !== isWorking || cached.activeTail !== activeTail || cached.rows.some( - (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, + (row) => + (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) || + (row.type === "agent-spawn" && expandedWorkGroupIds.has(row.id) !== row.expanded), ) ) { const rows: ThreadFeedEntry[] = []; @@ -1908,11 +2080,27 @@ function appendActivityGroupRows( groupableRun = []; }; for (const activity of activities) { - if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) { + const spawn = activity.workEntry.agentSpawn; + if (activity.workEntry.tone !== "error" && spawn === undefined) { groupableRun.push(activity); continue; } flushGroupableRun(false); + if (spawn !== undefined) { + // Keyed by the batch, not the anchor activity: the anchor can change + // as members arrive, and a changed key remounts the card. + const groupId = `agent-spawn:${spawn.workflowId ?? activity.turnId ?? spawn.agentTaskIds[0]}`; + result.push({ + type: "agent-spawn", + id: groupId, + createdAt: activity.createdAt, + turnId: activity.turnId, + activity, + expanded: expandedWorkGroupIds.has(groupId), + summary: agentSpawnSummary(spawn, activity.lifecycleStatus), + }); + continue; + } result.push({ type: "activity-group", id: activity.id, @@ -1953,7 +2141,9 @@ function appendToolGroupRows( const latestActivity = latestActiveActivity ?? activities.at(-1)!; // Like web, the trailing run keeps shining after its latest call succeeds; // only a failed, declined, or stopped call hands the live slot to "Thinking". - const shimmer = active || (activeTail && latestActivity.status === "success"); + // Only the trailing run can be the turn's live slot; an in-progress row in + // an earlier run (a call whose end was never reported) stays in place. + const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live ? liveToolActivitySummary(latestActivity, live) @@ -1994,7 +2184,9 @@ function appendToolGroupRows( : undefined; result.push({ type: "work-toggle", - id: `${live ? "work-live" : "work-toggle"}:${groupId}`, + // The shimmering trailing row is the turn's live slot; it keeps that + // identity (and so its mounted view) until "Thinking" takes the slot. + id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`, createdAt: sourceGroup.createdAt, turnId: sourceGroup.turnId, groupId, From a495385584276d0e568df23646a49ce8b40a4707 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:06:13 -0700 Subject: [PATCH 54/65] fix(mobile): save linked media from chat (#10271) --- .../src/features/threads/ThreadFeed.tsx | 21 ++++-- .../src/features/threads/fileChipMenu.test.ts | 45 +++++++++++- .../src/features/threads/fileChipMenu.ts | 36 +++++++++- .../src/features/threads/useFileChipShare.ts | 70 +++++++++++++++++++ 4 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/threads/useFileChipShare.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 9b09ef6d903d..577fb3704ec7 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -178,6 +178,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; +import { useFileChipShare } from "./useFileChipShare"; import { MarkdownImageAvailableWidthContext, ThreadMarkdownImage, @@ -1964,6 +1965,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); + const fileShareSourceIdentifier = useId(); + const shareFileChip = useFileChipShare( + props.environmentId, + props.threadId, + fileShareSourceIdentifier, + ); useEffect(() => { setExpandedVideo(null); setExpandedFile(null); @@ -2116,10 +2123,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "open-file": onMarkdownLinkPress(href); return; + case "save": + shareFileChip(target); + return; } }, }), - [onMarkdownLinkPress, props.workspaceRoot], + [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], ); const renderMarkdownImage = useCallback( (image) => { @@ -2710,7 +2720,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } return ( - <> + ) : null} + setExpandedVideo(null)} /> + setExpandedFile(null)} /> - - setExpandedVideo(null)} /> - setExpandedFile(null)} /> - + ); }); diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index eb9bad3a4195..1627a72da5de 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -1,6 +1,7 @@ +import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; +import { fileChipMenu, fileChipShareSource, resolveFileChipTarget } from "./fileChipMenu"; describe("resolveFileChipTarget", () => { it("resolves a workspace-relative link to both paths", () => { @@ -42,3 +43,45 @@ describe("fileChipMenu", () => { ]); }); }); + +describe("file chip downloads", () => { + const threadId = ThreadId.make("thread-1"); + + it.each([ + [ + "/tmp/maria-counter/maria-counter-final.mp4", + "/tmp/maria-counter/maria-counter-final.mp4", + "video/mp4", + ], + ["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"], + ["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"], + ["screens/image.PNG", "/repo/screens/image.PNG", "image/png"], + ])("offers a host download for %s", (href, path, mimeType) => { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipMenu(target).actions).toContainEqual({ + id: "save", + title: "Save or share", + }); + expect(fileChipShareSource(target, threadId)).toEqual({ + name: path.split("/").at(-1), + mimeType, + resource: { _tag: "media-file", threadId, path }, + }); + }); + + it("retains the thread context for a relative file without a known workspace root", () => { + expect( + fileChipShareSource(resolveFileChipTarget("clips/demo.mp4", null)!, threadId), + ).toMatchObject({ + resource: { _tag: "media-file", threadId, path: "clips/demo.mp4" }, + }); + }); + + it("does not offer downloads the host asset endpoint cannot serve", () => { + for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipShareSource(target, threadId)).toBeNull(); + expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false); + } + }); +}); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 3630a62b3551..9f82e089444d 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,5 +1,8 @@ +import { fileBasename } from "@t3tools/client-runtime/markdown-links"; +import type { ThreadId } from "@t3tools/contracts"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; +import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { isAbsolutePath, @@ -7,7 +10,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; +export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save"; export interface FileChipTarget { /** The host path, when the link is absolute or the workspace root is known. */ @@ -36,7 +39,28 @@ export function resolveFileChipTarget( }; } -/** The same actions the web file chip offers on right-click. Opening is what a tap does. */ +function fileChipMetadata(target: FileChipTarget) { + const path = target.fullPath ?? target.relativePath; + if (!path) return null; + const name = fileBasename(path); + const dot = name.lastIndexOf("."); + const mimeType = dot < 0 ? null : hostPreviewMimeTypeFromExtension(name.slice(dot)); + return mimeType ? { path, name, mimeType } : null; +} + +/** Use literal resolved paths so encoded filename characters are not decoded twice. */ +export function fileChipShareSource(target: FileChipTarget, threadId: ThreadId) { + const metadata = fileChipMetadata(target); + return metadata + ? { + name: metadata.name, + mimeType: metadata.mimeType, + resource: { _tag: "media-file" as const, threadId, path: metadata.path }, + } + : null; +} + +/** Saving is available for the media and documents the host asset endpoint can serve. */ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { return { title: target.fullPath ?? target.relativePath ?? "", @@ -44,6 +68,14 @@ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), { id: "open-file", title: "Open in file viewer" }, + ...(fileChipMetadata(target) + ? [ + { + id: "save", + title: "Save or share", + }, + ] + : []), ], }; } diff --git a/apps/mobile/src/features/threads/useFileChipShare.ts b/apps/mobile/src/features/threads/useFileChipShare.ts new file mode 100644 index 000000000000..587aa8ad9572 --- /dev/null +++ b/apps/mobile/src/features/threads/useFileChipShare.ts @@ -0,0 +1,70 @@ +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { Alert } from "react-native"; + +import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; +import { assetEnvironment } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { fileChipShareSource, type FileChipTarget } from "./fileChipMenu"; + +/** Fetches host files through the selected environment before opening the native save/share sheet. */ +export function useFileChipShare( + environmentId: EnvironmentId, + threadId: ThreadId, + sourceIdentifier: string, +) { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + const connectionRef = useRef(httpBaseUrl); + useLayoutEffect(() => { + connectionRef.current = httpBaseUrl; + }, [httpBaseUrl]); + const requestRef = useRef(null); + useEffect(() => () => requestRef.current?.abort(), []); + + const share = useCallback( + (target: FileChipTarget) => { + const source = fileChipShareSource(target, threadId); + if (!source || requestRef.current) return; + const request = new AbortController(); + requestRef.current = request; + const httpBaseUrl = connectionRef.current; + void (async () => { + if (httpBaseUrl === null) throw new Error("Reconnect to the environment and try again."); + const result = await createUrl({ environmentId, input: { resource: source.resource } }); + if (request.signal.aborted) return; + const url = + result._tag === "Success" ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) : null; + if (url === null) throw new Error("The file could not be loaded. Reconnect and try again."); + await downloadAndShareAttachment({ + url, + attachment: source, + signal: request.signal, + sourceIdentifier, + }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (requestRef.current === request) { + requestRef.current = null; + } + }); + }, + [createUrl, environmentId, sourceIdentifier, threadId], + ); + return share; +} From 272d6d747ef214f50dbd9d0051120de12ffa3591 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:21:44 -0700 Subject: [PATCH 55/65] feat(markdown): show the GitHub mark for github.com links (#10324) Co-authored-by: Claude Code --- .../assets/link-icons/github.png | Bin 0 -> 1727 bytes .../t3-markdown-text/ios/T3MarkdownText.mm | 29 +++++++++------ .../ios/T3MarkdownTextShadowNode.h | 2 ++ .../ios/T3MarkdownTextShadowNode.mm | 11 ++++++ .../modules/t3-markdown-text/package.json | 1 + .../src/NativeMarkdownSelectableText.ios.tsx | 34 ++++++++++++++---- .../t3-markdown-text/src/markdownLinkIcons.ts | 12 +++++++ .../t3-markdown-text/src/markdownLinks.ts | 12 +++++++ .../src/features/threads/ThreadFeed.tsx | 17 ++++++--- apps/mobile/src/lib/markdownLinks.test.ts | 16 ++++++++- apps/web/src/components/ChatMarkdown.test.tsx | 13 +++++-- apps/web/src/components/ChatMarkdown.tsx | 15 ++++++-- 12 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts diff --git a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png new file mode 100644 index 0000000000000000000000000000000000000000..87eab9f5218ffce249bd6019b59b55d9106f44f5 GIT binary patch literal 1727 zcmV;w20;0VP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuCDM>^@RCodHnrn<+Lm0=qq*a%# zmM!YKl&xgT7up7~5j8!DnD2*a6y(&cfqC@Gn6yIO&!R z*{Tf9!jpcm3*-e*uYTE*P1$Z4bzu2spm(%hfm!v)wtSE;O{KY58v<5`8znDaz;uY8hk4I@pnb)cw&%^D{k>;6hiMd`C`5PXtk`6{35LQ=E=B+A>Rl+UG; zpsCbOPu_nx^1amS&<^jsAGF_A424K5s;{hF+vcx>zNs;vx~q{}2yOzJZlj>lN!o{v zm%%MyQ4N1kX4zFN(fb6wGebEFWo#?(>2MgUK_hmDzo2&j91VPW6-HJ9)Tfvxtp}4B z2x)8iB*-JlEK3nO4F0`f6_{3&`JHkT_yT+jc7bHZOs75*%mLk?7buw`IsX(`1C-bh z$<8C84v)8#wsfV^EP7&}-ilN$`#TiKZQx9Bze;I!0~+wD?&_S+5FNeVG*N*+ddM^%J?_szi?;FBb5B!X4va0R=ZyC;{i-i#ABf z!-**s^EiD%EoM+6^Yr-0>QwGF#-Q8gG13BJxw_W2^MwC~R_d=dQ`Da`JS9NC*TvFi1pkZ`ZjqdRQ;ek^CE&~)$hJz<;$xQ*&}r~gUx2EU z_5-ohX-&Yvv6@>bXYp|?0hUjoWwx{`2fB=jr4G#i-O9vL6*oY>?ZKSWs3RZo6*)yQY`IJ0!Ct`k(2xagM70^wXHwdV01(QZ8Hpz=!Hi4W{O*V+ol9; zF?v{~t1#bn>pkV?Qg9W>J30CN7x|Ve(C)?u?NWVTv)Wh6@8gVvr0^Y}*;6H2k26`p zhWfOqkCCpoLIPR5m9?=|C@w4D|Aa%6epSf|Hq@u-c4-Bo*N-nh74Uz;-NCs96euaW zgs?6=45=HPho+q%^nmhs3wj%Cvt#-;6v*Zp|>NY3NR8 zEq-|py^?1zqyO;;{dxA?e#MqcntpEscc5Ee;b=IBy4DD{->)xoW+IPf8S_tRL$ zPS)5m3D7GT{obcJF^!H5Ae3pX2ls(fK!^^3>29JzGI_kc2!w3s@!nvZ7U{Z>lJFeJ zGOBOv2VkgHj!^1{N+qET>A;m`H`MnMW8`yPNmFSMgbZf!4lU`0evQh~@Av7QclRnK z%}*YFEiB6pEp^{LS#^-J53V0%sI5=kjX@0!V9rJ7Mi6qu=jo7DhP0LTb^2|cQhUl6 zDKb{C#B!i_-*?DuYco@Zw2rv?-1?97-3}T$<@21MfxrMryOt_@QQ`)i)b*d^Yrzqq zGNgC?PB07-N57(8_A~gAqi-wqHK6RQLPcX~5$cx!9cjx_7Ydew^UEswe|#VV{{pUG Vd4ZqLelGw3002ovPDHLkV1lP;E0X{K literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 25f1e94c110f..d42be2e174db 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -60,14 +60,16 @@ static void T3MarkdownTextApplyAttachments( NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; UIImage *image = images[imageUri]; - if ([imageUri hasPrefix:@"sf:"]) { - NSString *symbolName = [imageUri substringFromIndex:3]; - UIColor *foregroundColor = - [attributedString attribute:NSForegroundColorAttributeName - atIndex:attachmentRange.location - effectiveRange:nil] ?: UIColor.labelColor; - image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor - renderingMode:UIImageRenderingModeAlwaysOriginal]; + const BOOL isSymbol = [imageUri hasPrefix:@"sf:"]; + if (isSymbol) { + image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]]; + } + UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil]; + if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) { + image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; } attachment.image = image ?: [[UIImage alloc] init]; const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); @@ -79,8 +81,15 @@ static void T3MarkdownTextApplyAttachments( const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - NSAttributedString *attachmentString = - [NSAttributedString attributedStringWithAttachment:attachment]; + NSMutableAttributedString *attachmentString = + [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; + // Keep the run color on the attachment so a later re-apply (after the image + // loads asynchronously) still tints with the link color, not labelColor. + if (foregroundColor != nil) { + [attachmentString addAttribute:NSForegroundColorAttributeName + value:foregroundColor + range:NSMakeRange(0, attachmentString.length)]; + } [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; } } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63b..e6ce2b3226f0 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -26,6 +26,8 @@ struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + /// Recolor the loaded image with the run's foreground color, like `sf:` symbols. + bool tintWithForeground; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb94..60bbcf2e4f84 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -11,6 +11,7 @@ static constexpr Float ParagraphStyleEncodingOffset = 1000; static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; +static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; static void applyParagraphStyles( NSMutableAttributedString *attributedString, @@ -192,6 +193,7 @@ static void applyAttachments( utf16Offset, 1, props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), + false, }); } else if ( props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { @@ -200,6 +202,15 @@ static void applyAttachments( 1, props.nativeId.substr( std::char_traits::length(SkillAttachmentNativeIdPrefix)), + false, + }); + } else if ( + props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset, + 1, + props.nativeId.substr(std::char_traits::length(LinkAttachmentNativeIdPrefix)), + true, }); } utf16Offset += fragmentLength; diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 1e52d7695ec6..8922c8868c44 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -21,6 +21,7 @@ "exports": { ".": "./index.ts", "./file-icons": "./src/markdownFileIcons.ts", + "./link-icons": "./src/markdownLinkIcons.ts", "./links": "./src/markdownLinks.ts", "./markdown": "./src/nativeMarkdownText.ts", "./primitive": "./src/MarkdownTextPrimitive.tsx", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index 590a2fb1bd1b..a5c6cf540f1c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -12,6 +12,8 @@ import { import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; +import { markdownLinkIconSource } from "./markdownLinkIcons"; +import { resolveMarkdownLinkIcon } from "./markdownLinks"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; import type { MarkdownFileContextMenu, @@ -177,10 +179,14 @@ export function NativeMarkdownSelectableText(props: { }) { const colorScheme = useColorScheme(); const menu = useContext(MarkdownFileContextMenuContext); - const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null); + const containsInlineIcon = props.runs.some( + (run) => + run.fileIcon != null || + (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), + ); const attachAndroidText = useCallback( (textView: RNText | null) => { - if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) { + if (Platform.OS !== "android" || !containsInlineIcon || textView === null) { return; } const reactTag = findNodeHandle(textView); @@ -188,7 +194,7 @@ export function NativeMarkdownSelectableText(props: { installMarkdownCopySanitizer(reactTag); } }, - [containsInlineFileIcon], + [containsInlineIcon], ); const occurrences = new Map(); const prefixedExternalLinks = new Set(); @@ -198,6 +204,7 @@ export function NativeMarkdownSelectableText(props: { occurrences.set(signature, occurrence + 1); let text = run.text; + let linkIcon = null; if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { @@ -207,10 +214,15 @@ export function NativeMarkdownSelectableText(props: { : `$${run.skillName}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); - text = `${EXTERNAL_LINK_PREFIX}${text}`; + linkIcon = resolveMarkdownLinkIcon(run.externalHost); + if (linkIcon === null) { + text = `${EXTERNAL_LINK_PREFIX}${text}`; + } else if (Platform.OS === "ios") { + text = `${INLINE_ATTACHMENT_PREFIX}${text}`; + } } - return { key: `${signature}:${occurrence}`, run, text }; + return { key: `${signature}:${occurrence}`, run, text, linkIcon }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A // color-only child update can otherwise leave the previous appearance cached. @@ -248,7 +260,7 @@ export function NativeMarkdownSelectableText(props: { lineHeight: props.textStyle.lineHeight, }} > - {keyedRuns.map(({ key, run, text }) => { + {keyedRuns.map(({ key, run, text, linkIcon }) => { const href = run.href; const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( @@ -260,7 +272,9 @@ export function NativeMarkdownSelectableText(props: { ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` : run.skillName ? "t3-skill:sf:cube" - : undefined + : linkIcon + ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}` + : undefined : undefined } contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} @@ -284,6 +298,12 @@ export function NativeMarkdownSelectableText(props: { > {Platform.OS === "android" && run.fileIcon ? ( + ) : Platform.OS === "android" && linkIcon ? ( + ) : null} {text} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts new file mode 100644 index 000000000000..568a51005798 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts @@ -0,0 +1,12 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownLinkIcon } from "./markdownLinks"; + +// Black-on-transparent marks; callers tint them with the link color. +const MARKDOWN_LINK_ICON_SOURCES = { + github: require("../assets/link-icons/github.png"), +} as const satisfies Readonly>; + +export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { + return MARKDOWN_LINK_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 176585344167..19f71f631663 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -33,6 +33,18 @@ export type MarkdownLinkPresentation = export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; +export type MarkdownLinkIcon = "github"; + +/** + * Sites whose brand mark replaces the generic external-link glyph. The marks + * are monochrome and tinted with the link color, so they follow the theme. + */ +export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { + const hostname = host.toLowerCase(); + if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; + return null; +} + const FILE_ICON_BY_NAME: Readonly> = { ".babelrc": "babel", ".babelrc.json": "babel", diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 577fb3704ec7..f1456aabd258 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -134,9 +134,11 @@ import { import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation, } from "@t3tools/mobile-markdown-text/links"; import { @@ -606,7 +608,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly onPress: (href: string) => void; }) { const [failedHost, setFailedHost] = useState(null); - const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); + const linkIcon = resolveMarkdownLinkIcon(props.host); + const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); return ( - {faviconUrl !== null && - failedHost !== props.host && - !failedMarkdownFaviconHosts.has(props.host) ? ( + {linkIcon ? ( + + ) : faviconUrl !== null && + failedHost !== props.host && + !failedMarkdownFaviconHosts.has(props.host) ? ( { + it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { + expect(resolveMarkdownLinkIcon("github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("GitHub.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("gist.github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("github.community")).toBeNull(); + expect(resolveMarkdownLinkIcon("notgithub.com")).toBeNull(); + expect(resolveMarkdownLinkIcon("example.com")).toBeNull(); + }); +}); describe("resolveMarkdownLinkPresentation", () => { it("treats protocol-relative media as an external URL, not a filesystem path", () => { diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 3243bf3c2788..2c4a1fa7af6e 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -5,6 +5,7 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; @@ -78,10 +79,10 @@ describe("ChatMarkdown favicon privacy", () => { const markdown = (url: string) => ; try { await act(async () => { - renderer = create(markdown("https://github.com")); + renderer = create(markdown("https://example.com")); }); expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([ - "https://www.google.com/s2/favicons?domain=github.com&sz=32", + "https://www.google.com/s2/favicons?domain=example.com&sz=32", ]); for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) { await act(async () => { @@ -90,9 +91,15 @@ describe("ChatMarkdown favicon privacy", () => { expect(renderer!.root.findAllByType("img")).toHaveLength(0); } await act(async () => { - renderer!.update(markdown("https://github.com")); + renderer!.update(markdown("https://example.com")); }); expect(renderer!.root.findAllByType("img")).toHaveLength(1); + // GitHub links draw the brand mark in currentColor instead of fetching a favicon. + await act(async () => { + renderer!.update(markdown("https://github.com/pingdotgg/t3code/pull/1")); + }); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + expect(renderer!.root.findAllByType(GitHubIcon)).toHaveLength(1); } finally { await act(async () => { renderer?.unmount(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 89d886ec7d1b..d72001eb9ddd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -119,6 +119,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { GitHubIcon } from "./Icons"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; @@ -1189,15 +1190,25 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); +/** Sites whose brand mark (drawn in `currentColor`) replaces the fetched favicon so it follows the theme. */ +function brandLinkIcon(host: string): typeof GitHubIcon | null { + const hostname = host.toLowerCase(); + if (hostname === "github.com" || hostname.endsWith(".github.com")) return GitHubIcon; + return null; +} + const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); - const faviconUrl = faviconUrlForOrigin(`https://${host}`); + const BrandIcon = brandLinkIcon(host); + const faviconUrl = BrandIcon ? null : faviconUrlForOrigin(`https://${host}`); return ( - {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( + {BrandIcon ? ( + + ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( Date: Sat, 5 Sep 2026 23:28:05 -0700 Subject: [PATCH 56/65] fix(marketing): show a real preview card when t3.codes is shared (#10305) Co-authored-by: Claude Fable 5.1 --- apps/marketing/astro.config.mjs | 1 + apps/marketing/src/layouts/Layout.astro | 33 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 6f37ae922dad..5ba3da4fba10 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,6 +1,7 @@ import { defineConfig } from "astro/config"; export default defineConfig({ + site: "https://t3.codes", server: { port: Number(process.env.PORT ?? 4173), }, diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index ca5ea15f61de..4c95bc86560e 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,6 +1,7 @@ --- -import { Image } from "astro:assets"; +import { getImage, Image } from "astro:assets"; import appIcon from "../assets/icon.webp"; +import desktopScreenshot from "../assets/app-desktop.webp"; import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; import "../styles/fonts.css"; import { @@ -21,6 +22,21 @@ const { description = "T3 Code. The open-source control plane for coding agents.", pageClass, } = Astro.props; + +// Social preview card. Link unfurlers (Instagram, iMessage, X, Slack) want a +// 1200x630 jpg or png at an absolute URL. Built from the hero screenshot so it +// stays in sync with the homepage. +const socialImage = await getImage({ + src: desktopScreenshot, + width: 1200, + height: 630, + fit: "cover", + position: "top", + format: "jpg", + quality: 90, +}); +const socialImageUrl = new URL(socialImage.src, Astro.site); +const canonicalUrl = new URL(Astro.url.pathname, Astro.site); --- @@ -28,6 +44,21 @@ const { + + + + + + + + + + + + + + + Date: Sun, 6 Sep 2026 00:02:37 -0700 Subject: [PATCH 57/65] feat(usage): pool subscription limits per provider across accounts and environments (#10300) --- .../src/usage/cliproxyUsageLimits.test.ts | 16 + apps/server/src/usage/cliproxyUsageLimits.ts | 4 +- apps/web/src/components/usage/UsageLimits.tsx | 327 +++------- .../components/usage/UsageLimitsPooled.tsx | 570 ++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 39 +- .../components/usage/usageLimitsFixture.ts | 413 +++++++++++++ docs/user/usage.md | 15 +- packages/shared/src/usageLimits.test.ts | 378 ++++++++++++ packages/shared/src/usageLimits.ts | 307 +++++++++- 9 files changed, 1823 insertions(+), 246 deletions(-) create mode 100644 apps/web/src/components/usage/UsageLimitsPooled.tsx create mode 100644 apps/web/src/components/usage/usageLimitsFixture.ts diff --git a/apps/server/src/usage/cliproxyUsageLimits.test.ts b/apps/server/src/usage/cliproxyUsageLimits.test.ts index 19767f3a9270..5e8d1b1fff7a 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.test.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.test.ts @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { }, ]); }); + + it("names a Codex five-hour window `primary`, as the Codex driver does", () => { + const accounts = cliproxyStatusToAccounts( + { + accounts: { + "codex-abc-someone@example.com-pro.json": { + provider: "codex", + plan: "pro", + five_hour: { hard_limited: false, known: true, used_percent: 40 }, + }, + }, + }, + checkedAt, + ); + expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]); + }); }); describe("accountEmailFromAuthFile", () => { diff --git a/apps/server/src/usage/cliproxyUsageLimits.ts b/apps/server/src/usage/cliproxyUsageLimits.ts index cd2b1e277da6..47ed200c3f22 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.ts @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( if (!window || window.known === false) continue; const resetsAt = isoFromHub(window.reset_at); windows.push({ - id: spec.id, + // Codex names its five-hour window by position, so a hub row and a + // native row for the same account pool together. + id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id, kind: spec.kind, label: spec.label, windowDurationMins: spec.windowDurationMins, diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 1a72af35c909..0f15631acb99 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -5,21 +5,16 @@ import { ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, - UsageLimitSourceAccount, - UsageLimitSourceSnapshot, UsageProviderKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { - collectLimitSources, - collectLimitsGroups, + collectLimitAccounts, elapsedShare, formatDuration, formatResetsIn, - limitsNotice, type LimitPace, paceOf, - providerLimitsLabel, remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; @@ -30,9 +25,6 @@ import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatUpcomingTimestamp } from "../../timestampFormat"; -import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { getDriverOption } from "../settings/providerDriverMeta"; -import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { AlertDialog, AlertDialogClose, @@ -44,6 +36,8 @@ import { } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { makeLimitsFixture } from "./usageLimitsFixture"; +import { UsageLimitsPooled } from "./UsageLimitsPooled"; import { PROVIDER_PRESENTATION } from "./usageProviders"; const PACE: Record = { @@ -53,14 +47,14 @@ const PACE: Record @@ -202,94 +196,6 @@ export function LimitWindows({ ); } -/** - * Heading shared by local providers and source accounts: icon, driver, instance, plan, - * and the signed-in email blurred until clicked, as provider settings do. - */ -function AccountHeading({ - driver, - label, - instanceLabel, - plan, - email, - accentColor, -}: { - readonly driver: ServerProvider["driver"]; - readonly label: string; - readonly instanceLabel: string; - readonly plan: string | undefined; - readonly email: string | undefined; - readonly accentColor?: string | undefined; -}) { - return ( -

- - {label} - {instanceLabel !== label ? ( - - · {instanceLabel} - - ) : null} - {plan ? · {plan} : null} - {email ? ( - - ) : null} -

- ); -} - -function ProviderLimits({ - provider, - environmentId, - now, -}: { - readonly provider: ServerProvider; - readonly environmentId: EnvironmentId; - readonly now: number; -}) { - const limits = provider.usageLimits; - if (!limits) return null; - const notice = limitsNotice(limits); - return ( -
- getDriverOption(driver)?.label)} - plan={provider.auth.label} - email={provider.auth.email} - accentColor={provider.accentColor} - /> - {notice ? ( - {notice} - ) : ( - - )} - {limits.resetCredits ? ( - - ) : null} -
- ); -} - const OUTCOME_TEXT: Record = { reset: "Reset applied. Your windows have cleared.", nothingToReset: "Nothing to reset right now.", @@ -297,36 +203,12 @@ const OUTCOME_TEXT: Record = { alreadyRedeemed: "That credit was already redeemed.", }; -/** - * Banked reset credits with a confirmed redeem action. Redeeming spends a - * credit the provider granted the user, so it never fires on a bare click. - */ -export function ResetCredits({ - environmentId, - instanceId, - credits, - now, -}: { - readonly environmentId: EnvironmentId; - readonly instanceId: ProviderInstanceId; - readonly credits: ServerProviderResetCredits; - readonly now: number; -}) { +/** Everything a redeem needs: where to send it and what to say afterwards. */ +export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; - - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; const redeem = async () => { setConfirming(false); @@ -345,138 +227,115 @@ export function ResetCredits({ ); }; - return ( -
- {summary} - {credits.availableCount > 0 ? ( - - ) : null} - {status ? {status} : null} - - - - Use a reset credit? - - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. - - - - }>Cancel - - - - -
- ); + return { confirming, setConfirming, busy, status, redeem }; } -/** One account pooled by a usage-limit source, drawn like a provider row. */ -function SourceAccountLimits({ - account, - sourceKind, - now, +/** + * The confirm for a redeem. Redeeming spends a credit the provider granted the + * user, so it never fires on a bare click. Mount it outside any popover that + * holds the button: dialogs stack under popovers, and closing the popover + * would unmount a dialog rendered inside it. + */ +export function ResetCreditDialog({ + open, + onOpenChange, + onConfirm, }: { - readonly account: UsageLimitSourceAccount; - readonly sourceKind: string; - readonly now: number; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onConfirm: () => void; }) { - const notice = limitsNotice(account.usageLimits); return ( -
- - {notice ? ( - {notice} - ) : ( - - )} -
+ + + + Use a reset credit? + + This redeems one credit on your account and clears the current rate-limit windows. It + cannot be undone. + + + + }>Cancel + + + + ); } -const SOURCE_KIND_LABEL: Record = { - cliproxy: "CLI Proxy", -}; - -type LimitsSource = ReturnType[number]; +/** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */ +export function resetCreditsSummary( + credits: ServerProviderResetCredits, + now: number, + compact = false, +): string { + const expiresIn = credits.nextExpiresAt + ? formatDuration(Date.parse(credits.nextExpiresAt) - now) + : null; + if (credits.availableCount === 0) return "No reset credits banked"; + if (compact) + return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; + return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ + expiresIn ? ` · next expires in ${expiresIn}` : "" + }`; +} -/** Read-only accounts pooled by a configured usage source. */ -function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { - const kind = SOURCE_KIND_LABEL[source.kind]; +/** Banked reset credits with the redeem button and its confirm, self-contained. */ +export function ResetCredits({ + environmentId, + instanceId, + credits, + now, +}: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly credits: ServerProviderResetCredits; + readonly now: number; +}) { + const { confirming, setConfirming, busy, status, redeem } = useResetCredit( + environmentId, + instanceId, + ); + if (credits.availableCount === 0 && status === null) return null; return ( -
- {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account) => ( - - )) - )} +
+ {resetCreditsSummary(credits, now)} + {credits.availableCount > 0 ? ( + + ) : null} + {status ? {status} : null} + void redeem()} + />
); } /** - * Subscription quota windows from every connected environment's providers. - * Countdowns anchor to render time rather than ticking: a live clock would - * repaint the page every minute for no decision-changing gain. + * Subscription quota across every connected environment's providers and hubs, + * pooled per provider. Countdowns anchor to render time rather than ticking: a + * live clock would repaint the page every minute for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, + fixture = null, }: { readonly selectedEnvironmentIds: ReadonlySet | null; + /** Dev-only synthetic presentations standing in for the live ones. */ + readonly fixture?: ReturnType | null; }) { - const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const live = useAtomValue(environmentPresentations.presentationsAtom); + // Anchored once per mount on purpose: countdowns must not tick (see above). + const [now] = useState(() => Date.now()); + const presentations: Parameters[0] = fixture ?? live; const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - const groups = collectLimitsGroups(selected); - const sources = collectLimitSources(selected); - // Anchored once per mount on purpose: countdowns must not tick (see below). - const [now] = useState(() => Date.now()); - - return ( -
- {groups.length === 0 && sources.length === 0 ? ( -

- No provider on the selected environments reports subscription limits. -

- ) : null} - {sources.map((source) => ( - - ))} - {groups.map((group) => ( -
- {group.environmentLabel ? ( -

- {group.environmentLabel} -

- ) : null} - {group.providers.map((provider) => ( - - ))} -
- ))} -
- ); + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..1d57cdb96340 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -0,0 +1,570 @@ +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + type LimitAccount, + type LimitPool, + type LimitPoolMember, + type LimitPoolWindow, + remainingPercent, +} from "@t3tools/shared/usageLimits"; +import { TicketIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { usePrimarySettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + PaceIcon, + ResetCreditDialog, + barColor, + resetCreditsSummary, + useResetCredit, +} from "./UsageLimits"; + +/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ +function accountInitials(email: string): string { + const [local = "", domain = ""] = email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?"; +} + +/** A stable hue per email, so the same account gets the same chip on every visit. */ +function accountHue(email: string): number { + let hash = 0; + for (let index = 0; index < email.length; index += 1) { + hash = (hash * 31 + email.charCodeAt(index)) | 0; + } + return Math.abs(hash) % 360; +} + +/** The two-letter chip for an email, coloured by a stable hue per address. */ +function AccountChip({ email }: { readonly email: string }) { + const hue = accountHue(email); + return ( + + {accountInitials(email)} + + ); +} + +/** + * The same mark the model picker uses for a native instance (provider glyph, + * initials badge, accent); hub accounts have no instance, so they get the chip. + */ +function AccountAvatar({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.redeem) { + return ( + + ); + } + return account.email ? : null; +} + +/** + * Who an account is, without printing the email: the instance name when there + * is one, else a two-letter chip. The address itself is revealed on demand in + * the segment's popover. + */ +function AccountName({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.displayName) return {account.displayName}; + if (account.email) { + return ( + + + + ); + } + return ( + + {getDriverOption(account.driver)?.label ?? String(account.driver)} + + ); +} + +function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** + * Everything about one account in one window: plan, where it is signed in, + * the email on request, reset time and share of the pool it restores, and the + * reset-credit action. Opens on hover for a glance, on click to act. + */ +function SegmentPopover({ + account, + window, + reset, + now, + redeem, + onRedeem, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + /** Redeem state owned by the segment, since the confirm lives outside this popover. */ + readonly redeem: ReturnType | null; + readonly onRedeem: () => void; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const where = + account.environments.length > 0 + ? account.environments.map((environment) => environment.label).join(", ") + : account.sourceLabel; + const credits = + redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + return ( +
+
+ + + + {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} + + + {account.email ? ( + + ) : null} +
+
+ {account.plan ? {account.plan} : null} + {where ? ( + 0 ? "Signed in" : "Via"}>{where} + ) : null} +
+
+ {remaining}% + {window.resetsAt ? ( + + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + +{reset.restoresPercent}% of pool + ) : null} +
+ {credits && redeem ? ( +
+ + {resetCreditsSummary(credits, now, true)} + + +
+ ) : null} +
+ ); +} + +/** + * One account's share of one pooled window: the segment, its popover, and the + * reset confirm. The confirm is a sibling of the popover, not a child: dialogs + * stack under popovers, and the popover closes as the confirm opens. + */ +function PoolSegment({ + account, + window, + reset, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly color: string; + readonly now: number; + /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ + readonly index: number; +}) { + const [open, setOpen] = useState(false); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + } + > + {/* Translucent so the label reads over the fill for any provider colour and theme. */} +
+ {/* The spent share is hatched, not blank: it is what the countdown restores. */} + {remaining < 100 && reset ? ( +
+ ) : null} + + {index} + +
+ + {remaining}% + {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? ( + + · + + ) : null} + + + {credits} + + + ) : null} + +
+ + + {account.redeem ? ( + setOpen(false)} + /> + ) : ( + + {}} + /> + + )} + + ); +} + +/** + * Below the strip at narrow widths: one row per account in bar order, carrying + * the text the segment has no room for. Tapping a row opens the same popover + * as its segment, so the two are one control with two handles. + */ +function LegendRow({ + account, + window, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly color: string; + readonly now: number; + readonly index: number; +}) { + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + + Segment + {index} + + + {remaining}% + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + {credits} reset {credits === 1 ? "credit" : "credits"} banked + + + ) : null} + + + ); +} + +/** Split out so the redeem hook only runs for accounts that can redeem. */ +function RedeemableSegmentPopup({ + account, + window, + reset, + now, + redeemAt, + closePopover, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + readonly redeemAt: NonNullable; + readonly closePopover: () => void; +}) { + const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId); + return ( + <> + + { + closePopover(); + redeem.setConfirming(true); + }} + /> + + void redeem.redeem()} + /> + {/* The popover closed before the confirm, so the outcome needs a home outside it. */} + {redeem.status ? ( + + {redeem.status} + + ) : null} + + ); +} + +/** + * One pooled window as equal-width segments, one per account, each filled by + * the share of that account's quota still open. Equal widths are honest: every + * account contributes the same share of the pool, whatever its plan. + * + * Wide, each segment carries its own label. Narrow, the bar is a bare strip + * and a legend below lists the accounts in the same order; both open the + * same popover. + */ +function PoolBar({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); + return ( +
+
+ {pool.members.map(({ account, window }, position) => ( + + ))} +
+
+ ); +} + +/** + * Big pooled number and the segment bar. The bar is sorted by reset, so who + * refills next is its left edge; the exact time and share restored live in + * each segment's popover rather than a list restating the bar. + */ +function PoolWindowCard({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + // The soonest reset that hands anything back; an untouched account resets to no effect. + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + return ( +
+
+ {pool.label} + + + {pool.remainingPercent}% + + left + {pool.pace ? : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} +
+ +
+ ); +} + +function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { + const color = barColor(pool.driver); + const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + return ( +
+

+ + {label} +

+ {pool.windows.map((window) => ( + + ))} +
+ ); +} + +/** + * Accounts pooled per provider: what is open across all of them, who resets + * next, and how much of the pool that hands back. Answers "can I keep going" + * before "on which account". + */ +export function UsageLimitsPooled({ + presentations, + now, +}: { + readonly presentations: Parameters[0]; + readonly now: number; +}) { + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const notices = collectLimitNotices(presentations); + return ( +
+ {pools.length === 0 ? ( +

+ No provider on the selected environments reports subscription limits. +

+ ) : null} + {pools.map((pool) => ( + + ))} + +
+ ); +} + +/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */ +function LimitNotices({ notices }: { readonly notices: readonly string[] }) { + if (notices.length === 0) return null; + return ( +
    + {notices.map((notice) => ( +
  • {notice}
  • + ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..066f8549f5e9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -59,6 +59,7 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; +import { makeLimitsFixture } from "./usageLimitsFixture"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -109,10 +110,33 @@ export function UsagePage() { useState | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( - window, - selectedEnvironmentIds, - ); + const usage = useUsage(window, selectedEnvironmentIds); + // Dev only: `/usage?limitsFixture=` lists synthetic environments in the + // picker and feeds the Limits view from them, so merge rules can be eyeballed. + const [fixture] = useState(() => { + if (!import.meta.env.DEV) return null; + const name = new URLSearchParams(globalThis.location.search).get("limitsFixture"); + return name ? makeLimitsFixture(name, Date.now()) : null; + }); + const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = + useMemo(() => { + if (!fixture || !showingLimits) return usage; + const all = [...fixture].map(([environmentId, presentation]) => ({ + environmentId, + label: presentation.entry.target.label, + isPending: false, + error: null, + summary: null, + })); + return { + ...usage, + environments: all, + selectedEnvironments: + selectedEnvironmentIds === null + ? all + : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)), + }; + }, [fixture, selectedEnvironmentIds, showingLimits, usage]); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, @@ -166,6 +190,8 @@ export function UsagePage() { if (refreshingRef.current) return; if (showingLimits) { + // Synthetic data has nothing to re-read. + if (fixture) return; refreshingRef.current = true; setIsRefreshing(true); void Promise.all( @@ -349,7 +375,10 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : ( diff --git a/apps/web/src/components/usage/usageLimitsFixture.ts b/apps/web/src/components/usage/usageLimitsFixture.ts new file mode 100644 index 000000000000..8958707b98de --- /dev/null +++ b/apps/web/src/components/usage/usageLimitsFixture.ts @@ -0,0 +1,413 @@ +/** + * Dev-only stand-ins for `environmentPresentations` that exercise the pooled + * Limits view's merge rules, one scenario per named fixture. Reached with + * `/usage?limitsFixture=` on a dev build; never bundled otherwise. + */ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageWindow, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +interface Presentation { + readonly entry: { readonly target: { readonly label: string } }; + readonly serverConfig: { + readonly providers: readonly ServerProvider[]; + readonly usageLimitSources: readonly UsageLimitSourceSnapshot[]; + }; +} + +type Fixture = ReadonlyMap; + +const codex = ProviderDriverKind.make("codex"); +const claude = ProviderDriverKind.make("claudeAgent"); + +function makeHelpers(now: number) { + const at = (ms: number) => new Date(now + ms).toISOString(); + const checked = (agoMs: number) => new Date(now - agoMs).toISOString(); + + const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: used, + windowDurationMins: 300, + resetsAt: at(resetsInMs), + }); + const weekly = ( + id: string, + label: string, + used: number, + resetsInMs: number, + ): ServerProviderUsageWindow => ({ + id, + kind: "weekly", + label, + usedPercent: used, + windowDurationMins: 7 * 24 * 60, + resetsAt: at(resetsInMs), + }); + /** Codex names its five-hour window `primary` and its weekly one `secondary`. */ + const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ + ...session(used, resetsInMs), + id: "primary", + }); + const codexWeekly = (used: number, resetsInMs: number) => + weekly("secondary", "Weekly", used, resetsInMs); + + const provider = ( + overrides: Partial & Pick, + ): ServerProvider => ({ + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: checked(0), + models: [], + slashCommands: [], + skills: [], + ...overrides, + }); + + const codexInstance = (input: { + readonly instanceId: string; + readonly displayName?: string; + readonly accentColor?: string; + readonly email: string; + readonly plan?: string; + readonly checkedAgoMs?: number; + readonly windows: readonly ServerProviderUsageWindow[]; + readonly credits?: number; + }) => + provider({ + instanceId: ProviderInstanceId.make(input.instanceId), + driver: codex, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + auth: { + status: "authenticated", + label: input.plan ?? "ChatGPT Pro 20x Subscription", + email: input.email, + }, + usageLimits: { + checkedAt: checked(input.checkedAgoMs ?? MINUTE), + windows: input.windows, + ...(input.credits + ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } } + : {}), + }, + }); + + const claudeHubAccount = ( + email: string | null, + windows: readonly ServerProviderUsageWindow[], + checkedAgoMs = 4 * MINUTE, + ): UsageLimitSourceSnapshot["accounts"][number] => ({ + id: email ? `claude-${email}.json` : "claude-team-seat.json", + driver: claude, + ...(email ? { email } : {}), + plan: "Claude Subscription", + usageLimits: { checkedAt: checked(checkedAgoMs), windows }, + }); + + const hub = ( + id: string, + label: string, + accounts: UsageLimitSourceSnapshot["accounts"], + error?: string, + ): UsageLimitSourceSnapshot => ({ + id: UsageLimitSourceId.make(id), + kind: "cliproxy", + label, + checkedAt: checked(2 * MINUTE), + accounts, + ...(error ? { error } : {}), + }); + + const environment = ( + id: string, + label: string, + providers: readonly ServerProvider[], + usageLimitSources: readonly UsageLimitSourceSnapshot[] = [], + ): readonly [EnvironmentId, Presentation] => [ + EnvironmentId.make(id), + { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } }, + ]; + + return { + at, + checked, + session, + weekly, + codexSession, + codexWeekly, + provider, + codexInstance, + claudeHubAccount, + hub, + environment, + }; +} + +const FIXTURES: Record Fixture> = { + /** + * The same Codex account signed in on two machines with different snapshot + * ages, plus a hub that also reports it. Must collapse to one segment with + * the freshest figures and both machines listed. + */ + "same-account": (now) => { + const h = makeHelpers(now); + const email = "main@example.com"; + const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ + { + id: `codex-abc-${email}-pro.json`, + driver: codex, + email, + plan: "ChatGPT Pro 20x Subscription", + usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] }, + }, + ]; + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email, + windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], + credits: 2, + }), + ], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment("env-nucbox", "nucbox-1", [ + h.codexInstance({ + instanceId: "codex", + email, + checkedAgoMs: 9 * MINUTE, + windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)], + credits: 2, + }), + ]), + ]); + }, + + /** + * Three machines, no two alike: one has only Codex, one only Claude via a + * hub, one has both natively. Filtering to any single environment should + * drop whole provider sections. + */ + "uneven-environments": (now) => { + const h = makeHelpers(now); + return new Map([ + h.environment("env-macbook", "MacBook Pro", [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email: "main@example.com", + windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], + credits: 2, + }), + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [ + h.session(2, 4 * HOUR), + h.weekly("seven_day", "Weekly", 38, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), + ], + }, + }), + ]), + h.environment( + "env-nucbox", + "nucbox-1", + [], + [ + h.hub("cliproxy-nucbox", "CLI Proxy", [ + h.claudeHubAccount("personal@example.com", [ + h.session(100, 4 * HOUR), + h.weekly("seven_day", "Weekly", 50, DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY), + ]), + h.claudeHubAccount("second@example.org", [ + h.session(63, 2 * HOUR), + h.weekly("seven_day", "Weekly", 37, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY), + ]), + ]), + ], + ), + h.environment("env-macmini", "Mac Mini", [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Work", + email: "work@example.com", + windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)], + credits: 1, + }), + ]), + ]); + }, + + /** + * Codex plans that report only one window (Go reports a monthly allowance; + * a hub often has no five-hour figure for an account) mixed with a plan that + * reports both. Each pool lists only the accounts that have that window. + */ + "codex-window-mix": (now) => { + const h = makeHelpers(now); + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email: "main@example.com", + windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)], + credits: 2, + }), + h.codexInstance({ + instanceId: "codex-go", + displayName: "Codex Go", + accentColor: "#10b981", + email: "go@example.com", + plan: "ChatGPT Go Subscription", + windows: [ + { + id: "primary", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: h.at(11 * DAY), + }, + ], + }), + ], + [ + h.hub("cliproxy-nucbox", "CLI Proxy", [ + { + id: "codex-def-work@example.com-pro.json", + driver: codex, + email: "work@example.com", + plan: "ChatGPT Pro 20x Subscription", + usageLimits: { + checkedAt: h.checked(3 * MINUTE), + windows: [h.codexWeekly(88, 6 * DAY)], + }, + }, + { + id: "codex-ghi-team@example.net-plus.json", + driver: codex, + email: "team@example.net", + plan: "ChatGPT Plus Subscription", + usageLimits: { + checkedAt: h.checked(3 * MINUTE), + windows: [h.codexWeekly(12, DAY)], + }, + }, + ]), + ], + ), + ]); + }, + + /** + * A hub configured on two environments, a hub that is down, a provider + * whose probe failed, an API-key account, and a hub account with no email. + */ + "failures-and-strays": (now) => { + const h = makeHelpers(now); + const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ + h.claudeHubAccount("main@example.com", [ + h.session(2, 4 * HOUR), + h.weekly("seven_day", "Weekly", 38, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), + ]), + h.claudeHubAccount(null, [ + h.session(40, 2 * HOUR), + h.weekly("seven_day", "Weekly", 20, 6 * DAY), + ]), + ]; + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { status: "authenticated", label: "Claude API Key" }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [], + unavailable: { + reason: "unsupported", + message: "This account has no subscription limits.", + }, + }, + }), + ], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment( + "env-nucbox", + "nucbox-1", + [], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment( + "env-macmini", + "Mac Mini", + [ + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { + status: "authenticated", + label: "Claude Max", + email: "work@example.com", + }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [], + unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." }, + }, + }), + ], + [ + h.hub( + "cliproxy-aws", + "AWS proxy", + [], + "fetch failed: connect ECONNREFUSED 10.0.0.4:8318", + ), + ], + ), + ]); + }, +}; + +export function makeLimitsFixture(name: string, now: number): Fixture | null { + return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null; +} diff --git a/docs/user/usage.md b/docs/user/usage.md index 4e4196a46337..2f3e1013fdce 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,9 +39,18 @@ the dialog. ## Track subscription limits -**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and -Claude subscriptions. For windows with timing data, each bar also marks how much of the window is -left, so you can judge your pace before the next reset. +On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex +or Claude accounts across your environments and hubs you read one number per window rather than a +list. Each window card shows how much of the pool is left and a bar with one segment per account, +ordered by which resets soonest; when the provider reports reset times, the card also says when +the next reset lands and how much it hands back. The hatched +part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is +signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the +segment and the **Use reset** action in that popover. On narrow screens, numbered rows below +the bar show each account's quota, countdown, and credits. Tap a row to open its details. + +The same account signed in on more than one environment, or reported by a hub as well, counts once. +Filter with the environment dropdown to see what a single machine has. If a window looks stale, refresh Limits to re-check every provider and hub. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index fede8813e913..32fb4eaa9503 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -13,6 +13,9 @@ import { collectProviderUsageLimits, sameUsageLimitCommandCoverage, withUsageLimitsCommands, + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -310,6 +313,381 @@ describe("collectLimitSources", () => { }); }); +describe("pools", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: 7 * 24 * 60, + resetsAt: "2026-09-06T12:00:00.000Z", + } as const; + const claude = ProviderDriverKind.make("claudeAgent"); + const source = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + }; + const laptop = { entry: { target: { label: "Laptop" } } }; + + it("merges one account reported natively on two environments and by a hub into one entry", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "Same@example.com" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { + ...native, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + plan: "Claude Subscription", + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] }, + }, + ], + }, + ], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + key: "env-a:claude", + sourceLabel: null, + // Desktop's read is fresher, so its credits and its redeem are the ones on show. + redeem: { environmentId: "env-b", instanceId: "claude" }, + environments: [ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ], + }); + // The fresher native snapshot wins; the hub row is pre-filtered by email. + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); + }); + + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + resetCredits: { availableCount: 2 }, + }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.windows[0]?.usedPercent).toBe(55); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" }); + expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); + }); + + it("redeems on the environment whose snapshot supplied the credits on show", () => { + const stale = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [window], + resetCredits: { availableCount: 0 }, + }, + }); + const fresh = { + ...stale, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [window], + resetCredits: { availableCount: 2 }, + }, + }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" }); + }); + + it("names an environment once however many of its instances share the account", () => { + const shared = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + ]); + }); + + it("keys a hub account without an email by hub, so two environments on one hub share it", () => { + const seat = { + id: "claude-team-seat.json", + driver: claude, + usageLimits: { checkedAt, windows: [window] }, + }; + const hub = { ...source, accounts: [seat] }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]); + expect(accounts[0]?.displayName).toBe("claude-team-seat"); + }); + + it("pools windows by id across accounts and orders resets by when they land", () => { + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "a", + driver: claude, + usageLimits: { + checkedAt, + windows: [ + { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" }, + { ...weekly, usedPercent: 20 }, + ], + }, + }, + { + id: "b", + driver: claude, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + }, + }, + { + id: "c", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }, + { + id: "unsupported", + driver: claude, + usageLimits: { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" as const }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const pools = collectLimitPools(collectLimitAccounts(input), now); + expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([ + ["claudeAgent", 2], + ["codex", 1], + ]); + const [session, week] = pools[0]!.windows; + // A member with no reset has no clock, so it does not vote on pace. + const untimed = collectLimitPools( + collectLimitAccounts(input).map((account) => + account.key === "hub:b" + ? { + ...account, + limits: { + ...account.limits, + windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })), + }, + } + : account, + ), + now, + ); + // Only a votes: 80% used, 80% elapsed. + expect(untimed[0]?.windows[0]?.pace).toBe("on"); + // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used. + expect(session).toMatchObject({ + id: "five_hour", + remainingPercent: 40, + usedPercent: 60, + pace: "under", + }); + expect( + session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]), + ).toEqual([ + ["hub:a", 40], + ["hub:b", 20], + ]); + expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] }); + // Codex reports `primary` for both its five-hour and (on Go) monthly window. + const mixed = collectLimitPools( + [ + ...collectLimitAccounts(input), + { + key: "go", + driver: claude, + displayName: "Go", + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: null, + redeem: null, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: "2026-09-14T12:00:00.000Z", + }, + ], + }, + }, + ], + now, + ); + expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([ + ["session", 2], + ["weekly", 1], + ["monthly", 1], + ]); + // Segments read left to right as "who refills next", matching the reset list. + expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); + expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); + }); +}); + +describe("collectLimitNotices", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const claude = ProviderDriverKind.make("claudeAgent"); + const laptop = { entry: { target: { label: "Laptop" } } }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + accounts: [], + }; + + it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => { + const failed = provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + displayName: "Claude Max", + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + const apiKey = provider({ + instanceId: ProviderInstanceId.make("api"), + driver: claude, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }); + const silent = provider({ usageLimits: { checkedAt, windows: [] } }); + const one = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [failed, apiKey, silent], + usageLimitSources: [ + hub, + { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" }, + ], + }, + }, + ], + ]); + expect(collectLimitNotices(one)).toEqual([ + "Claude Max: Could not read limits.", + "codex: No limits reported.", + "hub: No accounts reported.", + "down: ECONNREFUSED", + ]); + + one.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [], usageLimitSources: [] }, + }); + expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); + }); +}); + describe("/usage-limits", () => { const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; const selected = provider({ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5cb5303320bd..419e1944c538 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -58,7 +58,9 @@ export function collectLimitsGroups( EnvironmentId, { readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null; + readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; + } | null; } >, ): readonly LimitsGroup[] { @@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) return normalizedEmail ? `${driver}:${normalizedEmail}` : null; } +/** + * One subscription account as the pooled views see it, whichever way it was + * reported. The same email signed in natively on two environments, or reported + * by a hub as well as natively, is one account: its quota is one bucket, so + * counting it twice would misstate what is left. + */ +export interface LimitAccount { + readonly key: string; + readonly driver: ServerProvider["driver"]; + /** The instance's configured name, which is not sensitive; null for hub accounts. */ + readonly displayName: string | null; + readonly email: string | undefined; + readonly plan: string | undefined; + readonly accentColor: string | undefined; + /** Environments the account is signed in on; empty when only a hub reports it. */ + readonly environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; + /** The hub that reported it, when no environment has it natively. */ + readonly sourceLabel: string | null; + /** Where a reset credit can be redeemed; only native instances can. */ + readonly redeem: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + } | null; + readonly limits: ServerProviderUsageLimits; +} + +/** + * Every account with usable windows across the connected environments, one + * entry per distinct account. Native instances win over hub reports, and the + * freshest snapshot wins when the same account is reported twice. + */ +export function collectLimitAccounts( + presentations: Parameters[0], +): readonly LimitAccount[] { + const accounts = new Map(); + const merge = (key: string, next: LimitAccount) => { + const previous = accounts.get(key); + if (!previous) { + accounts.set(key, next); + return; + } + const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); + // Two instances on one machine sharing an account still name it once. + const environments = [ + ...previous.environments, + ...next.environments.filter( + (candidate) => + !previous.environments.some((seen) => seen.environmentId === candidate.environmentId), + ), + ]; + const winner = fresher ? next : previous; + // Windows come from the freshest snapshot, wherever it was read. Reset + // credits only ever come from a native instance, and the redeem must go + // to the instance whose credits are on show, so the two travel together: + // the freshest native snapshot supplies both, or neither. + const native = [previous, next] + .filter((candidate) => candidate.redeem !== null) + .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; + accounts.set(key, { + ...previous, + displayName: previous.displayName ?? next.displayName, + plan: previous.plan ?? next.plan, + accentColor: previous.accentColor ?? next.accentColor, + environments, + // A hub only names the account when no environment has it natively. + sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), + redeem: native?.redeem ?? null, + limits: { + ...winner.limits, + ...(native?.limits.resetCredits + ? { resetCredits: native.limits.resetCredits } + : { resetCredits: undefined }), + }, + }); + }; + for (const [environmentId, presentation] of presentations) { + const label = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; + merge( + accountKey(provider.driver, provider.auth.email) ?? + `${environmentId}:${provider.instanceId}`, + { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, instanceId: provider.instanceId }, + limits: provider.usageLimits, + }, + ); + } + } + // Every hub account, including those a native instance also knows: the hub + // may hold a fresher read of the same subscription, and the merge above + // keeps the redeem target consistent with whichever snapshot wins. + const labelEnvironment = presentations.size > 1; + for (const presentation of presentations.values()) { + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + const sourceLabel = labelEnvironment + ? `${presentation.entry.target.label} · ${source.label}` + : source.label; + for (const account of source.accounts) { + if (limitsNotice(account.usageLimits) !== null) continue; + merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: null, + limits: account.usageLimits, + }); + } + } + } + return [...accounts.values()]; +} + +/** + * What the pooled views cannot draw as a bar: a hub that failed to read, a + * provider whose probe failed. Accounts that can never report (API keys) + * are left out; there is nothing for the user to act on. The environment + * is named only when more than one is connected. + */ +export function collectLimitNotices( + presentations: Parameters[0], +): readonly string[] { + const label = (environmentLabel: string, subject: string) => + presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; + const notices: string[] = []; + for (const presentation of presentations.values()) { + const environmentLabel = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + // An account that can never report (API key) is left out; one that + // failed, or reported nothing at all, is worth a line. + if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; + const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; + const name = provider.displayName?.trim() || String(provider.driver); + if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); + } + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + if (source.error) { + notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); + } else if (source.accounts.length === 0) { + notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); + } + } + } + return notices; +} + +export interface LimitPoolMember { + readonly account: LimitAccount; + readonly window: ServerProviderUsageWindow; +} + +/** + * One window id across every account that reports it: the pooled share left, + * pace against the clock, and the resets in the order they will land, each + * with the share of the pool it hands back. + */ +export interface LimitPoolWindow { + readonly id: string; + readonly kind: ServerProviderUsageWindow["kind"]; + readonly label: string; + readonly members: readonly LimitPoolMember[]; + readonly remainingPercent: number; + readonly usedPercent: number; + readonly pace: LimitPace | null; + readonly resets: ReadonlyArray<{ + readonly member: LimitPoolMember; + readonly at: number; + /** Points of the pool the reset restores: the member's used share over the member count. */ + readonly restoresPercent: number; + }>; +} + +export interface LimitPool { + readonly driver: ServerProvider["driver"]; + readonly accounts: readonly LimitAccount[]; + readonly windows: readonly LimitPoolWindow[]; +} + +const WINDOW_KIND_ORDER: Record = { + session: 0, + weekly: 1, + monthly: 2, + other: 3, +}; + +/** + * Accounts grouped by driver, each with its windows pooled by kind and id. + * Window ids are stable per provider, so a hub row and a native row for the + * same window land in the same pool; the kind is part of the key because + * Codex's `primary` is a position, not a duration (five hours on paid plans, + * a month on Free/Go), and a monthly allowance must not average into a + * five-hour pool. Pools order by kind, then first appearance. + * + * `accounts` is the table order: instances the user can act on (native, + * named) before hub-only accounts, each group alphabetical. Each window's + * `members` sort by reset instead, soonest first, so a bar reads left to + * right as "who refills next" and matches the reset list under it. + */ +export function collectLimitPools( + accounts: readonly LimitAccount[], + now: number, +): readonly LimitPool[] { + const byDriver = new Map(); + for (const account of accounts) { + const list = byDriver.get(account.driver); + if (list) list.push(account); + else byDriver.set(account.driver, [account]); + } + return [...byDriver].map(([driver, members]) => { + const sorted = members.toSorted( + (left, right) => + Number(left.redeem === null) - Number(right.redeem === null) || + accountSortName(left).localeCompare(accountSortName(right)), + ); + return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; + }); +} + +function accountSortName(account: LimitAccount): string { + return (account.displayName ?? account.email ?? account.key).toLowerCase(); +} + +function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { + const byKey = new Map(); + for (const account of accounts) { + for (const window of account.limits.windows) { + const key = `${window.kind}:${window.id}`; + const list = byKey.get(key); + if (list) list.push({ account, window }); + else byKey.set(key, [{ account, window }]); + } + } + const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { + const members = unordered.toSorted( + (left, right) => + (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - + (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), + ); + const first = members[0]!.window; + const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; + // Pace compares spend against the clock, so it is judged only over the + // members that have a clock; a window with no reset would otherwise + // count as spend with no time elapsed and skew the verdict. + const timed = members.flatMap((m) => { + const share = elapsedShare(m.window, now); + return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; + }); + const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; + const meanElapsed = + timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; + const resets = members + .flatMap((member) => { + const at = resetMillis(member.window); + return at === null + ? [] + : [ + { + member, + at, + restoresPercent: Math.round(member.window.usedPercent / members.length), + }, + ]; + }) + .toSorted((left, right) => left.at - right.at); + return { + id: first.id, + kind: first.kind, + label: first.label, + members, + usedPercent: Math.round(usedPercent), + remainingPercent: Math.round(100 - usedPercent), + pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), + resets, + }; + }); + return pools.toSorted( + (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], + ); +} + /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( provider: Pick, @@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under"; */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); - if (elapsed === null) return null; - const gap = window.usedPercent - elapsed * 100; + return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); +} + +function paceOfShares(usedPercent: number, elapsed: number): LimitPace { + const gap = usedPercent - elapsed * 100; if (gap > 5) return "ahead"; if (gap < -5) return "under"; return "on"; From 00d6109cbb1a13712d7347701969870aee83252d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 00:07:04 -0700 Subject: [PATCH 58/65] chore(web): remove usage limits demo fixtures (#10330) --- apps/web/src/components/usage/UsageLimits.tsx | 8 +- apps/web/src/components/usage/UsagePage.tsx | 39 +- .../components/usage/usageLimitsFixture.ts | 413 ------------------ 3 files changed, 6 insertions(+), 454 deletions(-) delete mode 100644 apps/web/src/components/usage/usageLimitsFixture.ts diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 0f15631acb99..3f7168ad32f8 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -9,7 +9,6 @@ import { } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { - collectLimitAccounts, elapsedShare, formatDuration, formatResetsIn, @@ -36,7 +35,6 @@ import { } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import type { makeLimitsFixture } from "./usageLimitsFixture"; import { UsageLimitsPooled } from "./UsageLimitsPooled"; import { PROVIDER_PRESENTATION } from "./usageProviders"; @@ -323,16 +321,12 @@ export function ResetCredits({ */ export function UsageLimitsSection({ selectedEnvironmentIds, - fixture = null, }: { readonly selectedEnvironmentIds: ReadonlySet | null; - /** Dev-only synthetic presentations standing in for the live ones. */ - readonly fixture?: ReturnType | null; }) { - const live = useAtomValue(environmentPresentations.presentationsAtom); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); // Anchored once per mount on purpose: countdowns must not tick (see above). const [now] = useState(() => Date.now()); - const presentations: Parameters[0] = fixture ?? live; const selected = selectedEnvironmentIds === null ? presentations diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 066f8549f5e9..deb05f266b98 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -59,7 +59,6 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; -import { makeLimitsFixture } from "./usageLimitsFixture"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -110,33 +109,10 @@ export function UsagePage() { useState | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const usage = useUsage(window, selectedEnvironmentIds); - // Dev only: `/usage?limitsFixture=` lists synthetic environments in the - // picker and feeds the Limits view from them, so merge rules can be eyeballed. - const [fixture] = useState(() => { - if (!import.meta.env.DEV) return null; - const name = new URLSearchParams(globalThis.location.search).get("limitsFixture"); - return name ? makeLimitsFixture(name, Date.now()) : null; - }); - const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = - useMemo(() => { - if (!fixture || !showingLimits) return usage; - const all = [...fixture].map(([environmentId, presentation]) => ({ - environmentId, - label: presentation.entry.target.label, - isPending: false, - error: null, - summary: null, - })); - return { - ...usage, - environments: all, - selectedEnvironments: - selectedEnvironmentIds === null - ? all - : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)), - }; - }, [fixture, selectedEnvironmentIds, showingLimits, usage]); + const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( + window, + selectedEnvironmentIds, + ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, @@ -190,8 +166,6 @@ export function UsagePage() { if (refreshingRef.current) return; if (showingLimits) { - // Synthetic data has nothing to re-read. - if (fixture) return; refreshingRef.current = true; setIsRefreshing(true); void Promise.all( @@ -375,10 +349,7 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : ( diff --git a/apps/web/src/components/usage/usageLimitsFixture.ts b/apps/web/src/components/usage/usageLimitsFixture.ts deleted file mode 100644 index 8958707b98de..000000000000 --- a/apps/web/src/components/usage/usageLimitsFixture.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** - * Dev-only stand-ins for `environmentPresentations` that exercise the pooled - * Limits view's merge rules, one scenario per named fixture. Reached with - * `/usage?limitsFixture=` on a dev build; never bundled otherwise. - */ -import { - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, - type ServerProviderUsageWindow, - type UsageLimitSourceSnapshot, - UsageLimitSourceId, -} from "@t3tools/contracts"; - -const MINUTE = 60_000; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; - -interface Presentation { - readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { - readonly providers: readonly ServerProvider[]; - readonly usageLimitSources: readonly UsageLimitSourceSnapshot[]; - }; -} - -type Fixture = ReadonlyMap; - -const codex = ProviderDriverKind.make("codex"); -const claude = ProviderDriverKind.make("claudeAgent"); - -function makeHelpers(now: number) { - const at = (ms: number) => new Date(now + ms).toISOString(); - const checked = (agoMs: number) => new Date(now - agoMs).toISOString(); - - const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ - id: "five_hour", - kind: "session", - label: "Session", - usedPercent: used, - windowDurationMins: 300, - resetsAt: at(resetsInMs), - }); - const weekly = ( - id: string, - label: string, - used: number, - resetsInMs: number, - ): ServerProviderUsageWindow => ({ - id, - kind: "weekly", - label, - usedPercent: used, - windowDurationMins: 7 * 24 * 60, - resetsAt: at(resetsInMs), - }); - /** Codex names its five-hour window `primary` and its weekly one `secondary`. */ - const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ - ...session(used, resetsInMs), - id: "primary", - }); - const codexWeekly = (used: number, resetsInMs: number) => - weekly("secondary", "Weekly", used, resetsInMs); - - const provider = ( - overrides: Partial & Pick, - ): ServerProvider => ({ - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: checked(0), - models: [], - slashCommands: [], - skills: [], - ...overrides, - }); - - const codexInstance = (input: { - readonly instanceId: string; - readonly displayName?: string; - readonly accentColor?: string; - readonly email: string; - readonly plan?: string; - readonly checkedAgoMs?: number; - readonly windows: readonly ServerProviderUsageWindow[]; - readonly credits?: number; - }) => - provider({ - instanceId: ProviderInstanceId.make(input.instanceId), - driver: codex, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - auth: { - status: "authenticated", - label: input.plan ?? "ChatGPT Pro 20x Subscription", - email: input.email, - }, - usageLimits: { - checkedAt: checked(input.checkedAgoMs ?? MINUTE), - windows: input.windows, - ...(input.credits - ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } } - : {}), - }, - }); - - const claudeHubAccount = ( - email: string | null, - windows: readonly ServerProviderUsageWindow[], - checkedAgoMs = 4 * MINUTE, - ): UsageLimitSourceSnapshot["accounts"][number] => ({ - id: email ? `claude-${email}.json` : "claude-team-seat.json", - driver: claude, - ...(email ? { email } : {}), - plan: "Claude Subscription", - usageLimits: { checkedAt: checked(checkedAgoMs), windows }, - }); - - const hub = ( - id: string, - label: string, - accounts: UsageLimitSourceSnapshot["accounts"], - error?: string, - ): UsageLimitSourceSnapshot => ({ - id: UsageLimitSourceId.make(id), - kind: "cliproxy", - label, - checkedAt: checked(2 * MINUTE), - accounts, - ...(error ? { error } : {}), - }); - - const environment = ( - id: string, - label: string, - providers: readonly ServerProvider[], - usageLimitSources: readonly UsageLimitSourceSnapshot[] = [], - ): readonly [EnvironmentId, Presentation] => [ - EnvironmentId.make(id), - { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } }, - ]; - - return { - at, - checked, - session, - weekly, - codexSession, - codexWeekly, - provider, - codexInstance, - claudeHubAccount, - hub, - environment, - }; -} - -const FIXTURES: Record Fixture> = { - /** - * The same Codex account signed in on two machines with different snapshot - * ages, plus a hub that also reports it. Must collapse to one segment with - * the freshest figures and both machines listed. - */ - "same-account": (now) => { - const h = makeHelpers(now); - const email = "main@example.com"; - const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ - { - id: `codex-abc-${email}-pro.json`, - driver: codex, - email, - plan: "ChatGPT Pro 20x Subscription", - usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] }, - }, - ]; - return new Map([ - h.environment( - "env-macbook", - "MacBook Pro", - [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Personal", - accentColor: "#6366f1", - email, - windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], - credits: 2, - }), - ], - [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], - ), - h.environment("env-nucbox", "nucbox-1", [ - h.codexInstance({ - instanceId: "codex", - email, - checkedAgoMs: 9 * MINUTE, - windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)], - credits: 2, - }), - ]), - ]); - }, - - /** - * Three machines, no two alike: one has only Codex, one only Claude via a - * hub, one has both natively. Filtering to any single environment should - * drop whole provider sections. - */ - "uneven-environments": (now) => { - const h = makeHelpers(now); - return new Map([ - h.environment("env-macbook", "MacBook Pro", [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Personal", - accentColor: "#6366f1", - email: "main@example.com", - windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], - credits: 2, - }), - h.provider({ - instanceId: ProviderInstanceId.make("claude"), - driver: claude, - auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" }, - usageLimits: { - checkedAt: h.checked(MINUTE), - windows: [ - h.session(2, 4 * HOUR), - h.weekly("seven_day", "Weekly", 38, 4 * DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), - ], - }, - }), - ]), - h.environment( - "env-nucbox", - "nucbox-1", - [], - [ - h.hub("cliproxy-nucbox", "CLI Proxy", [ - h.claudeHubAccount("personal@example.com", [ - h.session(100, 4 * HOUR), - h.weekly("seven_day", "Weekly", 50, DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY), - ]), - h.claudeHubAccount("second@example.org", [ - h.session(63, 2 * HOUR), - h.weekly("seven_day", "Weekly", 37, 4 * DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY), - ]), - ]), - ], - ), - h.environment("env-macmini", "Mac Mini", [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Work", - email: "work@example.com", - windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)], - credits: 1, - }), - ]), - ]); - }, - - /** - * Codex plans that report only one window (Go reports a monthly allowance; - * a hub often has no five-hour figure for an account) mixed with a plan that - * reports both. Each pool lists only the accounts that have that window. - */ - "codex-window-mix": (now) => { - const h = makeHelpers(now); - return new Map([ - h.environment( - "env-macbook", - "MacBook Pro", - [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Personal", - accentColor: "#6366f1", - email: "main@example.com", - windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)], - credits: 2, - }), - h.codexInstance({ - instanceId: "codex-go", - displayName: "Codex Go", - accentColor: "#10b981", - email: "go@example.com", - plan: "ChatGPT Go Subscription", - windows: [ - { - id: "primary", - kind: "monthly", - label: "Monthly", - usedPercent: 82, - windowDurationMins: 30 * 24 * 60, - resetsAt: h.at(11 * DAY), - }, - ], - }), - ], - [ - h.hub("cliproxy-nucbox", "CLI Proxy", [ - { - id: "codex-def-work@example.com-pro.json", - driver: codex, - email: "work@example.com", - plan: "ChatGPT Pro 20x Subscription", - usageLimits: { - checkedAt: h.checked(3 * MINUTE), - windows: [h.codexWeekly(88, 6 * DAY)], - }, - }, - { - id: "codex-ghi-team@example.net-plus.json", - driver: codex, - email: "team@example.net", - plan: "ChatGPT Plus Subscription", - usageLimits: { - checkedAt: h.checked(3 * MINUTE), - windows: [h.codexWeekly(12, DAY)], - }, - }, - ]), - ], - ), - ]); - }, - - /** - * A hub configured on two environments, a hub that is down, a provider - * whose probe failed, an API-key account, and a hub account with no email. - */ - "failures-and-strays": (now) => { - const h = makeHelpers(now); - const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ - h.claudeHubAccount("main@example.com", [ - h.session(2, 4 * HOUR), - h.weekly("seven_day", "Weekly", 38, 4 * DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), - ]), - h.claudeHubAccount(null, [ - h.session(40, 2 * HOUR), - h.weekly("seven_day", "Weekly", 20, 6 * DAY), - ]), - ]; - return new Map([ - h.environment( - "env-macbook", - "MacBook Pro", - [ - h.provider({ - instanceId: ProviderInstanceId.make("claude"), - driver: claude, - auth: { status: "authenticated", label: "Claude API Key" }, - usageLimits: { - checkedAt: h.checked(MINUTE), - windows: [], - unavailable: { - reason: "unsupported", - message: "This account has no subscription limits.", - }, - }, - }), - ], - [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], - ), - h.environment( - "env-nucbox", - "nucbox-1", - [], - [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], - ), - h.environment( - "env-macmini", - "Mac Mini", - [ - h.provider({ - instanceId: ProviderInstanceId.make("claude"), - driver: claude, - auth: { - status: "authenticated", - label: "Claude Max", - email: "work@example.com", - }, - usageLimits: { - checkedAt: h.checked(MINUTE), - windows: [], - unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." }, - }, - }), - ], - [ - h.hub( - "cliproxy-aws", - "AWS proxy", - [], - "fetch failed: connect ECONNREFUSED 10.0.0.4:8318", - ), - ], - ), - ]); - }, -}; - -export function makeLimitsFixture(name: string, now: number): Fixture | null { - return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null; -} From e00d06c433e5f5844135d1dbdcf4bb9a75c56825 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:09:49 +0200 Subject: [PATCH 59/65] fix(shared): copy-and-sort usage limit pools for Hermes Upstream pooled-limits used Array.toSorted, which Hermes does not provide. Copy then sort so mobile and shared keep the same ordering. --- packages/shared/src/usageLimits.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 419e1944c538..ddfabc742fe0 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -209,7 +209,8 @@ export function collectLimitAccounts( // the freshest native snapshot supplies both, or neither. const native = [previous, next] .filter((candidate) => candidate.redeem !== null) - .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; + .slice() + .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; accounts.set(key, { ...previous, displayName: previous.displayName ?? next.displayName, @@ -374,7 +375,7 @@ export function collectLimitPools( else byDriver.set(account.driver, [account]); } return [...byDriver].map(([driver, members]) => { - const sorted = members.toSorted( + const sorted = [...members].sort( (left, right) => Number(left.redeem === null) - Number(right.redeem === null) || accountSortName(left).localeCompare(accountSortName(right)), @@ -398,7 +399,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L } } const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { - const members = unordered.toSorted( + const members = [...unordered].sort( (left, right) => (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), @@ -428,7 +429,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L }, ]; }) - .toSorted((left, right) => left.at - right.at); + .slice() + .sort((left, right) => left.at - right.at); return { id: first.id, kind: first.kind, @@ -440,7 +442,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L resets, }; }); - return pools.toSorted( + return [...pools].sort( (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], ); } From 127efae4401cf2cc0fcd7ec7c5b2e2c037fe4151 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 6 Sep 2026 17:11:41 +1000 Subject: [PATCH 60/65] fix(web): expose error disclosure state (#10125) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/web/src/components/settings/ExpandableText.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ExpandableText.tsx b/apps/web/src/components/settings/ExpandableText.tsx index de18739e5090..fa5fb94aadd1 100644 --- a/apps/web/src/components/settings/ExpandableText.tsx +++ b/apps/web/src/components/settings/ExpandableText.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { cn } from "../../lib/utils"; @@ -17,12 +17,14 @@ export function ExpandableText({ collapsedClassName?: string; expandLabel?: string; }) { + const textId = useId(); const [expanded, setExpanded] = useState(false); const canExpand = text.length > 180 || text.includes("\n"); return (
setExpanded((value) => !value)} > From f4a9f28d94be9b075c79384a331d1b9d9654e9c3 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:12:52 +0200 Subject: [PATCH 61/65] fix: keep queued-send feed still and project-defaults in settings search The mobile existence contract requires queue-bound sends to set the timeline anchor only inside `if (!sendWillQueue)`. Shared project defaults also match the "work" settings query, so the catalog test includes that row. --- .../features/threads/ThreadDetailScreen.tsx | 27 +++---------------- .../settings/settingsSearch.test.ts | 1 + 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index fb171a7fa908..1379fdc20946 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -93,7 +93,6 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; -import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; import type { ThreadContentPresentation } from "./threadContentPresentation"; export interface ThreadDetailScreenProps { @@ -697,9 +696,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; const sendWillQueue = sendEntersQueue; - const hasUserMessage = selectedThreadFeed.some( - (entry) => entry.type === "message" && entry.message.role === "user", - ); const messageId = await props.onSendMessage(); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { return messageId; @@ -717,29 +713,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // applied. Enabling end maintenance alone is ineffective when the list // was scrolled into older history. listRef.current?.scrollToEnd({ animated: false }); + setSubmittedMessageId(messageId); + setAnchorMessageId(messageId); } - setSubmittedMessageId(messageId); - setAnchorMessageId( - resolveThreadFeedSubmissionAnchor({ - currentAnchorMessageId: anchorMessageId, - submittedMessageId: messageId, - hasStartedTurn: props.selectedThread.latestTurn !== null, - hasUserMessage, - queuedMessageCount: props.selectedThreadQueueCount, - }), - ); composerEditorRef.current?.blur(); return messageId; - }, [ - anchorMessageId, - clearUsageLimitsFor, - props.onSendMessage, - props.selectedThread.latestTurn, - props.selectedThreadQueueCount, - selectedThreadFeed, - selectedThreadKey, - sendEntersQueue, - ]); + }, [clearUsageLimitsFor, props.onSendMessage, selectedThreadKey, sendEntersQueue]); const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index fae54a73c42b..f750d7d56acf 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -60,6 +60,7 @@ describe("searchSettings", () => { expect(searchSettings("work").map((item) => item.id)).toEqual([ "worktree-remove-confirmation", "network-access", + "project-defaults", "environment-identification", "continue-threads-after-server-update", "new-threads", From bc028738aabf20350ca3c2aa17ecfd41a1f74721 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 6 Sep 2026 17:19:53 +1000 Subject: [PATCH 62/65] fix(web): name the editor picker accurately (#10124) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/web/src/components/chat/OpenInPicker.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index b9bf831c14d9..9f8e81bdba67 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -294,13 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ - } + render={
- Connect an environment to get started + Connect to a computer running T3 Code + + This browser connects to T3 Code running on your computer or a server. Start the T3 + Code desktop app or command-line server on that machine and keep it running. + {cloudEnabled - ? "Sign in to T3 Connect to connect a linked environment through its managed tunnel, or add a reachable backend manually." - : "Add a reachable backend manually to start working from this browser."} + ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." + : "Open Connections and add that machine using its pairing link. This browser must be able to reach it."}
From 55333833ec260a5c9eefa05690086de768d6970c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 6 Sep 2026 17:22:21 +1000 Subject: [PATCH 65/65] fix(web): name combobox chip removal targets (#10127) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/web/src/components/ui/combobox.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index e2253100d2d8..d9f09690331d 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -352,27 +352,37 @@ function ComboboxChips({ } function ComboboxChip({ children, ...props }: ComboboxPrimitive.Chip.Props) { + const labelId = React.useId(); + return ( - {children} - + {children} + ); } -function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props) { +function ComboboxChipRemove({ + labelId, + ...props +}: ComboboxPrimitive.ChipRemove.Props & { labelId: string }) { + const removeLabelId = `${labelId}-remove`; + return ( - + + Remove + + ); }