diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 44a1d3114532..6e56524770b6 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,6 +1,7 @@ import { ConnectionPersistenceError, EnvironmentCacheStore, + encodeShellSnapshotForCache, } from "@t3tools/client-runtime/platform"; import { type EnvironmentId, @@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredShellSnapshot), ); -const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredThreadSnapshot), ); @@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { - const payload = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe( + Effect.mapError((cause) => persistenceError("save-shell", cause)), + ); + const payload = yield* Effect.try({ + try: () => + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* database .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-shell"))); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 9a876c899ac4..8c4dd0b0f097 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -14,6 +14,7 @@ import { sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, + sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -606,11 +607,7 @@ export function buildThreadListV2Items(input: { : orderedSnoozed.filter( (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); - const orderedSettled = [...settled].sort( - (left, right) => - parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - - parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), - ); + const orderedSettled = sortSettledThreads(settled); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const pagedSettled = orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 1e116a91fea7..5e3b4c6e4afe 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -420,6 +420,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => Effect.void, }), ), diff --git a/apps/server/src/cli/trace.ts b/apps/server/src/cli/trace.ts index 7b8a83592ef8..61a209471d6e 100644 --- a/apps/server/src/cli/trace.ts +++ b/apps/server/src/cli/trace.ts @@ -13,11 +13,10 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import { Command, Flag } from "effect/unstable/cli"; import * as ServerConfig from "../config.ts"; -import { toRotatedTracePaths, TraceFileReadError } from "../diagnostics/TraceDiagnostics.ts"; +import { streamTraceFileLines, toRotatedTracePaths } from "../diagnostics/TraceDiagnostics.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { baseDirFlag, DurationFromString, traceFileConfig, traceMaxFilesConfig } from "./config.ts"; @@ -187,27 +186,9 @@ const traceSummaryCommand = Command.make("summary", { ? (yield* Clock.currentTimeMillis) - Duration.toMillis(flags.since.value) : undefined; const summarizer = makeTraceSpanSummary(sinceMs); - // Stream each file so only one chunk of text is in memory at a time. yield* Effect.forEach( toRotatedTracePaths(traceFilePath, yield* traceMaxFilesConfig), - (path) => - fs.stream(path).pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runForEachArray((lines) => Effect.sync(() => lines.forEach(summarizer.addLine))), - Effect.catchTags({ - PlatformError: (cause) => - cause.reason._tag === "NotFound" - ? Effect.void - : Effect.fail( - new TraceFileReadError({ - traceFilePath: path, - causeTag: cause.reason._tag, - cause, - }), - ), - }), - ), + (path) => streamTraceFileLines(fs, path, summarizer.addLine), { discard: true }, ); const summary = summarizer.finish(); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 64a512a33aba..1d6b78e56cef 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -33,6 +33,7 @@ import { type ServiceUpdateRecord, } from "./serviceProtocol.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { @@ -81,6 +82,12 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }); const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); +// Linking wakes the awareness relay; these tests do not run it. +const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ + publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, + start: () => Effect.void, +}); const decodeManagedTunnelRecoveryRegistration = Schema.decodeUnknownEffect( Schema.fromJsonString(RelayManagedEndpointRecoveryRegistrationRequest), ); @@ -262,6 +269,7 @@ describe("reconcileDesiredCloudLink", () => { HttpClient.HttpClient, HttpClient.make(() => unusedSecretStoreOperation()), ), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provide(NodeServices.layer), ), ); @@ -375,6 +383,7 @@ describe("releaseManagedTunnelOnShutdown", () => { (effect: Effect.Effect) => effect.pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provideService( ServerEnvironment.ServerEnvironment, ServerEnvironment.ServerEnvironment.of({ diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 8e2d0e361458..a7e3de8c7283 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -69,6 +69,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { SERVICE_STATE_FILE, @@ -411,6 +412,7 @@ interface CloudHttpDependencies { readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]; readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"]; readonly httpClient: HttpClient.HttpClient; + readonly awarenessRelay: AgentAwarenessRelay.AgentAwarenessRelay["Service"]; } const cloudHttpDependencies = Effect.gen(function* () { @@ -421,6 +423,7 @@ const cloudHttpDependencies = Effect.gen(function* () { environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, httpClient: yield* HttpClient.HttpClient, + awarenessRelay: yield* AgentAwarenessRelay.AgentAwarenessRelay, } satisfies CloudHttpDependencies; }); @@ -668,6 +671,7 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey), ); + yield* dependencies.awarenessRelay.requestCatchUp(); if (payload.endpointRuntime) { const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); yield* dependencies.secrets.set( @@ -1349,6 +1353,7 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes(String(payload.publishAgentActivity)), ); + yield* dependencies.awarenessRelay.requestCatchUp(); return yield* readCloudLinkState(dependencies); }, Effect.catchIf( diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 70bb4dc815c3..3b86841b99ba 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -7,6 +7,7 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; +import * as Stream from "effect/Stream"; import * as TraceDiagnostics from "./TraceDiagnostics.ts"; @@ -40,72 +41,78 @@ function record(input: { }); } +const traceFilePath = "/tmp/server.trace.ndjson"; +const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + +/** Aggregates whole lines in memory, the way diagnostics read traces before streaming. */ +function aggregateLines(lines: ReadonlyArray) { + const aggregator = TraceDiagnostics.makeTraceDiagnosticsAggregator(); + lines.forEach(aggregator.addLine); + return aggregator.finish({ + traceFilePath, + scannedFilePaths: TraceDiagnostics.toRotatedTracePaths(traceFilePath, 1), + readAt, + }); +} + +/** Reads the trace file and one rotated backup through a fake file system. */ +function readTraces(fileSystem: Partial) { + return TraceDiagnostics.readTraceDiagnostics({ traceFilePath, maxFiles: 1, readAt }).pipe( + Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(FileSystem.layerNoop(fileSystem)))), + ); +} + describe("TraceDiagnostics", () => { it.effect("aggregates failures, slow spans, log levels, and parse errors", () => Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - slowSpanThresholdMs: 1_000, - files: [ - { - path: "/tmp/server.trace.ndjson.1", - text: [ - record({ - name: "server.getConfig", - traceId: "trace-a", - spanId: "span-a", - startMs: 1_000, - durationMs: 50, - }), - "not-json", - ].join("\n"), - }, - { - path: "/tmp/server.trace.ndjson", - text: [ - record({ - name: "orchestration.dispatch", - traceId: "trace-b", - spanId: "span-b", - startMs: 2_000, - durationMs: 1_500, - exit: { _tag: "Failure", cause: "Provider crashed" }, - events: [ - { - name: "provider failed", - timeUnixNano: ns(3_400), - attributes: { "effect.logLevel": "Error" }, - }, - ], - }), - record({ - name: "orchestration.dispatch", - traceId: "trace-c", - spanId: "span-c", - startMs: 4_000, - durationMs: 250, - exit: { _tag: "Failure", cause: "Provider crashed" }, - }), - record({ - name: "git.status", - traceId: "trace-d", - spanId: "span-d", - startMs: 5_000, - durationMs: 25, - exit: { _tag: "Interrupted", cause: "Interrupted" }, - events: [ - { - name: "status delayed", - timeUnixNano: ns(5_010), - attributes: { "effect.logLevel": "Warning" }, - }, - ], - }), - ].join("\n"), - }, - ], - }); + const diagnostics = aggregateLines([ + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + "not-json", + record({ + name: "orchestration.dispatch", + traceId: "trace-b", + spanId: "span-b", + startMs: 2_000, + durationMs: 1_500, + exit: { _tag: "Failure", cause: "Provider crashed" }, + events: [ + { + name: "provider failed", + timeUnixNano: ns(3_400), + attributes: { "effect.logLevel": "Error" }, + }, + ], + }), + record({ + name: "orchestration.dispatch", + traceId: "trace-c", + spanId: "span-c", + startMs: 4_000, + durationMs: 250, + exit: { _tag: "Failure", cause: "Provider crashed" }, + }), + record({ + name: "git.status", + traceId: "trace-d", + spanId: "span-d", + startMs: 5_000, + durationMs: 25, + exit: { _tag: "Interrupted", cause: "Interrupted" }, + events: [ + { + name: "status delayed", + timeUnixNano: ns(5_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ]); assert.equal(diagnostics.recordCount, 4); assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z"); @@ -139,47 +146,110 @@ describe("TraceDiagnostics", () => { ); it.effect("returns a not-found diagnostic when no files are available", () => - Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/missing.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [], - }); + Effect.gen(function* () { + const diagnostics = yield* readTraces({}); assert.equal(diagnostics.recordCount, 0); assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-not-found"); }), ); - it.effect("preserves full failure causes and log messages", () => - Effect.sync(() => { - const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); - const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [ - { - path: "/tmp/server.trace.ndjson", - text: record({ - name: "VcsProcess.run", - traceId: "trace-long", - spanId: "span-long", + it.effect("streams rotated files into the same result as reading them whole", () => + Effect.gen(function* () { + // CRLF and LF endings plus multi-byte text, served one byte per chunk so + // chunks split lines, line endings, and characters. + const files = new Map([ + [ + `${traceFilePath}.1`, + [ + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", startMs: 1_000, + durationMs: 50, + }), + "not-json", + record({ + name: "orchestration.dispatch", + traceId: "trace-b", + spanId: "span-b", + startMs: 2_000, + durationMs: 1_500, + exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" }, + }), + "", + ].join("\r\n"), + ], + [ + traceFilePath, + [ + record({ + name: "git.status", + traceId: "trace-c", + spanId: "span-c", + startMs: 3_000, durationMs: 25, - exit: { _tag: "Failure", cause: longCause }, + exit: { _tag: "Interrupted", cause: "Interrupted" }, events: [ { - name: longMessage, - timeUnixNano: ns(1_010), + name: "status delayed ⏳", + timeUnixNano: ns(3_010), attributes: { "effect.logLevel": "Warning" }, }, ], }), - }, + "", + record({ + name: "orchestration.dispatch", + traceId: "trace-d", + spanId: "span-d", + startMs: 4_000, + durationMs: 250, + exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" }, + }), + ].join("\n"), ], + ]); + const encoder = new TextEncoder(); + + const diagnostics = yield* readTraces({ + stream: (path) => + Stream.fromIterable( + Array.from(encoder.encode(files.get(path)), (byte) => Uint8Array.of(byte)), + ), }); + assert.equal(diagnostics.recordCount, 4); + assert.deepStrictEqual( + diagnostics, + aggregateLines([...files.values()].flatMap((text) => text.split(/\r?\n/))), + ); + }), + ); + + it.effect("preserves full failure causes and log messages", () => + Effect.sync(() => { + const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); + const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); + const diagnostics = aggregateLines([ + record({ + name: "VcsProcess.run", + traceId: "trace-long", + spanId: "span-long", + startMs: 1_000, + durationMs: 25, + exit: { _tag: "Failure", cause: longCause }, + events: [ + { + name: longMessage, + timeUnixNano: ns(1_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ]); + assert.equal(diagnostics.latestFailures[0]?.cause, longCause); assert.equal(diagnostics.commonFailures[0]?.cause, longCause); assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, longMessage); @@ -188,45 +258,34 @@ describe("TraceDiagnostics", () => { it.effect("keeps loaded trace data when one rotated trace file fails to read", () => Effect.gen(function* () { - const traceFilePath = "/tmp/server.trace.ndjson"; const readFailure = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", - method: "readFileString", + method: "open", description: "permission denied", pathOrDescriptor: `${traceFilePath}.1`, }); - const fileSystemLayer = FileSystem.layerNoop({ - readFileString: (path) => - path === `${traceFilePath}.1` - ? Effect.fail(readFailure) - : Effect.succeed( - record({ - name: "server.getConfig", - traceId: "trace-a", - spanId: "span-a", - startMs: 1_000, - durationMs: 50, - }), - ), - }); const logAnnotations: Array> = []; const logger = Logger.make((options) => { logAnnotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) }); }); - const diagnostics = yield* TraceDiagnostics.readTraceDiagnostics({ - traceFilePath, - maxFiles: 1, - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - }).pipe( - Effect.provide( - Layer.mergeAll( - TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); + const diagnostics = yield* readTraces({ + stream: (path) => + path === `${traceFilePath}.1` + ? Stream.fail(readFailure) + : Stream.make( + new TextEncoder().encode( + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + ), + ), + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); assert.equal(diagnostics.recordCount, 1); assert.equal( @@ -251,32 +310,43 @@ describe("TraceDiagnostics", () => { }), ); - it.effect("keeps only the slowest span occurrences while aggregating large inputs", () => + it.effect("keeps only the top spans, failures, and warning logs from large inputs", () => Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [ - { - path: "/tmp/server.trace.ndjson", - text: Array.from({ length: 25 }, (_, index) => - record({ - name: `span-${index}`, - traceId: `trace-${index}`, - spanId: `span-${index}`, - startMs: index * 1_000, - durationMs: index, - }), - ).join("\n"), - }, - ], - }); + // Shuffled, so some older records arrive after the lists are full. + const indexes = Array.from({ length: 30 }, (_, step) => (step * 7) % 30); + const diagnostics = aggregateLines( + indexes.map((index) => + record({ + name: `span-${index}`, + traceId: `trace-${index}`, + spanId: `span-${index}`, + startMs: index * 1_000, + durationMs: index, + exit: { _tag: "Failure", cause: "Provider crashed" }, + events: [ + { + name: `warning ${index}`, + timeUnixNano: ns(index * 1_000), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ), + ); + const newestTwenty = Array.from({ length: 20 }, (_, rank) => `trace-${29 - rank}`); - assert.equal(diagnostics.recordCount, 25); - assert.equal(diagnostics.slowestSpans.length, 10); + assert.equal(diagnostics.recordCount, 30); assert.deepStrictEqual( diagnostics.slowestSpans.map((span) => span.durationMs), - [24, 23, 22, 21, 20, 19, 18, 17, 16, 15], + [29, 28, 27, 26, 25, 24, 23, 22, 21, 20], + ); + assert.deepStrictEqual( + diagnostics.latestFailures.map((failure) => failure.traceId), + newestTwenty, + ); + assert.deepStrictEqual( + diagnostics.latestWarningAndErrorLogs.map((log) => log.traceId), + newestTwenty, ); }), ); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index afb7b4e923bb..6dbc4afdec93 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -16,6 +16,7 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; interface TraceRecordLike { readonly name?: unknown; @@ -63,16 +64,6 @@ export class TraceDiagnostics extends Context.Service< } >()("t3/diagnostics/TraceDiagnostics") {} -interface TraceDiagnosticsInput { - readonly traceFilePath: string; - readonly files: ReadonlyArray<{ readonly path: string; readonly text: string }>; - readonly scannedFilePaths?: ReadonlyArray; - readonly slowSpanThresholdMs?: number; - readonly readAt: DateTime.Utc; - readonly error?: TraceDiagnosticsErrorSummary; - readonly partialFailure?: boolean; -} - interface TraceDiagnosticsErrorSummary { readonly kind: ServerTraceDiagnosticsErrorKind; readonly message: string; @@ -173,44 +164,50 @@ function isNotFoundError(error: PlatformError.PlatformError): boolean { return error.reason._tag === "NotFound"; } -function insertBoundedSlowestSpan( - slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], - span: ServerTraceDiagnosticsSpanOccurrence, +/** + * Adds `item` to `items`, which stays sorted by `order` and holds at most + * `limit` entries. Same result as a stable sort and slice over every item, but + * memory stays bounded however many items stream in. + */ +function insertBounded( + items: A[], + item: A, + limit: number, + order: (left: A, right: A) => number, ): void { - if ( - slowestSpans.length >= TOP_LIMIT && - span.durationMs <= slowestSpans[slowestSpans.length - 1]!.durationMs - ) { + if (items.length >= limit && order(item, items[items.length - 1]!) >= 0) { return; } - slowestSpans.push(span); - slowestSpans.sort((left, right) => right.durationMs - left.durationMs); - if (slowestSpans.length > TOP_LIMIT) { - slowestSpans.length = TOP_LIMIT; + items.push(item); + items.sort(order); + if (items.length > limit) { + items.length = limit; } } -export function aggregateTraceDiagnostics( - input: TraceDiagnosticsInput, -): ServerTraceDiagnosticsResult { - const readAt = input.readAt; - const slowSpanThresholdMs = input.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; - const scannedFilePaths = input.scannedFilePaths ?? input.files.map((file) => file.path); - if (input.files.length === 0) { - return makeEmptyDiagnostics({ - traceFilePath: input.traceFilePath, - scannedFilePaths, - readAt, - slowSpanThresholdMs, - error: input.error ?? { - kind: "trace-file-not-found", - message: "No local trace files were found.", - }, - ...(input.partialFailure ? { partialFailure: true } : {}), - }); - } - +const slowestFirst = ( + left: ServerTraceDiagnosticsSpanOccurrence, + right: ServerTraceDiagnosticsSpanOccurrence, +) => right.durationMs - left.durationMs; + +const latestEndedFirst = ( + left: ServerTraceDiagnosticsRecentFailure, + right: ServerTraceDiagnosticsRecentFailure, +) => DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt); + +const latestSeenFirst = ( + left: ServerTraceDiagnosticsLogEvent, + right: ServerTraceDiagnosticsLogEvent, +) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt); + +/** + * Folds trace NDJSON into diagnostics. Call `addLine` once per line as the + * rotated files stream in, then `finish` for the result. + */ +export function makeTraceDiagnosticsAggregator( + slowSpanThresholdMs = DEFAULT_SLOW_SPAN_THRESHOLD_MS, +) { let parseErrorCount = 0; let recordCount = 0; let failureCount = 0; @@ -229,182 +226,184 @@ export function aggregateTraceDiagnostics( const latestWarningAndErrorLogs: ServerTraceDiagnosticsLogEvent[] = []; const logLevelCounts: Record = {}; - for (const file of input.files) { - const lines = file.text.split(/\r?\n/); - for (const line of lines) { - if (line.trim().length === 0) continue; - - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - parseErrorCount += 1; - continue; - } + const addLine = (line: string) => { + if (line.trim().length === 0) return; - if (!isRecordObject(parsed)) { - parseErrorCount += 1; - continue; - } + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + parseErrorCount += 1; + return; + } - const name = toStringValue(parsed.name); - const traceId = toStringValue(parsed.traceId); - const spanId = toStringValue(parsed.spanId); - const durationMs = toNumberValue(parsed.durationMs); - const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); - const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); + if (!isRecordObject(parsed)) { + parseErrorCount += 1; + return; + } - if (!name || !traceId || !spanId || durationMs === null || !endedAt) { - parseErrorCount += 1; - continue; - } + const name = toStringValue(parsed.name); + const traceId = toStringValue(parsed.traceId); + const spanId = toStringValue(parsed.spanId); + const durationMs = toNumberValue(parsed.durationMs); + const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); + const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); - recordCount += 1; - firstSpanAt = - startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) - ? startedAt - : firstSpanAt; - lastSpanAt = - lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; - - const exitTag = readExitTag(parsed.exit); - const isFailure = exitTag === "Failure"; - const isInterrupted = exitTag === "Interrupted"; - if (isFailure) failureCount += 1; - if (isInterrupted) interruptionCount += 1; - - const spanSummary = spansByName.get(name) ?? { - count: 0, - failureCount: 0, - totalDurationMs: 0, - maxDurationMs: 0, - }; - spanSummary.count += 1; - spanSummary.totalDurationMs += durationMs; - spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); - if (isFailure) spanSummary.failureCount += 1; - spansByName.set(name, spanSummary); - - const spanItem = { name, durationMs, endedAt, traceId, spanId }; - if (durationMs >= slowSpanThresholdMs) { - slowSpanCount += 1; - } - insertBoundedSlowestSpan(slowestSpans, spanItem); - - if (isFailure) { - const cause = readExitCause(parsed.exit); - latestFailures.push({ ...spanItem, cause }); - - const failureKey = `${name}\0${cause}`; - const existing = failuresByKey.get(failureKey); - const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); - failuresByKey.set(failureKey, { - name, - cause, - count: (existing?.count ?? 0) + 1, - lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, - traceId: isLatestFailure ? traceId : existing!.traceId, - spanId: isLatestFailure ? spanId : existing!.spanId, - }); - } + if (!name || !traceId || !spanId || durationMs === null || !endedAt) { + parseErrorCount += 1; + return; + } - if (Array.isArray(parsed.events)) { - for (const rawEvent of parsed.events) { - if (!isTraceEvent(rawEvent)) continue; - const attributes = readEventAttributes(rawEvent); - const level = toStringValue(attributes["effect.logLevel"]); - if (!level) continue; - - logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; - const normalizedLevel = level.toLowerCase(); - if ( - normalizedLevel !== "warning" && - normalizedLevel !== "warn" && - normalizedLevel !== "error" && - normalizedLevel !== "fatal" - ) { - continue; - } - - const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; - const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; - latestWarningAndErrorLogs.push({ - spanName: name, - level, - message, - seenAt, - traceId, - spanId, - }); + recordCount += 1; + firstSpanAt = + startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) + ? startedAt + : firstSpanAt; + lastSpanAt = + lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; + + const exitTag = readExitTag(parsed.exit); + const isFailure = exitTag === "Failure"; + const isInterrupted = exitTag === "Interrupted"; + if (isFailure) failureCount += 1; + if (isInterrupted) interruptionCount += 1; + + const spanSummary = spansByName.get(name) ?? { + count: 0, + failureCount: 0, + totalDurationMs: 0, + maxDurationMs: 0, + }; + spanSummary.count += 1; + spanSummary.totalDurationMs += durationMs; + spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); + if (isFailure) spanSummary.failureCount += 1; + spansByName.set(name, spanSummary); + + const spanItem = { name, durationMs, endedAt, traceId, spanId }; + if (durationMs >= slowSpanThresholdMs) { + slowSpanCount += 1; + } + insertBounded(slowestSpans, spanItem, TOP_LIMIT, slowestFirst); + + if (isFailure) { + const cause = readExitCause(parsed.exit); + insertBounded(latestFailures, { ...spanItem, cause }, RECENT_LIMIT, latestEndedFirst); + + const failureKey = `${name}\0${cause}`; + const existing = failuresByKey.get(failureKey); + const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); + failuresByKey.set(failureKey, { + name, + cause, + count: (existing?.count ?? 0) + 1, + lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, + traceId: isLatestFailure ? traceId : existing!.traceId, + spanId: isLatestFailure ? spanId : existing!.spanId, + }); + } + + if (Array.isArray(parsed.events)) { + for (const rawEvent of parsed.events) { + if (!isTraceEvent(rawEvent)) continue; + const attributes = readEventAttributes(rawEvent); + const level = toStringValue(attributes["effect.logLevel"]); + if (!level) continue; + + logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; + const normalizedLevel = level.toLowerCase(); + if ( + normalizedLevel !== "warning" && + normalizedLevel !== "warn" && + normalizedLevel !== "error" && + normalizedLevel !== "fatal" + ) { + continue; } + + const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; + const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; + insertBounded( + latestWarningAndErrorLogs, + { spanName: name, level, message, seenAt, traceId, spanId }, + RECENT_LIMIT, + latestSeenFirst, + ); } } - } - - const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] - .map(([name, span]) => ({ - name, - count: span.count, - failureCount: span.failureCount, - totalDurationMs: span.totalDurationMs, - averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, - maxDurationMs: span.maxDurationMs, - })) - .toSorted((left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs) - .slice(0, TOP_LIMIT); + }; - return { - traceFilePath: input.traceFilePath, - scannedFilePaths, - readAt, - recordCount, - parseErrorCount, - firstSpanAt: Option.fromNullishOr(firstSpanAt), - lastSpanAt: Option.fromNullishOr(lastSpanAt), - failureCount, - interruptionCount, - slowSpanThresholdMs, - slowSpanCount, - logLevelCounts, - topSpansByCount, - slowestSpans, - commonFailures: [...failuresByKey.values()] - .toSorted( - (left, right) => - right.count - left.count || - DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), - ) - .slice(0, TOP_LIMIT), - latestFailures: latestFailures + const finish = (input: { + readonly traceFilePath: string; + readonly scannedFilePaths: ReadonlyArray; + readonly readAt: DateTime.Utc; + readonly error?: TraceDiagnosticsErrorSummary; + readonly partialFailure?: boolean; + }): ServerTraceDiagnosticsResult => { + const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] + .map(([name, span]) => ({ + name, + count: span.count, + failureCount: span.failureCount, + totalDurationMs: span.totalDurationMs, + averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, + maxDurationMs: span.maxDurationMs, + })) .toSorted( - (left, right) => - DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt), + (left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs, ) - .slice(0, RECENT_LIMIT), - latestWarningAndErrorLogs: latestWarningAndErrorLogs - .toSorted( - (left, right) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt), - ) - .slice(0, RECENT_LIMIT), - partialFailure: input.partialFailure ? Option.some(true) : Option.none(), - error: Option.fromNullishOr(input.error), + .slice(0, TOP_LIMIT); + + return { + traceFilePath: input.traceFilePath, + scannedFilePaths: input.scannedFilePaths, + readAt: input.readAt, + recordCount, + parseErrorCount, + firstSpanAt: Option.fromNullishOr(firstSpanAt), + lastSpanAt: Option.fromNullishOr(lastSpanAt), + failureCount, + interruptionCount, + slowSpanThresholdMs, + slowSpanCount, + logLevelCounts, + topSpansByCount, + slowestSpans, + commonFailures: [...failuresByKey.values()] + .toSorted( + (left, right) => + right.count - left.count || + DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), + ) + .slice(0, TOP_LIMIT), + latestFailures, + latestWarningAndErrorLogs, + partialFailure: input.partialFailure ? Option.some(true) : Option.none(), + error: Option.fromNullishOr(input.error), + }; }; -} -type TraceFileReadResult = - | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } - | { readonly _tag: "Missing"; readonly path: string }; + return { addLine, finish }; +} -function readTraceFile( +/** + * Feeds each line of one trace file to `onLine`, streaming so only one chunk of + * text is in memory at a time. Succeeds with false when the file does not exist. + */ +export function streamTraceFileLines( fileSystem: FileSystem.FileSystem, path: string, -): Effect.Effect { - return fileSystem.readFileString(path).pipe( - Effect.map((text): TraceFileReadResult => ({ _tag: "Loaded", path, text })), + onLine: (line: string) => void, +): Effect.Effect { + return fileSystem.stream(path).pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEachArray((lines) => Effect.sync(() => lines.forEach(onLine))), + Effect.as(true), Effect.catchTags({ PlatformError: (cause) => isNotFoundError(cause) - ? Effect.succeed({ _tag: "Missing", path }) + ? Effect.succeed(false) : Effect.fail( new TraceFileReadError({ traceFilePath: path, @@ -425,10 +424,11 @@ export const make = Effect.gen(function* () { const readAt = options.readAt ?? (yield* DateTime.now); const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); + const aggregator = makeTraceDiagnosticsAggregator(slowSpanThresholdMs); const results = yield* Effect.forEach( paths, (path) => - readTraceFile(fileSystem, path).pipe( + streamTraceFileLines(fileSystem, path, aggregator.addLine).pipe( Effect.tapError((cause) => Effect.logWarning("Failed to read local trace file.").pipe( Effect.annotateLogs({ @@ -440,15 +440,10 @@ export const make = Effect.gen(function* () { ), Effect.result, ), - { - concurrency: 1, - }, - ); - const files = results.flatMap((result) => - Result.isSuccess(result) && result.success._tag === "Loaded" - ? [{ path: result.success.path, text: result.success.text }] - : [], + // Every file feeds one aggregator, so read them one at a time, oldest first. + { concurrency: 1 }, ); + const foundFile = results.some((result) => Result.isSuccess(result) && result.success); const readFailure = results.find(Result.isFailure); const readFailureError = readFailure ? ({ @@ -457,7 +452,7 @@ export const make = Effect.gen(function* () { } satisfies TraceDiagnosticsErrorSummary) : undefined; - if (files.length === 0) { + if (!foundFile) { return makeEmptyDiagnostics({ traceFilePath: options.traceFilePath, scannedFilePaths: paths, @@ -472,12 +467,10 @@ export const make = Effect.gen(function* () { }); } - return aggregateTraceDiagnostics({ + return aggregator.finish({ traceFilePath: options.traceFilePath, - files, scannedFilePaths: paths, readAt, - slowSpanThresholdMs, ...(readFailureError ? { partialFailure: true, error: readFailureError } : {}), }); }, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7533b8c1db19..30b368e62167 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -311,9 +311,12 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ readonly cause: unknown; - readonly bodyJson: OtlpTracer.TraceData; }> {} +// Renderers export up to once a second while they have spans buffered, so +// tracing this proxy would add more server spans than it forwards. +// withTracerEnabled(false) drops the handler's spans, including the forward. +// untracedRequestsLayer drops the HTTP server span. export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, @@ -330,15 +333,10 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( yield* Effect.try({ try: () => decodeOtlpTraceRecords(bodyJson), - catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), + catch: (cause) => new DecodeOtlpTraceRecordsError({ cause }), }).pipe( Effect.flatMap((records) => browserTraceCollector.record(records)), - Effect.catch((cause) => - Effect.logWarning("Failed to decode browser OTLP traces", { - cause, - bodyJson, - }), - ), + Effect.catch((cause) => Effect.logWarning("Failed to decode browser OTLP traces", { cause })), ); if (otlpTracesUrl === undefined) { @@ -369,9 +367,25 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( EnvironmentInternalError: HttpServerRespondable.toResponse, EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, }), + Effect.withTracerEnabled(false), ), ); +const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_PATH]); + +// Skips the HTTP server span for UNTRACED_REQUEST_PATHS. That span starts +// before routing, so a route handler cannot skip it. TracerDisabledWhen is one +// predicate for the whole server and the last layer to provide it wins, so +// makeRoutesLayer provides this one last. Add paths here instead of providing +// TracerDisabledWhen again; server.test.ts fails if a later layer replaces it. +// The query string is ignored, as in routing. +export const untracedRequestsLayer = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { + const queryIndex = request.url.indexOf("?"); + return UNTRACED_REQUEST_PATHS.has( + queryIndex === -1 ? request.url : request.url.slice(0, queryIndex), + ); +}); + export const assetRouteLayer = HttpRouter.add( "GET", `${ASSET_ROUTE_PREFIX}/*`, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d25c442ac8b2..207ad9a20d34 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -108,6 +108,7 @@ describe("OrchestrationReactor", () => { Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => { started.push("agent-awareness-relay"); return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 179d04843c7e..e8c7154f38e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -21,6 +21,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; @@ -114,6 +115,66 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cleanup-span-")))( + "OrchestrationProjectionPipeline attachment cleanup span", + (it) => { + it.effect("runs attachment cleanup only for events that remove attachments", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + let cleanupSpans = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "applyAttachmentSideEffects") cleanupSpans += 1; + return new Tracer.NativeSpan(options); + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-cleanup-span"); + const threadId = ThreadId.make("thread-cleanup-span"); + + const projectCreated = yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-cleanup-span-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-cleanup-span-project"), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + projectId, + title: "Cleanup span project", + workspaceRoot: "/tmp/project-cleanup-span", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionPipeline.projectEvent(projectCreated).pipe(Effect.withTracer(tracer)); + assert.strictEqual(cleanupSpans, 0); + + const threadDeleted = yield* eventStore.append({ + type: "thread.deleted", + eventId: EventId.make("evt-cleanup-span-thread-delete"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-cleanup-span-thread-delete"), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + yield* projectionPipeline.projectEvent(threadDeleted).pipe(Effect.withTracer(tracer)); + assert.strictEqual(cleanupSpans, 1); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( "imported thread shell projection", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4163168157e7..1acb3c360b15 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1991,13 +1991,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const applyAttachmentSideEffects = Effect.fn("applyAttachmentSideEffects")( function* (event: OrchestrationEvent, sideEffects: AttachmentSideEffects) { - if ( - sideEffects.deletedThreadIds.size === 0 && - sideEffects.prunedThreadRelativePaths.size === 0 - ) { - return; - } - const deletedThreadIds = new Set(); for (const threadId of sideEffects.deletedThreadIds) { const recreatedLater = yield* eventStore.hasEventAfter({ @@ -2113,9 +2106,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); }), ); + const hasCleanup = + attachmentSideEffects.deletedThreadIds.size > 0 || + attachmentSideEffects.prunedThreadRelativePaths.size > 0; // Return the cleanup effect so the caller runs it after the outer transaction commits. + // Most events have no cleanup, so they skip the call and write no cleanup span. // @effect-diagnostics-next-line returnEffectInGen:off - return applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid); + return hasCleanup + ? applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid) + : Effect.void; }, Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b80e13726811..cfba90297633 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -3643,6 +3643,85 @@ it.effect("reads one sweep thread and its projects like the shell snapshot", () }).pipe(Effect.provide(layer)); }); +it.effect("reads a full sweep from unsettled threads and every project", () => { + const resolved: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (root) => + Effect.sync(() => { + resolved.push(root); + return null; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'), + ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z'), + ('p4', 'Four', '/four', '[]', '2026-09-04T00:00:00Z', '2026-09-04T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch_pull_request_json, latest_turn_id, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('t-open', 'p1', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-open', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL), + ('t-resumed', 'p1', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'active', NULL), + ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', + '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}', + NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL), + ('t-settled', 'p3', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-settled', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'settled', '2026-09-03T00:00:00Z'), + ('t-archived', 'p4', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-04T00:00:00Z', NULL, NULL)`; + // The open and the settled thread both have a row in each joined table. The + // settled thread's turn and session are the newest rows, so updatedAt shows + // whether those two reads skip it. + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('t-open', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z'), + ('t-settled', 'github.com', 'acme/web', 9, 'https://github.com/acme/web/pull/9', 'agent', '2026-09-02T00:00:00Z')`; + yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, checkpoint_files_json) + VALUES ('t-open', 'turn-open', 'completed', '2026-09-02T00:00:00Z', '[]'), + ('t-settled', 'turn-settled', 'completed', '2026-09-09T00:00:00Z', '[]')`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at) + VALUES ('t-open', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:00Z'), + ('t-settled', 'stopped', 'codex', NULL, NULL, '2026-09-10T00:00:00Z')`; + + const full = yield* query.getShellSnapshot(); + // The settled thread's rows must reach the full read, or skipping them proves nothing. + const settled = full.threads.find((thread) => thread.id === ThreadId.make("t-settled")); + assert.strictEqual(settled?.pullRequests[0]?.number, 9); + assert.strictEqual(settled?.latestTurn?.turnId, asTurnId("turn-settled")); + assert.strictEqual(settled?.session?.status, "stopped"); + assert.strictEqual(full.updatedAt, "2026-09-10T00:00:00Z"); + resolved.length = 0; + + const sweep = yield* readSweepSnapshot(query, null); + assert.strictEqual(sweep.snapshotSequence, full.snapshotSequence); + assert.deepStrictEqual( + sweep.threads, + full.threads.filter((thread) => thread.id !== ThreadId.make("t-settled")), + ); + assert.deepStrictEqual( + sweep.threads.map((thread) => thread.id), + ["t-branch", "t-open", "t-resumed"], + ); + // Like the full read, the sweep resolves every project, so it keeps the + // repository identity cache warm for client connects. + assert.deepStrictEqual(sweep.projects, full.projects); + assert.deepStrictEqual(resolved.toSorted(), ["/four", "/one", "/three", "/two"]); + // A settled thread's link that no longer decodes breaks the full read, but + // not the sweep, which never reads it. + yield* sql`UPDATE projection_thread_pull_requests SET snapshot_json = 'invalid-json' WHERE thread_id = 't-settled'`; + assert.strictEqual((yield* Effect.exit(query.getShellSnapshot()))._tag, "Failure"); + const unsettled = yield* query.getShellSnapshot({ unsettledOnly: true }); + assert.deepStrictEqual(unsettled.threads, sweep.threads); + assert.strictEqual(unsettled.updatedAt, "2026-09-04T00:00:00Z"); + }).pipe(Effect.provide(layer)); +}); + projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => { it.effect("lists one kind across active threads only, without hydrating the threads", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 71ef1ad97321..07d30e47e27b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -185,6 +185,7 @@ const EventReplayStatsRowSchema = Schema.Struct({ eventCount: Schema.Number, payloadBytes: Schema.Number, }); +const ActiveThreadRowsRequest = Schema.Struct({ unsettledOnly: Schema.Boolean }); const ProjectionThreadSearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int, @@ -600,10 +601,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Background sweeps skip settled threads, the same check PR discovery makes. + const unsettledThreadsFilter = (unsettledOnly: boolean) => + unsettledOnly + ? sql`AND threads.settled_at IS NULL AND threads.settled_override IS NOT 'settled'` + : sql``; + const listActiveThreadRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadDbRowSchema, - execute: () => + execute: (request) => sql` SELECT thread_id AS "threadId", @@ -637,9 +644,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" - FROM projection_threads + FROM projection_threads threads WHERE deleted_at IS NULL AND archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); @@ -779,9 +787,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveThreadPullRequestRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadPullRequestDbRowSchema, - execute: () => + execute: (request) => sql` SELECT links.thread_id AS "threadId", @@ -798,6 +806,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = links.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC `, }); @@ -907,9 +916,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveThreadSessionRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadSessionDbRowSchema, - execute: () => + execute: (request) => sql` SELECT sessions.thread_id AS "threadId", @@ -927,6 +936,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY sessions.thread_id ASC `, }); @@ -1001,9 +1011,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveLatestTurnRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionLatestTurnDbRowSchema, - execute: () => + execute: (request) => sql` SELECT turns.thread_id AS "threadId", @@ -1022,6 +1032,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL AND threads.latest_turn_id IS NOT NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY turns.thread_id ASC `, }); @@ -2654,8 +2665,9 @@ pending_approval_requests AS ( }), ); - const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () => - sql + const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = (options) => { + const unsettledOnly = options?.unsettledOnly === true; + return sql .withTransaction( Effect.all([ listProjectRows(undefined).pipe( @@ -2666,7 +2678,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadRows(undefined).pipe( + listActiveThreadRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", @@ -2674,7 +2686,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadSessionRows(undefined).pipe( + listActiveThreadSessionRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", @@ -2682,7 +2694,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadPullRequestRows(undefined).pipe( + listActiveThreadPullRequestRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query", @@ -2690,7 +2702,7 @@ pending_approval_requests AS ( ), ), ), - listActiveLatestTurnRows(undefined).pipe( + listActiveLatestTurnRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", @@ -2812,6 +2824,7 @@ pending_approval_requests AS ( return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error); }), ); + }; const listThreadsWithPullRequests: ProjectionSnapshotQueryShape["listThreadsWithPullRequests"] = () => diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 48e63d6fc8b0..08da6e00940c 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -120,11 +120,15 @@ export interface ProjectionSnapshotQueryShape { * * Returns only projects and thread shell summaries so clients can bootstrap * lightweight navigation state without hydrating every thread body. + * + * `unsettledOnly` is for background sweeps, not clients. It skips settled + * threads and their sessions, PR links, and turns, and its `updatedAt` + * ignores those rows. It still resolves every project, which keeps + * repository identities cached for client connects. */ - readonly getShellSnapshot: () => Effect.Effect< - OrchestrationShellSnapshot, - ProjectionRepositoryError - >; + readonly getShellSnapshot: (options?: { + readonly unsettledOnly?: boolean; + }) => Effect.Effect; /** * Read archived thread shell summaries for the archive page. diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index cefd5bcb42b4..2a08dd03bc54 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -27,13 +27,21 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { PullRequestService } from "../pullRequest/PullRequestService.ts"; import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; import { ServerActivation } from "../serverActivation.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts"; const NOW = "2026-09-01T12:00:00.000Z"; @@ -135,6 +143,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options readonly existingWorktrees?: ReadonlyArray; readonly project?: OrchestrationProjectShell; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"]; + /** Serve full sweep reads from this instead of `threads`. */ + readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"]; }) { const activation = yield* Deferred.make(); const snapshots = yield* Ref.make({ @@ -154,7 +164,10 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options let uuid = 0; const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getShellSnapshot: () => Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, null))), + getShellSnapshot: (readOptions) => + (options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)).pipe( + Effect.tap(() => Queue.offer(reads, null)), + ), getSnapshotSequence: () => Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), getThreadShellById: (threadId) => @@ -644,6 +657,113 @@ describe("ThreadPullRequestReactor", () => { ), ); + it.effect("discovers the same PRs from the unsettled read as from the full read", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}), + ('dormant', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('open', 'project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open', NULL, ${NOW}, ${NOW}, NULL, NULL, NULL), + ('resumed', 'project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'resumed', NULL, ${NOW}, ${NOW}, NULL, 'active', NULL), + ('linked', 'project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'linked', '{"projectId":"project","repository":"owner/repository","number":3,"url":"https://github.com/owner/repository/pull/3"}', ${NOW}, ${NOW}, NULL, NULL, NULL), + ('settled', 'project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'settled', '{"projectId":"project","repository":"owner/repository","number":4,"url":"https://github.com/owner/repository/pull/4"}', ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('backfill', 'project', 'Backfill', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'backfill', NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('imported', 'dormant', 'Imported', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('archived', 'project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'archived', NULL, ${NOW}, ${NOW}, ${NOW}, NULL, NULL)`; + const numbers = new Map([ + ["open", 1], + ["resumed", 2], + ["linked", 3], + ["settled", 4], + ["backfill", 5], + ["archived", 6], + ]); + + // Startup, then two periodic passes. The startup backfill lookup fails, + // so the first periodic pass retries it from the full read. + const discover = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) => + Effect.gen(function* () { + const online = yield* Ref.make(false); + const reads: Array> = []; + const fixture = yield* makeHarness({ + threads: [], + getShellSnapshot: (options) => + read(options).pipe( + Effect.tap((snapshot) => + Effect.sync(() => reads.push(snapshot.threads.map(({ id }) => id).toSorted())), + ), + ), + branchPullRequest: ({ cwd, branch }) => + Effect.gen(function* () { + if (branch === "backfill" && !(yield* Ref.get(online))) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }); + } + const number = numbers.get(branch); + return number === undefined ? null : branchPullRequest(number); + }), + }); + return yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + const startupCalls = (yield* Ref.get(fixture.branchCalls)).length; + const startupCommands = (yield* Ref.get(fixture.commands)).length; + yield* Ref.set(online, true); + for (let pass = 0; pass < 2; pass++) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + return { + reads, + branchCalls: (yield* Ref.get(fixture.branchCalls)) + .slice(startupCalls) + .map(({ branch }) => branch) + .toSorted(), + commands: (yield* Ref.get(fixture.commands)) + .slice(startupCommands) + .map( + ({ threadId, branchPullRequest }) => `${threadId} ${branchPullRequest?.number}`, + ) + .toSorted(), + }; + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.scoped); + + const { reads: unsettledReads, ...unsettled } = yield* discover(query.getShellSnapshot); + const { reads: fullReads, ...full } = yield* discover(() => query.getShellSnapshot()); + expect(unsettled).toEqual(full); + expect(unsettled.commands).toEqual([ + "backfill 5", + "open 1", + "open 1", + "resumed 2", + "resumed 2", + ]); + // The last pass has no backfill left, so it reads no settled thread. + expect(fullReads.at(-1)).toContain("imported"); + expect(unsettledReads.at(-1)).toEqual(["linked", "open", "resumed"]); + }).pipe( + Effect.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver, { + resolve: () => Effect.succeed(project.repositoryIdentity), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), + ); + it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 2d9d92d64364..66ff81d5a9e7 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -74,7 +74,9 @@ export function pullRequestMatchesProject( /** * Read the shell state for a discovery or settlement sweep. A sweep for one - * thread reads that thread and the projects it names, not every thread. + * thread reads that thread and the projects it names, not every thread. A + * sweep over all threads reads only unsettled threads, since both sweeps skip + * settled ones. Discovery's backfill does its own full read. */ export const readSweepSnapshot = ( snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQueryShape, @@ -84,7 +86,7 @@ export const readSweepSnapshot = ( ProjectionRepositoryError > => threadId === null - ? snapshots.getShellSnapshot() + ? snapshots.getShellSnapshot({ unsettledOnly: true }) : Effect.gen(function* () { // Read the sequence first. The thread is then at least this new, so a // command guarded by the sequence is rejected rather than missing a change. @@ -129,7 +131,11 @@ export const make = Effect.gen(function* () { const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( request: RefreshRequest, ) { - const snapshot = yield* readSweepSnapshot(snapshots, request.threadId); + // Backfill looks up settled threads, so its passes read every thread. + const snapshot = + request.threadId === null && (request.backfill || pendingBackfill.size > 0) + ? yield* snapshots.getShellSnapshot() + : yield* readSweepSnapshot(snapshots, request.threadId); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { @@ -141,11 +147,15 @@ export const make = Effect.gen(function* () { } } } - // A single-thread read only shows whether its own thread is gone. - const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); + // A single-thread read only shows whether its own thread is gone. A thread + // with no branch has nothing to look up, and its entry would keep every + // periodic pass on the full read. + const branchThreadIds = new Set( + snapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id), + ); const checkedIds = request.threadId === null ? pendingBackfill.keys() : [request.threadId]; for (const threadId of checkedIds) { - if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); + if (!branchThreadIds.has(threadId)) pendingBackfill.delete(threadId); } const threads = snapshot.threads.filter( (thread) => diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index bee487ceb721..cad45007665c 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -30,8 +30,11 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; import { PullRequestService, type PullRequestMergeEvent, @@ -39,11 +42,17 @@ import { import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, } from "./Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Path from "effect/Path"; @@ -169,6 +178,8 @@ function makeBranchPullRequest( interface HarnessOptions { readonly snapshot: OrchestrationShellSnapshot; + /** Serve full sweep reads from this instead of `snapshot`. */ + readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"]; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; @@ -256,10 +267,10 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getShellSnapshot: () => + getShellSnapshot: (readOptions) => Ref.update(snapshotReadCount, (count) => count + 1).pipe( Effect.andThen(Queue.offer(snapshotReads, null)), - Effect.andThen(Ref.get(snapshots)), + Effect.andThen(options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)), ), getSnapshotSequence: () => Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), @@ -1437,6 +1448,88 @@ describe("ThreadSettlementReactor", () => { }), ), ); + + it.effect("settles the same threads from the unsettled read as from the full read", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('settlement-project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}), + ('linked-settlement-project', 'Linked', '/workspace/linked', '[]', ${NOW}, ${NOW}), + ('dormant-project', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, latest_user_message_at, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('idle', 'settlement-project', 'Idle', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('merged', 'settlement-project', 'Merged', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature', + '{"projectId":"linked-settlement-project","repository":"owner/repository","number":42,"url":"https://example.test/owner/repository/pull/42"}', + '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('linked', 'settlement-project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('open', 'settlement-project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open-feature', NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('resumed', 'settlement-project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'active', NULL), + ('settled', 'dormant-project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'done', NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'settled', '2026-08-21T00:00:00.000Z'), + ('archived', 'settlement-project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, ${NOW}, NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at, snapshot_json) + VALUES ('linked', 'example.test', 'owner/repository', 7, 'https://example.test/owner/repository/pull/7', 'manual', ${NOW}, + '{"state":"merged","title":"Review","headBranch":"linked","baseBranch":"main","isDraft":false,"updatedAt":"2026-08-28T12:00:00.000Z","syncedAt":"2026-08-28T12:00:00.000Z","mergedAt":"2026-08-28T12:00:00.000Z","closedAt":null}')`; + + const sweep = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) => + Effect.gen(function* () { + const readThreadIds: Array = []; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + getShellSnapshot: (options) => + read(options).pipe( + Effect.tap((snapshot) => + Effect.sync(() => readThreadIds.push(...snapshot.threads.map(({ id }) => id))), + ), + ), + branchPullRequest: ({ branch }) => + Effect.succeed(branch === "open-feature" ? makeBranchPullRequest("open") : null), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), + }); + return yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + return { + readThreadIds: readThreadIds.toSorted(), + commands: (yield* Ref.get(fixture.commands)) + .map(({ threadId, settledAt }) => `${threadId} ${settledAt}`) + .toSorted(), + branchCalls: (yield* Ref.get(fixture.branchCalls)) + .map(({ branch }) => branch) + .toSorted(), + summaryCalls: yield* Ref.get(fixture.summaryCalls), + }; + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.scoped); + + const { readThreadIds: unsettledReads, ...unsettled } = yield* sweep(query.getShellSnapshot); + const { readThreadIds: fullReads, ...full } = yield* sweep(() => query.getShellSnapshot()); + assert.deepStrictEqual(unsettled, full); + // A settle for inactivity, for a synced merged link, and for a saved + // branch PR whose project only that PR names. + assert.deepStrictEqual(unsettled.commands, [ + "idle 2026-08-20T00:00:00.000Z", + "linked 2026-08-27T00:00:00.000Z", + "merged 2026-08-27T00:00:00.000Z", + ]); + assert.deepStrictEqual(fullReads, [...unsettledReads, "settled"].toSorted()); + assert.deepStrictEqual(unsettledReads, ["idle", "linked", "merged", "open", "resumed"]); + }).pipe( + Effect.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver, { resolve: () => Effect.succeed(null) }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), + ); }); describe("storage cleanup", () => { diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 80588f58858f..1ec7adcd623b 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -102,11 +102,14 @@ export class ProviderSessionRuntimeRepository extends Context.Service< >; /** - * List all provider runtime rows. + * List provider runtime rows. * - * Returned in ascending last-seen order. + * Returned in ascending last-seen order. `excludeStopped` filters stopped + * rows in SQL. Long-lived installs keep thousands for their resume cursors. */ - readonly list: () => Effect.Effect< + readonly list: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionRuntimeRepositoryError >; @@ -336,9 +339,9 @@ export const make = Effect.gen(function* () { }); const listRuntimeRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: Schema.Struct({ excludeStopped: Schema.Boolean }), Result: ProviderSessionRuntimeRawDbRowSchema, - execute: () => + execute: ({ excludeStopped }) => sql` SELECT thread_id AS "threadId", @@ -351,6 +354,7 @@ export const make = Effect.gen(function* () { resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime + ${excludeStopped ? sql`WHERE status != 'stopped'` : sql``} ORDER BY last_seen_at ASC, thread_id ASC `, }); @@ -414,8 +418,8 @@ export const make = Effect.gen(function* () { ), ); - const list: ProviderSessionRuntimeRepository["Service"]["list"] = () => - listRuntimeRows(undefined).pipe( + const list: ProviderSessionRuntimeRepository["Service"]["list"] = (options) => + listRuntimeRows({ excludeStopped: options?.excludeStopped === true }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.list:query", diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 7fa15defeca9..d790002f95f1 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -18,6 +18,7 @@ import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { expect } from "vite-plus/test"; import { FetchHttpClient } from "effect/unstable/http"; @@ -441,6 +442,26 @@ effectIt.effect("stops probing a subscriber's configured paths after its scope c }).pipe(Effect.scoped, Effect.provide(layer)); }); +effectIt.effect("writes no poll span while no client retains the scanner", () => { + let pollSpans = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "PortDiscovery.pollTick") pollSpans += 1; + return new Tracer.NativeSpan(options); + }, + }); + const layer = makeProbeFailureLayer(processProbeFailure); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* TestClock.adjust(Duration.seconds(15)); + expect(pollSpans).toBe(0); + + yield* scanner.retain; + expect(pollSpans).toBe(1); + }).pipe(Effect.scoped, Effect.provide(layer), Effect.withTracer(tracer)); +}); + effectIt.effect("uses the current configured fragment when readiness comes from cache", () => { const requests: string[] = []; const fetchFn = ((input: Parameters[0]) => { diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index f4d73d62320d..9eee1a3e215e 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -550,7 +550,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { - if ((yield* Ref.get(stateRef)).retainCount <= 0) return; const configuredUrls = [ ...new Set( [...(yield* Ref.get(stateRef)).listeners.values()].flatMap( @@ -579,9 +578,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ), ); - // Single layer-scoped polling fiber. Ticks are no-ops when no client is - // currently retained, so the cost is one Ref.get every POLL_INTERVAL. - yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + // Single layer-scoped polling fiber. Ticks skip the scan and its span when no + // client is currently retained, so the cost is one Ref.get every POLL_INTERVAL. + const pollIfRetained = Ref.get(stateRef).pipe( + Effect.flatMap((state) => (state.retainCount > 0 ? pollTick() : Effect.void)), + ); + yield* Effect.forkScoped(pollIfRetained.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { const wasIdle = yield* Ref.modify(stateRef, (state) => [ diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 36bb5b649f06..049125de3fbf 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -171,7 +171,8 @@ export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsComman }, ); -const collectText = Effect.fn("processRunner.collectText")(function* (input: { +// Untraced: no attributes, and its time is the runProcessCore span. Errors fail that span. +const collectText = Effect.fnUntraced(function* (input: { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string | undefined; diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index e12a320c4d48..c5e6400d11b4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -786,9 +786,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi stopOwned, Effect.gen(function* () { const mcp = McpProviderSession.readMcpProviderSession(input.threadId); - // The attachments dir grant lets the agent read pasted files at - // the paths ProviderService injects into the turn text. It is a - // leaf directory holding only uploads. + // The attachments dir grant lets the agent read path-only uploads + // at the paths ProviderService injects into the turn text. It is + // a leaf directory holding only uploads. const runtime = yield* options.makeRuntime({ cwd, clientInfo: { name: "t3-code", version: "0.0.0" }, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 242d0b55aa2f..7f0465b9c401 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4801,6 +4801,57 @@ validation.layer("ProviderServiceLive validation", (it) => { }), ); + it.effect("rejects a file when its path cannot fit in the prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + validation.codex.sendTurn.mockClear(); + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-file-path-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [ + { + type: "file", + id: "thread-attach-12345678-1234-1234-1234-123456789abc-zip", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: 1024, + }, + ], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + + it.effect("sends a native image when its path cannot fit in the prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-image-path-context-limit"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + validation.codex.sendTurn.mockClear(); + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-png", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1024, + }; + const input = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + yield* provider.sendTurn({ threadId, input, attachments: [attachment] }); + assert.equal(validation.codex.sendTurn.mock.calls[0]?.[0].input, input); + assert.deepEqual(validation.codex.sendTurn.mock.calls[0]?.[0].attachments, [attachment]); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 12f8869da486..f4e7b0b39bdb 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1634,10 +1634,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); - if (isPastedText && !appended) { + // Most adapters see generic files only through this path line, so a file + // without one would be silently dropped. Images still go natively. + if (!appended && attachment.type === "file") { return yield* toValidationError( "ProviderService.sendTurn", - `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, + `Input plus attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, ); } } diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 8b41bd3e518c..45615fe7b440 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -372,6 +372,41 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }), ); + it.effect("lists only bindings that are not stopped when asked", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const statuses = ["running", "starting", "error", "stopped"] as const; + const threadIds = new Set(); + + for (const status of statuses) { + const threadId = ThreadId.make(`thread-exclude-stopped-${status}`); + threadIds.add(threadId); + yield* runtimeRepository.upsert({ + threadId, + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "full-access", + status, + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); + } + + const liveStatuses = (yield* directory.listBindings({ excludeStopped: true })) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + const allStatuses = (yield* directory.listBindings()) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + + assert.deepEqual(liveStatuses.toSorted(), ["error", "running", "starting"]); + assert.deepEqual(allStatuses.toSorted(), ["error", "running", "starting", "stopped"]); + }), + ); + it.effect( "resets adapterKey to the new provider when provider changes without an explicit adapter key", () => diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 03f1ece0b5e0..0e9de8fb868f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -182,8 +182,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.threadId)), ); - const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => - repository.list().pipe( + const listBindings: ProviderSessionDirectoryShape["listBindings"] = (options) => + repository.list(options).pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), Effect.flatMap((rows) => Effect.forEach( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index 8f04bd1821fd..bf8199f80eac 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -35,15 +35,13 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); const sweep = Effect.gen(function* () { - const bindings = yield* directory.listBindings(); + // Stopped rows stay for their resume cursors and far outnumber live + // ones, so the query skips them. + const bindings = yield* directory.listBindings({ excludeStopped: true }); const now = yield* Clock.currentTimeMillis; let reapedCount = 0; for (const binding of bindings) { - if (binding.status === "stopped") { - continue; - } - const lastSeenMs = Date.parse(binding.lastSeenAt); if (Number.isNaN(lastSeenMs)) { yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { @@ -122,7 +120,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = if (reapedCount > 0) { yield* Effect.logInfo("provider.session.reaper.sweep-complete", { reapedCount, - totalBindings: bindings.length, + liveBindings: bindings.length, }); } }); diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 9dbafd3e804e..1b5c47ec1d62 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -70,7 +70,10 @@ export interface ProviderSessionDirectoryShape { ProviderSessionDirectoryPersistenceError >; - readonly listBindings: () => Effect.Effect< + /** `excludeStopped` skips stopped rows in the query, not after decoding. */ + readonly listBindings: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError >; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index aee10782a613..7d6cfb1e2620 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ANTIGRAVITY_DEFAULT_MODEL, - PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, type RuntimeMode, @@ -15,7 +14,6 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { - ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES, antigravityPermissionMode, applyAntigravityAcpModelSelection, buildAntigravityPrompt, @@ -324,6 +322,41 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect.each([ + { name: "archive.zip", mimeType: "application/zip" }, + { name: "clip.mp4", mimeType: "video/mp4" }, + { name: "recording.aiff", mimeType: "audio/aiff" }, + { name: "large.pdf", mimeType: "application/pdf", sizeBytes: 75_000_000 }, + { name: "large.txt", mimeType: "text/plain", sizeBytes: 1024 * 1024 + 1 }, + { name: "large.wav", mimeType: "audio/wav", sizeBytes: 20 * 1024 * 1024 + 1 }, + ])("keeps $name as a file path without reading or spending the native media budget", (file) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const attachment = { ...textAttachment, sizeBytes: 50 * 1024 * 1024, ...file }; + const upload = yield* fixture.write(attachment, ""); + yield* fixture.fs.truncate(upload.filePath, attachment.sizeBytes); + const pdf = yield* fixture.write(pdfAttachment, ""); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024); + const input = `Inspect the file at ${upload.filePath}`; + const prompt = yield* buildAntigravityPrompt({ + input, + attachments: [attachment, pdfAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => { + throw new Error("Path attachments must not be read into the prompt"); + }, + }), + ); + expect(prompt).toEqual([ + { type: "text", text: input }, + { type: "resource_link", uri: pdf.uri, name: "report.pdf", mimeType: "application/pdf" }, + ]); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); @@ -378,8 +411,6 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { it.effect.each([ { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, - { ...textAttachment, name: "archive.zip", mimeType: "application/zip" }, - { ...textAttachment, name: "recording.aiff", mimeType: "audio/aiff" }, ] satisfies ReadonlyArray)( "rejects $name instead of silently dropping it from a valid prompt", (attachment) => @@ -400,32 +431,25 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect.each([ - { attachment: textAttachment, bytes: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES + 1 }, - { attachment: imageAttachment, bytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }, - { attachment: pdfAttachment, bytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }, - ])( - "rejects oversized $attachment.name using file size instead of upload metadata", - ({ attachment, bytes }) => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const upload = yield* fixture.write(attachment, ""); - yield* fixture.fs.truncate(upload.filePath, bytes); - const error = yield* buildAntigravityPrompt({ - input: "Read this attachment.", - attachments: [attachment], - attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining(`'${attachment.name}' is too large`), - }); - }), + it.effect("rejects oversized images using file size instead of upload metadata", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const upload = yield* fixture.write(imageAttachment, ""); + yield* fixture.fs.truncate(upload.filePath, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); + const error = yield* buildAntigravityPrompt({ + input: "Read this attachment.", + attachments: [imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining("'screen.png' is too large"), + }); + }), ); - it.effect("accepts 50 MiB in total but rejects one byte more across files", () => + it.effect("keeps PDF overflow on the file-path route", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); const secondAttachment = { @@ -435,25 +459,57 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }; const first = yield* fixture.write(pdfAttachment, ""); const second = yield* fixture.write(secondAttachment, ""); - yield* fixture.fs.truncate(first.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); + yield* fixture.fs.truncate(first.filePath, (50 * 1024 * 1024) / 2); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2); const input = { - input: undefined, + input: `Read ${first.filePath} and ${second.filePath}`, attachments: [pdfAttachment, secondAttachment], attachmentsDir: fixture.attachmentsDir, }; const prompt = yield* buildAntigravityPrompt(input); expect(prompt).toEqual([ - { type: "resource_link", uri: first.uri, name: "report.pdf", mimeType: "application/pdf" }, - { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf" }, + { type: "text", text: input.input }, + { + type: "resource_link", + uri: first.uri, + name: "report.pdf", + mimeType: "application/pdf", + }, + { + type: "resource_link", + uri: second.uri, + name: "second.pdf", + mimeType: "application/pdf", + }, ]); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2 + 1); - const error = yield* buildAntigravityPrompt(input).pipe(Effect.flip); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2 + 1); + expect(yield* buildAntigravityPrompt(input)).toEqual([ + { type: "text", text: input.input }, + { + type: "resource_link", + uri: first.uri, + name: "report.pdf", + mimeType: "application/pdf", + }, + ]); + }), + ); + + it.effect("still rejects images when the native budget is full", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pdf = yield* fixture.write(pdfAttachment, ""); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024); + yield* fixture.write(imageAttachment, new Uint8Array([1])); + const error = yield* buildAntigravityPrompt({ + input: "Inspect both attachments.", + attachments: [pdfAttachment, imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining("'second.pdf' is too large"), + errorMessage: expect.stringContaining("'screen.png' is too large"), }); }), ); @@ -463,7 +519,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const fixture = yield* makeAttachmentFixture(); const pdf = yield* fixture.write(pdfAttachment, ""); const text = yield* fixture.write(textAttachment, "a"); - yield* fixture.fs.truncate(pdf.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES - 1); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024 - 1); const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [pdfAttachment, textAttachment], diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index f2f370068181..8f2877330bbe 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -243,10 +243,14 @@ const TEXT_FILE_EXTENSIONS = new Set([ ".ini", ".conf", ]); -export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; +const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; -/** Sends uploads as native ACP content instead of workspace path hints. */ +/** + * Sends supported uploads as native ACP content. Other files, and native + * candidates over their limits, reach the agent through the saved path + * ProviderService puts in the text block. + */ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { readonly input: ProviderSendTurnInput["input"]; readonly attachments: ProviderSendTurnInput["attachments"]; @@ -280,7 +284,9 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi (mimeType.startsWith("text/") || TEXT_MIME_TYPES.has(mimeType) || TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); - if (!image && !audio && !pdf && !textFile) { + const isPathOnly = + attachment.type === "file" && (isPastedText || (!audio && !pdf && !textFile)); + if (attachment.type === "image" && !image) { return yield* EffectAcpErrors.AcpRequestError.invalidParams( `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`, ); @@ -303,26 +309,30 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); - if (isPastedText) { - if (info.type !== "File") { - return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Could not read attachment '${attachment.name}'.`, - ); - } - continue; + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); } + if (isPathOnly) continue; const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES : audio ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES : pdf - ? PROVIDER_SEND_TURN_MAX_FILE_BYTES + ? MAX_TOTAL_ATTACHMENT_BYTES : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; + if ( + attachment.type === "file" && + (size > limit || totalBytes + size > MAX_TOTAL_ATTACHMENT_BYTES) + ) { + continue; + } totalBytes += size; - if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + if (size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Attachment '${attachment.name}' is too large. Antigravity accepts text files up to 1 MiB, images up to 10 MiB, audio up to 20 MiB, and 50 MiB total attachments.`, + `Image '${attachment.name}' is too large. Antigravity accepts images up to 10 MiB and 50 MiB of native attachments per message.`, ); } const uri = yield* path.toFileUrl(attachmentPath).pipe( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index db4f1f53b710..71ec0719325d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -28,6 +28,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -975,3 +976,114 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ), ); }); + +describe.sequential("startup catch-up", () => { + // An unlinked relay with publishing off. `link` writes the link secrets and + // `enablePublishing` the opt-in. Counts link checks (relay URL reads) and + // catch-up publishes (shell snapshot reads). + function makeUnlinkedRelay() { + const secrets = makeMemorySecretStore(); + const counts = { linkChecks: 0, catchUpPublishes: 0 }; + const countingStore = { + ...secrets.store, + get: (name: string) => + Effect.suspend(() => { + if (name === RELAY_URL_SECRET) counts.linkChecks += 1; + return secrets.store.get(name); + }), + } satisfies ServerSecretStore.ServerSecretStore["Service"]; + + const layer = AgentAwarenessRelay.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, countingStore), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed("env-1" as EnvironmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(OrchestrationEngineService, { + streamDomainEvents: Stream.never, + } as unknown as OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => + Effect.sync(() => { + counts.catchUpPublishes += 1; + return { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-05-25T00:00:00.000Z", + } satisfies OrchestrationShellSnapshot; + }), + } as unknown as ProjectionSnapshotQueryShape), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + const link = Effect.all( + [ + secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"), + secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"), + ], + { discard: true }, + ); + const enablePublishing = secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + return { counts, layer, link, enablePublishing }; + } + + it.effect("checks an unlinked environment once a minute and still catches up once linked", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Get past the backoff ramp, then count checks in a steady window. + yield* TestClock.adjust("10 minutes"); + const checksBeforeWindow = counts.linkChecks; + yield* TestClock.adjust("10 minutes"); + expect(counts.linkChecks - checksBeforeWindow).toBe(10); + expect(counts.catchUpPublishes).toBe(0); + + yield* link; + yield* TestClock.adjust("1 minute"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("publishes at once when this process links while the check is backed off", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Backed off to 60 s: the next check is still seconds away. + yield* TestClock.adjust("10 minutes"); + yield* link; + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(0); + + yield* relay.requestCatchUp(); + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("catches up within 5 s when another process enables publishing on a link", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* link; + yield* relay.start(); + + yield* TestClock.adjust("10 minutes"); + expect(counts.catchUpPublishes).toBe(0); + + // `t3 connect publish` writes the opt-in without waking this process. + yield* enablePublishing; + yield* TestClock.adjust("5 seconds"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); +}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 29d2abecebb7..c9acf6b510fc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -25,6 +25,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -51,6 +52,8 @@ export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, { readonly publishThread: (threadId: ThreadId) => Effect.Effect; + /** Retries a pending catch-up publish now. Call after this process links or enables publishing. */ + readonly requestCatchUp: () => Effect.Effect; readonly start: () => Effect.Effect; } >()("t3/relay/AgentAwarenessRelay") {} @@ -309,6 +312,8 @@ export const make = Effect.gen(function* () { const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); const startedAt = (yield* DateTime.now).epochMilliseconds; const activeSnapshotPublishedRef = yield* Ref.make(false); + // Holds at most one pending wake, so a burst of requests costs one retry. + const catchUpRequests = yield* Queue.dropping(1); const publishedStateByThreadRef = yield* Ref.make(new Map()); const readSecretString = (name: string) => @@ -534,18 +539,29 @@ export const make = Effect.gen(function* () { withRelayClientTracing, ); + // Publishes the active threads once. Returns why it did not, so the retry + // knows whether it is waiting on a link or on the publish setting. const publishActiveThreadsUnsafe = Effect.gen(function* () { + // One secret read settles the common never-linked case; the full link + // config is read only once publishing is on. + const relayUrl = yield* readSecretString(RELAY_URL_SECRET).pipe( + Effect.orElseSucceed(() => null), + ); + if (!relayUrl) { + yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); + return "unlinked" as const; + } const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( Effect.orElseSucceed(() => false), ); if (!publishAgentActivity) { yield* Effect.logDebug("agent activity snapshot skipped; publication disabled"); - return false; + return "disabled" as const; } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); - return false; + return "unlinked" as const; } const environmentId = yield* serverEnvironment.getEnvironmentId; const snapshot = yield* snapshotQuery.getShellSnapshot(); @@ -557,20 +573,29 @@ export const make = Effect.gen(function* () { }); if (activeThreadIds.length === 0) { yield* Effect.logDebug("agent activity snapshot has no publishable threads"); - return true; + return "published" as const; } yield* Effect.logInfo("publishing active agent activity snapshot", { count: activeThreadIds.length, }); yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); - return true; + return "published" as const; }); + // Publishes the catch-up snapshot of active threads once the environment is + // linked and publishing is enabled. Many environments never link, so while + // unlinked the retry backs off from 5 s to 60 s. Only this process writes + // the link, and it calls `requestCatchUp`, which ends the wait early. A + // linked environment keeps the 5 s retry, because `t3 connect publish` can + // turn publishing on from another process. const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => Effect.gen(function* () { + let unlinkedRetryDelayMs = 5_000; while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); - if (published) { + const result = yield* publishActiveThreadsUnsafe.pipe( + Effect.orElseSucceed(() => "failed" as const), + ); + if (result === "published") { yield* Ref.set(activeSnapshotPublishedRef, true); if (logEnabledWhenReady) { const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); @@ -580,7 +605,11 @@ export const make = Effect.gen(function* () { } return; } - yield* Effect.sleep("5 seconds"); + const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; + yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); + if (result === "unlinked") { + unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + } } }); @@ -657,6 +686,7 @@ export const make = Effect.gen(function* () { return AgentAwarenessRelay.of({ publishThread, + requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid), start, }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5dc793b5cdf1..43595dbafce1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -181,6 +181,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; @@ -562,6 +563,7 @@ const buildAppUnderTest = (options?: { CloudManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"] >; relayClient?: Partial; + agentAwarenessRelay?: Partial; cloudCliTokenManager?: Partial; httpClient?: HttpClient.HttpClient; nativeTelemetryClient?: Partial; @@ -1173,14 +1175,20 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.succeed( - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: () => Effect.succeed({ status: "disabled" }), - recoveryRequests: Stream.empty, - requestRecovery: () => Effect.void, - withLinkStateLock: (effect) => effect, - ...options?.layers?.cloudManagedEndpointRuntime, + Layer.mergeAll( + Layer.succeed( + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, + ...options?.layers?.cloudManagedEndpointRuntime, + }), + ), + Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ + requestCatchUp: () => Effect.void, + ...options?.layers?.agentAwarenessRelay, }), ), ), @@ -3114,6 +3122,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("wakes the agent awareness relay when this server links or changes publishing", () => + Effect.gen(function* () { + let catchUpRequests = 0; + yield* buildAppUnderTest({ + layers: { + agentAwarenessRelay: { + requestCatchUp: () => + Effect.sync(() => { + catchUpRequests += 1; + }), + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/relay-config"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }, + ); + assert.equal(relayConfigResponse.status, 200); + assert.equal(catchUpRequests, 1); + + const preferencesResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/preferences"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ publishAgentActivity: true }), + }, + ); + assert.equal(preferencesResponse.status, 200); + assert.equal(catchUpRequests, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects relay config with an invalid cloud mint public key", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -5772,6 +5829,56 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("does not trace browser OTLP trace exports on the server", () => + Effect.gen(function* () { + const spanNames: Array = []; + const forwardedUrls: Array = []; + yield* buildAppUnderTest({ + config: { otlpTracesUrl: "http://collector.test/v1/traces" }, + layers: { + httpClient: HttpClient.make((request) => + Effect.sync(() => { + forwardedUrls.push(request.url); + return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })); + }), + ), + }, + }).pipe( + Effect.provideService( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + spanNames.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ), + ); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + spanNames.length = 0; + + // The query string must not bring back the HTTP server span. + for (const url of ["/api/observability/v1/traces", "/api/observability/v1/traces?x=1"]) { + const response = yield* HttpClient.post(url, { + headers: { cookie, "content-type": "application/json" }, + body: yield* HttpBody.json({ resourceSpans: [] }), + }); + assert.equal(response.status, 204); + } + + assert.deepEqual(forwardedUrls, [ + "http://collector.test/v1/traces", + "http://collector.test/v1/traces", + ]); + assert.deepEqual(spanNames, []); + + // Other routes keep their HTTP server span. + const session = yield* HttpClient.get("/api/auth/session", { headers: { cookie } }); + assert.equal(session.status, 200); + assert.include(spanNames, "http.server GET"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b7020458160d..88c08fd3bdee 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import { staticAndDevRouteLayer, browserApiCorsLayer, httpCompressionLayer, + untracedRequestsLayer, } from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; @@ -605,6 +606,8 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), + // Last, so no route layer can replace the server's one TracerDisabledWhen. + untracedRequestsLayer, ).pipe( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 47ca4e178700..85c4d1a59d1d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1918,6 +1918,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("does not start Git auto-maintenance from background upstream fetches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["repack", "-d"]); + yield* writeTextFile(cwd, "second.txt", "second\n"); + yield* git(cwd, ["add", "second.txt"]); + yield* git(cwd, ["commit", "-m", "second commit"]); + yield* git(cwd, ["push"]); + yield* git(cwd, ["repack", "-d"]); + // Two packs make `git gc --auto` due, and without detaching it would run inside the fetch. + yield* git(cwd, ["config", "gc.autoPackLimit", "1"]); + yield* git(cwd, ["config", "gc.autoDetach", "false"]); + yield* git(cwd, ["config", "maintenance.autoDetach", "false"]); + const packCount = git(cwd, ["count-objects", "-v"]).pipe( + Effect.map((stdout) => stdout.match(/^packs: (\d+)$/m)?.[1]), + ); + assert.equal(yield* packCount, "2"); + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(yield* packCount, "2"); + }), + ); + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 8ec274a46611..0dd73af687f9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -549,7 +549,9 @@ function trace2ChildKey(record: Record): string | null { const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); const decodeTrace2Record = decodeJsonResult(Trace2Record); -const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( +// Untraced because it runs on every git spawn and returns at once without hook +// callbacks. Its errors fail the runGitCommand span. +const createTrace2Monitor = Effect.fnUntraced(function* ( input: Pick, progress: GitVcsDriver.ExecuteGitProgress | undefined, ): Effect.fn.Return< @@ -1125,10 +1127,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps + // this poll from starting `git gc --auto`. When that gc fails, for example on a repository + // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` + // behind each time, so a background poll could fill the disk. return executeGit( "GitVcsDriver.fetchRemoteForStatus", fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", "--no-auto-gc", remoteName], { env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", diff --git a/apps/web/package.json b/apps/web/package.json index ec7334e1fd86..665cdbf71736 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -75,6 +75,7 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "jsdom": "^30.0.1", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "unified": "^11.0.5", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0cf965c4bd22..7e3eaad1125b 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -79,6 +79,7 @@ function readInitialThreadSidebarWidth(): number { } function SidebarControl() { + const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); @@ -86,7 +87,9 @@ function SidebarControl() { const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", ); - const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); + const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { + context: { usagePageOpen }, + }); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -106,7 +109,11 @@ function SidebarControl() { // available everywhere else, including the plain-text composer. return; } - if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; + if ( + resolveShortcutCommand(event, keybindings, { context: { usagePageOpen } }) !== + "sidebar.toggle" + ) + return; event.preventDefault(); event.stopPropagation(); @@ -116,7 +123,7 @@ function SidebarControl() { // Capture before focused editors consume commands such as Mod+B for rich-text formatting. window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); - }, [keybindings, toggleSidebar]); + }, [keybindings, toggleSidebar, usagePageOpen]); return ( // The right-side layout controls carry mr-px (border compensation inside diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b08bf12176a5..3262ff9cc4a3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -324,12 +324,13 @@ import { terminalContextReference, } from "../lib/composerContextRecords"; import { - isQueuedMessageDue, latestCompletedToolActivityId, type QueuedComposerMessage, + type QueuedMessageSendSettings, useQueuedMessages, useQueuedMessageStore, } from "../queuedMessageStore"; +import { sendQueuedMessage } from "./chat/sendQueuedMessage"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -7248,10 +7249,27 @@ export default function ChatView(props: ChatViewProps) { }; const queuedMessages = useQueuedMessages(activeThreadKey ?? ""); - // Puts queued messages back into the composer, e.g. after Stop or a failed - // send. Prompts join with blank lines; attachments and contexts are added. + // The composer's model and modes, as a queued message keeps them for its send. + const readComposerSendSettings = ( + sendCtx: ReturnType, + ): QueuedMessageSendSettings => ({ + modelSelection: sendCtx.selectedModelSelection, + runtimeMode, + interactionMode: sendCtx.interactionMode, + promptEffort: resolvePromptInjectedEffort( + getProviderModelCapabilities( + sendCtx.selectedProviderModels, + sendCtx.selectedModel, + sendCtx.selectedProvider, + ), + sendCtx.selectedPromptEffort, + ), + }); + // Puts queued messages back into the composer after Stop or Cancel. Prompts + // join with blank lines; attachments and contexts are added. const restoreQueuedMessagesToComposer = (messages: ReadonlyArray) => { - if (messages.length === 0) return; + const [firstMessage] = messages; + if (!firstMessage) return; const prompts = [promptRef.current, ...messages.map((message) => message.prompt)] .map((prompt) => prompt.trim()) .filter((prompt) => prompt.length > 0); @@ -7279,6 +7297,8 @@ export default function ChatView(props: ChatViewProps) { if (restoredImages.length > 0) addComposerDraftImages(composerDraftTarget, restoredImages); if (restoredFiles.length > 0) addComposerDraftFiles(composerDraftTarget, restoredFiles); if (overflow.length > 0 && activeThreadKey) { + // The overflow is the rest of the restored draft, so it follows the composer. + const sendCtx = composerRef.current?.getSendContext(); useQueuedMessageStore.getState().enqueue(activeThreadKey, { prompt: "", images: overflow.filter((attachment) => attachment.type === "image"), @@ -7286,7 +7306,7 @@ export default function ChatView(props: ChatViewProps) { terminalContexts: [], previewAnnotations: [], reviewComments: [], - submissionIntent: "foreground", + sendSettings: sendCtx ? readComposerSendSettings(sendCtx) : firstMessage.sendSettings, queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), // Restoration is not a send. The user decides when the overflow goes. holdUntilUserAction: true, @@ -7329,8 +7349,6 @@ export default function ChatView(props: ChatViewProps) { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; }, - /** A queued message being sent now instead of the live composer draft. */ - queuedMessage?: QueuedComposerMessage, ) => { e?.preventDefault(); // Typed out in full rather than picked from the menu. Attachments or contexts @@ -7339,7 +7357,6 @@ export default function ChatView(props: ChatViewProps) { usageLimitsOffered && usageLimitsKey !== null && !directAnnotation && - !queuedMessage && !composerHasNonPromptContent && isUsageLimitsCommand(promptRef.current) ) { @@ -7401,9 +7418,7 @@ export default function ChatView(props: ChatViewProps) { return; } if (activePendingProgress) { - // A queued message waits until the question is answered; it must not - // be submitted as the answer. - if (directAnnotation || queuedMessage) { + if (directAnnotation) { notifyDirectAnnotationAttached(); return; } @@ -7415,7 +7430,7 @@ export default function ChatView(props: ChatViewProps) { notifyDirectAnnotationAttached(); return; } - const multipleModelSelections = queuedMessage ? null : sendCtx.multipleModelSelections; + const multipleModelSelections = sendCtx.multipleModelSelections; if ( multipleModelSelections !== null && serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true @@ -7446,8 +7461,6 @@ export default function ChatView(props: ChatViewProps) { terminalContexts: composerTerminalContexts, previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, - } = queuedMessage ?? sendCtx; - const { selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -7490,13 +7503,11 @@ export default function ChatView(props: ChatViewProps) { : sendContextPreviewAnnotations; // A direct "send annotation" writes the draft and sends in the same tick; the reference // must be in the text now, not after the next render. - const promptForSend = queuedMessage - ? queuedMessage.prompt - : directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ - previewAnnotationContextReference(directAnnotation.annotation), - ]) - : promptRef.current; + const promptForSend = directAnnotation + ? ensureInlineContextReferences(promptRef.current, [ + previewAnnotationContextReference(directAnnotation.annotation), + ]) + : promptRef.current; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, @@ -7517,7 +7528,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseCodexFeedbackCommand(trimmed) : null; - if (feedbackCommand && !queuedMessage && multipleModelSelections === null) { + if (feedbackCommand && multipleModelSelections === null) { if (!isServerThread || activeThread.session === null) { toastManager.add( stackedThreadToast({ @@ -7568,7 +7579,6 @@ export default function ChatView(props: ChatViewProps) { } if ( !directAnnotation && - !queuedMessage && sendInteractionModeEnabled && showPlanFollowUpPrompt && activeProposedPlan && @@ -7640,7 +7650,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; - if (standaloneSlashCommand && !queuedMessage && multipleModelSelections === null) { + if (standaloneSlashCommand && multipleModelSelections === null) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -7661,12 +7671,6 @@ export default function ChatView(props: ChatViewProps) { }), ); } - // A queued message whose only content expired would retry on every - // boundary and block the rest of the queue. Nothing sendable is left - // in it, so drop it and let the queue move on. - if (queuedMessage && activeThreadKey) { - useQueuedMessageStore.getState().remove(activeThreadKey, queuedMessage.id); - } return; } if (!activeProject) { @@ -7679,14 +7683,26 @@ export default function ChatView(props: ChatViewProps) { ); return; } + // A queued message that will still leave on its own goes first, so a new + // send lines up behind it instead of overtaking it. + const queueStillSending = + activeThreadKey !== null && + (useQueuedMessageStore.getState().queuesByThreadKey[activeThreadKey] ?? []).some( + (message) => message.sending !== undefined || !message.holdUntilUserAction, + ); if ( - !queuedMessage && !directAnnotation && - phase === "running" && activeThreadKey && - (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate") + (queueStillSending || + (phase === "running" && + (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate"))) ) { - if (composerRef.current?.validateProviderInput(promptForSend) === false) { + const sendSettings = readComposerSendSettings(sendCtx); + if ( + composerRef.current?.validateProviderInput( + applyClaudePromptEffortPrefix(promptForSend, sendSettings.promptEffort), + ) === false + ) { return; } useQueuedMessageStore.getState().enqueue(activeThreadKey, { @@ -7696,7 +7712,7 @@ export default function ChatView(props: ChatViewProps) { terminalContexts: [...composerTerminalContexts], previewAnnotations: [...composerPreviewAnnotations], reviewComments: [...composerReviewComments], - submissionIntent, + sendSettings, queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), createdAt: new Date().toISOString(), }); @@ -7766,11 +7782,6 @@ export default function ChatView(props: ChatViewProps) { text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { - // A queued message that no longer fits is held at the head for the - // user to edit via Cancel, instead of failing on every boundary. - if (queuedMessage && activeThreadKey) { - useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); - } return; } @@ -7837,41 +7848,10 @@ export default function ChatView(props: ChatViewProps) { sendInFlightRef.current = true; const sendGeneration = ++composerSendGenerationRef.current; - // Every early return above leaves a queued message in the queue for a - // later retry. From here on a failure hands it back to the composer. - if (queuedMessage) { - const taken = activeThreadKey - ? useQueuedMessageStore - .getState() - .take( - activeThreadKey, - queuedMessage.id, - latestCompletedToolActivityId(threadActivities), - ) - : null; - if (!taken) { - sendInFlightRef.current = false; - return; - } - } - // Stop drains the queue. A queued send whose upload was still running at - // that moment must not start a turn afterwards; it checks this before - // dispatch and hands the message back to the composer instead. - const drainGenerationAtTake = useQueuedMessageStore.getState().drainGeneration; - // A queued send that fails goes back to the head of the queue, held. The - // messages behind it keep their order and wait; the composer is not - // touched, which also keeps a failure after navigation off the new - // thread's draft. The user retries with Send now or edits with Cancel. - const abortQueuedReplay = () => { - if (queuedMessage && activeThreadKey) { - useQueuedMessageStore.getState().holdAtFront(activeThreadKey, queuedMessage); - } - }; const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason); - abortQueuedReplay(); return; } const turnUsesAttachmentUploads = @@ -7891,26 +7871,15 @@ export default function ChatView(props: ChatViewProps) { if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason); - abortQueuedReplay(); return; } if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) { sendInFlightRef.current = false; setThreadError(threadIdForSend, "Retry or remove failed uploads before sending."); - abortQueuedReplay(); return; } } - if ( - queuedMessage && - useQueuedMessageStore.getState().drainGeneration !== drainGenerationAtTake - ) { - sendInFlightRef.current = false; - restoreQueuedMessagesToComposer([queuedMessage]); - return; - } - const resolvedSubmissionIntent = (multipleModelSelections !== null || submissionIntent === "background") && isLocalDraftThread ? "background" @@ -7951,7 +7920,6 @@ export default function ChatView(props: ChatViewProps) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); - abortQueuedReplay(); return; } beginLocalDispatch({ @@ -8313,11 +8281,9 @@ export default function ChatView(props: ChatViewProps) { }), ); } - if (!queuedMessage) { - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - } + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { @@ -8552,24 +8518,7 @@ export default function ChatView(props: ChatViewProps) { ); clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); } - if (queuedMessage) { - setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); - for (const message of removed) { - revokeUserMessagePreviewUrls(message); - } - const next = existing.filter((message) => message.id !== messageIdForSend); - return next.length === existing.length ? existing : next; - }); - // The optimistic row's preview URLs were just revoked, so the images - // need fresh ones before the row can show them again. - if (activeThreadKey) { - useQueuedMessageStore.getState().holdAtFront(activeThreadKey, { - ...queuedMessage, - images: queuedMessage.images.map(cloneComposerImageForRetry), - }); - } - } else if ( + if ( backgroundDraftOpened ? !composerDraftHasUserContent( useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), @@ -8659,46 +8608,12 @@ export default function ChatView(props: ChatViewProps) { } }; - // Sends the oldest queued message once it is due: a tool call finished - // after it was queued, or the turn ended. Only one leaves per boundary; the - // take inside onSend re-anchors the rest. - const sendQueuedMessage = useEffectEvent((message: QueuedComposerMessage) => { - void onSend(undefined, message.submissionIntent, undefined, message); - }); - const nextQueuedMessage = queuedMessages[0] ?? null; - const latestToolActivityId = useMemo( - () => (nextQueuedMessage ? latestCompletedToolActivityId(threadActivities) : null), - [nextQueuedMessage, threadActivities], - ); - // Approvals and questions block the agent; a steer landing on top of them - // would answer nothing and confuse the turn, so the queue holds until the - // user resolves them. + // Queued messages go out from QueuedMessageSender, which also covers + // threads that are not on screen. Send now uses the same path but skips the + // wait for a boundary. Approvals and questions still hold it: a steer on + // top of them would answer nothing and confuse the turn. const queueBlockedByPendingRequest = activePendingApproval !== null || pendingUserInputs.length > 0; - // onSend bails early on transient gates (environment offline, settings not - // hydrated, checkpoint rewinding, messages loading, machine not chosen) and - // leaves the message queued. Re-run when any of them clear so a due message - // does not wait for an unrelated phase change. - const queueSendGate = - activeEnvironmentUnavailable || - !clientSettingsHydrated || - isRevertingCheckpoint || - threadDetailLoading || - needsLoadBalancing || - activeProviderStatus === null; - useEffect(() => { - if (!nextQueuedMessage || isSendBusy || queueBlockedByPendingRequest || queueSendGate) return; - if (sendInFlightRef.current) return; - if (!isQueuedMessageDue({ message: nextQueuedMessage, phase, latestToolActivityId })) return; - sendQueuedMessage(nextQueuedMessage); - }, [ - isSendBusy, - latestToolActivityId, - nextQueuedMessage, - phase, - queueBlockedByPendingRequest, - queueSendGate, - ]); // The row handlers are read from refs at call-time so their identity stays // stable and does not bust TimelineRowCtx on every ChatView render. @@ -8708,9 +8623,8 @@ export default function ChatView(props: ChatViewProps) { }); queuedMessageActionsRef.current = { steer: (id) => { - const message = queuedMessages.find((entry) => entry.id === id); - if (!message || sendInFlightRef.current || queueBlockedByPendingRequest) return; - void onSend(undefined, message.submissionIntent, undefined, message); + if (!activeThreadRef || queueBlockedByPendingRequest) return; + void sendQueuedMessage(activeThreadRef, id); }, remove: (id) => { if (!activeThreadKey) return; diff --git a/apps/web/src/components/QueuedMessageSender.test.tsx b/apps/web/src/components/QueuedMessageSender.test.tsx new file mode 100644 index 000000000000..dc31694f8b96 --- /dev/null +++ b/apps/web/src/components/QueuedMessageSender.test.tsx @@ -0,0 +1,231 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { act, createElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useQueuedMessageStore, type QueuedComposerMessage } from "../queuedMessageStore"; +import { sendQueuedMessage } from "./chat/sendQueuedMessage"; +import { QueuedMessageSender } from "./QueuedMessageSender"; + +const io = vi.hoisted(() => ({ + run: vi.fn(), + upload: vi.fn(), + toast: vi.fn(), + thread: null as unknown, + shell: { runtimeMode: "full-access", interactionMode: "default" } as Record, +})); +const config = { + environment: { capabilities: { attachmentUploads: true, inlineMessageContext: true } }, +}; +vi.mock("@t3tools/client-runtime/state/runtime", async (load) => ({ + ...(await load()), + runAtomCommand: (...args: unknown[]) => io.run(...args), +})); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { get: () => new Map([["env-a", config]]) }, +})); +vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); +vi.mock("../state/threads", () => ({ + threadEnvironment: { + updateMetadata: "metadata", + setRuntimeMode: "runtime", + setInteractionMode: "interaction", + startTurn: "start", + }, +})); +vi.mock("../state/environments", () => ({ + useEnvironment: () => ({ connection: { phase: "connected" } }), +})); +vi.mock("../state/entities", () => ({ + useThread: () => io.thread, + useThreadStatus: () => "live", + useServerConfigs: () => new Map([["env-a", config]]), + readThreadShell: () => io.shell, + readThread: () => io.thread, +})); +vi.mock("./ui/toast", () => ({ toastManager: { add: (...args: unknown[]) => io.toast(...args) } })); +vi.mock("../lib/attachmentUploadQueue", () => ({ + startAttachmentUpload: vi.fn(), + awaitAttachmentUploads: (...args: unknown[]) => io.upload(...args), + getUploadedAttachments: () => [ + { type: "image", id: "uploaded", name: "a.png", mimeType: "image/png", sizeBytes: 4 }, + ], + releaseDraftAttachments: vi.fn(), +})); + +const threadRef = scopeThreadRef(EnvironmentId.make("env-a"), ThreadId.make("thread-a")); +const threadKey = scopedThreadKey(threadRef); +const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }; + +function enqueue(overrides: Partial = {}) { + return useQueuedMessageStore.getState().enqueue(threadKey, { + prompt: "follow up", + images: [], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + sendSettings: { + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + promptEffort: null, + }, + queuedAfterToolActivityId: null, + createdAt: "2026-09-25T00:00:00Z", + ...overrides, + }); +} + +const commandsRun = () => io.run.mock.calls.map((call) => call[1]); +const queue = () => useQueuedMessageStore.getState().queuesByThreadKey[threadKey]; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + useQueuedMessageStore.setState({ queuesByThreadKey: {}, lastDispatchByThreadKey: {} }); + io.thread = null; + io.run.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + io.upload.mockReset().mockResolvedValue(undefined); + io.toast.mockReset(); + io.shell = { + modelSelection, + branch: null, + runtimeMode: "full-access", + interactionMode: "default", + }; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("QueuedMessageSender", () => { + const thread = ( + status: string, + { toolActivityIds = [] as string[], userMessageIds = [] as string[] } = {}, + ) => ({ + session: { status, activeTurnId: null, updatedAt: status }, + activities: toolActivityIds.map((id, index) => ({ + id, + kind: "tool.completed", + sequence: index, + createdAt: "2026-09-25T00:00:01Z", + })), + messages: userMessageIds.map((id) => ({ id, role: "user" })), + latestTurn: null, + }); + let root: ReactTestRenderer | null = null; + const render = () => + act(() => { + if (root) root.update(createElement(QueuedMessageSender)); + else root = create(createElement(QueuedMessageSender)); + }); + afterEach(async () => { + await act(() => root?.unmount()); + root = null; + }); + + it("sends a queued message when the turn ends, with no chat view open", async () => { + enqueue(); + io.thread = thread("running"); + await render(); + expect(commandsRun()).toEqual([]); + + io.thread = thread("ready"); + await render(); + + expect(commandsRun()).toEqual(["start"]); + expect(io.run.mock.calls[0]?.[2]).toMatchObject({ + environmentId: "env-a", + input: { threadId: "thread-a", message: { text: "follow up" }, modelSelection }, + }); + expect(queue()).toBeUndefined(); + }); + + it("holds the next message until the server picks up the one before it", async () => { + enqueue({ prompt: "first" }); + enqueue({ prompt: "second" }); + io.thread = thread("ready"); + await render(); + await render(); + expect(commandsRun()).toEqual(["start"]); + + // The first message started a turn; the second waits for its next tool call. + io.thread = thread("running", { userMessageIds: ["first"] }); + await render(); + expect(commandsRun()).toEqual(["start"]); + io.thread = thread("running", { userMessageIds: ["first"], toolActivityIds: ["tool-1"] }); + await render(); + expect(commandsRun()).toEqual(["start", "start"]); + }); + + it("moves on to the next message after a failed one is cancelled", async () => { + io.run.mockResolvedValueOnce({ _tag: "Failure", cause: Cause.fail(new Error("offline")) }); + const first = enqueue({ prompt: "first" }); + enqueue({ prompt: "second" }); + io.thread = thread("ready"); + await render(); + expect(queue()?.[0]).toMatchObject({ prompt: "first", holdUntilUserAction: true }); + + await act(() => { + useQueuedMessageStore.getState().remove(threadKey, first.id); + }); + await render(); + + expect(commandsRun()).toEqual(["start", "start"]); + expect(io.run.mock.calls[1]?.[2]).toMatchObject({ input: { message: { text: "second" } } }); + }); +}); + +describe("sendQueuedMessage", () => { + it("saves a mode changed before queueing, then starts the turn", async () => { + io.shell = { ...io.shell, runtimeMode: "approval-required" }; + const message = enqueue(); + + await sendQueuedMessage(threadRef, message.id); + + expect(commandsRun()).toEqual(["runtime", "start"]); + expect(io.run.mock.calls[1]?.[2]).toMatchObject({ input: { runtimeMode: "full-access" } }); + expect(queue()).toBeUndefined(); + }); + + it("gives a message back to Stop while its upload runs, without starting a turn", async () => { + let finishUpload!: () => void; + io.upload.mockReturnValue(new Promise((resolve) => (finishUpload = resolve))); + const image = { + type: "image" as const, + id: "image-1", + name: "a.png", + mimeType: "image/png", + sizeBytes: 4, + previewUrl: "data:image/png;base64,AAAA", + file: new File(["AAAA"], "a.png", { type: "image/png" }), + }; + const message = enqueue({ images: [image] }); + + const sending = sendQueuedMessage(threadRef, message.id); + expect(useQueuedMessageStore.getState().drain(threadKey)).toHaveLength(1); + finishUpload(); + await sending; + + expect(commandsRun()).toEqual([]); + expect(io.toast).not.toHaveBeenCalled(); + expect(queue()).toBeUndefined(); + }); + + it("holds a message at the head when the turn start fails", async () => { + io.run.mockResolvedValue({ _tag: "Failure", cause: Cause.fail(new Error("offline")) }); + enqueue({ prompt: "first" }); + const second = enqueue({ prompt: "second" }); + + await sendQueuedMessage(threadRef, second.id); + + expect(queue()?.map((entry) => [entry.prompt, entry.holdUntilUserAction])).toEqual([ + ["second", true], + ["first", undefined], + ]); + expect(io.toast).toHaveBeenCalledWith(expect.objectContaining({ description: "offline" })); + }); +}); diff --git a/apps/web/src/components/QueuedMessageSender.tsx b/apps/web/src/components/QueuedMessageSender.tsx new file mode 100644 index 000000000000..86dfa8a265fe --- /dev/null +++ b/apps/web/src/components/QueuedMessageSender.tsx @@ -0,0 +1,100 @@ +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; +import { useEffect, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { + isQueuedMessageDue, + latestCompletedToolActivityId, + useQueuedMessageStore, + useQueuedMessages, +} from "../queuedMessageStore"; +import { derivePhase } from "../session-logic"; +import { useServerConfigs, useThread, useThreadStatus } from "../state/entities"; +import { useEnvironment } from "../state/environments"; +import { hasServerAcknowledgedLocalDispatch, latestTurnStartFailureId } from "./ChatView.logic"; +import { sendQueuedMessage } from "./chat/sendQueuedMessage"; + +/** + * Sends queued messages when they are due, for every thread with a queue, + * whether or not the thread is on screen. Mounted once at the root. + */ +export function QueuedMessageSender() { + const threadKeys = useQueuedMessageStore( + useShallow((state) => Object.keys(state.queuesByThreadKey)), + ); + return threadKeys.map((threadKey) => ); +} + +/** + * Watches one thread while it has queued messages. Reading the thread keeps + * its detail subscribed, so tool boundaries and the end of the turn are + * visible while the user is elsewhere. + */ +function ThreadQueueSender({ threadKey }: { threadKey: string }) { + const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]); + const thread = useThread(threadRef); + const threadStatus = useThreadStatus(threadRef); + const environmentId = threadRef?.environmentId ?? null; + const environment = useEnvironment(environmentId); + const serverConfigs = useServerConfigs(); + const serverConfigLoaded = environmentId !== null && serverConfigs.has(environmentId); + const rewinding = useComposerDraftStore((store) => store.rewindingThreadKeys.has(threadKey)); + const queue = useQueuedMessages(threadKey); + const next = queue[0]; + const sending = queue.some((message) => message.sending); + const activities = thread?.activities; + const latestToolActivityId = useMemo( + () => latestCompletedToolActivityId(activities ?? []), + [activities], + ); + const pendingRequests = useMemo(() => derivePendingRequests(activities ?? []), [activities]); + const phase = derivePhase(thread?.session ?? null); + + // A send that starts a new turn leaves the thread idle until the server + // picks it up. Hold the next message until then, as the composer does for + // its own sends. + const lastDispatch = useQueuedMessageStore( + (state) => state.lastDispatchByThreadKey[threadKey]?.thread ?? null, + ); + const latestUserMessageId = thread?.messages.findLast((m) => m.role === "user")?.id ?? null; + const waitingForServer = + lastDispatch !== null && + !hasServerAcknowledgedLocalDispatch({ + localDispatch: lastDispatch, + phase, + latestTurn: thread?.latestTurn ?? null, + latestUserMessageId, + session: thread?.session ?? null, + hasPendingApproval: pendingRequests.approvals.length > 0, + hasPendingUserInput: pendingRequests.userInputs.length > 0, + latestTurnStartFailureId: latestTurnStartFailureId(thread ?? undefined, latestUserMessageId), + threadError: null, + }); + + // Approvals and questions block the agent; a steer landing on top of them + // would answer nothing and confuse the turn, so the queue holds until the + // user resolves them. + const blocked = + threadRef === null || + thread === null || + threadStatus !== "live" || + (environment !== null && environment.connection.phase !== "connected") || + !serverConfigLoaded || + rewinding || + sending || + waitingForServer || + pendingRequests.approvals.length > 0 || + pendingRequests.userInputs.length > 0; + const due = + next !== undefined && + !blocked && + isQueuedMessageDue({ message: next, phase, latestToolActivityId }); + const nextId = next?.id; + useEffect(() => { + if (!due || !threadRef || nextId === undefined) return; + void sendQueuedMessage(threadRef, nextId); + }, [due, nextId, threadRef]); + return null; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 30044e641137..b4b07805596d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -31,7 +31,6 @@ import { shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, - sortSettledThreadsForSidebar, resolveSidebarDropTarget, pinOrderKeyBetween, planPinnedReorder, @@ -51,6 +50,7 @@ import { resolveSidebarDropVerb, } from "./Sidebar.logic"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { EnvironmentId, OrchestrationLatestTurn, @@ -1690,12 +1690,12 @@ describe("applySidebarThreadDrop", () => { }; const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; expect(preview).toEqual({ ...final, settledAt: now }); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([ "dragged", "newer", ]); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual( - sortSettledThreadsForSidebar([existing, final]).map((row) => row.id), + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual( + sortSettledThreads([existing, final]).map((row) => row.id), ); }); @@ -1710,7 +1710,7 @@ describe("applySidebarThreadDrop", () => { const final = { ...source, snoozedAt: null, snoozedUntil: null }; const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; expect(preview).toEqual(final); - expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([ "newer", "dragged", ]); @@ -1820,74 +1820,6 @@ describe("sortPinnedThreadsForSidebar", () => { }); }); -describe("sortSettledThreadsForSidebar", () => { - const settled = (input: { - id: string; - settledAt?: string | null; - latestUserMessageAt?: string | null; - latestTurn?: OrchestrationLatestTurn | null; - updatedAt?: string; - }) => ({ - id: input.id, - settledAt: input.settledAt ?? null, - latestUserMessageAt: input.latestUserMessageAt ?? null, - latestTurn: input.latestTurn ?? null, - updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", - }); - - it("orders by settle time, most recently settled first", () => { - const sorted = sortSettledThreadsForSidebar([ - settled({ - id: "settled-first", - settledAt: "2026-03-09T10:00:00.000Z", - // Created/active later than the other thread: settle time must win. - latestUserMessageAt: "2026-03-09T09:59:00.000Z", - }), - settled({ - id: "settled-last", - settledAt: "2026-03-09T12:00:00.000Z", - latestUserMessageAt: "2026-03-09T08:00:00.000Z", - }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); - }); - - it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { - const sorted = sortSettledThreadsForSidebar([ - settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), - settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), - settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); - }); - - it("counts a turn completion as activity for auto-settled threads", () => { - // The message came in before the other thread's, but its turn finished - // after: completion time is the real "work ended" moment. - const sorted = sortSettledThreadsForSidebar([ - settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), - settled({ - id: "completed-later", - latestUserMessageAt: "2026-03-09T10:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), - }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); - }); - - it("breaks timestamp ties by id so the order is stable", () => { - const sorted = sortSettledThreadsForSidebar([ - settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), - settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); - }); -}); - describe("resolveWorkingStartedAt", () => { const session = { threadId: ThreadId.make("thread-1"), @@ -2375,6 +2307,49 @@ describe("sortProjectsForSidebar", () => { ]); }); + it.each(["updated_at", "created_at"] as const)( + "matches the per-comparison %s order on a shuffled list with ties", + (sortOrder) => { + const minute = (value: number) => `2026-03-09T10:0${value}:00.000Z`; + // (index * 7) % 24 scrambles the input order. Titles repeat, and + // projects 16-23 have no threads, so they use their own stamps. + const projects = Array.from({ length: 24 }, (_, index) => { + const n = (index * 7) % 24; + return makeProject({ + id: ProjectId.make(`project-${n}`), + title: n % 2 === 0 ? "Alpha" : "Beta", + createdAt: minute(n % 3), + updatedAt: n % 5 === 0 ? "invalid" : minute(n % 2), + }); + }); + const threads = Array.from({ length: 48 }, (_, n) => ({ + projectId: ProjectId.make(`project-${n % 16}`), + createdAt: minute(n % 6), + updatedAt: minute(n % 3), + latestUserMessageAt: n % 4 === 0 ? null : minute(n % 5), + })); + // The comparator this sort replaced: it walked each project's threads + // on every call. + const timestamp = (project: Project) => + getProjectSortTimestamp( + project, + threads.filter((thread) => thread.projectId === project.id), + sortOrder, + ); + const expected = projects.toSorted((left, right) => { + const rightTimestamp = timestamp(right); + const leftTimestamp = timestamp(left); + const byTimestamp = + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; + return ( + byTimestamp || left.title.localeCompare(right.title) || left.id.localeCompare(right.id) + ); + }); + + expect(sortProjectsForSidebar(projects, threads, sortOrder)).toEqual(expected); + }, + ); + it("returns the project timestamp when no threads are present", () => { const timestamp = getProjectSortTimestamp( makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f4b13a5000bc..3e12090f3228 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,10 +16,8 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { getThreadSortTimestamp, - resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, - type SettledThreadTimestampInput, type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; @@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState( } } -// Settled rows are history, so they order by when the work ENDED, not when -// the thread was created or last touched. -export function sortSettledThreadsForSidebar< - T extends SettledThreadTimestampInput & { readonly id: string }, ->(threads: readonly T[]): T[] { - const timestampMs = (thread: T) => { - const timestamp = resolveSettledThreadTimestamp(thread); - return timestamp === null ? 0 : Date.parse(timestamp); - }; - return [...threads].toSorted( - (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), - ); -} - /** The timestamp a working thread's elapsed label counts from: the running turn's start (request time until adoption), falling back to the session's last transition when the turn projection lags behind. Malformed @@ -1136,13 +1120,19 @@ function sortProjectsByActivity( return [...projects]; } - return [...projects].toSorted((left, right) => { - const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder); - const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - return byTimestamp || compareTies(left, right); - }); + // Each project's timestamp walks all of its threads, so compute it once + // per project instead of once per comparison. + return projects + .map((project) => ({ + project, + timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder), + })) + .sort((left, right) => { + const byTimestamp = + right.timestamp === left.timestamp ? 0 : right.timestamp > left.timestamp ? 1 : -1; + return byTimestamp || compareTies(left.project, right.project); + }) + .map(({ project }) => project); } export function sortProjectsForSidebar< diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 968fd0a5f310..4926df4948ae 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,7 +21,10 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { + resolveSettledThreadTimestamp, + sortSettledThreads, +} from "@t3tools/client-runtime/state/thread-sort"; import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, @@ -181,7 +184,6 @@ import { sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, - sortSettledThreadsForSidebar, sortThreadsForSidebar, useRetainedValue, useSidebarRowSubscriptionLease, @@ -2626,7 +2628,7 @@ export default function Sidebar() { firstValidTimestampMs(left.snoozedUntil ?? null) - firstValidTimestampMs(right.snoozedUntil ?? null), ), - settledThreads: sortSettledThreadsForSidebar(settled), + settledThreads: sortSettledThreads(settled), snoozeNow: preciseNow, }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); @@ -3452,7 +3454,7 @@ export default function Sidebar() { if (dragState === null || thread === undefined) return []; const key = (candidate: EnvironmentThreadShell) => scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); - return sortSettledThreadsForSidebar([ + return sortSettledThreads([ ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), applySidebarThreadDrop(thread, "settled", dragState.occurredAt), ]).map(key); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b46302dd25..d95e22517630 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1100,7 +1100,12 @@ describe("deriveMessagesTimelineRows", () => { terminalContexts: [], previewAnnotations: [], reviewComments: [], - submissionIntent: "foreground" as const, + sendSettings: { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + promptEffort: null, + }, queuedAfterToolActivityId: null, createdAt: "2026-01-01T00:00:01Z", }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f812f67f2bd4..a1d8176207ae 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1788,11 +1788,14 @@ function QueuedMessageTimelineRow({ queuedMessage.previewAnnotations.length + queuedMessage.reviewComments.length; const text = queuedMessage.prompt.trim(); - const statusLabel = queuedMessage.holdUntilUserAction - ? "Waits for Send now" - : row.isNext - ? "Sends after the next tool call or when the turn ends" - : "Sends after the messages above it"; + const sending = queuedMessage.sending !== undefined; + const statusLabel = sending + ? "Sending to the agent" + : queuedMessage.holdUntilUserAction + ? "Waits for Send now" + : row.isNext + ? "Sends after the next tool call or when the turn ends" + : "Sends after the messages above it"; return (
@@ -1820,14 +1823,14 @@ function QueuedMessageTimelineRow({ } - aria-label={`Queued. ${statusLabel}.`} + aria-label={`${sending ? "Sending" : "Queued"}. ${statusLabel}.`} > - Queued + {sending ? "Sending" : "Queued"} {statusLabel} -
+
(command: AtomCommand, input: W): Promise { + const result = await runAtomCommand(appAtomRegistry, command, input, { reportFailure: false }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + return result.value; +} + +/** + * Sends one queued message as a turn on its thread. `QueuedMessageSender` + * calls it when the head message is due, and Send now calls it directly. It + * reads nothing from the composer, so it works for threads that are not on + * screen. A failed send stays at the head of the queue, held for Send now. + */ +export async function sendQueuedMessage( + threadRef: ScopedThreadRef, + messageId: string, +): Promise { + const { environmentId, threadId } = threadRef; + const threadKey = scopedThreadKey(threadRef); + const queue = useQueuedMessageStore.getState(); + const message = queue.beginSend( + threadKey, + messageId, + latestCompletedToolActivityId(readThread(threadRef)?.activities ?? []), + ); + if (!message) return; + const { sendSettings } = message; + const attachments = [...message.images, ...message.files]; + const readConfig = () => appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId); + const assertFilesAllowed = () => { + const config = readConfig(); + const reason = fileAttachmentCapabilityBlockReason({ + files: message.files, + attachmentUploadsCapabilityKnown: config !== undefined, + supportsAttachmentUploads: config?.environment.capabilities.attachmentUploads === true, + maxFileAttachmentBytes: + config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null, + }); + if (reason !== null) throw new Error(reason); + }; + try { + const { sendableTerminalContexts, hasSendableContent } = deriveComposerSendState({ + prompt: message.prompt, + imageCount: attachments.length, + terminalContexts: message.terminalContexts, + elementContextCount: message.previewAnnotations.length + message.reviewComments.length, + }); + // Only expired terminal context was left. Retrying would block the queue + // on every boundary, so drop it and let the queue move on. + if (!hasSendableContent) { + queue.finishSend(threadKey, message.id); + return; + } + // Expired terminal excerpts are not sent; their chips leave the text with them. + const prompt = message.terminalContexts + .filter((context) => !sendableTerminalContexts.includes(context)) + .reduce( + (text, context) => + removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, + message.prompt, + ) + .trim(); + const text = applyClaudePromptEffortPrefix( + prompt || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + sendSettings.promptEffort, + ); + + assertFilesAllowed(); + const useUploads = readConfig()?.environment.capabilities.attachmentUploads === true; + if (useUploads && attachments.length > 0) { + for (const attachment of attachments) { + startAttachmentUpload({ environmentId, image: attachment, draftTarget: threadRef }); + } + await awaitAttachmentUploads(attachments.map((attachment) => attachment.id)); + } + const wireAttachments = await Promise.all( + attachments.map(async (attachment) => { + if (useUploads) { + const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; + if (!uploaded) throw new Error(`Attachment '${attachment.name}' did not upload.`); + return uploaded; + } + if (attachment.type !== "image") { + throw new Error("This server does not support file attachments."); + } + return { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await readFileAsDataUrl(attachment.file), + ...(attachment.source ? { source: attachment.source } : {}), + }; + }), + ); + assertFilesAllowed(); + + // The server starts the turn with the thread's stored modes, so a change + // made in the composer before queueing is saved first. + const createdAt = new Date().toISOString(); + const shell = readThreadShell(threadRef); + const metadataUpdate = shell + ? resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: shell.modelSelection, + nextModelSelection: sendSettings.modelSelection, + currentBranch: shell.branch, + }) + : null; + if (metadataUpdate) { + await run(threadEnvironment.updateMetadata, { + environmentId, + input: { threadId, ...metadataUpdate }, + }); + } + if (shell && shell.runtimeMode !== sendSettings.runtimeMode) { + await run(threadEnvironment.setRuntimeMode, { + environmentId, + input: { threadId, runtimeMode: sendSettings.runtimeMode, createdAt }, + }); + } + if (shell && shell.interactionMode !== sendSettings.interactionMode) { + await run(threadEnvironment.setInteractionMode, { + environmentId, + input: { threadId, interactionMode: sendSettings.interactionMode, createdAt }, + }); + } + + // Stop hands a preparing message back to the composer. Past this point + // the send can no longer be taken back. + const thread = readThread(threadRef) ?? undefined; + if (!queue.markDispatching(threadKey, message.id, createLocalDispatchSnapshot(thread))) return; + const context = buildMessageContext({ + terminalContexts: sendableTerminalContexts, + reviewComments: message.reviewComments, + previewAnnotations: message.previewAnnotations, + attachments: attachments.map((attachment, index) => ({ + attachment, + attachmentId: wireAttachments[index]?.id ?? attachment.id, + })), + }); + // Servers from before inline context drop the records, so their turns + // carry the payload in the text instead. + const inlineContext = readConfig()?.environment.capabilities.inlineMessageContext === true; + await run(threadEnvironment.startTurn, { + environmentId, + input: { + threadId, + message: { + messageId: newMessageId(), + role: "user", + text: + context !== undefined && !inlineContext + ? serializeLegacyContextMessage({ text, records: context.records }) + : text, + attachments: wireAttachments, + ...(context !== undefined && inlineContext ? { context } : {}), + }, + modelSelection: sendSettings.modelSelection, + runtimeMode: sendSettings.runtimeMode, + interactionMode: sendSettings.interactionMode, + createdAt, + }, + }); + queue.finishSend(threadKey, message.id); + if (useUploads) releaseDraftAttachments(attachments); + for (const image of message.images) revokeBlobPreviewUrl(image.previewUrl); + } catch (error) { + if (!queue.failSend(threadKey, message.id)) return; + const title = readThreadShell(threadRef)?.title; + toastManager.add({ + type: "error", + title: title ? `Queued message not sent in "${title}"` : "Queued message not sent", + description: error instanceof Error ? error.message : "Use Send now to try again.", + }); + } +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 05fa0943fba8..7729ce183754 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -46,6 +46,24 @@ describe("KeybindingsSettings.logic", () => { ); }, ); + it("orders Usage bindings and command choices like the page", () => { + const expected = [ + "usage.cost", + "usage.open", + "usage.tokens", + "usage.limits", + "usage.period.day", + "usage.period.week", + "usage.period.month", + "usage.period.quarter", + ]; + const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed(); + expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected); + expect( + buildKeybindingCommandOptions(bindings).filter((command) => command.startsWith("usage.")), + ).toEqual(expected); + }); + it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index dc4d33bc28ba..a06628e5d505 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -13,6 +13,17 @@ import { import { shortcutKeyFromEvent } from "../../keybindings"; import { isMacPlatform } from "../../lib/utils"; +import { METRIC_OPTIONS, WINDOW_OPTIONS } from "../usage/usageShortcuts"; + +const usageCommandOrder = new Map( + [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), +); + +function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null { + const leftIndex = usageCommandOrder.get(left); + const rightIndex = usageCommandOrder.get(right); + return leftIndex !== undefined && rightIndex !== undefined ? leftIndex - rightIndex : null; +} export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -204,7 +215,9 @@ export function buildKeybindingRows( }); rowsWithConflicts.sort((left, right) => { - const commandCompare = left.command.localeCompare(right.command); + const commandCompare = + compareUsageCommands(left.command, right.command) ?? + left.command.localeCompare(right.command); if (commandCompare !== 0) return commandCompare; return left.key.localeCompare(right.key); }); @@ -277,13 +290,18 @@ export function buildKeybindingCommandOptions( for (const binding of keybindings) { commands.add(binding.command); } - return [...commands].toSorted((left, right) => - commandLabel(left).localeCompare(commandLabel(right)), + return [...commands].toSorted( + (left, right) => + compareUsageCommands(left, right) ?? commandLabel(left).localeCompare(commandLabel(right)), ); } export function commandLabel(command: KeybindingCommand): string { if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; + const usageMetric = METRIC_OPTIONS.find((option) => option.command === command); + if (usageMetric) return `Usage: ${usageMetric.label}`; + const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command); + if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`; const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index 43c7f0328363..61ad487ade70 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -1,6 +1,7 @@ import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { StrictMode, act } from "react"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -9,11 +10,21 @@ const state = vi.hoisted(() => ({ refreshProviders: vi.fn(async () => undefined), metric: "limits", })); -vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => + atom === "keybindings" ? DEFAULT_RESOLVED_KEYBINDINGS : state.presentations, +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), + useCanGoBack: () => false, +})); vi.mock("../../state/presentation", () => ({ environmentPresentations: { presentationsAtom: null }, })); -vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/server", () => ({ + serverEnvironment: { refreshProviders: null }, + primaryServerKeybindingsAtom: "keybindings", +})); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); vi.mock("../../env", () => ({ isElectron: false })); vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" })); @@ -86,6 +97,7 @@ import { UsagePage } from "./UsagePage"; let renderer: ReactTestRenderer; let environmentNumber = 0; beforeEach(() => { + vi.stubGlobal("window", new EventTarget()); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); environmentNumber += 1; diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx new file mode 100644 index 000000000000..6d8caac6a36d --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -0,0 +1,152 @@ +import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + useUsage: vi.fn(), + navigate: vi.fn(), + canGoBack: true, +})); + +vi.mock("../../env", () => ({ isElectron: false })); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => testState.navigate, + useCanGoBack: () => testState.canGoBack, +})); +vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); +vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); +vi.mock("../ui/select", () => ({ + Select: "div", + SelectItem: "div", + SelectPopup: "div", + SelectTrigger: "div", + SelectValue: "div", +})); +vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); +vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); +vi.mock("../WorkspaceBreadcrumb", () => ({ + WorkspaceBreadcrumb: "div", + WorkspaceBreadcrumbItem: "div", + WorkspaceBreadcrumbSeparator: "span", +})); +vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); +vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); +vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); +vi.mock("./usageProviders", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + PROVIDER_PRESENTATION: { + codex: { color: "white", label: "Codex", mark: "span" }, + claude: { color: "orange", label: "Claude Code", mark: "span" }, + }, + }; +}); + +import { UsagePage } from "./UsagePage"; +const environments = [ + { + environmentId: EnvironmentId.make("test-environment"), + label: "Test environment", + isPending: false, + error: null, + summary: { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-08-11T12:37:00.000Z", + sinceDay: UsageDay.make("2026-08-10"), + untilDay: UsageDay.make("2026-08-11"), + timeZone: "UTC", + buckets: [], + sources: [], + pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 }, + scanDurationMs: 1, + }, + }, +]; + +beforeEach(() => { + testState.useUsage.mockReturnValue({ + merged: mergeUsage([], USAGE_CONTRACT_VERSION), + environments, + selectedEnvironments: environments, + isPending: false, + isPartial: false, + refresh: vi.fn(), + }); +}); + +describe("UsagePage Escape navigation", () => { + let renderer: Root; + let container: HTMLDivElement; + let back: ReturnType; + + beforeEach(async () => { + back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + testState.navigate.mockClear(); + testState.canGoBack = true; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + renderer = createRoot(container); + await act(() => { + renderer.render(); + }); + }); + + afterEach(async () => { + await act(() => renderer.unmount()); + container.remove(); + back.mockRestore(); + vi.unstubAllGlobals(); + }); + + function escape(properties: { repeat?: boolean; isComposing?: boolean } = {}) { + return new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + ...properties, + }); + } + + it("returns to the previous page on Escape", () => { + document.body.dispatchEvent(escape()); + expect(back).toHaveBeenCalledOnce(); + expect(testState.navigate).not.toHaveBeenCalled(); + }); + + it("returns home when there is no previous app page", async () => { + testState.canGoBack = false; + await act(() => renderer.render()); + + document.body.dispatchEvent(escape()); + expect(testState.navigate).toHaveBeenCalledWith({ to: "/" }); + expect(back).not.toHaveBeenCalled(); + }); + + it("closes the environment menu before Escape navigates back", async () => { + const trigger = container.querySelector('[data-slot="menu-trigger"]')!; + await act(() => trigger.click()); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); + + await act(() => { + document.activeElement!.dispatchEvent(escape()); + }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(back).not.toHaveBeenCalled(); + + document.body.dispatchEvent(escape()); + expect(back).toHaveBeenCalledOnce(); + }); + + it.each([{ repeat: true }, { isComposing: true }])("ignores Escape with %j", (properties) => { + document.body.dispatchEvent(escape(properties)); + expect(back).not.toHaveBeenCalled(); + expect(testState.navigate).not.toHaveBeenCalled(); + }); +}); + +// @vitest-environment jsdom diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 5f2d7dd45632..7d306d1a5c07 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -25,7 +25,10 @@ import { import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { environmentPresentations } from "../../state/presentation"; -import { serverEnvironment } from "../../state/server"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "../../state/server"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { isModelPickerOpen } from "../../modelPickerVisibility"; +import { shortcutLabelForCommand } from "../../keybindings"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { useAtomCommand } from "../../state/use-atom-command"; import { @@ -65,8 +68,15 @@ import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; -import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { UsageProviderChart } from "./UsageProviderChart"; import { sortModelsByTokens } from "./usageBreakdown"; +import { + METRIC_OPTIONS, + WINDOW_OPTIONS, + resolveUsageShortcut, + type UsageMetric, +} from "./usageShortcuts"; +import { useEscapeToGoBack } from "../../hooks/useNavigateBack"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; import { readUsagePagePreferences, @@ -74,30 +84,26 @@ import { type UsagePagePreferences, } from "./usagePagePreferences"; -type UsageMetric = UsageChartMetric | "limits"; -const METRIC_OPTIONS = [ - { value: "cost", label: "Cost" }, - { value: "tokens", label: "Tokens" }, - { value: "limits", label: "Limits" }, -] as const satisfies readonly { value: UsageMetric; label: string }[]; - function isUsageMetric(value: string | null | undefined): value is UsageMetric { return METRIC_OPTIONS.some((option) => option.value === value); } -const WINDOW_OPTIONS = [ - { days: 1, label: "Past 24h" }, - { days: 7, label: "7 days" }, - { days: 30, label: "30 days" }, - { 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); + useEscapeToGoBack(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const shortcutTitle = ( + option: (typeof METRIC_OPTIONS)[number] | (typeof WINDOW_OPTIONS)[number], + ) => { + const shortcut = shortcutLabelForCommand(keybindings, option.command, { + context: { usagePageOpen: true }, + }); + return shortcut ? `${option.label} (${shortcut})` : option.label; + }; const [windowSelection, setWindowSelection] = useState(() => ({ days: preferences.windowDays, window: makeWindow( @@ -217,6 +223,32 @@ export function UsagePage() { setLimitsNow(Date.now()); } }; + const onUsageKeyDown = useEffectEvent((event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.repeat || + event.isComposing || + isCommandPaletteOpen() || + isModelPickerOpen() + ) + return; + + const command = resolveUsageShortcut(event, keybindings); + const metricOption = METRIC_OPTIONS.find((option) => option.command === command); + const periodOption = WINDOW_OPTIONS.find((option) => option.command === command); + if (!metricOption && !periodOption) return; + + event.preventDefault(); + event.stopPropagation(); + if (metricOption) selectMetric(metricOption.value); + if (periodOption && !showingLimits) selectWindow(periodOption.days); + }); + + useEffect(() => { + globalThis.window.addEventListener("keydown", onUsageKeyDown, true); + return () => globalThis.window.removeEventListener("keydown", onUsageKeyDown, true); + }, []); + const refreshWindow = () => { if (refreshingRef.current) return; @@ -302,7 +334,7 @@ export function UsagePage() { }} > {METRIC_OPTIONS.map((option) => ( - + {option.label} ))} @@ -320,7 +352,7 @@ export function UsagePage() { }} > {WINDOW_OPTIONS.map((option) => ( - + {option.label} ))} @@ -355,7 +387,7 @@ export function UsagePage() { {METRIC_OPTIONS.map((option) => ( - + {option.label} ))} @@ -378,7 +410,11 @@ export function UsagePage() { {WINDOW_OPTIONS.map((option) => ( - + {option.label} ))} diff --git a/apps/web/src/components/usage/usageShortcuts.test.ts b/apps/web/src/components/usage/usageShortcuts.test.ts new file mode 100644 index 000000000000..81eac013c856 --- /dev/null +++ b/apps/web/src/components/usage/usageShortcuts.test.ts @@ -0,0 +1,44 @@ +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { resolveUsageShortcut } from "./usageShortcuts"; + +class TestElement extends EventTarget { + constructor( + readonly tagName: string, + readonly isContentEditable = false, + ) { + super(); + } + + closest() { + return null; + } +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("Usage shortcuts while typing", () => { + it.each([new TestElement("INPUT"), new TestElement("TEXTAREA"), new TestElement("DIV", true)])( + "leaves letters to the focused $tagName field", + (target) => { + vi.stubGlobal("HTMLElement", TestElement); + vi.stubGlobal("navigator", { platform: "Linux" }); + const event = { + key: "t", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + target, + }; + + expect(resolveUsageShortcut(event, DEFAULT_RESOLVED_KEYBINDINGS)).toBeNull(); + expect( + resolveUsageShortcut( + { ...event, target: new TestElement("BODY") }, + DEFAULT_RESOLVED_KEYBINDINGS, + ), + ).toBe("usage.tokens"); + }, + ); +}); diff --git a/apps/web/src/components/usage/usageShortcuts.ts b/apps/web/src/components/usage/usageShortcuts.ts new file mode 100644 index 000000000000..a7132c7caeb7 --- /dev/null +++ b/apps/web/src/components/usage/usageShortcuts.ts @@ -0,0 +1,39 @@ +import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import type { UsageChartMetric } from "./UsageProviderChart"; +import { resolveShortcutCommand, type ShortcutEventLike } from "../../keybindings"; + +export type UsageMetric = UsageChartMetric | "limits"; +export const METRIC_OPTIONS = [ + { value: "cost", label: "Cost", command: "usage.cost" }, + { value: "tokens", label: "Tokens", command: "usage.tokens" }, + { value: "limits", label: "Limits", command: "usage.limits" }, +] as const satisfies readonly { value: UsageMetric; label: string; command: KeybindingCommand }[]; + +export const WINDOW_OPTIONS = [ + { days: 1, label: "Past 24h", command: "usage.period.day" }, + { days: 7, label: "7 days", command: "usage.period.week" }, + { days: 30, label: "30 days", command: "usage.period.month" }, + { days: 90, label: "90 days", command: "usage.period.quarter" }, +] as const; + +/** Resolves page shortcuts without taking letters from fields or popup controls. */ +export function resolveUsageShortcut( + event: ShortcutEventLike & { target: EventTarget | null }, + keybindings: ResolvedKeybindingsConfig, +) { + const target = event.target; + if ( + target instanceof HTMLElement && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable || + target.closest('[role="dialog"], [aria-modal="true"], [data-slot$="popup"]')) + ) { + return null; + } + + return resolveShortcutCommand(event, keybindings, { + context: { usagePageOpen: true }, + }); +} diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 773d20c3b882..206220768e9a 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => { expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); }), ); + + it.effect("fails IndexedDB writes whose commit aborts", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + const transaction = Object.assign(new EventTarget(), { + error: null as DOMException | null, + objectStore: () => ({ + put: () => { + // A failed commit aborts the transaction without an "error" event. + queueMicrotask(() => { + transaction.error = new DOMException("Quota exceeded", "QuotaExceededError"); + transaction.dispatchEvent(new Event("abort")); + }); + }, + }), + }); + const backend = makeCatalogBackend({ transaction: () => transaction } as never); + + const error = yield* backend.write("{}").pipe(Effect.flip); + + expect(error.message).toContain("QuotaExceededError"); + }), + ); }); describe("browser GitHub routing permissions", () => { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 4c6287e1b739..ec2e4ea5175c 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -6,6 +6,7 @@ import { ConnectionTargetStore, EMPTY_CONNECTION_CATALOG_DOCUMENT, EnvironmentCacheStore, + encodeShellSnapshotForCache, putRemoteDpopTokenInCatalog, registerConnectionInCatalog, removeCatalogValue, @@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); -const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); @@ -188,7 +188,9 @@ function writeDatabaseValue( ) { return Effect.callback((resume) => { const transaction = database.transaction(storeName, "readwrite"); - transaction.addEventListener("error", () => { + // Every failed write fires "abort". A failed commit, such as + // QuotaExceededError, fires only "abort" and no "error". + transaction.addEventListener("abort", () => { resume( Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), ); @@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext( ), saveShell: (environmentId, snapshot) => Effect.gen(function* () { - const encoded = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot); + const encoded = yield* Effect.try({ + try: () => + // @effect-diagnostics-next-line preferSchemaOverJson:off - the snapshot is already encoded. + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); }).pipe( Effect.mapError((cause) => diff --git a/apps/web/src/hooks/useNavigateBack.ts b/apps/web/src/hooks/useNavigateBack.ts new file mode 100644 index 000000000000..887e55d054de --- /dev/null +++ b/apps/web/src/hooks/useNavigateBack.ts @@ -0,0 +1,40 @@ +import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect } from "react"; + +/** Returns to the previous app page, or home when opened without app history. */ +function useNavigateBack() { + const navigate = useNavigate(); + const canGoBack = useCanGoBack(); + + return useCallback(() => { + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, navigate]); +} + +/** Enables page-level Escape navigation, letting controls consume Escape first. */ +export function useEscapeToGoBack(onEscape?: () => void) { + const navigateBack = useNavigateBack(); + const handleEscape = onEscape ?? navigateBack; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || event.isComposing || event.key !== "Escape") + return; + event.preventDefault(); + + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + activeElement.blur(); + } + + handleEscape(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleEscape]); +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index e568e06cb31d..a5fc2374be56 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1391,3 +1391,60 @@ describe("composer and pull request shortcuts", () => { }); } }); + +describe("Usage shortcuts", () => { + it("scopes letter shortcuts to Usage", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "t" }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Linux", + context: { usagePageOpen: true }, + }), + "usage.tokens", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "t" }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Linux", + }), + ); + }); + + it.each(["Linux", "MacIntel"])( + "preserves desktop numbered thread shortcuts on Usage on %s", + (platform) => { + const shortcut = event({ + key: "2", + ctrlKey: platform === "Linux", + metaKey: platform === "MacIntel", + }); + assert.strictEqual( + resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { usagePageOpen: true, isDesktop: true }, + }), + "thread.jump.2", + ); + assert.isNotNull( + shortcutLabelForCommand(DEFAULT_RESOLVED_KEYBINDINGS, "thread.jump.2", { + platform, + context: { usagePageOpen: true, isDesktop: true }, + }), + ); + }, + ); + + it("matches shifted number keys for periods and only on Usage", () => { + const shortcut = event({ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }); + assert.strictEqual( + resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Linux", + context: { usagePageOpen: true }, + }), + "usage.period.day", + ); + assert.isNull( + resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Linux", + }), + ); + }); +}); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 7785bceaac73..ac3dea3aca5d 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,9 +1,7 @@ export { getLatestThreadForProject, getThreadSortTimestamp, - resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, - type SettledThreadTimestampInput, type ThreadSortInput, } from "@t3tools/client-runtime/state/thread-sort"; diff --git a/apps/web/src/queuedMessageStore.test.ts b/apps/web/src/queuedMessageStore.test.ts index 33869d89da62..8d72ab65054b 100644 --- a/apps/web/src/queuedMessageStore.test.ts +++ b/apps/web/src/queuedMessageStore.test.ts @@ -1,5 +1,7 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { createLocalDispatchSnapshot } from "./components/ChatView.logic"; import { isQueuedMessageDue, latestCompletedToolActivityId, @@ -15,15 +17,23 @@ function makeMessage(prompt: string): Omit { terminalContexts: [], previewAnnotations: [], reviewComments: [], - submissionIntent: "foreground", + sendSettings: { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + promptEffort: null, + }, queuedAfterToolActivityId: null, createdAt: "2026-09-11T00:00:00.000Z", }; } +const queue = (threadKey: string) => + useQueuedMessageStore.getState().queuesByThreadKey[threadKey] ?? []; + describe("queuedMessageStore", () => { beforeEach(() => { - useQueuedMessageStore.setState({ queuesByThreadKey: {}, drainGeneration: 0 }); + useQueuedMessageStore.setState({ queuesByThreadKey: {}, lastDispatchByThreadKey: {} }); }); it("keeps messages in submission order per thread", () => { @@ -32,71 +42,112 @@ describe("queuedMessageStore", () => { enqueue("thread-a", makeMessage("second")); enqueue("thread-b", makeMessage("other")); - const queues = useQueuedMessageStore.getState().queuesByThreadKey; - expect(queues["thread-a"]?.map((message) => message.prompt)).toEqual(["first", "second"]); - expect(queues["thread-b"]?.map((message) => message.prompt)).toEqual(["other"]); + expect(queue("thread-a").map((message) => message.prompt)).toEqual(["first", "second"]); + expect(queue("thread-b").map((message) => message.prompt)).toEqual(["other"]); }); - it("take hands the message to exactly one caller", () => { - const { enqueue, take } = useQueuedMessageStore.getState(); - const entry = enqueue("thread-a", makeMessage("first")); + it("allows one send per thread and re-anchors the rest to the current tool boundary", () => { + const { enqueue, beginSend } = useQueuedMessageStore.getState(); + const first = enqueue("thread-a", makeMessage("first")); + const second = enqueue("thread-a", makeMessage("second")); - expect(take("thread-a", entry.id, null)?.prompt).toBe("first"); - expect(take("thread-a", entry.id, null)).toBeNull(); - expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toBeUndefined(); + expect(beginSend("thread-a", first.id, "tool-2")?.prompt).toBe("first"); + expect(beginSend("thread-a", first.id, "tool-2")).toBeNull(); + expect(beginSend("thread-a", second.id, "tool-2")).toBeNull(); + + const [sending, waiting] = queue("thread-a"); + expect(sending?.sending).toBe("preparing"); + expect(waiting?.queuedAfterToolActivityId).toBe("tool-2"); + expect( + isQueuedMessageDue({ message: waiting!, phase: "running", latestToolActivityId: "tool-2" }), + ).toBe(false); }); - it("take re-anchors the remaining messages to the current tool boundary", () => { - const { enqueue, take } = useQueuedMessageStore.getState(); + it("finishSend drops the sent message", () => { + const { enqueue, beginSend, finishSend } = useQueuedMessageStore.getState(); const first = enqueue("thread-a", makeMessage("first")); - enqueue("thread-a", makeMessage("second")); + beginSend("thread-a", first.id, null); - take("thread-a", first.id, "tool-2"); + finishSend("thread-a", first.id); - const [second] = useQueuedMessageStore.getState().queuesByThreadKey["thread-a"] ?? []; - expect(second?.queuedAfterToolActivityId).toBe("tool-2"); - expect( - isQueuedMessageDue({ message: second!, phase: "running", latestToolActivityId: "tool-2" }), - ).toBe(false); + expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toBeUndefined(); + }); + + it("failSend returns the message to the head, held", () => { + const { enqueue, beginSend, failSend } = useQueuedMessageStore.getState(); + enqueue("thread-a", makeMessage("first")); + const second = enqueue("thread-a", makeMessage("second")); + beginSend("thread-a", second.id, null); + + expect(failSend("thread-a", second.id)).toBe(true); + + const [head] = queue("thread-a"); + expect(queue("thread-a").map((message) => message.prompt)).toEqual(["second", "first"]); + expect(head?.sending).toBeUndefined(); + expect(isQueuedMessageDue({ message: head!, phase: "ready", latestToolActivityId: null })).toBe( + false, + ); }); - it("remove keeps the other messages' anchors", () => { - const { enqueue, remove } = useQueuedMessageStore.getState(); + it("remove keeps the other messages' anchors and refuses a message being sent", () => { + const { enqueue, remove, beginSend } = useQueuedMessageStore.getState(); const first = enqueue("thread-a", { ...makeMessage("first"), queuedAfterToolActivityId: "t1" }); const second = enqueue("thread-a", makeMessage("second")); expect(remove("thread-a", second.id)?.prompt).toBe("second"); expect(remove("thread-a", second.id)).toBeNull(); - expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-a"]).toEqual([first]); + expect(queue("thread-a")).toEqual([first]); + + beginSend("thread-a", first.id, "t1"); + expect(remove("thread-a", first.id)).toBeNull(); }); - it("holdAtFront returns a failed message to the head, held", () => { - const { enqueue, take, holdAtFront } = useQueuedMessageStore.getState(); + it("a failed send keeps waiting on the dispatch before it", () => { + const { enqueue, beginSend, markDispatching, finishSend, failSend } = + useQueuedMessageStore.getState(); + const earlier = createLocalDispatchSnapshot(undefined); const first = enqueue("thread-a", makeMessage("first")); - enqueue("thread-a", makeMessage("second")); - const taken = take("thread-a", first.id, "t1")!; + const second = enqueue("thread-a", makeMessage("second")); + const third = enqueue("thread-a", makeMessage("third")); + const lastDispatch = () => useQueuedMessageStore.getState().lastDispatchByThreadKey["thread-a"]; + beginSend("thread-a", first.id, null); + markDispatching("thread-a", first.id, earlier); + finishSend("thread-a", first.id); + + // Fails before its turn start went out: the first send is still the one to wait on. + beginSend("thread-a", second.id, null); + failSend("thread-a", second.id); + expect(lastDispatch()?.thread).toBe(earlier); + + // Fails after going out: it never reached the server, so the first still counts. + beginSend("thread-a", third.id, null); + markDispatching("thread-a", third.id, { ...earlier, startedAt: "later" }); + failSend("thread-a", third.id); + expect(lastDispatch()?.thread).toBe(earlier); + }); - holdAtFront("thread-a", taken); + it("Stop takes back a preparing send but not one already dispatching", () => { + const { enqueue, beginSend, markDispatching, drain, failSend } = + useQueuedMessageStore.getState(); + const preparing = enqueue("thread-a", makeMessage("preparing")); + enqueue("thread-a", makeMessage("waiting")); + beginSend("thread-a", preparing.id, null); - const queue = useQueuedMessageStore.getState().queuesByThreadKey["thread-a"] ?? []; - expect(queue.map((message) => message.prompt)).toEqual(["first", "second"]); - expect(queue[0]?.holdUntilUserAction).toBe(true); - expect( - isQueuedMessageDue({ message: queue[0]!, phase: "ready", latestToolActivityId: null }), - ).toBe(false); - }); + expect(drain("thread-a").map((message) => message.prompt)).toEqual(["preparing", "waiting"]); + expect(markDispatching("thread-a", preparing.id, createLocalDispatchSnapshot(undefined))).toBe( + false, + ); + expect(failSend("thread-a", preparing.id)).toBe(false); - it("drain empties one thread's queue in order", () => { - const { enqueue, drain } = useQueuedMessageStore.getState(); - enqueue("thread-a", makeMessage("first")); - enqueue("thread-a", makeMessage("second")); - enqueue("thread-b", makeMessage("other")); + const dispatching = enqueue("thread-b", makeMessage("dispatching")); + enqueue("thread-b", makeMessage("waiting")); + beginSend("thread-b", dispatching.id, null); + expect( + markDispatching("thread-b", dispatching.id, createLocalDispatchSnapshot(undefined)), + ).toBe(true); - expect(drain("thread-a").map((message) => message.prompt)).toEqual(["first", "second"]); - expect(useQueuedMessageStore.getState().drainGeneration).toBe(1); - expect(drain("thread-a")).toEqual([]); - expect(useQueuedMessageStore.getState().drainGeneration).toBe(1); - expect(useQueuedMessageStore.getState().queuesByThreadKey["thread-b"]).toHaveLength(1); + expect(drain("thread-b").map((message) => message.prompt)).toEqual(["waiting"]); + expect(queue("thread-b").map((message) => message.prompt)).toEqual(["dispatching"]); }); }); diff --git a/apps/web/src/queuedMessageStore.ts b/apps/web/src/queuedMessageStore.ts index b342a1380f7a..735cebf33b1c 100644 --- a/apps/web/src/queuedMessageStore.ts +++ b/apps/web/src/queuedMessageStore.ts @@ -1,12 +1,30 @@ -import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import type { + ModelSelection, + PreviewAnnotationPayload, + ProviderInteractionMode, + RuntimeMode, +} from "@t3tools/contracts"; import { create } from "zustand"; -import type { ComposerSubmissionIntent } from "./composer-logic"; +import type { LocalDispatchSnapshot } from "./components/ChatView.logic"; import type { ComposerFileAttachment, ComposerImageAttachment } from "./composerDraftStore"; import type { TerminalContextDraft } from "./lib/terminalContext"; import { randomUUID } from "./lib/utils"; import type { ReviewCommentContext } from "./reviewCommentContext"; +/** + * The composer's model and modes when the message was queued. The send uses + * these instead of the live composer, so it can go out while the user is on + * another thread. + */ +export interface QueuedMessageSendSettings { + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + /** Effort written into the prompt text, for providers that read it there. */ + promptEffort: string | null; +} + /** * A composer submission held back while the thread's turn is running. It * carries the full draft snapshot so the send path can dispatch it later with @@ -20,7 +38,7 @@ export interface QueuedComposerMessage { terminalContexts: TerminalContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; - submissionIntent: ComposerSubmissionIntent; + sendSettings: QueuedMessageSendSettings; /** * The newest completed tool activity at queue time. A different id later * means a tool call finished after the user queued, which is the boundary @@ -32,125 +50,172 @@ export interface QueuedComposerMessage { * user pressing send. It waits for Send now instead of leaving on its own. */ holdUntilUserAction?: boolean; + /** + * Set while a send is under way; the row stays until it settles. Stop can + * still take a "preparing" message back (uploads, thread settings), but not + * a "dispatching" one, whose turn start is already on the wire. + */ + sending?: "preparing" | "dispatching"; createdAt: string; } +/** + * The thread as it was when its last queued turn start went out. The next + * message waits until the server has moved past it, so a send that starts a + * new turn and the message after it do not leave on one boundary. + */ +interface QueuedDispatch { + /** The message that went out, or null for a dispatch restored after a failure. */ + messageId: string | null; + thread: LocalDispatchSnapshot; + /** The dispatch this one replaced. If this send fails, that one still counts. */ + previous: LocalDispatchSnapshot | null; +} + interface QueuedMessageStoreState { queuesByThreadKey: Record; - /** - * Bumped by `drain`. A send that took a message before a drain and finishes - * its upload after it compares this to the value it captured and gives up, - * so Stop cannot be followed by a queued message starting a new turn. - */ - drainGeneration: number; + lastDispatchByThreadKey: Record; enqueue: (threadKey: string, message: Omit) => QueuedComposerMessage; /** - * Removes one message and returns it, or null when another caller already - * took it. The remaining messages are re-anchored to `toolActivityId` so - * only one queued message leaves per tool boundary. + * Marks one message as sending and returns it, or null when it is gone or + * the thread already has a send under way. The other messages are + * re-anchored to `toolActivityId` so only one leaves per tool boundary. */ - take: ( + beginSend: ( threadKey: string, id: string, toolActivityId: string | null, ) => QueuedComposerMessage | null; - /** Removes one message without touching the others' anchors. Null when already gone. */ - remove: (threadKey: string, id: string) => QueuedComposerMessage | null; + /** The turn start is going out. False when Stop took the message back first. */ + markDispatching: (threadKey: string, id: string, thread: LocalDispatchSnapshot) => boolean; + /** Drops a message whose send went out, or that had nothing left to send. */ + finishSend: (threadKey: string, id: string) => void; /** - * Puts a message back at the head, held for user action. Used when its - * send failed: the queue keeps its order and nothing behind it overtakes. + * Moves a message whose send failed back to the head, held for user action. + * The queue keeps its order and nothing behind it overtakes. False when + * Stop already took the message back. */ - holdAtFront: (threadKey: string, message: QueuedComposerMessage) => void; - /** Removes and returns every queued message for the thread, oldest first. */ + failSend: (threadKey: string, id: string) => boolean; + /** Removes one message without touching the others' anchors. Null when gone or sending. */ + remove: (threadKey: string, id: string) => QueuedComposerMessage | null; + /** Removes and returns every message for the thread that is not already on the wire. */ drain: (threadKey: string) => QueuedComposerMessage[]; } const EMPTY_QUEUE: QueuedComposerMessage[] = []; +type QueueState = Pick; + +/** Replaces one thread's queue. `lastDispatch` null forgets it; an empty queue always does. */ +function withQueue( + state: QueueState, + threadKey: string, + queue: QueuedComposerMessage[], + lastDispatch?: QueuedDispatch | null, +): QueueState { + const queuesByThreadKey = { ...state.queuesByThreadKey, [threadKey]: queue }; + const lastDispatchByThreadKey = { ...state.lastDispatchByThreadKey }; + if (lastDispatch) lastDispatchByThreadKey[threadKey] = lastDispatch; + if (queue.length === 0) delete queuesByThreadKey[threadKey]; + if (queue.length === 0 || lastDispatch === null) delete lastDispatchByThreadKey[threadKey]; + return { queuesByThreadKey, lastDispatchByThreadKey }; +} + /** In-memory only: a queued message is a live intent, not a draft worth persisting. */ -export const useQueuedMessageStore = create()((set, get) => ({ - queuesByThreadKey: {}, - drainGeneration: 0, - enqueue: (threadKey, message) => { - const entry: QueuedComposerMessage = { ...message, id: randomUUID() }; - set((state) => ({ - queuesByThreadKey: { - ...state.queuesByThreadKey, - [threadKey]: [...(state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE), entry], - }, - })); - return entry; - }, - take: (threadKey, id, toolActivityId) => { - const queue = get().queuesByThreadKey[threadKey]; - const entry = queue?.find((message) => message.id === id); - if (!queue || !entry) { - return null; - } - set((state) => { - const remaining = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE) - .filter((message) => message.id !== id) - .map((message) => - message.queuedAfterToolActivityId === toolActivityId - ? message - : { ...message, queuedAfterToolActivityId: toolActivityId }, - ); - const queuesByThreadKey = { ...state.queuesByThreadKey }; - if (remaining.length === 0) { - delete queuesByThreadKey[threadKey]; - } else { - queuesByThreadKey[threadKey] = remaining; - } - return { queuesByThreadKey }; - }); - return entry; - }, - remove: (threadKey, id) => { - const queue = get().queuesByThreadKey[threadKey]; - const entry = queue?.find((message) => message.id === id); - if (!queue || !entry) { - return null; - } - set((state) => { - const remaining = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE).filter( - (message) => message.id !== id, - ); - const queuesByThreadKey = { ...state.queuesByThreadKey }; - if (remaining.length === 0) { - delete queuesByThreadKey[threadKey]; - } else { - queuesByThreadKey[threadKey] = remaining; - } - return { queuesByThreadKey }; - }); - return entry; - }, - holdAtFront: (threadKey, message) => { - set((state) => { - const rest = (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE).filter( - (entry) => entry.id !== message.id, +export const useQueuedMessageStore = create()((set, get) => { + const queueOf = (threadKey: string) => get().queuesByThreadKey[threadKey] ?? EMPTY_QUEUE; + const update = ( + threadKey: string, + queue: QueuedComposerMessage[], + lastDispatch?: QueuedDispatch | null, + ) => set((state) => withQueue(state, threadKey, queue, lastDispatch)); + return { + queuesByThreadKey: {}, + lastDispatchByThreadKey: {}, + enqueue: (threadKey, message) => { + const entry: QueuedComposerMessage = { ...message, id: randomUUID() }; + update(threadKey, [...queueOf(threadKey), entry]); + return entry; + }, + beginSend: (threadKey, id, toolActivityId) => { + const queue = queueOf(threadKey); + const entry = queue.find((message) => message.id === id); + if (!entry || queue.some((message) => message.sending)) return null; + update( + threadKey, + queue.map((message) => + message.id === id + ? { ...message, sending: "preparing" } + : message.queuedAfterToolActivityId === toolActivityId + ? message + : { ...message, queuedAfterToolActivityId: toolActivityId }, + ), ); - return { - queuesByThreadKey: { - ...state.queuesByThreadKey, - [threadKey]: [{ ...message, holdUntilUserAction: true }, ...rest], + return entry; + }, + markDispatching: (threadKey, id, thread) => { + const queue = queueOf(threadKey); + if (!queue.some((message) => message.id === id && message.sending)) return false; + update( + threadKey, + queue.map((message) => + message.id === id ? { ...message, sending: "dispatching" } : message, + ), + { + messageId: id, + thread, + previous: get().lastDispatchByThreadKey[threadKey]?.thread ?? null, }, - }; - }); - }, - drain: (threadKey) => { - const queue = get().queuesByThreadKey[threadKey]; - if (!queue || queue.length === 0) { - return EMPTY_QUEUE; - } - set((state) => { - const queuesByThreadKey = { ...state.queuesByThreadKey }; - delete queuesByThreadKey[threadKey]; - return { queuesByThreadKey, drainGeneration: state.drainGeneration + 1 }; - }); - return queue; - }, -})); + ); + return true; + }, + finishSend: (threadKey, id) => { + const queue = queueOf(threadKey); + if (!queue.some((message) => message.id === id)) return; + update( + threadKey, + queue.filter((message) => message.id !== id), + ); + }, + failSend: (threadKey, id) => { + const queue = queueOf(threadKey); + const entry = queue.find((message) => message.id === id); + if (!entry) return false; + const { sending: _sending, ...rest } = entry; + // This send never reached the server, so only an earlier one is worth + // waiting for. + const dispatch = get().lastDispatchByThreadKey[threadKey]; + update( + threadKey, + [{ ...rest, holdUntilUserAction: true }, ...queue.filter((message) => message.id !== id)], + dispatch?.messageId !== id + ? undefined + : dispatch.previous && { messageId: null, thread: dispatch.previous, previous: null }, + ); + return true; + }, + remove: (threadKey, id) => { + const queue = queueOf(threadKey); + const entry = queue.find((message) => message.id === id); + if (!entry || entry.sending) return null; + update( + threadKey, + queue.filter((message) => message.id !== id), + ); + return entry; + }, + drain: (threadKey) => { + const queue = queueOf(threadKey); + const drained = queue.filter((message) => message.sending !== "dispatching"); + if (drained.length === 0) return EMPTY_QUEUE; + update( + threadKey, + queue.filter((message) => message.sending === "dispatching"), + ); + return drained; + }, + }; +}); /** * The newest finished tool call. Its id changing is the boundary a queued diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e89240878c33..b9dcbae5c327 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -29,6 +29,7 @@ import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAp import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; +import { QueuedMessageSender } from "../components/QueuedMessageSender"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -227,6 +228,7 @@ function RootRouteView() { + diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 87439e8a4bc9..1b7239a0bef5 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -132,6 +132,7 @@ import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { toastManager } from "../components/ui/toast"; +import { useEscapeToGoBack } from "../hooks/useNavigateBack"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { PULL_REQUESTS_PANEL_REF, @@ -340,6 +341,7 @@ export const Route = createFileRoute("/_chat/pull-requests")({ }); function PullRequestsRouteView() { + useEscapeToGoBack(); const search = Route.useSearch(); const sort = search.sort ?? "ready"; const statsPolicy: PullRequestStatsPolicy = @@ -2063,8 +2065,9 @@ function PullRequestsRouteView() { if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event); if (command === "thread.copyReference") copyPullRequestFromShortcut(event); }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + // Let panel shortcuts consume Escape before page navigation at window. + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); }, [keybindings]); return ( diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index d529148e17b9..4f4ac7b891cc 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,5 +1,5 @@ import { Outlet, createFileRoute, redirect, useLocation } from "@tanstack/react-router"; -import { useEffect, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { RotateCcwIcon } from "lucide-react"; import { Button } from "../components/ui/button"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; @@ -9,6 +9,7 @@ import { SidebarInset } from "../components/ui/sidebar"; import { useNavigateToMainApp } from "../components/sidebar/mainAppLocation"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; import { isElectron } from "../env"; +import { useEscapeToGoBack } from "../hooks/useNavigateBack"; import { SettingsScopeProvider, useSettingsScope, @@ -112,30 +113,10 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child function SettingsContentLayout() { const location = useLocation(); const navigateToMainApp = useNavigateToMainApp(); + useEscapeToGoBack(navigateToMainApp); const { search } = useSettingsScope(); const [restoreSignal, setRestoreSignal] = useState(0); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key === "Escape") { - event.preventDefault(); - - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - activeElement.blur(); - } - - void navigateToMainApp(); - } - }; - - window.addEventListener("keydown", onKeyDown); - return () => { - window.removeEventListener("keydown", onKeyDown); - }; - }, [navigateToMainApp]); - return (
diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index dcd6358cb52c..af977d567f2b 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -183,6 +183,14 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } +/** The thread as `useThread` returns it, read outside React. */ +export function readThread(ref: ScopedThreadRef): EnvironmentThread | null { + return mergeEnvironmentThread( + appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)), + readThreadShell(ref), + ); +} + /** Whether the environment's server understands thread.settle/unsettle. False for pre-settlement servers (capability defaults false on decode), so clients under version skew fall back instead of erroring. */ diff --git a/docs/user/composer.md b/docs/user/composer.md index 6e699d0669c1..7ee8edcbb121 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -19,8 +19,7 @@ Attach up to 100 files per message. Each image can be up to 10 MiB, with at most 50 MiB each, subject to the environment's upload support and limit. The agent receives them on the environment's machine. Provider and model limits still apply, including images already in the conversation. A video attachment gives -the agent a file path; it does not enable native video input. Antigravity does -not accept video attachments. +the agent a file path; it does not enable native video input. Uploads begin when you add an attachment. All uploads must finish before the message can send. Retry or remove a failed upload. On web and desktop, reloading @@ -37,7 +36,7 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin On web and desktop, a message sent during a running turn waits at the end of the conversation as a dashed bubble. It goes out on its own when the agent finishes its next tool -call, or when the turn ends. Use the arrow under the bubble to send it right +call, or when the turn ends, even while you have another thread open. Use the arrow under the bubble to send it right away, or the X to move it back into the composer. Stop returns every queued message to the composer. diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index ad1b157bbb75..2525fed64d94 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -84,7 +84,7 @@ Join modifiers and a key with `+`, such as `mod+shift+d` or `ctrl+l`. ## When conditions Available context keys are `terminalFocus`, `terminalOpen`, `previewFocus`, -`previewOpen`, `modelPickerOpen`, `editableFocus`, `isWeb`, and `isDesktop`. +`previewOpen`, `modelPickerOpen`, `usagePageOpen`, `editableFocus`, `isWeb`, and `isDesktop`. `editableFocus` is true while a text field, the composer, or another editor has the keyboard. `isWeb` is true in a browser tab. `isDesktop` is true in the desktop app. Unknown keys evaluate to `false`. diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index fe5010abf1f4..bdf4548cf29f 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -94,9 +94,12 @@ so a skill there only appears when the project itself is your home directory. Antigravity accepts images, PDFs, text files, and supported audio formats directly. Its limits are 1 MiB per text file, 10 MiB per image, 20 MiB per audio clip, and -50 MiB total attachments per message. Unsupported formats are rejected. These -limits can be lower than the general upload limit; uploading a file does not -mean this provider can use it. +50 MiB total native attachments per message. PDFs, text, and audio that exceed +their native limits or the remaining native budget are passed as file paths +for the agent to inspect with tools. ZIP archives and videos also use file paths. +These files do not count toward the native attachment budget or enable native video input. +Unsupported image formats are rejected. The native limits can be lower than +the general upload limit. ### Subagents diff --git a/docs/user/usage.md b/docs/user/usage.md index ab77f70a6b3a..dc55aba41023 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -120,3 +120,12 @@ Add **Subscription usage** from your iOS or Android widget gallery to see remain Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session, Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget requires Android 12L or later. + +## Keyboard shortcuts + +On web and desktop, open Usage from the command palette. While on Usage, +press `C`, `T`, or `L` for Cost, Tokens, or Limits while not typing in a field. +Use `Ctrl+Shift+1/2/3/4` (`Cmd+Shift+1/2/3/4` on macOS) for the past +24 hours, 7 days, 30 days, or 90 days. Period shortcuts do nothing on Limits. +Press `Escape` to return to the previous page. Customize these shortcuts in +**Settings → Keybindings**. diff --git a/knip.jsonc b/knip.jsonc index a6e4f94143a0..2c7b76acfae0 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -53,6 +53,8 @@ "ignoreDependencies": ["playwright-core", "electron-builder"], }, "apps/web": { + // Vitest loads this through the per-file @vitest-environment directive. + "ignoreDependencies": ["jsdom"], // Worktree setup (scripts/setup-worktree.ts) runs this directly. "entry": ["scripts/warm-dep-cache.ts"], // UI component modules are copied and adapted as cohesive sets. Keep their diff --git a/packages/client-runtime/src/platform/persistence.test.ts b/packages/client-runtime/src/platform/persistence.test.ts new file mode 100644 index 000000000000..3e856e847976 --- /dev/null +++ b/packages/client-runtime/src/platform/persistence.test.ts @@ -0,0 +1,55 @@ +import { + OrchestrationProjectShell, + OrchestrationShellSnapshot, + OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Arr from "effect/Array"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary"; + +import { encodeShellSnapshotForCache } from "./persistence.ts"; + +// Generated values can hold untrimmed strings, which a decoded value never +// has. One encode and decode gives a value a client can hold; values that +// fail are dropped. Size 30 makes the generator fill optional fields. +const sampleDecoded = (schema: S) => + Effect.gen(function* () { + const encode = Schema.encodeEffect(schema); + const decode = Schema.decodeEffect(schema); + const generated = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), { + count: 1000, + size: 30, + }); + const decoded = yield* Effect.forEach(generated, (value) => + encode(value).pipe(Effect.flatMap(decode), Effect.option), + ); + return Arr.getSomes(decoded); + }); +const encodeSnapshot = Schema.encodeEffect(OrchestrationShellSnapshot); + +describe("encodeShellSnapshotForCache", () => { + it.effect("matches the Schema encoding of a generated snapshot", () => + Effect.gen(function* () { + const threads = yield* sampleDecoded(OrchestrationThreadShell); + const projects = yield* sampleDecoded(OrchestrationProjectShell); + const snapshot: OrchestrationShellSnapshot = { + snapshotSequence: 1, + // The generator rarely makes monogram icons, and they are the one + // project field whose encoding differs from the decoded value. + projects: projects.map((project, index) => + index % 2 === 0 + ? { ...project, projectIcon: { kind: "monogram", text: "T3", color: "blue" } } + : project, + ), + threads, + updatedAt: "2026-09-25T00:00:00.000Z", + }; + + expect(threads.length).toBeGreaterThan(0); + expect(projects.length).toBeGreaterThan(0); + expect(yield* encodeShellSnapshotForCache(snapshot)).toEqual(yield* encodeSnapshot(snapshot)); + }), + ); +}); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 99469081dc10..b2bc18222250 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadDetailSnapshot, type ServerConfig, @@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service< } >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} +const encodeProjectShells = Schema.encodeEffect(Schema.Array(OrchestrationProjectShell)); + +/** + * Encodes a shell snapshot for `EnvironmentCacheStore.saveShell`. The result + * equals `Schema.encode(OrchestrationShellSnapshot)`, so the cache format does + * not change. Walking thousands of threads through Schema blocks the UI + * thread, and a decoded thread shell is already in its encoded form, so only + * the projects go through Schema: their icon has a real encode transform. + */ +export const encodeShellSnapshotForCache = (snapshot: OrchestrationShellSnapshot) => + Effect.map( + encodeProjectShells(snapshot.projects), + (projects) => ({ ...snapshot, projects }) satisfies typeof OrchestrationShellSnapshot.Encoded, + ); + export class EnvironmentOwnedDataCleanup extends Context.Reference<{ readonly clear: (environmentId: EnvironmentId) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index 0e1fba746cf3..9fe8df570499 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,4 +1,4 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, TurnId, type OrchestrationLatestTurn } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -10,7 +10,9 @@ import { resolveSettledThreadTimestamp, sortActiveThreadsByOrderKey, sortPinnedThreadsByOrderKey, + sortSettledThreads, sortThreads, + type SettledThreadTimestampInput, type ThreadSortInput, } from "./threadSort.ts"; @@ -59,6 +61,109 @@ describe("resolveSettledThreadTimestamp", () => { }); }); +describe("sortSettledThreads", () => { + const settled = (input: { + id: string; + settledAt?: string | null; + latestUserMessageAt?: string | null; + latestTurn?: OrchestrationLatestTurn | null; + updatedAt?: string; + }) => ({ + id: input.id, + settledAt: input.settledAt ?? null, + latestUserMessageAt: input.latestUserMessageAt ?? null, + latestTurn: input.latestTurn ?? null, + updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", + }); + + it("orders by settle time, most recently settled first", () => { + const sorted = sortSettledThreads([ + settled({ + id: "settled-first", + settledAt: "2026-03-09T10:00:00.000Z", + // Created/active later than the other thread: settle time must win. + latestUserMessageAt: "2026-03-09T09:59:00.000Z", + }), + settled({ + id: "settled-last", + settledAt: "2026-03-09T12:00:00.000Z", + latestUserMessageAt: "2026-03-09T08:00:00.000Z", + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); + }); + + it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { + const sorted = sortSettledThreads([ + settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), + settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); + }); + + it("counts a turn completion as activity for auto-settled threads", () => { + // The message came in before the other thread's, but its turn finished + // after: completion time is the real "work ended" moment. + const sorted = sortSettledThreads([ + settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), + settled({ + id: "completed-later", + latestUserMessageAt: "2026-03-09T10:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + assistantMessageId: null, + requestedAt: "2026-03-09T10:00:00.000Z", + startedAt: "2026-03-09T10:00:00.000Z", + completedAt: "2026-03-09T10:30:00.000Z", + }, + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); + }); + + it("breaks timestamp ties by id so the order is stable", () => { + const sorted = sortSettledThreads([ + settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); + + it("matches the per-comparison order on a shuffled list with ties", () => { + const stamps = [ + { settledAt: "2026-03-09T10:00:00.000Z" }, + { settledAt: "invalid", latestUserMessageAt: "2026-03-09T10:00:00.000Z" }, + { latestUserMessageAt: "2026-03-09T11:00:00.000Z" }, + { updatedAt: "2026-03-09T09:00:00.000Z" }, + { updatedAt: "invalid" }, + ]; + // Ids repeat every 3 rows and stamps every 5, so rows tie on the time, + // on the id, and on both. (index * 7) % 30 scrambles the input order. + const threads = Array.from({ length: 30 }, (_, index) => { + const row = (index * 7) % 30; + return { ...settled({ id: `thread-${row % 3}`, ...stamps[row % 5] }), row }; + }); + // The comparator this sort replaced: it resolved both keys on every call. + const timestampMs = (thread: SettledThreadTimestampInput) => { + const timestamp = resolveSettledThreadTimestamp(thread); + return timestamp === null ? 0 : Date.parse(timestamp); + }; + const expected = threads.toSorted( + (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), + ); + + expect(sortSettledThreads(threads).map((thread) => thread.row)).toEqual( + expected.map((thread) => thread.row), + ); + }); +}); + describe("sortThreads", () => { it.each(["created_at", "updated_at"] as const)( "preserves references, input order and descending id ties for %s", diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 93878310839f..b6a7264f3052 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -47,6 +47,24 @@ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInpu return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt; } +/** Settled rows are history, so they order by when the work ENDED, newest + first, with an id tiebreak. Each key resolves once per sort, not once + per comparison. Shared by web and mobile so both render the same order. */ +export function sortSettledThreads( + threads: readonly T[], +): T[] { + return threads + .map((thread) => { + const timestamp = resolveSettledThreadTimestamp(thread); + return { thread, timestampMs: timestamp === null ? 0 : Date.parse(timestamp) }; + }) + .sort( + (left, right) => + right.timestampMs - left.timestampMs || left.thread.id.localeCompare(right.thread.id), + ) + .map(({ thread }) => thread); +} + function getFirstSortableTimestamp(...values: Array): number | null { for (const value of values) { const timestamp = toSortableTimestamp(value ?? undefined); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index cb5768420306..239f9905c534 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -92,6 +92,13 @@ export const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + "usage.cost", + "usage.tokens", + "usage.limits", + "usage.period.day", + "usage.period.week", + "usage.period.month", + "usage.period.quarter", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 28fa05be85b8..8029be96f939 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -76,6 +76,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ command, when: "modelPickerOpen && isDesktop", })), + { key: "c", command: "usage.cost", when: "usagePageOpen" }, + { key: "t", command: "usage.tokens", when: "usagePageOpen" }, + { key: "l", command: "usage.limits", when: "usagePageOpen" }, + { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" }, + { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" }, + { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" }, + { key: "mod+shift+4", command: "usage.period.quarter", when: "usagePageOpen" }, ]; function normalizeKeyToken(token: string): string { diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 081291f34b86..b9bf4ef1375d 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -16,6 +16,7 @@ import * as Tracer from "effect/Tracer"; import { causeErrorTag, compactTraceAttributes, + decodeOtlpTraceRecords, errorTag, makeLocalFileTracer, makeTraceSink, @@ -138,6 +139,53 @@ describe("truncateTraceAttributes", () => { }); }); +describe("decodeOtlpTraceRecords", () => { + it("clamps oversized renderer span and event attributes", () => { + const long = "x".repeat(2_000); + const clamped = `${"x".repeat(500)}…[truncated]`; + const [record] = decodeOtlpTraceRecords({ + resourceSpans: [ + { + resource: { attributes: [], droppedAttributesCount: 0 }, + scopeSpans: [ + { + scope: { name: "effect" }, + spans: [ + { + traceId: "11111111111111111111111111111111", + spanId: "2222222222222222", + parentSpanId: undefined, + name: "client.span", + kind: 1, + startTimeUnixNano: "1000000", + endTimeUnixNano: "2000000", + attributes: [{ key: "payload", value: { stringValue: long } }], + droppedAttributesCount: 0, + events: [ + { + name: "log", + timeUnixNano: "1500000", + attributes: [{ key: "effect.cause", value: { stringValue: long } }], + droppedAttributesCount: 0, + }, + ], + droppedEventsCount: 0, + status: { code: 1 }, + links: [], + droppedLinksCount: 0, + }, + ], + }, + ], + }, + ], + }); + + assert.equal(record?.attributes["payload"], clamped); + assert.equal(record?.events[0]?.attributes["effect.cause"], clamped); + }); +}); + describe("observability", () => { it("normalizes circular arrays, maps, and sets without recursing forever", () => { const array: Array = ["alpha"]; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 6393ed0879db..b4dbdf88651a 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -668,7 +668,7 @@ function decodeAttributes( entries[attribute.key] = decodeValue(attribute.value); } - return compactTraceAttributes(entries); + return truncateTraceAttributes(compactTraceAttributes(entries)); } function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 0a25785d916d..6cb08d2890be 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -681,7 +681,8 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); -export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(function* ( +// Untraced because it runs before most spawns and returns at once off Windows. +export const resolveSpawnCommand = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray, options: CommandAvailabilityOptions = {}, diff --git a/patches/react-native-nitro-markdown@0.5.8.patch b/patches/react-native-nitro-markdown@0.5.8.patch new file mode 100644 index 000000000000..3bf3788bdd3f --- /dev/null +++ b/patches/react-native-nitro-markdown@0.5.8.patch @@ -0,0 +1,70 @@ +diff --git a/cpp/md4c/md4c.c b/cpp/md4c/md4c.c +--- a/cpp/md4c/md4c.c ++++ b/cpp/md4c/md4c.c +@@ -3908,10 +3908,10 @@ + int min_components; + const MD_CHAR optional_end_char; + } URL_MAP[] = { +- { _T('\0'), _T('.'), _T(".-_"), 2, _T('\0') }, /* host, mandatory */ +- { _T('/'), _T('/'), _T("/.-_"), 0, _T('/') }, /* path */ +- { _T('?'), _T('&'), _T("&.-+_=()"), 1, _T('\0') }, /* query */ +- { _T('#'), _T('\0'), _T(".-+_") , 1, _T('\0') } /* fragment */ ++ { _T('\0'), _T('.'), _T(".-_"), 2, _T('\0') }, /* host, mandatory */ ++ { _T('/'), _T('/'), _T("/.-_():~%+@&!,;"), 0, _T('/') }, /* path */ ++ { _T('?'), _T('&'), _T("&.-+_=().:!~%,;@"), 1, _T('\0') }, /* query */ ++ { _T('#'), _T('\0'), _T(".-+_:%~") , 1, _T('\0') } /* fragment */ + }; + + MD_MARK* opener = &ctx->marks[mark_index]; +@@ -3981,8 +3981,11 @@ + } else if(end < line_end && + ISANYOF(end, URL_MAP[i].allowed_nonalnum_chars) && + md_scan_right_for_resolved_mark(ctx, right_cursor, end, &right_cursor) == NULL && +- ((end > line_beg && (ISALNUM(end-1) || CH(end-1) == _T(')'))) || CH(end) == _T('(')) && +- ((end+1 < line_end && (ISALNUM(end+1) || CH(end+1) == _T('('))) || CH(end) == _T(')'))) ++ /* Scheme URLs keep '...' and ':' in the path and query. ++ * Host, e-mail, and www labels stay flanked by alphanumerics. */ ++ ((opener->ch == _T(':') && i > 0) || ++ (((end > line_beg && (ISALNUM(end-1) || CH(end-1) == _T(')'))) || CH(end) == _T('(')) && ++ ((end+1 < line_end && (ISALNUM(end+1) || CH(end+1) == _T('('))) || CH(end) == _T(')'))))) + { + if(CH(end) == URL_MAP[i].delim_char) + n_components++; +@@ -4006,16 +4009,35 @@ + CH(end) == URL_MAP[i].optional_end_char) + end++; + +- if(n_components < URL_MAP[i].min_components || n_open_brackets != 0) ++ /* Scheme autolinks allow one host label, so localhost and devbox link. ++ * E-mail and www autolinks still require a dotted host. */ ++ if(n_components < (i == 0 && opener->ch == ':' ? 1 : URL_MAP[i].min_components) || ++ n_open_brackets != 0) + return; + ++ /* Optional port on a scheme autolink: ':' followed by digits. */ ++ if(i == 0 && opener->ch == ':' && end + 1 < line_end && ++ CH(end) == _T(':') && ISDIGIT(end + 1)) { ++ end++; ++ while(end < line_end && ISDIGIT(end)) ++ end++; ++ } ++ + if(opener->ch == '@') /* E-mail autolinks wants only the host. */ + break; ++ } ++ ++ /* GFM leaves a trailing run of punctuation outside the link, including ++ * the sentence dots in "https://example.com/a...". */ ++ if(opener->ch == _T(':')) { ++ while(end > opener->end && ISANYOF(end - 1, _T("?!.,:*"))) ++ end--; + } + + /* Verify there's line boundary, whitespace, allowed punctuation or + * resolved emphasis mark just after the suspected autolink. */ +- if(end == line_end || ISUNICODEWHITESPACE(end) || ISANYOF(end, _T(")}].!?,;"))) { ++ if(end == line_end || ISUNICODEWHITESPACE(end) || ++ ISANYOF(end, opener->ch == _T(':') ? _T(")}].!?,;:*_~'\"`<>") : _T(")}].!?,;"))) { + right_boundary_ok = TRUE; + } else { + MD_MARK* right_mark; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e2feb8d278e..c001be981c10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,7 @@ catalogs: version: 0.3.3 overrides: + '@exodus/bytes>@noble/hashes': 1.8.0 '@react-navigation/core': 7.21.13 dbus-next>usocket: '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-arm64': '-' @@ -108,6 +109,7 @@ patchedDependencies: expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 + react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0 @@ -134,7 +136,7 @@ importers: version: 7.0.2 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -213,7 +215,7 @@ importers: version: 4.3.3 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -298,7 +300,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(6f0a94b6f541bbd4be3f343e27991996) + version: file:apps/mobile/modules/t3-markdown-text(c883758f8793b29a89016166016b354e) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -445,7 +447,7 @@ importers: version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: 0.35.9 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -576,7 +578,7 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -764,6 +766,9 @@ importers: compression: specifier: ^1.8.1 version: 1.8.1 + jsdom: + specifier: ^30.0.1 + version: 30.1.0(@noble/hashes@1.8.0) react-test-renderer: specifier: 19.2.6 version: 19.2.6(react@19.2.6) @@ -778,7 +783,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -833,7 +838,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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) oxlint-plugin-t3code: dependencies: @@ -852,7 +857,7 @@ importers: version: 4.0.0-rc.115(patch_hash=172bd95ef73a13d322888984415d5d18d9d2aa8e13fa529eac235efac837e902)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)) vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -895,7 +900,7 @@ importers: version: 2.0.2 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -908,7 +913,7 @@ importers: version: 4.0.0-rc.115(patch_hash=172bd95ef73a13d322888984415d5d18d9d2aa8e13fa529eac235efac837e902)(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)) vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -930,7 +935,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -952,7 +957,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -986,7 +991,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/ssh: dependencies: @@ -1011,7 +1016,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -1030,7 +1035,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -1073,7 +1078,7 @@ importers: version: typescript@6.0.3 vite-plus: specifier: 'catalog:' - version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -1143,6 +1148,14 @@ packages: zod: optional: true + '@asamuzakjp/css-color@7.0.1': + resolution: {integrity: sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@9.2.1': + resolution: {integrity: sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@astrojs/check@0.9.10': resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} hasBin: true @@ -1771,6 +1784,10 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bruits/satteri-darwin-arm64@0.10.5': resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} cpu: [arm64] @@ -2026,6 +2043,42 @@ packages: resolution: {integrity: sha512-WTYHwlBhImGmmMt81crxaRvNdZXlBNemFyrP5GGHj2WeDTJMBrHgHTaladTypXHXrviFFBBGROl5moijRQ4ODA==} engines: {node: '>=18'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.0': + resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.3': + resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.1': + resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==} + engines: {node: '>=20.19.0'} + '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} @@ -2647,6 +2700,15 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': 1.8.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@exodus/schemasafe@1.3.0': resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} @@ -6280,6 +6342,9 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -6721,6 +6786,10 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} @@ -6756,6 +6825,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -7119,6 +7191,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -7908,6 +7984,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -8046,6 +8126,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -8142,6 +8225,15 @@ packages: jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + jsdom@30.1.0: + resolution: {integrity: sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -8365,6 +8457,10 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -9149,6 +9245,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -9873,6 +9972,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -10339,6 +10442,10 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -10346,6 +10453,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -10836,6 +10947,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -10862,12 +10977,20 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + whatwg-url-minimum@0.1.2: resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} @@ -10875,6 +10998,14 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.2: + resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -10946,6 +11077,10 @@ packages: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -10970,6 +11105,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -11148,6 +11286,21 @@ snapshots: optionalDependencies: zod: 4.6.5 + '@asamuzakjp/css-color@7.0.1': + dependencies: + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1) + '@csstools/css-color-parser': 4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1) + '@csstools/css-tokenizer': 4.0.1 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@9.2.1': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + '@astrojs/check@0.9.10(prettier@3.8.3)(typescript@6.0.3)': dependencies: '@astrojs/language-server': 2.16.10(prettier@3.8.3)(typescript@6.0.3) @@ -12016,6 +12169,10 @@ snapshots: '@blazediff/core@1.9.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bruits/satteri-darwin-arm64@0.10.5': optional: true @@ -12247,6 +12404,30 @@ snapshots: '@crowecawcaw/xa11y-win32-arm64-msvc': 0.13.0 '@crowecawcaw/xa11y-win32-x64-msvc': 0.13.0 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1) + '@csstools/css-tokenizer': 4.0.1 + + '@csstools/css-color-parser@4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1) + '@csstools/css-tokenizer': 4.0.1 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1)': + dependencies: + '@csstools/css-tokenizer': 4.0.1 + + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.1': {} + '@date-fns/tz@1.5.0': {} '@daypicker/react@10.0.1(@types/react@19.2.16)(react@19.2.6)': @@ -12792,6 +12973,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@exodus/schemasafe@1.3.0': {} '@expo-google-fonts/dm-sans@0.4.2': {} @@ -15082,7 +15267,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(6f0a94b6f541bbd4be3f343e27991996)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(c883758f8793b29a89016166016b354e)': dependencies: '@t3tools/client-runtime': link:packages/client-runtime '@t3tools/shared': link:packages/shared @@ -15093,7 +15278,7 @@ snapshots: expo-symbols: 57.0.2(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-nitro-markdown: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -15774,12 +15959,12 @@ snapshots: '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.2.5) babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)))': + '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))) - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) transitivePeerDependencies: - bufferutil - msw @@ -15791,14 +15976,14 @@ snapshots: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vitest@4.1.11) - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)))': + '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)))': dependencies: '@blazediff/core': 1.9.1 '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) @@ -15807,7 +15992,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -15824,7 +16009,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -16600,6 +16785,10 @@ snapshots: before-after-hook@4.0.0: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + big-integer@1.6.52: {} bippy@0.5.41(react@19.2.6): @@ -17047,6 +17236,13 @@ snapshots: culori@4.0.2: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + date-fns@4.4.0: {} dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4): @@ -17075,6 +17271,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -17344,6 +17542,8 @@ snapshots: entities@6.0.1: {} + entities@8.1.0: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -18482,6 +18682,12 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@3.0.3: {} html-url-attributes@3.0.1: {} @@ -18607,6 +18813,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-unicode-supported@2.1.0: {} @@ -18688,6 +18896,31 @@ snapshots: jsc-safe-url@0.2.4: {} + jsdom@30.1.0(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 7.0.1 + '@asamuzakjp/dom-selector': 9.2.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.2(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -18887,6 +19120,8 @@ snapshots: lru-cache@11.5.2: {} + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -20103,7 +20338,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - oxfmt@0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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)): + oxfmt@0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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)): dependencies: tinypool: 2.1.2 optionalDependencies: @@ -20126,9 +20361,9 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.68.0 '@oxfmt/binding-win32-ia32-msvc': 0.68.0 '@oxfmt/binding-win32-x64-msvc': 0.68.0 - vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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) + vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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) - oxfmt@0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.2 optionalDependencies: @@ -20151,7 +20386,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.68.0 '@oxfmt/binding-win32-ia32-msvc': 0.68.0 '@oxfmt/binding-win32-x64-msvc': 0.68.0 - vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -20162,7 +20397,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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)): + oxlint@1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.83.0 '@oxlint/binding-android-arm64': 1.83.0 @@ -20184,9 +20419,9 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.83.0 '@oxlint/binding-win32-x64-msvc': 1.83.0 oxlint-tsgolint: 7.0.2001 - vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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) + vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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) - oxlint@1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.83.0 '@oxlint/binding-android-arm64': 1.83.0 @@ -20208,7 +20443,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.83.0 '@oxlint/binding-win32-x64-msvc': 1.83.0 oxlint-tsgolint: 7.0.2001 - vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -20261,6 +20496,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -20550,8 +20789,7 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: - optional: true + punycode@2.3.1: {} pvtsutils@1.3.6: dependencies: @@ -20677,7 +20915,7 @@ snapshots: react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: 4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -21198,6 +21436,10 @@ snapshots: sax@1.6.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} schema-utils@4.3.3: @@ -21687,13 +21929,11 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.4.2: - optional: true + tldts-core@7.4.2: {} tldts@7.4.2: dependencies: tldts-core: 7.4.2 - optional: true tmp-promise@3.0.3: dependencies: @@ -21718,6 +21958,10 @@ snapshots: tldts: 7.4.2 optional: true + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.2 + tr46@0.0.3: {} tr46@5.1.1: @@ -21725,6 +21969,10 @@ snapshots: punycode: 2.3.1 optional: true + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -22003,12 +22251,12 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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): + vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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): dependencies: '@oxc-project/types': 0.150.0 '@oxlint/plugins': 1.79.0 - '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))) - '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))) '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.11 @@ -22016,11 +22264,11 @@ snapshots: '@vitest/snapshot': 4.1.11 '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 - oxfmt: 0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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)) - oxlint: 1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(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)) + oxfmt: 0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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)) + oxlint: 1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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)) oxlint-tsgolint: 7.0.2001 vite: '@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vitest@4.1.11))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vitest@4.1.11))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.3.3 '@voidzero-dev/vite-plus-darwin-x64': 0.3.3 @@ -22060,7 +22308,7 @@ snapshots: - utf-8-validate - yaml - vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.150.0 '@oxlint/plugins': 1.79.0 @@ -22073,11 +22321,11 @@ snapshots: '@vitest/snapshot': 4.1.11 '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 - oxfmt: 0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxfmt: 0.68.0(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.83.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.3(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 7.0.2001 vite: '@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.3.3 '@voidzero-dev/vite-plus-darwin-x64': 0.3.3 @@ -22121,7 +22369,7 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vitest@4.1.11))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vitest@4.1.11))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) @@ -22146,10 +22394,11 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vitest@4.1.11) + jsdom: 30.1.0(@noble/hashes@1.8.0) transitivePeerDependencies: - msw - vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)): + vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2)) @@ -22174,6 +22423,7 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(utf-8-validate@6.0.6)(vitest@4.1.11) + jsdom: 30.1.0(@noble/hashes@1.8.0) transitivePeerDependencies: - msw @@ -22278,6 +22528,10 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} walker@1.0.8: @@ -22305,10 +22559,14 @@ snapshots: webidl-conversions@7.0.0: optional: true + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} whatwg-fetch@3.6.20: {} + whatwg-mimetype@5.0.0: {} + whatwg-url-minimum@0.1.2: {} whatwg-url@14.2.0: @@ -22317,6 +22575,22 @@ snapshots: webidl-conversions: 7.0.0 optional: true + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.2(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -22383,6 +22657,8 @@ snapshots: simple-plist: 1.3.1 uuid: 7.0.3 + xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} xml-naming@0.3.0: {} @@ -22401,6 +22677,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ccd9e4436a3..56c740febf1f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -98,6 +98,8 @@ minimumReleaseAgeExclude: - cn@0.3.2 overrides: + # Keep jsdom's optional peer consistent so Vite+ has one test-runner instance. + "@exodus/bytes>@noble/hashes": "1.8.0" # Earlier React Navigation declarations create a circular type alias in TypeScript 7. "@react-navigation/core": 7.21.13 # The dbus-next patch drops usocket from the package, but pnpm resolves from the @@ -197,6 +199,8 @@ patchedDependencies: react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch + # Link http(s) URLs with a port or a single-label host. md4c otherwise requires a dotted host and stops at ':'. + react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch # Preserve the final layout frame. Backport of [#10171](https://github.com/software-mansion/react-native-reanimated/pull/10171). react-native-reanimated@4.5.5: patches/react-native-reanimated@4.5.5.patch react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch