From af7ec2c0bf29929c1e2eec5358e67a170897c7e7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:00:33 +0000 Subject: [PATCH 001/215] perf(server): load pending migrations and cache current schemas --- apps/server/src/persistence/Migrations.ts | 493 +++++++++++++----- .../reconcileV2PreviewMigration.test.ts | 35 ++ .../reconcileV2PreviewMigration.ts | 10 +- 3 files changed, 406 insertions(+), 132 deletions(-) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 537854640c2ff..f19ca67103347 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -2,7 +2,7 @@ * Migration runner with an inline loader. * * Uses Migrator.make with fromRecord to define migrations inline. - * All migrations are statically imported - no dynamic file system loading. + * Pending migrations load on demand through explicit module imports. * * `runMigrations` is called by the SQLite persistence layer at startup, so the * schema is always up to date before the application starts. @@ -13,71 +13,15 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; import { reconcileV2PreviewMigration } from "./reconcileV2PreviewMigration.ts"; -// Import all migrations statically -import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; -import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts"; -import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; -import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; -import Migration0005 from "./Migrations/005_Projections.ts"; -import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; -import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; -import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"; -import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts"; -import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; -import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; -import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; -import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; -import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; -import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; -import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; -import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; -import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; -import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; -import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; -import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; -import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; -import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; -import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; -import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; -import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; -import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; -import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; -import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; -import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; -import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; -import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; -import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; -import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; -import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; -import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; -import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; -import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; -import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; -import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; -import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; -import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; -import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; -import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; -import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; -import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; -import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; -import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; -import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; -import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; -import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; -import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; -import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; -import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; -import Migration0055 from "./Migrations/055_OrchestrationV2.ts"; -import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts"; -import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; -import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; -import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; -import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; -import Migration0061 from "./Migrations/061_ProjectionThreadSweepIndexes.ts"; -import Migration0062, { - ensureThreadReadProjection, -} from "./Migrations/062_ProjectionThreadReadPayload.ts"; +// The migrator only evaluates pending effects, so an up-to-date database never +// loads historical schemas or their data transforms. +const loadMigration = ( + load: () => Promise<{ default: Effect.Effect }>, +) => + Effect.promise(load).pipe( + Effect.flatMap((module) => module.default), + Effect.asVoid, + ); /** * Migration loader with all migrations defined inline. @@ -90,75 +34,312 @@ import Migration0062, { * returns migrations sorted by ID. */ export const migrationEntries = [ - [1, "OrchestrationEvents", Migration0001], - [2, "OrchestrationCommandReceipts", Migration0002], - [3, "CheckpointDiffBlobs", Migration0003], - [4, "ProviderSessionRuntime", Migration0004], - [5, "Projections", Migration0005], - [6, "ProjectionThreadSessionRuntimeModeColumns", Migration0006], - [7, "ProjectionThreadMessageAttachments", Migration0007], - [8, "ProjectionThreadActivitySequence", Migration0008], - [9, "ProviderSessionRuntimeMode", Migration0009], - [10, "ProjectionThreadsRuntimeMode", Migration0010], - [11, "OrchestrationThreadCreatedRuntimeMode", Migration0011], - [12, "ProjectionThreadsInteractionMode", Migration0012], - [13, "ProjectionThreadProposedPlans", Migration0013], - [14, "ProjectionThreadProposedPlanImplementation", Migration0014], - [15, "ProjectionTurnsSourceProposedPlan", Migration0015], - [16, "CanonicalizeModelSelections", Migration0016], - [17, "ProjectionThreadsArchivedAt", Migration0017], - [18, "ProjectionThreadsArchivedAtIndex", Migration0018], - [19, "ProjectionSnapshotLookupIndexes", Migration0019], - [20, "AuthAccessManagement", Migration0020], - [21, "AuthSessionClientMetadata", Migration0021], - [22, "AuthSessionLastConnectedAt", Migration0022], - [23, "ProjectionThreadShellSummary", Migration0023], - [24, "BackfillProjectionThreadShellSummary", Migration0024], - [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], - [26, "CanonicalizeModelSelectionOptions", Migration0026], - [27, "ProviderSessionRuntimeInstanceId", Migration0027], - [28, "ProjectionThreadSessionInstanceId", Migration0028], - [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], - [30, "ProjectionThreadShellArchiveIndexes", Migration0030], - [31, "AuthAuthorizationScopes", Migration0031], - [32, "AuthPairingProofKeyThumbprint", Migration0032], - [33, "ProjectionThreadsSettled", Migration0033], - [34, "ProjectionThreadsSnoozed", Migration0034], - [35, "ProjectionThreadTitleRegeneration", Migration0035], - [36, "ProjectionThreadsPinned", Migration0036], - [37, "ProjectionTurnsKeysetIndex", Migration0037], - [38, "ProjectionThreadsPinOrderKey", Migration0038], - [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], - [40, "ProjectionProjectFaviconPath", Migration0040], - [41, "AuthSessionClientConnection", Migration0041], - [42, "ProjectionThreadLinkedPullRequest", Migration0042], - [43, "ProjectionThreadsUnsettledAt", Migration0043], - [44, "ClearAutomaticProjectModelDefaults", Migration0044], - [45, "ProjectionProjectsAutoPull", Migration0045], - [46, "RepairAutomaticSettlementTimestamps", Migration0046], - [47, "ProjectionProjectIcon", Migration0047], - [48, "ProjectionThreadBranchPullRequest", Migration0048], - [49, "ProjectionThreadsActiveOrderKey", Migration0049], - [50, "ProjectionThreadPullRequests", Migration0050], - [51, "ProjectionThreadMessageContext", Migration0051], - [52, "ProjectionThreadTitleState", Migration0052], - [53, "PullRequestFilesViewed", Migration0053], - [54, "ProjectionThreadsAutoSettleDisabledAt", Migration0054], + [ + 1, + "OrchestrationEvents", + loadMigration(() => import("./Migrations/001_OrchestrationEvents.ts")), + ], + [ + 2, + "OrchestrationCommandReceipts", + loadMigration(() => import("./Migrations/002_OrchestrationCommandReceipts.ts")), + ], + [ + 3, + "CheckpointDiffBlobs", + loadMigration(() => import("./Migrations/003_CheckpointDiffBlobs.ts")), + ], + [ + 4, + "ProviderSessionRuntime", + loadMigration(() => import("./Migrations/004_ProviderSessionRuntime.ts")), + ], + [5, "Projections", loadMigration(() => import("./Migrations/005_Projections.ts"))], + [ + 6, + "ProjectionThreadSessionRuntimeModeColumns", + loadMigration(() => import("./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts")), + ], + [ + 7, + "ProjectionThreadMessageAttachments", + loadMigration(() => import("./Migrations/007_ProjectionThreadMessageAttachments.ts")), + ], + [ + 8, + "ProjectionThreadActivitySequence", + loadMigration(() => import("./Migrations/008_ProjectionThreadActivitySequence.ts")), + ], + [ + 9, + "ProviderSessionRuntimeMode", + loadMigration(() => import("./Migrations/009_ProviderSessionRuntimeMode.ts")), + ], + [ + 10, + "ProjectionThreadsRuntimeMode", + loadMigration(() => import("./Migrations/010_ProjectionThreadsRuntimeMode.ts")), + ], + [ + 11, + "OrchestrationThreadCreatedRuntimeMode", + loadMigration(() => import("./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts")), + ], + [ + 12, + "ProjectionThreadsInteractionMode", + loadMigration(() => import("./Migrations/012_ProjectionThreadsInteractionMode.ts")), + ], + [ + 13, + "ProjectionThreadProposedPlans", + loadMigration(() => import("./Migrations/013_ProjectionThreadProposedPlans.ts")), + ], + [ + 14, + "ProjectionThreadProposedPlanImplementation", + loadMigration(() => import("./Migrations/014_ProjectionThreadProposedPlanImplementation.ts")), + ], + [ + 15, + "ProjectionTurnsSourceProposedPlan", + loadMigration(() => import("./Migrations/015_ProjectionTurnsSourceProposedPlan.ts")), + ], + [ + 16, + "CanonicalizeModelSelections", + loadMigration(() => import("./Migrations/016_CanonicalizeModelSelections.ts")), + ], + [ + 17, + "ProjectionThreadsArchivedAt", + loadMigration(() => import("./Migrations/017_ProjectionThreadsArchivedAt.ts")), + ], + [ + 18, + "ProjectionThreadsArchivedAtIndex", + loadMigration(() => import("./Migrations/018_ProjectionThreadsArchivedAtIndex.ts")), + ], + [ + 19, + "ProjectionSnapshotLookupIndexes", + loadMigration(() => import("./Migrations/019_ProjectionSnapshotLookupIndexes.ts")), + ], + [ + 20, + "AuthAccessManagement", + loadMigration(() => import("./Migrations/020_AuthAccessManagement.ts")), + ], + [ + 21, + "AuthSessionClientMetadata", + loadMigration(() => import("./Migrations/021_AuthSessionClientMetadata.ts")), + ], + [ + 22, + "AuthSessionLastConnectedAt", + loadMigration(() => import("./Migrations/022_AuthSessionLastConnectedAt.ts")), + ], + [ + 23, + "ProjectionThreadShellSummary", + loadMigration(() => import("./Migrations/023_ProjectionThreadShellSummary.ts")), + ], + [ + 24, + "BackfillProjectionThreadShellSummary", + loadMigration(() => import("./Migrations/024_BackfillProjectionThreadShellSummary.ts")), + ], + [ + 25, + "CleanupInvalidProjectionPendingApprovals", + loadMigration(() => import("./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts")), + ], + [ + 26, + "CanonicalizeModelSelectionOptions", + loadMigration(() => import("./Migrations/026_CanonicalizeModelSelectionOptions.ts")), + ], + [ + 27, + "ProviderSessionRuntimeInstanceId", + loadMigration(() => import("./Migrations/027_ProviderSessionRuntimeInstanceId.ts")), + ], + [ + 28, + "ProjectionThreadSessionInstanceId", + loadMigration(() => import("./Migrations/028_ProjectionThreadSessionInstanceId.ts")), + ], + [ + 29, + "ProjectionThreadDetailOrderingIndexes", + loadMigration(() => import("./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts")), + ], + [ + 30, + "ProjectionThreadShellArchiveIndexes", + loadMigration(() => import("./Migrations/030_ProjectionThreadShellArchiveIndexes.ts")), + ], + [ + 31, + "AuthAuthorizationScopes", + loadMigration(() => import("./Migrations/031_AuthAuthorizationScopes.ts")), + ], + [ + 32, + "AuthPairingProofKeyThumbprint", + loadMigration(() => import("./Migrations/032_AuthPairingProofKeyThumbprint.ts")), + ], + [ + 33, + "ProjectionThreadsSettled", + loadMigration(() => import("./Migrations/033_ProjectionThreadsSettled.ts")), + ], + [ + 34, + "ProjectionThreadsSnoozed", + loadMigration(() => import("./Migrations/034_ProjectionThreadsSnoozed.ts")), + ], + [ + 35, + "ProjectionThreadTitleRegeneration", + loadMigration(() => import("./Migrations/035_ProjectionThreadTitleRegeneration.ts")), + ], + [ + 36, + "ProjectionThreadsPinned", + loadMigration(() => import("./Migrations/036_ProjectionThreadsPinned.ts")), + ], + [ + 37, + "ProjectionTurnsKeysetIndex", + loadMigration(() => import("./Migrations/037_ProjectionTurnsKeysetIndex.ts")), + ], + [ + 38, + "ProjectionThreadsPinOrderKey", + loadMigration(() => import("./Migrations/038_ProjectionThreadsPinOrderKey.ts")), + ], + [ + 39, + "ProjectionProjectsDefaultThreadEnvMode", + loadMigration(() => import("./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts")), + ], + [ + 40, + "ProjectionProjectFaviconPath", + loadMigration(() => import("./Migrations/040_ProjectionProjectFaviconPath.ts")), + ], + [ + 41, + "AuthSessionClientConnection", + loadMigration(() => import("./Migrations/041_AuthSessionClientConnection.ts")), + ], + [ + 42, + "ProjectionThreadLinkedPullRequest", + loadMigration(() => import("./Migrations/042_ProjectionThreadLinkedPullRequest.ts")), + ], + [ + 43, + "ProjectionThreadsUnsettledAt", + loadMigration(() => import("./Migrations/043_ProjectionThreadsUnsettledAt.ts")), + ], + [ + 44, + "ClearAutomaticProjectModelDefaults", + loadMigration(() => import("./Migrations/044_ClearAutomaticProjectModelDefaults.ts")), + ], + [ + 45, + "ProjectionProjectsAutoPull", + loadMigration(() => import("./Migrations/045_ProjectionProjectsAutoPull.ts")), + ], + [ + 46, + "RepairAutomaticSettlementTimestamps", + loadMigration(() => import("./Migrations/046_RepairAutomaticSettlementTimestamps.ts")), + ], + [ + 47, + "ProjectionProjectIcon", + loadMigration(() => import("./Migrations/047_ProjectionProjectIcon.ts")), + ], + [ + 48, + "ProjectionThreadBranchPullRequest", + loadMigration(() => import("./Migrations/048_ProjectionThreadBranchPullRequest.ts")), + ], + [ + 49, + "ProjectionThreadsActiveOrderKey", + loadMigration(() => import("./Migrations/049_ProjectionThreadsActiveOrderKey.ts")), + ], + [ + 50, + "ProjectionThreadPullRequests", + loadMigration(() => import("./Migrations/050_ProjectionThreadPullRequests.ts")), + ], + [ + 51, + "ProjectionThreadMessageContext", + loadMigration(() => import("./Migrations/051_ProjectionThreadMessageContext.ts")), + ], + [ + 52, + "ProjectionThreadTitleState", + loadMigration(() => import("./Migrations/052_ProjectionThreadTitleState.ts")), + ], + [ + 53, + "PullRequestFilesViewed", + loadMigration(() => import("./Migrations/053_PullRequestFilesViewed.ts")), + ], + [ + 54, + "ProjectionThreadsAutoSettleDisabledAt", + loadMigration(() => import("./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts")), + ], // Released as 53 and 54 in V2 previews; reconcileV2PreviewMigration preserves their ledger. // Preserve this migration's schema. Future V2 schema changes need new migrations. - [55, "OrchestrationV2", Migration0055], - [56, "RemoveRedundantProjectionIndexes", Migration0056], - [57, "ScheduledTaskWebhooks", Migration0057], - [58, "WebhookRelayDeliveries", Migration0058], - [59, "McpAppModelContext", Migration0059], - [60, "ThreadSnapshotWindowIndexes", Migration0060], + [55, "OrchestrationV2", loadMigration(() => import("./Migrations/055_OrchestrationV2.ts"))], + [ + 56, + "RemoveRedundantProjectionIndexes", + loadMigration(() => import("./Migrations/056_RemoveRedundantProjectionIndexes.ts")), + ], + [ + 57, + "ScheduledTaskWebhooks", + loadMigration(() => import("./Migrations/057_ScheduledTaskWebhooks.ts")), + ], + [ + 58, + "WebhookRelayDeliveries", + loadMigration(() => import("./Migrations/058_WebhookRelayDeliveries.ts")), + ], + [59, "McpAppModelContext", loadMigration(() => import("./Migrations/059_McpAppModelContext.ts"))], + [ + 60, + "ThreadSnapshotWindowIndexes", + loadMigration(() => import("./Migrations/060_ThreadSnapshotWindowIndexes.ts")), + ], // Published previews ran PeerLinks as 61, so their databases skip this one (runMigrations // warns). They only lose the sweep index: no query names it with INDEXED BY. - [61, "ProjectionThreadSweepIndexes", Migration0061], - [62, "ProjectionThreadReadPayload", Migration0062], + [ + 61, + "ProjectionThreadSweepIndexes", + loadMigration(() => import("./Migrations/061_ProjectionThreadSweepIndexes.ts")), + ], + [ + 62, + "ProjectionThreadReadPayload", + loadMigration(() => import("./Migrations/062_ProjectionThreadReadPayload.ts")), + ], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); +const manifestKey = migrationManifest.map(([id, name]) => `${id}_${name}`).join(","); const makeMigrationLoader = (throughId?: number) => Migrator.fromRecord( @@ -192,6 +373,23 @@ export interface RunMigrationsOptions { export const runMigrations = Effect.fn("runMigrations")(function* ({ toMigrationInclusive, }: RunMigrationsOptions = {}) { + const sql = yield* SqlClient.SqlClient; + if (toMigrationInclusive === undefined) { + const cacheTables = yield* sql`SELECT name FROM sqlite_master WHERE name = 't3_schema_cache'`; + if (cacheTables.length > 0) { + const version = yield* sql<{ schema_version: number }>`PRAGMA schema_version`; + const cached = yield* sql<{ manifest: string; schema_version: number }>` + SELECT manifest, schema_version FROM t3_schema_cache WHERE id = 1 + `; + if ( + cached[0]?.manifest === manifestKey && + cached[0]?.schema_version === version[0]?.schema_version + ) { + yield* Effect.logDebug("Database schema is current"); + return []; + } + } + } const previewMigrations = toMigrationInclusive === undefined || toMigrationInclusive >= 55 ? yield* reconcileV2PreviewMigration() @@ -209,7 +407,6 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ // migration under a shared id (local or fork builds) keeps that id and // silently skips this build's migration at it. Surface the divergence so the // skipped schema change is diagnosable. - const sql = yield* SqlClient.SqlClient; const recorded = yield* sql<{ readonly migration_id: number; readonly name: string; @@ -243,7 +440,45 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ } // ProjectionStore reads this table, so it must exist even when its migration was skipped. if (toMigrationInclusive === undefined || toMigrationInclusive >= 62) { + const { ensureThreadReadProjection } = yield* Effect.promise( + () => import("./Migrations/062_ProjectionThreadReadPayload.ts"), + ); yield* ensureThreadReadProjection; } + if (toMigrationInclusive === undefined && divergent.length === 0 && skipped.length === 0) { + // Other builds can share this database. Schema changes miss this cache; ledger + // writes invalidate it even when they do not change SQLite's schema version. + yield* sql.withTransaction( + Effect.gen(function* () { + // Revalidate under the write transaction before caching a shared database. + const history = yield* sql<{ migration_id: number; name: string }>` + SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id + `; + if ( + history.length !== migrationManifest.length || + history.some( + (row, index) => + row.migration_id !== migrationManifest[index]?.[0] || + row.name !== migrationManifest[index]?.[1], + ) + ) + return; + const { ensureThreadReadProjection } = yield* Effect.promise( + () => import("./Migrations/062_ProjectionThreadReadPayload.ts"), + ); + yield* ensureThreadReadProjection; + yield* sql`CREATE TABLE IF NOT EXISTS t3_schema_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), manifest TEXT NOT NULL, schema_version INTEGER NOT NULL + )`; + for (const operation of ["INSERT", "UPDATE", "DELETE"] as const) { + yield* sql.unsafe(`CREATE TRIGGER IF NOT EXISTS t3_schema_cache_${operation.toLowerCase()} + AFTER ${operation} ON effect_sql_migrations BEGIN DELETE FROM t3_schema_cache; END`); + } + const version = yield* sql<{ schema_version: number }>`PRAGMA schema_version`; + yield* sql`INSERT OR REPLACE INTO t3_schema_cache (id, manifest, schema_version) + VALUES (1, ${manifestKey}, ${version[0]!.schema_version})`; + }), + ); + } return executedMigrations; }); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 6440b95b4242e..709b0199d1a59 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -146,3 +146,38 @@ describe("V2 preview upgrade", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); }); + +it.effect("revalidates a cached schema after another build changes its ledger or schema", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + assert.equal((yield* sql`SELECT * FROM t3_schema_cache`).length, 1); + yield* runMigrations(); + yield* sql`DELETE FROM effect_sql_migrations WHERE migration_id = 62`; + assert.deepStrictEqual(yield* sql`SELECT * FROM t3_schema_cache`, []); + assert.deepStrictEqual(yield* runMigrations(), [[62, "ProjectionThreadReadPayload"]]); + + yield* sql`UPDATE effect_sql_migrations SET name = 'OtherBuild' WHERE migration_id = 62`; + yield* runMigrations(); + assert.deepStrictEqual( + yield* sql`SELECT name FROM effect_sql_migrations WHERE migration_id = 62`, + [{ name: "OtherBuild" }], + ); + yield* sql`UPDATE effect_sql_migrations SET name = 'ProjectionThreadReadPayload' WHERE migration_id = 62`; + yield* runMigrations(); + + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (63, 'NewerBuild')`; + yield* runMigrations(); + assert.deepStrictEqual( + yield* sql`SELECT name FROM effect_sql_migrations WHERE migration_id = 63`, + [{ name: "NewerBuild" }], + ); + yield* sql`DELETE FROM effect_sql_migrations WHERE migration_id = 63`; + yield* runMigrations(); + + yield* sql`DROP TABLE orchestration_v2_projection_thread_reads`; + yield* runMigrations(); + assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_projection_thread_reads`, []); + assert.deepStrictEqual(yield* runMigrations(), []); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), +); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.ts index cbf83ef55f57d..9bfffa7842557 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.ts @@ -2,9 +2,6 @@ import * as Effect from "effect/Effect"; import * as Migrator from "effect/sql/Migrator"; import * as SqlClient from "effect/sql/SqlClient"; -import PullRequestFilesViewed from "./Migrations/053_PullRequestFilesViewed.ts"; -import AutoSettleDisabledAt from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; - // Published previews assigned V2 to 53, then 54. Keep their schema and import // progress intact while reserving main's migration ids for upgrades from main. export const reconcileV2PreviewMigration = Effect.fn("reconcileV2PreviewMigration")(function* () { @@ -36,6 +33,13 @@ export const reconcileV2PreviewMigration = Effect.fn("reconcileV2PreviewMigratio message: "Cannot upgrade V2 preview with unexpected later migrations.", }); } + const [{ default: PullRequestFilesViewed }, { default: AutoSettleDisabledAt }] = + yield* Effect.promise(() => + Promise.all([ + import("./Migrations/053_PullRequestFilesViewed.ts"), + import("./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"), + ]), + ); const executed: Array = []; if (legacy.migration_id === 53) { yield* PullRequestFilesViewed; From 8dadb4bbed72eda0835fd57db8f3e9e419953a2d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:00:38 +0000 Subject: [PATCH 002/215] perf(server): parallelize boot reads and streamline desktop startup --- apps/server/src/bin.ts | 3 ++ apps/server/src/binLightCli.ts | 21 +++++++--- apps/server/src/cliCompileCache.ts | 11 +++-- apps/server/src/serverRuntimeStartup.ts | 55 +++++++++++++------------ apps/server/src/ws.ts | 39 +++++++++++------- 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 58ccc79c6ef9b..d59e2b571d035 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -40,6 +40,9 @@ if ( } if (process.argv.length === 3 && command === "--version") { process.stdout.write(`t3 v${packageJson.version}\n`); + } else if (command === "--bootstrap-fd" || command?.startsWith("--bootstrap-fd=")) { + const { runLightCli } = await import("./binLightCli.ts"); + await runLightCli("desktop"); } else if ( command === "pair" || command === "auth" || diff --git a/apps/server/src/binLightCli.ts b/apps/server/src/binLightCli.ts index 0e42193052b4b..075a46a795d27 100644 --- a/apps/server/src/binLightCli.ts +++ b/apps/server/src/binLightCli.ts @@ -9,8 +9,22 @@ import { sharedServerCommandFlags } from "./cli/config.ts"; /** Load only the requested command; keep parsing and execution in the existing CLI. */ export async function runLightCli( - command: "pair" | "auth" | "service" | "project" | "serve" | "start", + command: "pair" | "auth" | "service" | "project" | "serve" | "start" | "desktop", ) { + const root = Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + ); + if (command === "desktop") { + const { runDefaultServerCommand } = await import("./cli/server.ts"); + Command.run(root.pipe(Command.withHandler(runDefaultServerCommand)), { + version: packageJson.version, + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), + NodeRuntime.runMain, + ); + return; + } const selected = command === "pair" ? (await import("./cli/pair.ts")).pairCommand @@ -23,10 +37,7 @@ export async function runLightCli( : command === "serve" ? (await import("./cli/server.ts")).serveCommand : (await import("./cli/server.ts")).startCommand; - const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withSubcommands([selected]), - ); + const cli = root.pipe(Command.withSubcommands([selected])); Command.run(cli, { version: packageJson.version }).pipe( Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), diff --git a/apps/server/src/cliCompileCache.ts b/apps/server/src/cliCompileCache.ts index ec1edf95fcb7e..47daa9b76aad2 100644 --- a/apps/server/src/cliCompileCache.ts +++ b/apps/server/src/cliCompileCache.ts @@ -47,9 +47,12 @@ export function enableCliCompileCache(input: { if (status !== NodeModule.constants.compileCacheStatus.ENABLED) return; // Bundle chunk names change with their contents, so other versions' entries // never hit again. Only the running version's directory stays. - for (const entry of NodeFS.readdirSync(root)) { - if (entry === input.version) continue; - NodeFS.rm(NodePath.join(root, entry), { recursive: true, force: true }, () => {}); - } + NodeFS.readdir(root, (error, entries) => { + if (error) return; + for (const entry of entries) { + if (entry === input.version) continue; + NodeFS.rm(NodePath.join(root, entry), { recursive: true, force: true }, () => {}); + } + }); } catch {} } diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 3ac1376fed7bb..c9f33999b7fce 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -467,34 +467,37 @@ const make = (options?: StartupOptions) => ); const startup = Effect.gen(function* () { - yield* Effect.logDebug("startup phase: starting keybindings runtime"); - yield* runStartupPhase( - "keybindings.start", - keybindings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start keybindings runtime", { - path: error.configPath, - detail: error.detail, - cause: error.cause, - }), + // Both runtimes read independent files and must be ready before recovery. + yield* Effect.all( + [ + runStartupPhase( + "keybindings.start", + keybindings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), + ), ), - ), - ); - - yield* Effect.logDebug("startup phase: starting server settings runtime"); - yield* runStartupPhase( - "settings.start", - serverSettings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start server settings runtime", { - path: error.settingsPath, - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }), + runStartupPhase( + "settings.start", + serverSettings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start server settings runtime", { + path: error.settingsPath, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }), + ), + ), ), - ), + ], + { concurrency: "unbounded", discard: true }, ); const welcomeBase = yield* resolveWelcomeBase; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b16ac2678c694..351c30afea1de 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1834,17 +1834,28 @@ const layerWsRpc = ( const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const editorConfig = - options.deferEditors === true - ? { availableEditors: [] } - : yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ), - resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ), - ); + const [editorConfig, availableRemoteOpenTargets, availableDirectEndpoints] = + yield* Effect.all( + [ + options.deferEditors === true + ? Effect.succeed({ availableEditors: [] }) + : resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ).pipe( + Effect.flatMap((editors) => + resolveEditorConfig( + editors, + resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ), + ), + ), + ), + resolveAvailableEditorsForConfig(remoteOpenTargets.resolveTargets()), + resolveAvailableEditorsForConfig(directEndpoints.resolve()), + ], + { concurrency: "unbounded" }, + ); return { environment, @@ -1857,10 +1868,8 @@ const layerWsRpc = ( ...editorConfig, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), + remoteOpenTargets: availableRemoteOpenTargets, + directEndpoints: availableDirectEndpoints, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, From f50e02bb5a55af1e7d9283649f6208bef4a4273d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 03:30:07 +0000 Subject: [PATCH 003/215] perf(server): avoid repeated thread and event encoding --- .../orchestration-v2/ProjectionStore.test.ts | 15 +++ .../src/orchestration-v2/ProjectionStore.ts | 127 +++++++++--------- .../orchestration-v2/StreamSerialization.ts | 92 +++++++++++++ .../orchestration-v2/WireProjection.test.ts | 49 +++++++ .../src/orchestration-v2/WireProjection.ts | 17 ++- .../persistence/OrchestrationEventStore.ts | 12 +- apps/server/src/ws.ts | 5 +- 7 files changed, 241 insertions(+), 76 deletions(-) create mode 100644 apps/server/src/orchestration-v2/StreamSerialization.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 092f0a70f82de..ae29950610ee1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5449,12 +5449,27 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = json_set(payload_json, '$.pullRequests', json('[]')) WHERE thread_id = ${threadId}`; + yield* store.apply({ + id: EventId.make("event:older-writer:visited"), + type: "thread.visit-recorded", + threadId, + occurredAt: at, + payload: { lastVisitedAt: at }, + }); assert.deepEqual(yield* store.getThreadsWithPullRequests(), []); // Its write marks the read projection stale, so shells read the payload it wrote. assert.deepEqual( (yield* store.getThreadShell(threadId, { compactPullRequests: true }))?.pullRequests, [], ); + yield* store.apply({ + id: EventId.make("event:older-writer:marked-unread"), + type: "thread.visit-recorded", + threadId, + occurredAt: at, + payload: { lastVisitedAt: null }, + }); + assert.equal((yield* store.getThreadShell(threadId))?.lastVisitedAt, null); yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${stored!.payload_json} WHERE thread_id = ${threadId}`; assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index c56d1c9dcb480..ab6974eef4e83 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1840,6 +1840,49 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Field events already carry decoded values. Patch JSON in SQLite rather than + // decoding and encoding every saved PR and stack for a timestamp change. + const patchThreadFields = ( + threadId: ThreadId, + fields: { + readonly updatedAt?: string; + readonly lastVisitedAt?: string | null; + readonly activeProviderThreadId?: ProviderThreadId; + }, + ) => + Effect.gen(function* () { + const readRows = yield* sql<{ readonly source_stale: number }>` + SELECT source_stale FROM orchestration_v2_projection_thread_reads + WHERE thread_id = ${threadId} + `; + const patches = Object.entries(fields); + yield* sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, ${sql.join( + ", ", + false, + )(patches.map(([key, value]) => sql`${`$.${key}`}, ${value}`))}) + ${fields.updatedAt === undefined ? sql`` : sql`, updated_at = ${fields.updatedAt}`} + ${fields.activeProviderThreadId === undefined ? sql`` : sql`, active_provider_thread_id = ${fields.activeProviderThreadId}`} + WHERE thread_id = ${threadId} + `; + // An older build may have changed other fields. Only restore a read row + // that was current before the canonical write marked it stale. + if (readRows[0]?.source_stale === 0) { + const readPatches = sql.join( + ", ", + false, + )(patches.map(([key, value]) => sql`${`$.thread.${key}`}, ${value}`)); + yield* sql` + UPDATE orchestration_v2_projection_thread_reads + SET payload_json = json_set(payload_json, ${readPatches}), + compact_payload_json = json_set(compact_payload_json, ${readPatches}), + source_stale = 0 + WHERE thread_id = ${threadId} + `; + } + }); + const upsertThreadRead = (thread: OrchestrationV2AppThread) => Effect.gen(function* () { const payload = yield* encodeThreadReadPayload(thread); @@ -1944,8 +1987,16 @@ export const layer: Layer.Layer = } // These events carry one field rather than the thread, so they patch the stored row. // A link sync keeps the thread's activity timestamp, just like its legacy event. - case "thread.pull-request-link-synced": case "thread.visit-recorded": { + yield* patchThreadFields(event.threadId, { + lastVisitedAt: + event.payload.lastVisitedAt === null + ? null + : DateTime.formatIso(event.payload.lastVisitedAt), + }); + break; + } + case "thread.pull-request-link-synced": { const rows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads @@ -1955,10 +2006,7 @@ export const layer: Layer.Layer = const row = rows[0]; if (row === undefined) break; const thread = yield* decodeThreadPayload(row.payload_json); - const updatedThread = - event.type === "thread.visit-recorded" - ? { ...thread, lastVisitedAt: event.payload.lastVisitedAt } - : applyThreadPullRequestLinkSync(thread, event.payload); + const updatedThread = applyThreadPullRequestLinkSync(thread, event.payload); const payloadJson = yield* encodeThreadPayload(updatedThread); yield* sql` UPDATE orchestration_v2_projection_threads @@ -2307,31 +2355,10 @@ export const layer: Layer.Layer = event.payload.appThreadId !== null && !isQueuedProviderThreadPlaceholder(event.payload) ) { - const threadRows = yield* sql` - SELECT payload_json - FROM orchestration_v2_projection_threads - WHERE thread_id = ${event.payload.appThreadId} - LIMIT 1 - `; - const threadRow = threadRows[0]; - if (threadRow !== undefined) { - const thread = yield* decodeThreadPayload(threadRow.payload_json); - const updatedThread = { - ...thread, - activeProviderThreadId: event.payload.id, - updatedAt: event.payload.updatedAt, - }; - const updatedThreadPayloadJson = yield* encodeThreadPayload(updatedThread); - yield* sql` - UPDATE orchestration_v2_projection_threads - SET - active_provider_thread_id = ${event.payload.id}, - updated_at = ${stringField(parseEncodedPayload(updatedThreadPayloadJson), "updatedAt")}, - payload_json = ${updatedThreadPayloadJson} - WHERE thread_id = ${event.payload.appThreadId} - `; - yield* upsertThreadRead(updatedThread); - } + yield* patchThreadFields(event.payload.appThreadId, { + activeProviderThreadId: event.payload.id, + updatedAt: DateTime.formatIso(event.payload.updatedAt), + }); } break; } @@ -2755,43 +2782,9 @@ export const layer: Layer.Layer = event.type !== "thread.model-selection-updated" && event.type !== "thread.provider-switched" ) { - const rows = yield* sql` - SELECT payload_json - FROM orchestration_v2_projection_threads - WHERE thread_id = ${event.threadId} - LIMIT 1 - `; - const row = rows[0]; - if (row !== undefined) { - const thread = yield* decodeThreadPayload(row.payload_json); - const updatedThread = { ...thread, updatedAt: event.occurredAt }; - const payloadJson = yield* encodeThreadPayload(updatedThread); - // The thread write marks its read row stale. Only a row that was current - // before it can be patched back to current. - const readRows = yield* sql<{ readonly source_stale: number }>` - SELECT source_stale FROM orchestration_v2_projection_thread_reads - WHERE thread_id = ${event.threadId} - `; - yield* sql` - UPDATE orchestration_v2_projection_threads - SET - updated_at = ${stringField(parseEncodedPayload(payloadJson), "updatedAt")}, - payload_json = ${payloadJson} - WHERE thread_id = ${event.threadId} - `; - // Activity-only events change no PR fields. Patch the small read - // projections without encoding their stacks again on every token. - if (readRows[0]?.source_stale === 0) { - const updatedAt = stringField(parseEncodedPayload(payloadJson), "updatedAt"); - yield* sql` - UPDATE orchestration_v2_projection_thread_reads - SET payload_json = json_set(payload_json, '$.thread.updatedAt', ${updatedAt}), - compact_payload_json = json_set(compact_payload_json, '$.thread.updatedAt', ${updatedAt}), - source_stale = 0 - WHERE thread_id = ${event.threadId} - `; - } - } + yield* patchThreadFields(event.threadId, { + updatedAt: DateTime.formatIso(event.occurredAt), + }); } }).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/StreamSerialization.ts b/apps/server/src/orchestration-v2/StreamSerialization.ts new file mode 100644 index 0000000000000..7f80c94b7c450 --- /dev/null +++ b/apps/server/src/orchestration-v2/StreamSerialization.ts @@ -0,0 +1,92 @@ +import { OrchestrationV2ThreadStreamItem } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as SchemaParser from "effect/SchemaParser"; +import * as RpcSerialization from "effect/rpc/RpcSerialization"; + +// Committed events are immutable and shared by subscriptions. The first subscriber +// validates and encodes an event; the others reuse its encoded stream item. +const cachedThreadItem = Schema.declareConstructor()( + [Schema.toCodecJson(OrchestrationV2ThreadStreamItem)], + ([codec]) => { + const parse = SchemaParser.decodeUnknownEffect(codec); + const cache = new WeakMap< + object, + { readonly sequence: number; readonly value: OrchestrationV2ThreadStreamItem } + >(); + return (input, _ast, options) => { + if ( + !Predicate.isObject(input) || + input.kind !== "event" || + typeof input.sequence !== "number" || + !Predicate.isObject(input.event) + ) + return parse(input, options); + const key = input.event; + const sequence = input.sequence; + const previous = cache.get(key); + if (previous?.sequence === sequence) return Effect.succeed(previous.value); + return parse(input, options).pipe( + Effect.tap((value) => + Effect.sync(() => { + cache.set(key, { sequence, value }); + }), + ), + ); + }; + }, +); +const threadChunkCodec = Schema.NonEmptyArray(cachedThreadItem); +const strings = new WeakMap(); + +const codecFor: RpcSerialization.CodecFor = (schema) => { + const ast = schema.ast; + if ( + ast._tag === "Arrays" && + ast.elements.length === 1 && + ast.rest.length === 1 && + ast.elements[0] === OrchestrationV2ThreadStreamItem.ast && + ast.rest[0] === OrchestrationV2ThreadStreamItem.ast + ) { + // The identity check above selects exactly NonEmptyArray(ThreadStreamItem). + return threadChunkCodec as Schema.Codec< + typeof schema.Type, + unknown, + typeof schema.DecodingServices, + typeof schema.EncodingServices + >; + } + return RpcSerialization.json.codecFor(schema); +}; + +export const layer = Layer.succeed(RpcSerialization.RpcSerialization, { + ...RpcSerialization.json, + codecFor, + makeUnsafe: () => { + const parser = RpcSerialization.json.makeUnsafe(); + const stringifyItem = (item: unknown) => { + if (!Predicate.isObject(item) || item.kind !== "event") return JSON.stringify(item); + const cached = strings.get(item); + if (cached !== undefined) return cached; + const encoded = JSON.stringify(item); + strings.set(item, encoded); + return encoded; + }; + return { + ...parser, + encode: (response: unknown) => { + if ( + !Predicate.isObject(response) || + response._tag !== "Chunk" || + !Array.isArray(response.values) || + !response.values.every((item) => Predicate.isObject(item) && item.kind === "event") + ) { + return parser.encode(response); + } + return `{"_tag":"Chunk","requestId":${JSON.stringify(response.requestId)},"values":[${response.values.map(stringifyItem).join(",")}]}`; + }, + }; + }, +}); diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index a44dc0fe81777..bbd9a6fbfdbb0 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -12,10 +12,15 @@ import { type OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, OrchestrationV2TurnItemJson, + OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; +import * as RpcSerialization from "effect/rpc/RpcSerialization"; +import * as StreamSerialization from "./StreamSerialization.ts"; import { projectTurnItemForWire, @@ -50,6 +55,50 @@ const base = { }; describe("orchestration V2 wire projection", () => { + it.effect("keeps streamed JSON compatible across subscribers and rejects invalid events", () => + Effect.gen(function* () { + const serialization = yield* RpcSerialization.RpcSerialization; + const schema = serialization.codecFor(Schema.NonEmptyArray(OrchestrationV2ThreadStreamItem)); + const event = projectDomainEventForWire({ + id: EventId.make("event:shared-wire"), + type: "turn-item.updated", + threadId: base.threadId, + occurredAt: base.updatedAt, + payload: { ...base, output: { text: "PRIVATE_TOOL_OUTPUT" } }, + }); + const encode = Schema.encodeEffect(schema); + const first = yield* encode([{ kind: "event", sequence: 1, event }]); + const second = yield* Schema.encodeEffect(schema)([{ kind: "event", sequence: 1, event }]); + for (const [requestId, values] of [ + ["first", first], + ["second", second], + ] as const) { + const response = { _tag: "Chunk", requestId, values }; + const json = serialization.makeUnsafe().encode(response); + expect(json).toBe(RpcSerialization.json.makeUnsafe().encode(response)); + expect(json).not.toContain("PRIVATE_TOOL_OUTPUT"); + expect(yield* Schema.decodeUnknownEffect(schema)(JSON.parse(String(json)).values)).toEqual([ + { kind: "event", sequence: 1, event }, + ]); + } + expect(yield* encode([{ kind: "event", sequence: 2, event }])).toMatchObject([ + { sequence: 2 }, + ]); + expect( + Exit.isFailure(yield* Effect.exit(encode([{ kind: "event", sequence: -1, event }]))), + ).toBe(true); + expect( + Exit.isFailure( + yield* Effect.exit( + Schema.encodeUnknownEffect(schema)([ + { kind: "event", sequence: 1, event: { ...event, occurredAt: null } }, + ]), + ), + ), + ).toBe(true); + }).pipe(Effect.provide(StreamSerialization.layer)), + ); + it("keeps copied handoff transcripts out of activity items and live events", () => { const item = { ...base, diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index f4e8f7542ad40..9efab52fbb334 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -230,12 +230,19 @@ export function projectThreadProjectionForWire( }; } +const wireEvents = new WeakMap(); + export function projectDomainEventForWire( event: OrchestrationV2DomainEvent, ): OrchestrationV2DomainEvent { - return event.type === "turn-item.updated" - ? { ...event, payload: projectTurnItemForWire(event.payload) } - : event.type === "context-handoff.updated" - ? { ...event, payload: projectContextHandoffForWire(event.payload) } - : event; + const cached = wireEvents.get(event); + if (cached !== undefined) return cached; + const projected = + event.type === "turn-item.updated" + ? { ...event, payload: projectTurnItemForWire(event.payload) } + : event.type === "context-handoff.updated" + ? { ...event, payload: projectContextHandoffForWire(event.payload) } + : event; + wireEvents.set(event, projected); + return projected; } diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index ef8b49c2c53f2..5eece5d91c064 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -199,6 +199,12 @@ interface ApplicationEventRow { const decodeV2EventJson = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson); const encodeV2EventJson = Schema.encodeEffect(OrchestrationV2DomainEventJson); const decodeV2StoredEvent = Schema.decodeUnknownEffect(OrchestrationV2StoredEvent); +const decodeV2StoredHeader = Schema.decodeUnknownEffect( + Schema.Struct({ + sequence: OrchestrationV2StoredEvent.fields.sequence, + commandId: OrchestrationV2StoredEvent.fields.commandId, + }), +); const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -480,11 +486,13 @@ const makeEventStore = Effect.gen(function* () { ) RETURNING sequence `; - return yield* decodeV2StoredEvent({ + // Encoding above already validated this internal event. Only the + // database-generated header needs decoding before publication. + const header = yield* decodeV2StoredHeader({ sequence: rows[0]?.sequence, commandId: input.commandId ?? null, - event, }); + return { ...header, event }; }), { concurrency: 1 }, ).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 351c30afea1de..cce9cff478a25 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -113,7 +113,7 @@ import { HttpServerRespondable, HttpServerResponse, } from "effect/http"; -import { RpcSerialization, RpcServer } from "effect/rpc"; +import { RpcServer } from "effect/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; @@ -121,6 +121,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; +import * as StreamSerialization from "./orchestration-v2/StreamSerialization.ts"; import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; @@ -3363,7 +3364,7 @@ export const layer = Layer.unwrap( previewAutomationBroker, serverBrowser, ).pipe( - Layer.provideMerge(RpcSerialization.layerJson), + Layer.provideMerge(StreamSerialization.layer), // Request fibers run in the handlers' context, so this reporter sees // their defects, not the rest of the server's. Layer.provide(DefectReporter.layer), From 91f17f99a0bb94fdbbba6a3a7c7a437986f1feb2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:08:54 +0000 Subject: [PATCH 004/215] fix(server): close inherited electron descriptors before startup --- apps/server/src/bootstrap.test.ts | 52 +++++++++++++++++++++++++++++++ apps/server/src/bootstrap.ts | 35 +++++++++++++++++++++ apps/server/src/cli/config.ts | 11 ++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 0b14c7dd1e238..42a4568ac41d9 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -56,6 +56,58 @@ vi.mock("node:fs", async (importOriginal) => { }); const windowsHost = HostProcess.Platform.defaultValue() === "win32"; + +it.effect( + "closes inherited Electron descriptors while preserving explicit channels and Node fds", + () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "linux") return; + for (const [platform, electron, closesInherited] of [ + ["linux", "1", true], + ["linux", "", false], + ["darwin", "1", false], + ] as const) { + const script = ` + import * as fs from 'node:fs'; + import * as Effect from ${JSON.stringify(import.meta.resolve("effect/Effect"))}; + import * as HostProcess from ${JSON.stringify(import.meta.resolve("@t3tools/shared/HostProcess"))}; + import { closeInheritedElectronDescriptors } from ${JSON.stringify(new URL("./bootstrap.ts", import.meta.url).href)}; + const ownFd = fs.openSync('/dev/null', 'r'); + await Effect.runPromise(closeInheritedElectronDescriptors([3]).pipe( + Effect.provideService(HostProcess.Platform, ${JSON.stringify(platform)}), + Effect.provideService(HostProcess.Environment, { ELECTRON_RUN_AS_NODE: ${JSON.stringify(electron)} }), + )); + let inheritedClosed = false; + try { fs.fstatSync(64); } catch (error) { inheritedClosed = error.code === 'EBADF'; } + fs.fstatSync(ownFd); + fs.writeSync(3, 'preserved'); + console.log(inheritedClosed); + `; + const result = yield* Effect.sync(() => + NodeChildProcess.spawnSync( + "bash", + [ + "-c", + 'exec 64 { try { diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 708a8fd9119bf..059a83ad1501b 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -150,6 +150,41 @@ const isUnavailableBootstrapFdError = Predicate.compose( (_) => _.code === "EBADF" || _.code === "ENOENT", ); +/** + * Chromium leaves descriptors without close-on-exec set in Electron's Node + * children. Node opens its own descriptors with close-on-exec; only the + * bootstrap's explicit channels and stdio should survive from the desktop. + * Run after reading the envelope, before services or the spawn host start. + */ +export const closeInheritedElectronDescriptors = Effect.fn("closeInheritedElectronDescriptors")( + function* (preservedFds: ReadonlyArray) { + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + if (platform !== "linux" || environment.ELECTRON_RUN_AS_NODE !== "1") return; + const preserved = new Set([0, 1, 2, ...preservedFds]); + yield* Effect.sync(() => { + let descriptors: string[]; + try { + descriptors = NodeFS.readdirSync("/proc/self/fd"); + } catch { + return; + } + for (const descriptor of descriptors) { + const fd = Number(descriptor); + if (preserved.has(fd)) continue; + try { + const info = NodeFS.readFileSync(`/proc/self/fdinfo/${fd}`, "utf8"); + const flags = /^flags:\s+([0-7]+)$/m.exec(info)?.[1]; + if (flags === undefined || (Number.parseInt(flags, 8) & 0o2000000) !== 0) continue; + NodeFS.closeSync(fd); + } catch { + // A descriptor may already have closed; unavailable procfs is harmless. + } + } + }); + }, +); + const isFdReady = (fd: number) => Effect.try({ try: () => NodeFS.fstatSync(fd), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index be2dd5789627d..d2f7dacc03f1a 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -21,7 +21,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/cli"; import * as CliError from "effect/cli/CliError"; -import { readBootstrapEnvelope } from "../bootstrap.ts"; +import { closeInheritedElectronDescriptors, readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; @@ -284,6 +284,15 @@ export const resolveServerConfig = ( ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd) : Option.none(); const bootstrap = Option.getOrUndefined(bootstrapEnvelope); + if (bootstrap !== undefined) { + yield* closeInheritedElectronDescriptors([ + bootstrapFd, + bootstrap.desktopTelemetryFd, + bootstrap.desktopTelemetryControlFd, + bootstrap.desktopBrowserFd, + bootstrap.desktopBrowserControlFd, + ]); + } const mode: ServerConfig.RuntimeMode = Option.getOrElse( resolveOptionPrecedence( From d3ae6b721f25596ae3c5d9bde1b9aa32e54ffba6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:09:14 +0000 Subject: [PATCH 005/215] fix(desktop): release closed preview tab generations --- apps/desktop/src/preview/Manager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index aa36055452eb8..28b8cf948a7f3 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -595,6 +595,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { readonly semaphore: Semaphore.Semaphore; users: number } >(); const tabLifecycleGenerations = new Map(); + let nextTabLifecycleGeneration = 0; const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => Effect.try({ @@ -1719,7 +1720,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }, ); if (result.created) { - tabLifecycleGenerations.set(tabId, (tabLifecycleGenerations.get(tabId) ?? 0) + 1); + // Generations remain unique when a closed tab id is reused. + tabLifecycleGenerations.set(tabId, ++nextTabLifecycleGeneration); } yield* emit(tabId, result.state); return result.state; @@ -1758,6 +1760,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ] as const; }); if (Option.isNone(tab)) return; + tabLifecycleGenerations.delete(tabId); const closedTab = tab.value; if (closedTab.webContentsId != null) { yield* Effect.all( From fef15ac1e8e5a20b998bd46c756dbb3b5553b338 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 02:12:18 +0000 Subject: [PATCH 006/215] fix(server): preserve electron-owned descriptors during cleanup --- apps/server/src/bootstrap.test.ts | 16 ++++++++++++++-- apps/server/src/bootstrap.ts | 12 +++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 42a4568ac41d9..b739db85913f8 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -68,11 +68,21 @@ it.effect( ["darwin", "1", false], ] as const) { const script = ` - import * as fs from 'node:fs'; + import fs from 'node:fs'; + import { syncBuiltinESMExports } from 'node:module'; import * as Effect from ${JSON.stringify(import.meta.resolve("effect/Effect"))}; import * as HostProcess from ${JSON.stringify(import.meta.resolve("@t3tools/shared/HostProcess"))}; import { closeInheritedElectronDescriptors } from ${JSON.stringify(new URL("./bootstrap.ts", import.meta.url).href)}; const ownFd = fs.openSync('/dev/null', 'r'); + const readFileSync = fs.readFileSync; + // A parent's POSIX lock metadata is absent from its inherited fd in the child. + fs.readFileSync = (path, ...args) => { + const value = readFileSync(path, ...args); + return path === '/proc/' + process.ppid + '/fdinfo/64' + ? value + 'lock:\\t1: POSIX ADVISORY WRITE parent 0 EOF\\n' + : value; + }; + syncBuiltinESMExports(); await Effect.runPromise(closeInheritedElectronDescriptors([3]).pipe( Effect.provideService(HostProcess.Platform, ${JSON.stringify(platform)}), Effect.provideService(HostProcess.Environment, { ELECTRON_RUN_AS_NODE: ${JSON.stringify(electron)} }), @@ -80,6 +90,7 @@ it.effect( let inheritedClosed = false; try { fs.fstatSync(64); } catch (error) { inheritedClosed = error.code === 'EBADF'; } fs.fstatSync(ownFd); + fs.fstatSync(65); fs.writeSync(3, 'preserved'); console.log(inheritedClosed); `; @@ -88,7 +99,8 @@ it.effect( "bash", [ "-c", - 'exec 64 { let descriptors: string[]; + let parentPid: string | undefined; try { descriptors = NodeFS.readdirSync("/proc/self/fd"); + parentPid = /^PPid:\s+(\d+)$/m.exec(NodeFS.readFileSync("/proc/self/status", "utf8"))?.[1]; } catch { return; } + if (parentPid === undefined) return; for (const descriptor of descriptors) { const fd = Number(descriptor); if (preserved.has(fd)) continue; @@ -176,6 +179,9 @@ export const closeInheritedElectronDescriptors = Effect.fn("closeInheritedElectr const info = NodeFS.readFileSync(`/proc/self/fdinfo/${fd}`, "utf8"); const flags = /^flags:\s+([0-7]+)$/m.exec(info)?.[1]; if (flags === undefined || (Number.parseInt(flags, 8) & 0o2000000) !== 0) continue; + // POSIX locks belong to the parent process and are absent in child fdinfo. + const parentInfo = NodeFS.readFileSync(`/proc/${parentPid}/fdinfo/${fd}`, "utf8"); + if (parentInfo.replace(/^lock:.*\n/gm, "") !== info.replace(/^lock:.*\n/gm, "")) continue; NodeFS.closeSync(fd); } catch { // A descriptor may already have closed; unavailable procfs is harmless. From e9bb84c2f80c03c26e433356678f5a62aa72168c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 02:13:24 +0000 Subject: [PATCH 007/215] perf(server): bound vcs status retention without json fingerprints --- .../src/vcs/VcsStatusBroadcaster.test.ts | 24 +++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 72 ++++++++++++------- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 310c84a1015e8..b76766c56f3d1 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -197,6 +197,30 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b } describe("VcsStatusBroadcaster", () => { + it.effect("releases old workspace statuses and loads an evicted workspace again", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + for (let index = 0; index < 300; index++) { + yield* broadcaster.getStatus({ cwd: `/repo-${index}` }); + } + const reads = state.localStatusCalls; + assert.deepStrictEqual(yield* broadcaster.getStatus({ cwd: "/repo-299" }), baseStatus); + assert.equal(state.localStatusCalls, reads); + state.currentLocalStatus = { ...baseLocalStatus, refName: "fresh-branch" }; + const reloaded = yield* broadcaster.getStatus({ cwd: "/repo-0" }); + assert.equal(reloaded.refName, "fresh-branch"); + assert.equal(state.localStatusCalls, reads + 1); + }).pipe(Effect.provide(layerTestFor(state))); + }); + it.effect.skipIf(!symlinksSupported)( "automatically pulls an enabled clean default branch when status detects it is behind", () => { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 701a798f532af..3900e6498a0d1 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -11,17 +11,18 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import type { - GitManagerServiceError, - VcsStatusInput, +import { + type GitManagerServiceError, + type VcsStatusInput, VcsStatusLocalResult, VcsStatusRemoteResult, - VcsStatusResult, - VcsStatusStreamEvent, - VcsStatusSubscriptionInput, + type VcsStatusResult, + type VcsStatusStreamEvent, + type VcsStatusSubscriptionInput, } from "@t3tools/contracts"; import { applyGitStatusStreamEvent, mergeGitStatusParts } from "@t3tools/shared/git"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; @@ -37,6 +38,7 @@ const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; +const MAX_CACHED_WORKSPACES = 256; function boundedDiagnosticValue(value: string): string { return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); @@ -127,7 +129,6 @@ interface VcsStatusChange { } interface CachedValue { - readonly fingerprint: string; readonly value: T; } @@ -210,8 +211,24 @@ export class VcsStatusBroadcaster extends Context.Service< } >()("t3/vcs/VcsStatusBroadcaster") {} -function fingerprintStatusPart(status: unknown): string { - return JSON.stringify(status); +const sameLocalStatus = Schema.toEquivalence(VcsStatusLocalResult); +const sameRemoteStatus = Schema.toEquivalence(Schema.NullOr(VcsStatusRemoteResult)); + +// Status already owns the file paths and PR fields. Comparing the typed values +// avoids retaining a second copy of them as a JSON fingerprint per workspace. +function cacheStatus( + cache: ReadonlyMap, + cwd: string, + status: CachedVcsStatus, +) { + const next = new Map(cache); + next.delete(cwd); + next.set(cwd, status); + if (next.size > MAX_CACHED_WORKSPACES) { + const oldest = next.keys().next().value; + if (oldest !== undefined) next.delete(oldest); + } + return next; } const normalizeCwd = (cwd: string) => @@ -251,17 +268,18 @@ export const make = Effect.gen(function* () { const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { - fingerprint: fingerprintStatusPart(local), value: local, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { + if (previous.local !== null && sameLocalStatus(previous.local.value, local)) { + return [false, cache] as const; + } + const nextCache = cacheStatus(cache, cwd, { ...previous, local: nextLocal, }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + return [true, nextCache] as const; }); if (options?.publish && shouldPublish) { @@ -281,17 +299,18 @@ export const make = Effect.gen(function* () { const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { const nextRemote = { - fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { + if (previous.remote !== null && sameRemoteStatus(previous.remote.value, remote)) { + return [false, cache] as const; + } + const nextCache = cacheStatus(cache, cwd, { ...previous, remote: nextRemote, }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + return [true, nextCache] as const; }); if (options?.publish && shouldPublish) { @@ -315,25 +334,26 @@ export const make = Effect.gen(function* () { options?: { publish?: boolean }, ) { const nextLocal = { - fingerprint: fingerprintStatusPart(local), value: local, } satisfies CachedValue; const nextRemote = { - fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { + if ( + previous.local !== null && + previous.remote !== null && + sameLocalStatus(previous.local.value, local) && + sameRemoteStatus(previous.remote.value, remote) + ) { + return [false, cache] as const; + } + const nextCache = cacheStatus(cache, cwd, { local: nextLocal, remote: nextRemote, }); - return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, - nextCache, - ] as const; + return [true, nextCache] as const; }); if (options?.publish && shouldPublish) { From d9ead439e1b50913a1e5de401375da7273b434f9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 02:28:02 +0000 Subject: [PATCH 008/215] fix(server): retain subscribed vcs statuses until release --- .../src/vcs/VcsStatusBroadcaster.test.ts | 21 +++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 80 +++++++++++++++---- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index b76766c56f3d1..453f087a5e11d 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -208,6 +208,15 @@ describe("VcsStatusBroadcaster", () => { }; return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const activeSnapshot = yield* Deferred.make(); + const activeStream = yield* broadcaster + .streamStatus({ cwd: "/active", includeRemote: false }) + .pipe( + Stream.runForEach(() => Deferred.succeed(activeSnapshot, undefined)), + Effect.forkScoped, + ); + yield* Deferred.await(activeSnapshot); + yield* broadcaster.getStatus({ cwd: "/active" }); for (let index = 0; index < 300; index++) { yield* broadcaster.getStatus({ cwd: `/repo-${index}` }); } @@ -218,6 +227,18 @@ describe("VcsStatusBroadcaster", () => { const reloaded = yield* broadcaster.getStatus({ cwd: "/repo-0" }); assert.equal(reloaded.refName, "fresh-branch"); assert.equal(state.localStatusCalls, reads + 1); + state.currentRemoteStatus = remoteStatusWithPr; + assert.deepStrictEqual( + yield* broadcaster.refreshPullRequestStatus("/active"), + remoteStatusWithPr, + ); + yield* Fiber.interrupt(activeStream); + for (let index = 0; index < 300; index++) { + yield* broadcaster.getStatus({ cwd: `/later-${index}` }); + } + const remoteReads = state.remoteStatusCalls; + yield* broadcaster.getStatus({ cwd: "/active" }); + assert.equal(state.remoteStatusCalls, remoteReads + 1); }).pipe(Effect.provide(layerTestFor(state))); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 3900e6498a0d1..bda92b7267335 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -220,15 +220,23 @@ function cacheStatus( cache: ReadonlyMap, cwd: string, status: CachedVcsStatus, + activeCwds: ReadonlyMap, ) { const next = new Map(cache); next.delete(cwd); next.set(cwd, status); - if (next.size > MAX_CACHED_WORKSPACES) { - const oldest = next.keys().next().value; - if (oldest !== undefined) next.delete(oldest); + return trimInactiveStatuses(next, activeCwds); +} + +function trimInactiveStatuses( + cache: Map, + activeCwds: ReadonlyMap, +) { + for (const cwd of cache.keys()) { + if (cache.size <= MAX_CACHED_WORKSPACES) break; + if (!activeCwds.has(cwd)) cache.delete(cwd); } - return next; + return cache; } const normalizeCwd = (cwd: string) => @@ -251,6 +259,7 @@ export const make = Effect.gen(function* () { Scope.close(scope, Exit.void), ); const cacheRef = yield* Ref.make(new Map()); + const activeCwdsRef = yield* Ref.make(new Map()); // One permit per cwd for remote reads that write the cache. Without it a // periodic poll that started before `gh pr create` can finish after the // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. @@ -270,15 +279,21 @@ export const make = Effect.gen(function* () { const nextLocal = { value: local, } satisfies CachedValue; + const activeCwds = yield* Ref.get(activeCwdsRef); const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; if (previous.local !== null && sameLocalStatus(previous.local.value, local)) { return [false, cache] as const; } - const nextCache = cacheStatus(cache, cwd, { - ...previous, - local: nextLocal, - }); + const nextCache = cacheStatus( + cache, + cwd, + { + ...previous, + local: nextLocal, + }, + activeCwds, + ); return [true, nextCache] as const; }); @@ -301,15 +316,21 @@ export const make = Effect.gen(function* () { const nextRemote = { value: remote, } satisfies CachedValue; + const activeCwds = yield* Ref.get(activeCwdsRef); const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; if (previous.remote !== null && sameRemoteStatus(previous.remote.value, remote)) { return [false, cache] as const; } - const nextCache = cacheStatus(cache, cwd, { - ...previous, - remote: nextRemote, - }); + const nextCache = cacheStatus( + cache, + cwd, + { + ...previous, + remote: nextRemote, + }, + activeCwds, + ); return [true, nextCache] as const; }); @@ -339,6 +360,7 @@ export const make = Effect.gen(function* () { const nextRemote = { value: remote, } satisfies CachedValue; + const activeCwds = yield* Ref.get(activeCwdsRef); const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; if ( @@ -349,10 +371,15 @@ export const make = Effect.gen(function* () { ) { return [false, cache] as const; } - const nextCache = cacheStatus(cache, cwd, { - local: nextLocal, - remote: nextRemote, - }); + const nextCache = cacheStatus( + cache, + cwd, + { + local: nextLocal, + remote: nextRemote, + }, + activeCwds, + ); return [true, nextCache] as const; }); @@ -727,6 +754,27 @@ export const make = Effect.gen(function* () { Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + // A stream still needs its remote cache for turn-end PR refreshes. + yield* Effect.acquireRelease( + Ref.update(activeCwdsRef, (active) => + new Map(active).set(cwd, (active.get(cwd) ?? 0) + 1), + ), + () => + Effect.gen(function* () { + const active = yield* Ref.updateAndGet(activeCwdsRef, (current) => { + const next = new Map(current); + const count = next.get(cwd) ?? 0; + if (count <= 1) next.delete(cwd); + else next.set(cwd, count - 1); + return next; + }); + yield* Ref.update(cacheRef, (cache) => + cache.size <= MAX_CACHED_WORKSPACES + ? cache + : trimInactiveStatuses(new Map(cache), active), + ); + }), + ); const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); From e3a148bb3146ba9431201701df042fe992e17feb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:23:32 +0000 Subject: [PATCH 009/215] fix(server): capture baselines before provider startup --- .../CheckpointService.test.ts | 62 +++++++++++++ .../src/orchestration-v2/CheckpointService.ts | 3 +- .../ProviderTurnStartService.test.ts | 75 ++++++++++++++-- .../ProviderTurnStartService.ts | 89 +++++++++++++------ .../RunExecutionService.test.ts | 15 ++-- .../orchestration-v2/RunExecutionService.ts | 19 ++-- .../src/orchestration-v2/runtimeLayer.ts | 1 + .../testkit/ProviderReplayHarness.ts | 1 + 8 files changed, 213 insertions(+), 52 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointService.test.ts b/apps/server/src/orchestration-v2/CheckpointService.test.ts index 3bb6d0be84528..4ca5acedc9e34 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.test.ts @@ -103,3 +103,65 @@ it.effect.each([false, true, "interrupt"] as const)( }).pipe(Effect.provide(layerTest)); }, ); + +it.effect.each([false, true, "failure", "interrupt"] as const)( + "baseline repository probe preserves non-Git workspaces and fails closed (%s)", + (probe) => { + const scope: OrchestrationV2CheckpointScope = { + id: CheckpointScopeId.make("checkpoint-scope:baseline-probe"), + threadId: ThreadId.make("thread:baseline-probe"), + runId: RunId.make("run:baseline-probe"), + nodeId: NodeId.make("node:baseline-probe"), + parentScopeId: null, + providerThreadId: ProviderThreadId.make("provider-thread:baseline-probe"), + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/repo", + createdAt: DateTime.makeUnsafe("2026-07-28T00:00:00.000Z"), + }; + const failure = new VcsProcessTimeoutError({ + operation: "test.detect", + command: "git", + cwd: scope.cwd, + timeoutMs: 30000, + }); + const hasCheckpointRef = vi.fn(() => Effect.succeed(false)); + const captureCheckpoint = vi.fn(() => Effect.void); + const layer = CheckpointService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: () => + probe === "interrupt" + ? Effect.interrupt + : probe === "failure" + ? Effect.fail(failure) + : Effect.succeed(probe), + hasCheckpointRef, + captureCheckpoint, + }), + ), + ), + Layer.provide(NodeCrypto.layer), + ); + return Effect.gen(function* () { + const checkpoints = yield* CheckpointService.CheckpointServiceV2; + const exit = yield* Effect.exit( + checkpoints.captureBaseline({ scope, ordinalWithinScope: 0 }), + ); + if (probe === "interrupt") assert.isTrue(Exit.hasInterrupts(exit)); + else if (probe === "failure") { + const error = Exit.findErrorOption(exit); + assert.isTrue(error._tag === "Some"); + if (error._tag === "Some") { + assert.equal(error.value._tag, "CheckpointBaselineCaptureError"); + assert.equal(error.value.cause, failure); + } + } else assert.isTrue(Exit.isSuccess(exit)); + assert.equal(hasCheckpointRef.mock.calls.length, probe === true ? 1 : 0); + assert.equal(captureCheckpoint.mock.calls.length, probe === true ? 1 : 0); + }).pipe(Effect.provide(layer)); + }, +); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 324777f603063..1d045e458853b 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -268,7 +268,8 @@ export const layer: Layer.Layer< withWorkspaceLock( input.scope.cwd, Effect.gen(function* () { - if (!(yield* isGitCheckpointable(input.scope.cwd))) { + // A failed probe must not let provider startup proceed without a baseline. + if (!(yield* checkpointStore.isGitRepository(input.scope.cwd))) { return; } diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d6c12fab7ee15..68bf253965421 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -27,6 +27,7 @@ import * as Schema from "effect/Schema"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; +import * as CheckpointService from "./CheckpointService.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -93,6 +94,7 @@ it("does not commit running state when inherited background routing cannot be re Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), + Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, Layer.succeed(FileSystem.FileSystem, { exists: () => Effect.succeed(false) } as never), @@ -130,7 +132,7 @@ it("does not commit running state when inherited background routing cannot be re tryHandlePromptCommand: () => Effect.succeed(false), }), Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), - Layer.mock(RuntimePolicy.RuntimePolicyV2)({}), + RuntimePolicy.layer, ), ), ); @@ -166,6 +168,9 @@ function makeLocalCommandHarness(input: { * fallback succeeds, then reading history for its handoff fails. */ readonly historyReadFailureAfterFallback?: unknown; + readonly baselineFailure?: unknown; + readonly interruptBaseline?: boolean; + readonly interruptRunDuringBaseline?: boolean; readonly interruptOpen?: boolean; readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; @@ -393,8 +398,26 @@ function makeLocalCommandHarness(input: { ), ensureThread: () => Effect.succeed(providerThread), }; - const open = vi.fn(() => - input.interruptOpen === true + let baselineCaptured = false; + const captureBaseline = vi.fn(() => + input.interruptBaseline === true + ? Effect.interrupt + : "baselineFailure" in input + ? Effect.fail( + new CheckpointService.CheckpointBaselineCaptureError({ + scopeId: checkpointScopeId, + ordinalWithinScope: 1, + cause: input.baselineFailure, + }), + ) + : Effect.sync(() => { + baselineCaptured = true; + if (input.interruptRunDuringBaseline === true) interruptRun(); + }), + ); + const open = vi.fn(() => { + expect(baselineCaptured).toBe(true); + return input.interruptOpen === true ? Effect.interrupt : "historyReadFailureAfterFallback" in input ? Effect.succeed(resumeFallbackSession as never) @@ -431,8 +454,8 @@ function makeLocalCommandHarness(input: { }, ensureThread: () => Effect.succeed(providerThread), } as never) - : Effect.die("A local command must not open a native session."), - ); + : Effect.die("A local command must not open a native session."); + }); const startRootRun = vi.fn< (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect >(() => @@ -489,6 +512,7 @@ function makeLocalCommandHarness(input: { Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({ prepareProviderHandoff: () => Effect.die("history read must fail first"), }), + Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, FileSystem.layerNoop({}), @@ -531,6 +555,7 @@ function makeLocalCommandHarness(input: { ), ); return { + captureBaseline, open, writeIfRunCurrent, startRootRun, @@ -855,3 +880,43 @@ for (const previousMessages of [[], ["/compact", " /COMPACT "]]) { }), ); } + +effectIt.effect("fails before provider startup when its baseline cannot be captured", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "Continue", baselineFailure: "disk full" }); + yield* harness.start; + expect(harness.captureBaseline).toHaveBeenCalledOnce(); + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)).toMatchObject({ status: "failed" }); + }), +); + +effectIt.effect("retries a baseline failure without starting the provider", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "Continue", baselineFailure: "disk full" }); + const error = yield* harness.startWithRetry.pipe(Effect.flip); + expect(error._tag).toBe("ProviderTurnStartError"); + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)).toMatchObject({ status: "starting" }); + }), +); + +effectIt.effect("does not open the provider when baseline capture is interrupted", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "Continue", interruptBaseline: true }); + const result = yield* harness.start.pipe(Effect.exit); + expect(result._tag).toBe("Failure"); + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.writeIfRunCurrent).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("does not open the provider for a run stopped during baseline capture", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "Continue", interruptRunDuringBaseline: true }); + yield* harness.start; + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)).toMatchObject({ status: "interrupted" }); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 684bde2c06695..2bee1a64c4191 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -25,6 +25,7 @@ import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; +import * as CheckpointService from "./CheckpointService.ts"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { @@ -87,6 +88,7 @@ export const layer: Layer.Layer< ProviderTurnStartServiceV2, never, | EventSink.EventSinkV2 + | CheckpointService.CheckpointServiceV2 | ContextHandoffService.ContextHandoffServiceV2 | IdAllocator.IdAllocatorV2 | FileSystem.FileSystem @@ -101,6 +103,7 @@ export const layer: Layer.Layer< ProviderTurnStartServiceV2, Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; + const checkpoints = yield* CheckpointService.CheckpointServiceV2; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const fileSystem = yield* FileSystem.FileSystem; @@ -503,9 +506,26 @@ export const layer: Layer.Layer< runs: current.runs, turnItems: current.turnItems, }); - const inheritedBackgroundTurnItems = yield* projectionStore - .getRuntimeRecoveryProjection(projection.thread.id) - .pipe(Effect.map(selectInheritedBackgroundItems)); + // These reads cannot start a workspace-capable provider. Do them while + // Git captures the baseline, then gate every session operation below. + const [inheritedBackgroundTurnItems, resolvedRuntimePolicy, baseline] = yield* Effect.all( + [ + projectionStore + .getRuntimeRecoveryProjection(projection.thread.id) + .pipe(Effect.map(selectInheritedBackgroundItems)), + runtimePolicy.resolve({ + thread: projection.thread, + modelSelection: run.modelSelection, + }), + checkpoints + .captureBaseline({ + scope: checkpointScope, + ordinalWithinScope: Math.max(0, run.ordinal - 1), + }) + .pipe(Effect.result), + ], + { concurrency: "unbounded" }, + ); const providerSessionId = providerThread.providerSessionId; const runControls = makeRunControls({ threadId: projection.thread.id, @@ -517,33 +537,9 @@ export const layer: Layer.Layer< }); const { isCurrentAttemptInStatus } = runControls; - const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ - thread: projection.thread, - modelSelection: run.modelSelection, - }); const existingSessionProjection = projection.providerSessions.find( (candidate) => candidate.id === providerSessionId, ); - const sessionResult = yield* Effect.result( - providerSessions.open({ - threadId: projection.thread.id, - providerSessionId, - modelSelection: run.modelSelection, - runtimePolicy: resolvedRuntimePolicy, - ...(existingSessionProjection === undefined - ? {} - : { resumeFromSession: existingSessionProjection }), - ...(providerThread.nativeThreadRef?.nativeId == null - ? {} - : { initialNativeThreadId: providerThread.nativeThreadRef.nativeId }), - ...(providerThread.nativeMetadata?.itemIdentityVersion === undefined - ? {} - : { - initialProviderItemIdentityVersion: - providerThread.nativeMetadata.itemIdentityVersion, - }), - }), - ); // The last start attempt fails the run with the provider's own reason // instead of leaving it `starting` after the effect gives up. A run that // already left `starting` is not overwritten, and a failed write returns @@ -552,6 +548,7 @@ export const layer: Layer.Layer< readonly signal: string; readonly title: string; readonly error: Error; + readonly failureClass?: "unknown"; }) => Effect.gen(function* () { const nestedCause = "cause" in failed.error ? failed.error.cause : undefined; @@ -572,11 +569,46 @@ export const layer: Layer.Layer< : typeof nestedCause === "string" ? nestedCause : failed.error.message, - class: "provider_error", + class: failed.failureClass ?? "provider_error", }), }, }); }); + // Session open and native thread setup can run hooks and MCP servers + // before a prompt. Capture their edits in the turn, never in its baseline. + if (baseline._tag === "Failure") { + if (input.willRetry === true) { + return yield* new ProviderTurnStartError({ runId, cause: baseline.failure }); + } + yield* settleStartFailure({ + signal: "checkpoint-baseline-capture-failure", + title: "Workspace baseline failed to capture", + error: baseline.failure, + failureClass: "unknown", + }); + return; + } + if (!(yield* isCurrentAttemptInStatus("starting"))) return; + const sessionResult = yield* Effect.result( + providerSessions.open({ + threadId: projection.thread.id, + providerSessionId, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(existingSessionProjection === undefined + ? {} + : { resumeFromSession: existingSessionProjection }), + ...(providerThread.nativeThreadRef?.nativeId == null + ? {} + : { initialNativeThreadId: providerThread.nativeThreadRef.nativeId }), + ...(providerThread.nativeMetadata?.itemIdentityVersion === undefined + ? {} + : { + initialProviderItemIdentityVersion: + providerThread.nativeMetadata.itemIdentityVersion, + }), + }), + ); if (sessionResult._tag === "Failure") { if (input.willRetry === true) return yield* sessionResult.failure; yield* settleStartFailure({ @@ -1232,6 +1264,7 @@ export const layer: Layer.Layer< run: runningRun, rootNode: runningRootNode, checkpointScope, + baselineCaptured: true, providerThread: runningProviderThread, attempt: runningAttempt, attemptId: attempt.id, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index c26be476bcd01..6489085de7086 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -934,7 +934,7 @@ it.effect("refreshes MCP credential liveness before calling the provider", () => }).pipe(Effect.provide(layerRunExecutionTest)), ); -it.effect("starts the provider when checkpoint baseline capture fails", () => +it.effect("fails the run without starting the provider when baseline capture fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-baseline-failure"); const runId = RunId.make("run:run-execution-baseline-failure"); @@ -970,6 +970,11 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => ), }), Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent: (input) => + Ref.update(writes, (current) => [ + ...current, + { events: input.events, effects: input.effects ?? [] }, + ]).pipe(Effect.as({ committed: true, storedEvents: [] })), writeWithEffects: (input) => Ref.update(writes, (current) => [ ...current, @@ -1015,7 +1020,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => providerTurnOrdinal: 1, message: { messageId: MessageId.make("message:run-execution-baseline-failure"), - text: "Start the provider without a baseline.", + text: "Keep provider startup behind the baseline.", attachments: [], createdBy: "user", creationSource: "web", @@ -1035,7 +1040,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }); }).pipe(Effect.provide(layerTest)); - assert.equal(yield* Ref.get(providerStarts), 1); + assert.equal(yield* Ref.get(providerStarts), 0); const events = (yield* Ref.get(writes)).flatMap((write) => [...write.events]); const failedEvent = events.find( @@ -1045,11 +1050,11 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => event.type === "node.updated") && event.payload.status === "failed", ); - assert.isUndefined(failedEvent); + assert.isDefined(failedEvent); const errorItem = events.find( (event) => event.type === "turn-item.updated" && event.payload.type === "error", ); - assert.isUndefined(errorItem); + assert.isDefined(errorItem); }), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 51f21e29ca78b..5d4ae43710f57 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -504,6 +504,8 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly run: OrchestrationV2Run; readonly rootNode: OrchestrationV2ExecutionNode; readonly checkpointScope: OrchestrationV2CheckpointScope; + /** Session startup already awaited the baseline, before opening the provider. */ + readonly baselineCaptured?: true; readonly providerThread: OrchestrationV2ProviderThread; readonly attempt: OrchestrationV2RunAttempt; readonly attemptId: RunAttemptId; @@ -841,21 +843,12 @@ export const layer: Layer.Layer< // Nothing provider-side may start before the baseline exists: // opening a Claude process already runs the user's SessionStart // hooks and MCP servers, which can edit the workspace. - yield* checkpointService - .captureBaseline({ + if (input.baselineCaptured !== true) { + yield* checkpointService.captureBaseline({ scope: input.checkpointScope, ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - .pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), - ), - ); + }); + } if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 575e38a96c14c..4a41bb990ba89 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -116,6 +116,7 @@ const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( const layerProviderTurnStartServiceProvided = ProviderTurnStartService.layer.pipe( Layer.provide( Layer.mergeAll( + layerCheckpointServiceProvided, layerContextHandoffServiceProvided, layerEventSinkProvided, IdAllocator.layer, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 27c1cc28ae5a4..de9f9cf53e93b 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -383,6 +383,7 @@ export function layerWithRegistry( const layerProviderTurnStartServiceProvided = ProviderTurnStartService.layer.pipe( Layer.provide( Layer.mergeAll( + layerCheckpointServiceProvided, layerContextHandoffServiceProvided, layerEventSinkProvided, IdAllocator.layer, From 3045c12b320b07664a0d5fd448a5067745b4bd25 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:39:35 +0000 Subject: [PATCH 010/215] perf(server): reuse validated indexes for warm checkpoints --- apps/server/src/vcs/GitVcsDriver.test.ts | 150 +++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 97 ++++++++++++--- 2 files changed, 228 insertions(+), 19 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index c25b786053de0..80c615a85f7f3 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -5,6 +5,7 @@ import * as Duration from "effect/Duration"; import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as ByteSize from "effect/ByteSize"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; @@ -935,6 +936,12 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "staged\n"); assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + yield* fileSystem.writeFileString(racyPath, "later!\n"); + yield* fileSystem.utimes(racyPath, timestamp, timestamp); + const next = CheckpointRef.make("refs/t3/checkpoints/racy-cached"); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef: next }); + assert.strictEqual((yield* git(["show", `${next}:racy.txt`])).stdout, "later!\n"); + assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); @@ -1043,6 +1050,149 @@ it.effect.each(["missing", "invalid"] as const)( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("oversized checkpoint indexes retain disk copies instead of buffer caching", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-large-index-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const index = path.join(cwd, ".git", "index"); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (file) => + fs + .stat(file) + .pipe( + Effect.map((info) => + file === index ? { ...info, size: ByteSize.bytes(8_388_609) } : info, + ), + ), + readFile: (file) => + file === index ? Effect.die("oversized index should stay on disk") : fs.readFile(file), + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("cached checkpoint indexes still capture new ignore rules, flags and HEAD", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-invalidation-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = (name: string, text: string) => fs.writeFileString(path.join(cwd, name), text); + yield* write("untracked.txt", "first\n"); + const capture = (step: number) => + Effect.gen(function* () { + const cached = CheckpointRef.make(`refs/t3/checkpoints/cached/${step}`); + const fresh = CheckpointRef.make(`refs/t3/checkpoints/fresh/${step}`); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: cached }); + const coldDriver = yield* GitVcsDriver.makeVcsDriverShape(); + yield* coldDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef: fresh }); + assert.strictEqual( + (yield* git(["rev-parse", `${cached}^{tree}`])).stdout, + (yield* git(["rev-parse", `${fresh}^{tree}`])).stdout, + ); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + return cached; + }); + yield* capture(0); + yield* write("file.txt", "second!!\n"); + yield* write(".gitignore", "untracked.txt\n"); + const ignored = yield* capture(1); + assert.strictEqual((yield* git(["ls-tree", ignored, "--", "untracked.txt"])).stdout, ""); + yield* git(["update-index", "--assume-unchanged", "file.txt"]); + yield* write("file.txt", "third!!!\n"); + const flagged = yield* capture(2); + assert.strictEqual((yield* git(["show", `${flagged}:file.txt`])).stdout, "third!!!\n"); + yield* git(["update-index", "--no-assume-unchanged", "file.txt"]); + yield* git(["add", "."]); + yield* git(["commit", "-m", "advance HEAD"]); + const advanced = yield* capture(3); + assert.strictEqual( + (yield* git(["rev-parse", `${advanced}^`])).stdout, + (yield* git(["rev-parse", "HEAD"])).stdout, + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("cached checkpoints capture submodule HEAD changes without capturing its files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-submodule-" }); + const source = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-submodule-source-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + yield* makeCheckpointFixture(driver, source); + yield* git(["-c", "protocol.file.allow=always", "submodule", "add", source, "module"]); + yield* git(["commit", "-am", "record submodule"]); + const first = CheckpointRef.make("refs/t3/checkpoints/submodule/first"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first }); + yield* fs.writeFileString(path.join(cwd, "module", "new.txt"), "submodule contents\n"); + yield* git(["-C", "module", "add", "."]); + yield* git([ + "-C", + "module", + "-c", + "user.name=Test", + "-c", + "user.email=test@test.com", + "commit", + "-m", + "advance submodule", + ]); + yield* fs.writeFileString(path.join(cwd, "module", "untracked.txt"), "private\n"); + const second = CheckpointRef.make("refs/t3/checkpoints/submodule/second"); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: second }); + const head = (yield* git(["-C", "module", "rev-parse", "HEAD"])).stdout.trim(); + assert.strictEqual( + (yield* git(["ls-tree", "-r", second, "--", "module"])).stdout, + `160000 commit ${head}\tmodule\n`, + ); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("cached checkpoints keep filtered LFS pointers and capture untracked binaries", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-lfs-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "*.bin filter=lfs\n"); + yield* fs.writeFileString( + path.join(cwd, ".git", "lfs-clean.cjs"), + 'const c=require("node:crypto"),a=[];process.stdin.on("data",x=>a.push(x));process.stdin.on("end",()=>{const b=Buffer.concat(a);process.stdout.write("version https://git-lfs.github.com/spec/v1\\noid sha256:"+c.createHash("sha256").update(b).digest("hex")+"\\nsize "+b.length+"\\n")});', + ); + yield* git(["config", "filter.lfs.clean", "node .git/lfs-clean.cjs"]); + yield* git(["config", "filter.lfs.required", "true"]); + yield* fs.writeFile(path.join(cwd, "tracked.bin"), new Uint8Array([0, 1, 2, 255])); + yield* git(["add", "."]); + yield* git(["commit", "-m", "record LFS pointer"]); + const initialPointer = (yield* git(["show", "HEAD:tracked.bin"])).stdout; + const first = CheckpointRef.make("refs/t3/checkpoints/lfs/first"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first }); + yield* fs.writeFile(path.join(cwd, "new.bin"), new Uint8Array([0, 1, 2, 255])); + const second = CheckpointRef.make("refs/t3/checkpoints/lfs/second"); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: second }); + assert.strictEqual((yield* git(["show", `${second}:tracked.bin`])).stdout, initialPointer); + assert.strictEqual((yield* git(["show", `${second}:new.bin`])).stdout, initialPointer); + assert.include(initialPointer, "version https://git-lfs.github.com/spec/v1\n"); + assert.include(initialPointer, "\nsize 4\n"); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 629d92df78933..7ce3553fa664d 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -842,6 +842,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ? gitCommonDir : path.resolve(cwd, gitCommonDir), headExists: result.exitCode === 0, + headCommit: result.exitCode === 0 ? output.slice(output.lastIndexOf("\n") + 1) : null, }; }); @@ -856,6 +857,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "core.fsyncMethod=fsync", ] as const; + // Keep only the last ordinary index, before add. Never reuse a captured tree: + // add still checks files, ignores, filters and submodules on every capture. + const checkpointIndexMaxBytes = 8_388_608; + let checkpointIndex: + | { + cwd: string; + headCommit: string; + source: Buffer; + normalized: Uint8Array; + } + | undefined; + const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; @@ -863,28 +876,31 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-c", "core.fsmonitor=false", "-c", + "core.splitIndex=false", + "-c", "sparse.expectFilesOutsideOfPatterns=false", ]; // These reads are independent, so they share one round of Git processes. // The index path is only used when HEAD exists; failing to read it only // skips index reuse, as before. - const [{ gitCommonDir, headExists }, sparseConfig, sourceIndexPath] = yield* Effect.all( - [ - resolveGitCommonDirAndHead(input.cwd), - execute({ - operation, - cwd: input.cwd, - args: ["config", "--bool", "core.sparseCheckout"], - allowNonZeroExit: true, - }), - execute({ - operation, - cwd: input.cwd, - args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], - }).pipe(Effect.option), - ], - { concurrency: "unbounded" }, - ); + const [{ gitCommonDir, headExists, headCommit }, sparseConfig, sourceIndexPath] = + yield* Effect.all( + [ + resolveGitCommonDirAndHead(input.cwd), + execute({ + operation, + cwd: input.cwd, + args: ["config", "--bool", "core.sparseCheckout"], + allowNonZeroExit: true, + }), + execute({ + operation, + cwd: input.cwd, + args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], + }).pipe(Effect.option), + ], + { concurrency: "unbounded" }, + ); const indexId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const commitEnv: NodeJS.ProcessEnv = { @@ -918,12 +934,35 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const reusedIndex = yield* Effect.gen(function* () { if (Option.isNone(sourceIndexPath)) return false; const indexPath = sourceIndexPath.value; - const { mtime } = yield* fileSystem.stat(indexPath.stdout.trim()); + const { mtime, size } = yield* fileSystem.stat(indexPath.stdout.trim()); if (Option.isNone(mtime)) return false; // Stay below the source timestamp even if Date rounded up, preserving Git's racy check. const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); if (indexTime <= 0) return false; - yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); + const sourceBytes = + !sparseCheckout && size <= BigInt(checkpointIndexMaxBytes) + ? yield* fileSystem.readFile(indexPath.stdout.trim()) + : undefined; + const source = + sourceBytes === undefined + ? undefined + : Buffer.from(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength); + if ( + source !== undefined && + checkpointIndex?.cwd === input.cwd && + checkpointIndex.headCommit === headCommit && + checkpointIndex.source.equals(source) + ) { + yield* fileSystem.writeFile(tempIndexPath, checkpointIndex.normalized); + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + return true; + } + if (source === undefined) { + checkpointIndex = undefined; + yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); + } else { + yield* fileSystem.writeFile(tempIndexPath, source); + } // Retain stat data only where the copied index already matches HEAD. yield* execute({ operation, @@ -985,6 +1024,26 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( specialFlags = selected.stdout.length > 0 || selected.stdoutTruncated; } // Sparse Git clears skip-worktree for present files. Manual flags still need a reset. + if ( + !specialFlags && + !sparseCheckout && + headCommit !== null && + source !== undefined && + source.length <= checkpointIndexMaxBytes + ) { + const normalizedSize = (yield* fileSystem.stat(tempIndexPath)).size; + checkpointIndex = + normalizedSize <= BigInt(checkpointIndexMaxBytes) + ? { + cwd: input.cwd, + headCommit, + source, + normalized: yield* fileSystem.readFile(tempIndexPath), + } + : undefined; + } else { + checkpointIndex = undefined; + } return !specialFlags; }).pipe(Effect.orElseSucceed(() => false)); if (!reusedIndex) { From 5921afdeebe26fd47f0513a76491a9621ddebe22 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 02:02:30 +0000 Subject: [PATCH 011/215] fix(server): pin cached indexes to resolved heads --- apps/server/src/vcs/GitVcsDriver.test.ts | 69 ++++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 6 +-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 80c615a85f7f3..29a4e3161e2ca 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1122,6 +1122,75 @@ it.effect("cached checkpoint indexes still capture new ignore rules, flags and H }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("cached indexes remain valid when HEAD moves while normalizing a nested workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-head-race-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const scope = path.join(cwd, "scope"); + yield* fs.makeDirectory(scope); + yield* fs.writeFileString(path.join(scope, "inside.txt"), "inside\n"); + yield* git(["add", "scope"]); + yield* git(["commit", "-m", "record nested workspace"]); + const head = (yield* git(["rev-parse", "HEAD"])).stdout.trim(); + const outside = (yield* git(["show", `${head}:file.txt`])).stdout; + const blob = (yield* driver.execute({ + operation: "checkpoint-test", + cwd, + args: ["hash-object", "-w", "--stdin"], + stdin: "raced outside\n", + })).stdout.trim(); + const tree = (yield* driver.execute({ + operation: "checkpoint-test", + cwd, + args: ["mktree"], + stdin: (yield* git(["ls-tree", head])).stdout.replace( + /blob [0-9a-f]+\tfile\.txt/, + `blob ${blob}\tfile.txt`, + ), + })).stdout.trim(); + const movedHead = (yield* git([ + "commit-tree", + tree, + "-p", + head, + "-m", + "move HEAD", + ])).stdout.trim(); + const sourceIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + let moveHead = true; + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) { + if (moveHead && input.args.includes("read-tree")) { + moveHead = false; + yield* git(["update-ref", "HEAD", movedHead]).pipe(Effect.orDie); + } + return yield* liveProcess.run(input); + }), + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ + cwd: scope, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/head-race/first"), + }); + yield* git(["update-ref", "HEAD", head]); + const cached = CheckpointRef.make("refs/t3/checkpoints/head-race/cached"); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd: scope, checkpointRef: cached }); + const fresh = CheckpointRef.make("refs/t3/checkpoints/head-race/fresh"); + yield* driver.checkpoints.captureCheckpoint({ cwd: scope, checkpointRef: fresh }); + assert.strictEqual((yield* git(["show", `${cached}:file.txt`])).stdout, outside); + assert.strictEqual( + (yield* git(["rev-parse", `${cached}^{tree}`])).stdout, + (yield* git(["rev-parse", `${fresh}^{tree}`])).stdout, + ); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), sourceIndex); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("cached checkpoints capture submodule HEAD changes without capturing its files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 7ce3553fa664d..a8024189bfb6f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -930,7 +930,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); sparseCheckout = /--(?:\[no-\])?sparse\b/.test(`${help.stdout}${help.stderr}`); } - if (headExists) { + if (headCommit !== null) { const reusedIndex = yield* Effect.gen(function* () { if (Option.isNone(sourceIndexPath)) return false; const indexPath = sourceIndexPath.value; @@ -963,11 +963,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } else { yield* fileSystem.writeFile(tempIndexPath, source); } - // Retain stat data only where the copied index already matches HEAD. + // Pin normalization to the cache key even if HEAD moves during capture. yield* execute({ operation, cwd: input.cwd, - args: [...indexConfig, "read-tree", "--reset", "HEAD"], + args: [...indexConfig, "read-tree", "--reset", headCommit], env: commitEnv, }); // read-tree can rewrite the index, so restore its racy timestamp afterward. From ea74e42fa7c2e4dec16ebcf00add3f9bff8ef543 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 04:23:08 +0000 Subject: [PATCH 012/215] perf(server): keep disk copies for cold checkpoint indexes --- apps/server/src/vcs/GitVcsDriver.test.ts | 63 ++++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 39 ++++++++------- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 29a4e3161e2ca..c1fe32fa8909b 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1191,6 +1191,69 @@ it.effect("cached indexes remain valid when HEAD moves while normalizing a neste }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect( + "a warm capture keeps its cached index when another workspace captures during validation", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-race-" }); + const other = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-other-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const { git: otherGit } = yield* makeCheckpointFixture(driver, other); + for (const [root, runGit] of [ + [cwd, git], + [other, otherGit], + ] as const) { + yield* fs.makeDirectory(path.join(root, "scope")); + yield* fs.writeFileString(path.join(root, "scope", "inside.txt"), "inside\n"); + yield* runGit(["add", "scope"]); + yield* runGit(["commit", "-m", "record nested workspace"]); + } + const outside = (yield* git(["show", "HEAD:file.txt"])).stdout; + const index = path.join(cwd, ".git", "index"); + const source = yield* fs.readFile(index); + yield* fs.writeFileString(path.join(other, "file.txt"), "other repo head\n"); + yield* otherGit(["add", "file.txt"]); + yield* otherGit(["commit", "-m", "change other HEAD"]); + yield* fs.writeFile(path.join(other, ".git", "index"), source); + // Both object stores can resolve either tree, making wrong reuse observable as bytes. + yield* driver.execute({ + operation: "checkpoint-test", + cwd, + args: ["hash-object", "-w", "--stdin"], + stdin: "other repo head\n", + }); + let interleave = Effect.void; + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readFile: (file) => + fs.readFile(file).pipe(Effect.tap(() => (file === index ? interleave : Effect.void))), + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ + cwd: path.join(cwd, "scope"), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/cache-race/first"), + }); + interleave = Effect.gen(function* () { + interleave = Effect.void; + yield* captureDriver.checkpoints.captureCheckpoint({ + cwd: path.join(other, "scope"), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/cache-race/other"), + }); + }).pipe(Effect.orDie); + const next = CheckpointRef.make("refs/t3/checkpoints/cache-race/next"); + yield* captureDriver.checkpoints.captureCheckpoint({ + cwd: path.join(cwd, "scope"), + checkpointRef: next, + }); + assert.strictEqual((yield* git(["show", `${next}:file.txt`])).stdout, outside); + assert.deepEqual(yield* fs.readFile(index), source); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("cached checkpoints capture submodule HEAD changes without capturing its files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index a8024189bfb6f..6bfd714519461 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -939,30 +939,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( // Stay below the source timestamp even if Date rounded up, preserving Git's racy check. const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); if (indexTime <= 0) return false; + const cacheable = !sparseCheckout && size <= BigInt(checkpointIndexMaxBytes); + const cachedIndex = checkpointIndex; + if ( + cacheable && + cachedIndex?.cwd === input.cwd && + cachedIndex.headCommit === headCommit + ) { + const bytes = yield* fileSystem.readFile(indexPath.stdout.trim()); + const source = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (cachedIndex.source.equals(source)) { + yield* fileSystem.writeFile(tempIndexPath, cachedIndex.normalized); + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + return true; + } + } + // Cache the exact copied bytes, even if the user's index changes + // during copy. A cold capture keeps the original cheap disk copy. + yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); const sourceBytes = - !sparseCheckout && size <= BigInt(checkpointIndexMaxBytes) - ? yield* fileSystem.readFile(indexPath.stdout.trim()) + cacheable && + (yield* fileSystem.stat(tempIndexPath)).size <= BigInt(checkpointIndexMaxBytes) + ? yield* fileSystem.readFile(tempIndexPath) : undefined; const source = sourceBytes === undefined ? undefined : Buffer.from(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength); - if ( - source !== undefined && - checkpointIndex?.cwd === input.cwd && - checkpointIndex.headCommit === headCommit && - checkpointIndex.source.equals(source) - ) { - yield* fileSystem.writeFile(tempIndexPath, checkpointIndex.normalized); - yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); - return true; - } - if (source === undefined) { - checkpointIndex = undefined; - yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); - } else { - yield* fileSystem.writeFile(tempIndexPath, source); - } // Pin normalization to the cache key even if HEAD moves during capture. yield* execute({ operation, From 162493b6a431303d459e12c00980d3e1c78f5081 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:53:00 +0000 Subject: [PATCH 013/215] perf(client): preserve state references and write smaller caches --- apps/web/src/components/ChatView.tsx | 7 +- apps/web/src/connection/storage.test.ts | 82 +++++- apps/web/src/connection/storage.ts | 241 +++++++++++++++++- apps/web/src/state/entities.ts | 11 + .../state/orchestrationV2Projection.test.ts | 2 + .../src/state/orchestrationV2Projection.ts | 12 + .../src/state/shellReducer.test.ts | 32 +++ .../client-runtime/src/state/shellReducer.ts | 59 ++++- packages/client-runtime/src/state/vcs.ts | 11 +- 9 files changed, 430 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b290a590e3bc3..bb7286b6391f6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -431,6 +431,7 @@ import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { resolveThreadDetailRef, useEnvironmentSupportsServerBrowser, + useEnvironmentShellBootstrapped, useProject, useProjects, useThreadProjection, @@ -442,7 +443,6 @@ import { useThreadVisibleTurnItems, waitForThreadShell, } from "../state/entities"; -import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; @@ -2735,10 +2735,9 @@ export default function ChatView(props: ChatViewProps) { params: { projectKey: activeDraftLogicalProjectKey }, }); }, [activeDraftLogicalProjectKey, navigate]); - const activeEnvironmentShell = useEnvironmentQuery( - activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, + const activeEnvironmentBootstrapComplete = useEnvironmentShellBootstrapped( + activeThread?.environmentId ?? null, ); - const activeEnvironmentBootstrapComplete = activeEnvironmentShell.data?.snapshot._tag === "Some"; const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 87d78dddbf8c1..6ae1c9183a411 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -2,7 +2,7 @@ import { ConnectionTransientError, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, TurnItemId, MessageId } from "@t3tools/contracts"; import { ConnectionCatalogDocument, Persistence } from "@t3tools/client-runtime/platform"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import { afterEach, vi } from "vite-plus/test"; import * as ConnectionStorage from "./storage"; +import { makeThreadProjectionFixture } from "../test-fixtures"; const emptyCatalog = { schemaVersion: 1, @@ -135,6 +136,85 @@ describe("ConnectionStorage.makeCatalogBackend", () => { }); describe("environment cache removal", () => { + it.effect("restores local turn items from a compact thread cache", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + const values = new Map(); + const database = Object.assign(new EventTarget(), { + close: vi.fn(), + transaction: () => { + const transaction = new EventTarget(); + return Object.assign(transaction, { + objectStore: () => ({ + get: (key: IDBValidKey) => { + const request = Object.assign(new EventTarget(), { + result: values.get(key), + error: null, + }); + queueMicrotask(() => request.dispatchEvent(new Event("success"))); + return request; + }, + put: (value: unknown, key: IDBValidKey) => { + values.set(key, value); + queueMicrotask(() => transaction.dispatchEvent(new Event("complete"))); + }, + }), + }); + }, + }); + vi.stubGlobal("indexedDB", { + open: () => { + const request = Object.assign(new EventTarget(), { result: database, error: null }); + queueMicrotask(() => request.dispatchEvent(new Event("success"))); + return request; + }, + }); + const v2Projection = makeThreadProjectionFixture(); + const v2Now = v2Projection.updatedAt; + const item = { + id: TurnItemId.make("cached-item"), + threadId: v2Projection.thread.id, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed" as const, + title: "Assistant", + startedAt: v2Now, + completedAt: v2Now, + updatedAt: v2Now, + type: "assistant_message" as const, + messageId: MessageId.make("cached-message"), + text: "cached reply", + streaming: false, + }; + const projection = { + ...v2Projection, + turnItems: [item], + visibleTurnItems: [ + { + position: 0, + visibility: "local" as const, + sourceThreadId: item.threadId, + sourceItemId: item.id, + item, + }, + ], + }; + yield* Effect.gen(function* () { + const cache = yield* Persistence.EnvironmentCacheStore; + const environmentId = EnvironmentId.make("env"); + yield* cache.saveThread(environmentId, { snapshotSequence: 1, projection }); + const loaded = Option.getOrThrow(yield* cache.loadThread(environmentId, item.threadId)); + expect(loaded.projection.turnItems).toEqual([item]); + expect(loaded.projection.turnItems[0]).toBe(loaded.projection.visibleTurnItems[0]?.item); + expect(loaded.snapshotSequence).toBe(1); + }).pipe(Effect.provide(ConnectionStorage.layer)); + }), + ); it.effect("fails both removal operations when IndexedDB aborts their commits", () => Effect.gen(function* () { vi.stubGlobal("window", {}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 97497307f37f7..697facdc3bf99 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -27,8 +27,21 @@ import { gitHubRoutingConnectionKey, gitHubRoutingPermissionFor, } from "@t3tools/client-runtime/connection"; -import { EnvironmentId, ServerConfig, ThreadId, VcsListRefsResult } from "@t3tools/contracts"; +import { + EnvironmentId, + OrchestrationV2ThreadShellJson, + ServerConfig, + ThreadId, + TurnItemId, + VcsListRefsResult, +} from "@t3tools/contracts"; +import { + boundedSnapshotProjection, + omitCheckpointItemFiles, + omitLocalVisibleTurnItems, +} from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -38,6 +51,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { layer as browserCryptoLayer } from "../cloud/dpop"; import { forgetBootEnvironment, readBootSnapshot, @@ -75,8 +89,33 @@ const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCat const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); +const decodeStoredShellObject = Schema.decodeUnknownEffect(StoredShellSnapshot); +const encodeShellRow = Schema.encodeEffect(Schema.fromJsonString(OrchestrationV2ThreadShellJson)); +const ShellRows = Schema.Struct({ + format: Schema.Literal("shell-rows-1"), + generation: Schema.String, + header: Schema.String, + threads: Schema.Array(Schema.String), + archivedThreads: Schema.Array(Schema.String), +}); +const isShellRows = Schema.is(ShellRows); +const shellCacheGeneration = Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.provide(browserCryptoLayer), + Effect.mapError((cause) => persistenceError("save-shell", cause)), +); +const shellRowsPrefix = (environmentId: EnvironmentId) => `${environmentId}\u0000`; +const shellRowKey = (environmentId: EnvironmentId, section: string, id: string) => + `${shellRowsPrefix(environmentId)}${section}\u0000${id}`; const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); +const CompactThreadCache = Schema.Struct({ + format: Schema.Literal("compact-thread-1"), + data: Schema.String, + omitLocal: Schema.Boolean, + checkpointItemIds: Schema.Array(TurnItemId), +}); +const isCompactThreadCache = Schema.is(CompactThreadCache); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson); const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson); @@ -274,6 +313,109 @@ function writeDatabaseValueOnConnection( }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); } +/** Read the cursor and its rows in one transaction, including across tabs. */ +function readShellRowsOnConnection(database: IDBDatabase, environmentId: EnvironmentId) { + return Effect.callback((resume) => { + try { + const store = database + .transaction(SHELL_STORE_NAME, "readonly") + .objectStore(SHELL_STORE_NAME); + const request = store.get(environmentId); + const fail = (cause: unknown) => resume(Effect.fail(catalogError("read", cause))); + request.addEventListener("error", () => fail(request.error)); + request.addEventListener("success", () => { + const raw: unknown = request.result; + if (!isShellRows(raw)) { + resume(Effect.succeed(raw)); + return; + } + try { + const value = JSON.parse(raw.header) as { + snapshot: { threads: unknown[]; archivedThreads: unknown[] }; + }; + let remaining = 2; + for (const section of ["threads", "archivedThreads"] as const) { + const prefix = `${shellRowsPrefix(environmentId)}${section}\u0000`; + const rows = store.getAll(IDBKeyRange.bound(prefix, `${prefix}\uffff`)); + rows.addEventListener("error", () => fail(rows.error)); + rows.addEventListener("success", () => { + try { + const byId = new Map(); + for (const row of rows.result) { + if (typeof row !== "string") throw new Error("Invalid shell cache row."); + const parsed = JSON.parse(row) as { readonly id: string }; + byId.set(parsed.id, parsed); + } + value.snapshot[section] = raw[section].map((id) => { + if (!byId.has(id)) throw new Error("Missing shell cache row."); + return byId.get(id); + }); + if (--remaining === 0) resume(Effect.succeed(value)); + } catch (cause) { + fail(cause); + } + }); + } + } catch (cause) { + fail(cause); + } + }); + } catch (cause) { + resume(Effect.fail(catalogError("read", cause))); + } + }); +} + +/** A generation check makes incremental writes safe when another build or tab wrote last. */ +function writeShellRowsOnConnection( + database: IDBDatabase, + environmentId: EnvironmentId, + manifest: typeof ShellRows.Type, + rows: ReadonlyMap, + previous: { readonly generation: string; readonly rows: ReadonlyMap } | undefined, +) { + return Effect.callback((resume) => { + try { + const transaction = database.transaction(SHELL_STORE_NAME, "readwrite"); + transaction.addEventListener("abort", () => + resume(Effect.fail(catalogError("write", transaction.error))), + ); + transaction.addEventListener("complete", () => resume(Effect.void)); + const store = transaction.objectStore(SHELL_STORE_NAME); + const request = store.get(environmentId); + request.addEventListener("success", () => { + try { + const raw: unknown = request.result; + const incremental = isShellRows(raw) && raw.generation === previous?.generation; + for (const [key, value] of rows) { + if (!incremental || previous?.rows.get(key) !== value) store.put(value, key); + } + const prefix = shellRowsPrefix(environmentId); + const keys = store.getAllKeys(IDBKeyRange.bound(prefix, `${prefix}\uffff`)); + keys.addEventListener("success", () => { + for (const key of keys.result) + if (typeof key === "string" && !rows.has(key)) store.delete(key); + }); + store.put(manifest, environmentId); + } catch { + transaction.abort(); + } + }); + } catch (cause) { + resume(Effect.fail(catalogError("write", cause))); + } + }); +} + +const waitForCacheIdle = Effect.callback((resume) => { + if (typeof window === "undefined" || typeof window.requestIdleCallback !== "function") { + resume(Effect.void); + return; + } + const id = window.requestIdleCallback(() => resume(Effect.void), { timeout: 1000 }); + return Effect.sync(() => window.cancelIdleCallback(id)); +}); + function removeDatabaseValueOnConnection( database: IDBDatabase, storeName: string, @@ -634,6 +776,11 @@ export const layer = Layer.effectContext( ); const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions(); + const encodedShellRows = new WeakMap(); + const savedShellRows = new Map< + EnvironmentId, + { readonly generation: string; readonly rows: ReadonlyMap } + >(); const targetStore = Persistence.ConnectionTargetStore.of({ list: catalog.read.pipe( @@ -745,14 +892,19 @@ export const layer = Layer.effectContext( Effect.as(Option.some(boot)), ); } - return readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + return withDatabase(database, (opened) => + readShellRowsOnConnection(opened, environmentId), + ).pipe( Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), Effect.flatMap((raw) => { - if (typeof raw !== "string") { + if (raw === undefined) { return Effect.succeedNone; } return decodeOrDiscardOrchestrationCache( - decodeStoredShellSnapshot(raw).pipe( + (typeof raw === "string" + ? decodeStoredShellSnapshot(raw) + : decodeStoredShellObject(raw) + ).pipe( Effect.mapError((cause) => persistenceError("load-shell", cause)), Effect.map((stored) => stored.environmentId === environmentId @@ -768,12 +920,42 @@ export const layer = Layer.effectContext( }, saveShell: (environmentId, snapshot) => Effect.gen(function* () { - const encoded = yield* encodeStoredShellSnapshot({ + yield* waitForCacheIdle; + const rows = new Map(); + for (const section of ["threads", "archivedThreads"] as const) { + for (const thread of snapshot[section]) { + let encoded = encodedShellRows.get(thread); + if (encoded === undefined) { + encoded = yield* encodeShellRow(thread).pipe( + Effect.mapError((cause) => persistenceError("save-shell", cause)), + ); + encodedShellRows.set(thread, encoded); + } + rows.set(shellRowKey(environmentId, section, thread.id), encoded); + } + } + const header = yield* encodeStoredShellSnapshot({ schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, environmentId, - snapshot, + snapshot: { ...snapshot, threads: [], archivedThreads: [] }, }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); - yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); + const generation = yield* shellCacheGeneration; + yield* withDatabase(database, (opened) => + writeShellRowsOnConnection( + opened, + environmentId, + { + format: "shell-rows-1", + generation, + header, + threads: snapshot.threads.map((thread) => thread.id), + archivedThreads: snapshot.archivedThreads.map((thread) => thread.id), + }, + rows, + savedShellRows.get(environmentId), + ), + ); + savedShellRows.set(environmentId, { generation, rows }); recordBootShell(environmentId, snapshot); }).pipe( Effect.mapError((cause) => @@ -829,15 +1011,26 @@ export const layer = Layer.effectContext( threadCacheKey(environmentId, threadId), ).pipe( Effect.flatMap((raw) => { - if (typeof raw !== "string") { + const compaction = isCompactThreadCache(raw) ? raw : undefined; + const encoded = compaction?.data ?? raw; + if (typeof encoded !== "string") { return Effect.succeedNone; } return decodeOrDiscardOrchestrationCache( - decodeStoredThreadSnapshot(raw).pipe( + decodeStoredThreadSnapshot(encoded).pipe( Effect.mapError((cause) => persistenceError("load-thread", cause)), Effect.map((stored) => stored.environmentId === environmentId && stored.threadId === threadId - ? Option.some(stored.snapshot) + ? Option.some({ + ...stored.snapshot, + projection: boundedSnapshotProjection({ + ...stored.snapshot, + ...(compaction?.omitLocal === true + ? { turnItemsOmitLocalVisible: true as const } + : {}), + checkpointFilesOmittedItemIds: compaction?.checkpointItemIds, + }), + }) : Option.none(), ), ), @@ -852,17 +1045,32 @@ export const layer = Layer.effectContext( ), saveThread: (environmentId, snapshot) => Effect.gen(function* () { + yield* waitForCacheIdle; + const withoutLocal = omitLocalVisibleTurnItems(snapshot.projection); + const withoutFiles = omitCheckpointItemFiles(withoutLocal ?? snapshot.projection); + const compact = withoutLocal !== null || withoutFiles !== null; const encoded = yield* encodeStoredThreadSnapshot({ schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, environmentId, threadId: snapshot.projection.thread.id, - snapshot, + snapshot: { + ...snapshot, + projection: withoutFiles?.projection ?? withoutLocal ?? snapshot.projection, + }, }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); yield* writeDatabaseValue( database, THREAD_STORE_NAME, threadCacheKey(environmentId, snapshot.projection.thread.id), - encoded, + // Older builds ignore object-valued cache entries and fetch live data. + compact + ? { + format: "compact-thread-1", + data: encoded, + omitLocal: withoutLocal !== null, + checkpointItemIds: withoutFiles?.itemIds ?? [], + } + : encoded, ); }).pipe( Effect.mapError((cause) => @@ -935,8 +1143,17 @@ export const layer = Layer.effectContext( Effect.all( [ Effect.sync(() => forgetBootEnvironment(environmentId)), + Effect.sync(() => savedShellRows.delete(environmentId)), Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), + removeDatabaseValuesInRange( + database, + SHELL_STORE_NAME, + IDBKeyRange.bound( + shellRowsPrefix(environmentId), + `${shellRowsPrefix(environmentId)}\uffff`, + ), + ), removeDatabaseValuesInRange( database, THREAD_STORE_NAME, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index ebf62f01be6fd..13db625435666 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -18,6 +18,7 @@ import { environmentServerConfigsAtom } from "./server"; import { allEnvironmentProjectSnapshotsReadyAtom, allEnvironmentShellsBootstrappedAtom, + environmentSnapshotAtom, } from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; import { waitForAtomValue } from "./waitForAtomValue"; @@ -49,6 +50,10 @@ const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe( const EMPTY_THREAD_HISTORY_ATOM = Atom.make(EMPTY_THREAD_HISTORY_META).pipe( Atom.withLabel("web-thread-history:empty"), ); +const EMPTY_SHELL_BOOTSTRAPPED_ATOM = Atom.make(false); +const shellBootstrappedAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(environmentSnapshotAtom(environmentId)) !== null), +); const activeEnvironmentIdAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -97,6 +102,12 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { return useAtomValue(allEnvironmentShellsBootstrappedAtom); } +export function useEnvironmentShellBootstrapped(environmentId: EnvironmentId | null): boolean { + return useAtomValue( + environmentId === null ? EMPTY_SHELL_BOOTSTRAPPED_ATOM : shellBootstrappedAtom(environmentId), + ); +} + export function useAllEnvironmentProjectSnapshotsReady(): boolean { return useAtomValue(allEnvironmentProjectSnapshotsReadyAtom); } diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index f3b0ab1c5e7d6..f72166c7d1934 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -530,5 +530,7 @@ it("does not scan every row against every run for a streaming item update", () = } as OrchestrationV2DomainEvent); expect(next?.visibleTurnItems.at(-1)?.item).toBe(payload); expect(next?.visibleTurnItems[0]).toBe(projection.visibleTurnItems[0]); + expect(next?.visibleTurnItems[998]).toBe(projection.visibleTurnItems[998]); + expect(next?.visibleTurnItems.at(-1)?.position).toBe(999); expect(runReads).toBeLessThanOrEqual(100); }); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 18537218bdd14..f7319512e70bb 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -171,6 +171,18 @@ function upsertVisibleTurnItem( ) { return rows; } + // Token updates replace the item in its existing slot. Only an ordinal or + // source change can require sorting and renumbering the visible timeline. + if ( + previous !== undefined && + previous.visibility === next.visibility && + previous.sourceThreadId === next.sourceThreadId && + previous.item.ordinal === item.ordinal + ) { + const updated = [...rows]; + updated[index] = { ...previous, item }; + return updated; + } if ( index === -1 && partialTimeline && diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 219ea312d2672..03e7b913e8b55 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -27,6 +27,38 @@ const otherIdentity = { }; describe("applyShellStreamEvent", () => { + it("shares unchanged shell fields across full wire row updates", () => { + const prior = { + ...v2ThreadShell, + lineage: { ...v2ThreadShell.lineage }, + modelSelection: { ...v2ThreadShell.modelSelection }, + }; + const snapshot = { ...v2ShellSnapshot, threads: [prior] }; + const next = applyShellStreamEvent(snapshot, { + kind: "thread.updated", + location: "active", + sequence: 1, + thread: { + ...prior, + title: "Renamed", + lineage: { ...prior.lineage }, + modelSelection: { ...prior.modelSelection }, + }, + }); + expect(next.threads[0]?.title).toBe("Renamed"); + expect(next.threads[0]?.lineage).toBe(prior.lineage); + expect(next.threads[0]?.modelSelection).toBe(prior.modelSelection); + const switched = applyShellStreamEvent(next, { + kind: "thread.updated", + location: "active", + sequence: 2, + thread: { + ...next.threads[0]!, + modelSelection: { ...prior.modelSelection, model: "changed" }, + }, + }); + expect(switched.threads[0]?.modelSelection.model).toBe("changed"); + }); it("updates a thread in place without moving its siblings", () => { const threads = ["a", "b", "c"].map((id) => ({ ...v2ThreadShell, id: ThreadId.make(id) })); const updated = { ...threads[1]!, title: "Streaming" }; diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index c2cba12675879..26cb30c032ac5 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -10,6 +10,38 @@ import * as Schema from "effect/Schema"; const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell); const sameProjectShell = Schema.toEquivalence(OrchestrationProjectShell); const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink))); +const sharedShellFields = [ + "modelSelection", + "lineage", + "forkedFrom", + "pendingRuntimeRequest", + "latestVisibleMessage", + "pendingBackgroundTasks", + "providerInstanceHistory", + "pullRequests", + "linkedPullRequest", + "branchPullRequest", + "goal", + "limitRecovery", +] as const satisfies ReadonlyArray; +const shellFieldEqual = sharedShellFields.map((key) => + Schema.toEquivalence(OrchestrationV2ThreadShell.fields[key] as Schema.Schema), +); + +/** Full wire rows still share fields the update left unchanged. */ +function shareThreadShell(previous: OrchestrationV2ThreadShell, next: OrchestrationV2ThreadShell) { + if (sameThreadShell(previous, next)) return previous; + let shared = next; + for (let index = 0; index < sharedShellFields.length; index++) { + const key = sharedShellFields[index]!; + const equal = shellFieldEqual[index]!; + if (previous[key] !== next[key] && equal(previous[key], next[key])) { + if (shared === next) shared = { ...next }; + Object.assign(shared, { [key]: previous[key] }); + } + } + return shared; +} /** Whether two rows' pull request links are equal, including both absent. */ export function sameThreadPullRequests( @@ -30,7 +62,7 @@ export function reuseUnchangedThreadShells( ): OrchestrationV2ShellSnapshot { if (previous === null || previous.threads.length === 0) return next; const previousById = new Map(previous.threads.map((thread) => [thread.id, thread] as const)); - let reused = 0; + let changed = false; const threads = next.threads.map((thread) => { const prior = previousById.get(thread.id); if (prior === undefined) return thread; @@ -38,11 +70,24 @@ export function reuseUnchangedThreadShells( thread.pullRequests === undefined && prior.pullRequests !== undefined ? { ...thread, pullRequests: prior.pullRequests } : thread; - if (!sameThreadShell(prior, candidate)) return thread; - reused += 1; + if (!sameThreadShell(prior, candidate)) { + const shared = shareThreadShell(prior, thread); + changed ||= shared !== thread; + return shared; + } + changed ||= prior !== thread; return prior; }); - return reused === 0 ? next : { ...next, threads }; + return changed + ? { + ...next, + threads: + previous.threads.length === threads.length && + previous.threads.every((thread, index) => thread === threads[index]) + ? previous.threads + : threads, + } + : next; } function upsertById( @@ -174,7 +219,9 @@ export function applyShellStreamEvent( event.location === "active" ? snapshot.threads.find((thread) => thread.id === event.thread.id) : undefined; - if (existing !== undefined && sameThreadShell(existing, event.thread)) { + const thread = + existing === undefined ? event.thread : shareThreadShell(existing, event.thread); + if (thread === existing) { return { ...snapshot, snapshotSequence: event.sequence }; } const withoutThread = (threads: OrchestrationV2ShellSnapshot["threads"]) => @@ -183,7 +230,7 @@ export function applyShellStreamEvent( ...snapshot, threads: event.location === "active" - ? upsertById(snapshot.threads, event.thread) + ? upsertById(snapshot.threads, thread) : withoutThread(snapshot.threads), // The archive has its own bounded query/subscription. Older servers may // still send archive-located deltas here; remove them from the normal diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 524c8d8a39812..348cd85a3360b 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -2,7 +2,7 @@ import { type EnvironmentId, type VcsListRefsInput, type VcsListRefsResult, - type VcsStatusResult, + VcsStatusResult, WS_METHODS, } from "@t3tools/contracts"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; @@ -39,6 +39,7 @@ const VCS_REFS_IDLE_TTL_MS = 30_000; // Rows keep the last status they rendered, so the live stream only needs a // short grace period when virtualization or scrolling releases its consumer. const VCS_STATUS_IDLE_TTL_MS = 10_000; +const sameVcsStatus = Schema.toEquivalence(VcsStatusResult); const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))), @@ -311,14 +312,16 @@ export function createVcsEnvironmentAtoms( ).pipe( Stream.filterMap((event) => { if (event._tag === "unchanged") return Result.failVoid; - current = applyGitStatusStreamEvent(current, event); + const next = applyGitStatusStreamEvent(current, event); + const unchanged = current !== null && sameVcsStatus(current, next); + if (!unchanged) current = next; statuses.delete(key); statuses.set(key, { - status: current, + status: current ?? next, version: event._tag === "snapshot" ? event.version : undefined, }); if (statuses.size > 128) statuses.delete(statuses.keys().next().value!); - return Result.succeed(current); + return unchanged ? Result.failVoid : Result.succeed(next); }), ); return cached === undefined From 79cfb204ba997fb7f39e44768cf50fb31b33439a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 03:40:16 +0000 Subject: [PATCH 014/215] perf(threads): hydrate older history outside the initial viewport --- .../src/orchestration-v2/Orchestrator.ts | 5 +- .../src/orchestration-v2/ProjectionStore.ts | 47 ++++++++++++++++++- .../ThreadManagementService.ts | 10 +++- .../src/orchestration-v2/ThreadStream.test.ts | 39 +++++++++++++++ .../src/orchestration-v2/ThreadStream.ts | 11 +++++ apps/server/src/orchestration-v2/http.ts | 27 +++++++++-- .../threadHistoryPaging.test.ts | 36 ++++++++++++++ .../orchestration-v2/threadHistoryPaging.ts | 29 +++++++++++- apps/server/src/ws.ts | 7 ++- .../src/state/boundedThreadSnapshotHttp.ts | 2 +- .../src/state/threadHistoryHttp.ts | 1 + .../src/state/threadHistoryMerge.test.ts | 45 ++++++++++++++++++ .../src/state/threadHistoryMerge.ts | 32 ++++++++++--- packages/client-runtime/src/state/threads.ts | 5 +- packages/contracts/src/environmentHttp.ts | 2 + packages/contracts/src/orchestrationV2.ts | 11 +++++ 16 files changed, 288 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1c47211efd3a7..3da3b251ba499 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -302,6 +302,7 @@ export interface OrchestratorV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + includeSupport?: boolean, ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -11360,9 +11361,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), ), ), - getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) => + getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly, includeSupport) => projectionStore - .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly) + .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly, includeSupport) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), getTimelinePage: (threadId, options) => projectionStore diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ab6974eef4e83..a41953c0d7121 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -361,6 +361,7 @@ export interface ProjectionStoreV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + includeSupport?: boolean, ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -529,6 +530,8 @@ export interface ProjectionStoreV2Shape { readonly rowLimit: number; /** User-turn window, with extra anchors for the inclusive cursor and has-more check. */ readonly userTurnLimit?: number | undefined; + /** Historical checkpoints hydrate with their rows instead of node ancestry. */ + readonly deferHistoricalSupport?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly anchorThreadId?: ThreadId | undefined; readonly requiredRunId?: RunId | undefined; @@ -2801,6 +2804,7 @@ export const layer: Layer.Layer = window?: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferHistoricalSupport?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3286,7 +3290,7 @@ export const layer: Layer.Layer = AND (status IN ('pending','capturing') OR checkpoint_id IN (SELECT value FROM json_each(${cohortCheckpointIds})) OR run_id IN (SELECT value FROM json_each(${cohortRunIds})) - OR node_id IN (SELECT value FROM json_each(${cohortNodeIds}))) + OR node_id IN (SELECT value FROM json_each(${window.deferHistoricalSupport ? cohortJson("nodeId") : cohortNodeIds}))) ORDER BY scope_id ASC, ordinal_within_scope ASC `, fields !== undefined && !fields.includes("contextHandoffs") @@ -3408,6 +3412,7 @@ export const layer: Layer.Layer = window?: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferHistoricalSupport?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly requiredRunId?: RunId | undefined; readonly suppressLocal?: boolean | undefined; @@ -3524,6 +3529,7 @@ export const layer: Layer.Layer = return { rowLimit: window.rowLimit, userTurnLimit: window.userTurnLimit, + deferHistoricalSupport: window.deferHistoricalSupport, requiredRunId: forkedFrom.runId, suppressLocal: anchor === undefined || anchorIsInDescendant, ...(anchor === undefined || anchorIsInDescendant @@ -5053,6 +5059,7 @@ export const layer: Layer.Layer = cursor, throughEntryId, conversationOnly, + includeSupport, ) => sql .withTransaction( @@ -5084,6 +5091,29 @@ export const layer: Layer.Layer = catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; + const supportProjection = + includeSupport && page.items.length > 0 + ? yield* readProjection(threadId, new Set(), { + rowLimit: page.items.length, + deferHistoricalSupport: true, + ...(page.items.at(-1)!.sourceThreadId === threadId + ? { anchorItemId: page.items.at(-1)!.sourceItemId } + : {}), + historyAnchor: { + threadId: page.items.at(-1)!.sourceThreadId, + itemId: page.items.at(-1)!.sourceItemId, + }, + }) + : undefined; + const support = + supportProjection === undefined + ? undefined + : { + nodes: supportProjection.nodes, + checkpoints: supportProjection.checkpoints, + runs: supportProjection.runs, + attempts: supportProjection.attempts, + }; const hasUserTurns = index.visible .slice(0, end) .some((row) => isThreadHistoryUserTurn(row.item)); @@ -5130,6 +5160,7 @@ export const layer: Layer.Layer = ...page, snapshotSequence, items: items.map((row, position) => ({ ...row, position })), + ...(support === undefined ? {} : { support }), }; // The no-turn fallback also respects the encoded byte budget after projection. const bounded = selectRecentTimelineWindow({ @@ -5149,6 +5180,7 @@ export const layer: Layer.Layer = : bounded.items, nextCursor: bounded.hasMoreHistory ? bounded.nextCursor : page.nextCursor, hasMoreHistory: bounded.hasMoreHistory || page.hasMoreHistory, + ...(support === undefined ? {} : { support }), }; }), ) @@ -5243,6 +5275,7 @@ export const layer: Layer.Layer = const projection = yield* readProjection(threadId, new Set(), { rowLimit: options.rowLimit, userTurnLimit: options.userTurnLimit, + deferHistoricalSupport: options.deferHistoricalSupport, ...(historyAnchor?.threadId === threadId ? { anchorItemId: historyAnchor.itemId } : {}), @@ -6734,7 +6767,7 @@ export const layerMemory: Layer.Layer = Layer.effect( ), ), ), - getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) => + getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly, includeSupport) => service.getThreadSnapshot(threadId).pipe( Effect.flatMap(({ projection, snapshotSequence }) => Effect.gen(function* () { @@ -6757,6 +6790,16 @@ export const layerMemory: Layer.Layer = Layer.effect( items: conversationOnly ? page.items.filter((row) => isConversationHistoryItem(row.item)) : page.items, + ...(includeSupport + ? { + support: { + nodes: projection.nodes, + checkpoints: projection.checkpoints, + runs: projection.runs, + attempts: projection.attempts, + }, + } + : {}), }; }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 5b11370976b2d..7ccca6d1a0de0 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -881,10 +881,16 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.searchThread(input)), ), dispatch, - getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) => + getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly, includeSupport) => ensureProjectionTranscript(threadId).pipe( Effect.andThen( - orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly), + orchestrator.getThreadHistoryPage( + threadId, + cursor, + throughEntryId, + conversationOnly, + includeSupport, + ), ), ), getTimelinePage: (threadId, options) => diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index d64de56cbf8aa..373e5e96e72d3 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,5 +1,6 @@ import { EventId, + MessageId, OrchestrationV2ThreadStreamItem, ThreadId, TurnItemId, @@ -209,6 +210,44 @@ describe("decideThreadResume", () => { expect(snapshot.payloadBudgetExceeded).toBe(false); }); + it("keeps a large turn pageable only for clients requesting a viewport window", () => { + const base = timelineProjection(100); + const first = base.visibleTurnItems[0]!; + const user = { + ...first.item, + type: "user_message" as const, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + messageId: MessageId.make("viewport-user"), + text: "Start a long turn", + attachments: [], + inputIntent: "turn_start" as const, + createdBy: "user" as const, + creationSource: "web" as const, + createdAt: NOW, + }; + const visibleTurnItems = [{ ...first, item: user }, ...base.visibleTurnItems.slice(1)]; + const projection = { + ...base, + visibleTurnItems, + turnItems: visibleTurnItems.map((row) => row.item), + }; + const legacy = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, projection }); + const viewport = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 44, + projection, + viewportWindow: true, + }); + expect(legacy.projection.visibleTurnItems).toHaveLength(100); + expect(legacy.hasMoreHistory).toBe(false); + expect(viewport.projection.visibleTurnItems).toHaveLength(75); + expect(viewport.hasMoreHistory).toBe(true); + expect(viewport.historyCursor).not.toBeNull(); + expect(viewport.payloadBudgetExceeded).toBe(false); + }); + it("rejects a 10 MiB raw replay even when its projected form fits the wire budget", () => { const item = { id: TurnItemId.make("large-dynamic-tool"), diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 47e944d1711fe..277c4e6915db4 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -11,6 +11,7 @@ import { import { buildBoundedThreadProjection, type BoundedProjectionResult, + THREAD_HISTORY_PAGE_POLICY, } from "./threadHistoryPaging.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -85,10 +86,20 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly projection: OrchestrationV2ThreadProjection; readonly compactTurnItems?: boolean; readonly compactCheckpointItems?: boolean; + readonly viewportWindow?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, projection: projectThreadProjectionForWire(input.projection), + viewportWindow: input.viewportWindow === true, + ...(input.viewportWindow + ? { + policy: { + maxItems: THREAD_HISTORY_PAGE_POLICY.maxItems, + maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + }, + } + : {}), }); return { kind: "snapshot", diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index ff74f5eba80e8..14e7a9697f8f2 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -132,12 +132,15 @@ export const layer = HttpApiBuilder.group( anchorThreadId?: Parameters< typeof threadManagement.getThreadSnapshotWindow >[1]["anchorThreadId"], + viewportWindow = false, ) { return yield* threadManagement .getThreadSnapshotWindow(threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, - userTurnLimit: - anchorItemId === undefined + deferHistoricalSupport: viewportWindow, + userTurnLimit: viewportWindow + ? undefined + : anchorItemId === undefined ? THREAD_HISTORY_PAGE_POLICY.maxUserTurns : OLDER_THREAD_USER_TURN_LIMIT, ...(anchorItemId === undefined ? {} : { anchorItemId }), @@ -194,12 +197,25 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe( - traceLocalHandlerWork, - ); + const viewportWindow = args.query.viewportWindow === "1"; + const snapshot = yield* loadThreadSnapshotWindow( + args.params.threadId, + undefined, + undefined, + viewportWindow, + ).pipe(traceLocalHandlerWork); const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, snapshotSequence: snapshot.snapshotSequence, + viewportWindow, + ...(viewportWindow + ? { + policy: { + maxItems: THREAD_HISTORY_PAGE_POLICY.maxItems, + maxEncodedBytes: THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + }, + } + : {}), }); return { snapshotSequence: snapshot.snapshotSequence, @@ -229,6 +245,7 @@ export const layer = HttpApiBuilder.group( args.query.cursor, args.query.throughEntryId, args.query.view === "conversation", + args.query.includeSupport === "1", ) .pipe( traceLocalHandlerWork, diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 57a6bf86da587..f79ab41fd904d 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -159,6 +159,42 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { + it("defers unrelated historical checkpoints only for viewport clients", () => { + const projection = makeProjection(Array.from({ length: 90 }, (_, index) => makeRow(index))); + const recent = { id: "recent", runId: "recent-run", nodeId: "recent-node", files: [] }; + const historical = { + id: "old", + runId: "old-run", + nodeId: "old-node", + files: [{ path: "x".repeat(1_100_000) }], + }; + const withCheckpoints = { + ...projection, + runs: [ + { id: "recent-run", status: "completed" }, + { id: "old-run", status: "completed" }, + ], + checkpoints: [historical, recent], + visibleTurnItems: projection.visibleTurnItems.map((row) => ({ + ...row, + item: { ...row.item, runId: "recent-run", nodeId: "recent-node" }, + })), + } as unknown as typeof projection; + const current = buildBoundedThreadProjection({ + projection: withCheckpoints, + snapshotSequence: 1, + viewportWindow: true, + }); + expect(current.projection.checkpoints).toEqual([recent]); + expect(current.projection.visibleTurnItems).toHaveLength(75); + expect(current.payloadBudgetExceeded).toBe(false); + const legacy = buildBoundedThreadProjection({ + projection: withCheckpoints, + snapshotSequence: 1, + }); + expect(legacy.projection.checkpoints).toEqual([historical, recent]); + expect(legacy.payloadBudgetExceeded).toBe(true); + }); it("builds a resumable bounded socket snapshot frame", () => { const projection = makeProjection(Array.from({ length: 90 }, (_, index) => makeRow(index))); const item = buildBoundedThreadStreamSnapshot({ diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index ce7fccfd7d26c..d4fb847c72865 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -53,6 +53,7 @@ type HistoryRow = Pick< readonly messageId?: string; readonly inputIntent?: string; readonly createdBy?: string; + readonly runId?: string | null; }; }; export type SelectTimelinePageResult = { @@ -294,7 +295,9 @@ export function selectHistoryPageFromCursor(input: { }; if (input.throughEntryId !== undefined) { const targetIndex = input.items.findIndex( - (row) => (row.item.messageId ?? row.item.id) === input.throughEntryId, + (row) => + (row.item.messageId ?? row.item.id) === input.throughEntryId || + row.item.runId === input.throughEntryId, ); if (targetIndex >= 0 && targetIndex < anchorIndex) { // A search jump can combine two pages, but never grow into an unbounded read. @@ -459,11 +462,35 @@ export function buildBoundedThreadProjection(input: { readonly projection: OrchestrationV2ThreadProjection; readonly snapshotSequence: number; readonly policy?: ThreadHistoryPagePolicy | undefined; + readonly viewportWindow?: boolean; }): BoundedProjectionResult { const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY; const threadId = input.projection.thread.id; + const recentRows = input.projection.visibleTurnItems.slice(-policy.maxItems); + const retainedRunIds = new Set(recentRows.map(({ item }) => item.runId)); + for (const run of input.projection.runs) { + if (["queued", "preparing", "starting", "running", "waiting"].includes(run.status)) { + retainedRunIds.add(run.id); + } + } + const retainedNodeIds = new Set(recentRows.map(({ item }) => item.nodeId)); + const retainedCheckpointIds = new Set( + recentRows.flatMap(({ item }) => ("checkpointId" in item ? [item.checkpointId] : [])), + ); const controlProjection = { ...input.projection, + ...(input.viewportWindow + ? { + // Ancestor nodes can belong to old runs with enormous checkpoints. + // Their file lists hydrate with the corresponding history page. + checkpoints: input.projection.checkpoints.filter( + (checkpoint) => + (checkpoint.runId !== null && retainedRunIds.has(checkpoint.runId)) || + retainedNodeIds.has(checkpoint.nodeId) || + retainedCheckpointIds.has(checkpoint.id), + ), + } + : {}), plans: input.projection.plans.filter((plan) => plan.status === "active"), contextHandoffs: input.projection.contextHandoffs.filter( (handoff) => handoff.status === "pending" || handoff.status === "ready", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cce9cff478a25..99101bb0c9977 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -824,6 +824,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; readonly acceptCompactCheckpointItems?: boolean; + readonly acceptViewportWindow?: boolean; readonly acceptThreadFieldEvents?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -929,7 +930,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs useBoundedSnapshot ? threadManagement.getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, - userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, + deferHistoricalSupport: input.acceptViewportWindow === true, + userTurnLimit: input.acceptViewportWindow + ? undefined + : THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }) : threadManagement.getThreadSnapshot(input.threadId) ).pipe( @@ -948,6 +952,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, compactCheckpointItems: input.acceptCompactCheckpointItems === true, + viewportWindow: input.acceptViewportWindow === true, }) : { kind: "snapshot" as const, diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index ac8f6adb8f345..c01ebf4437161 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -37,7 +37,7 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }) { const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1", compactCheckpointItems: "1" }, + query: { compactTurnItems: "1", compactCheckpointItems: "1", viewportWindow: "1" }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index 2d99d7dbf0e0b..edbf9b65b21fe 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -28,6 +28,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( params: { threadId: input.threadId }, query: { cursor: input.cursor, + includeSupport: "1", ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), }, diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 56ef3d235df92..b6b9744be2e3c 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -45,6 +45,51 @@ function row(index: number): OrchestrationV2ProjectedTurnItem { } describe("threadHistoryMerge", () => { + it("hydrates older execution metadata without overwriting live entities", () => { + const node = { + id: "history-node" as never, + threadId: v2ThreadId, + runId: null, + parentNodeId: null, + rootNodeId: "history-node" as never, + kind: "system" as const, + status: "completed" as const, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: NOW, + completedAt: NOW, + }; + const current = { + ...v2Projection, + nodes: [node], + turnItems: [row(4).item], + visibleTurnItems: [row(4)], + }; + const older = { ...node, id: "older-node" as never, rootNodeId: "older-node" as never }; + const merged = mergeOlderHistoryIntoProjection(current, [row(1)], { + nodes: [{ ...node, status: "running" }, older], + checkpoints: [], + runs: [], + attempts: [], + }); + expect(merged.nodes).toEqual([older, node]); + expect(merged.nodes[1]).toBe(node); + expect(merged.visibleTurnItems.map((item) => item.sourceItemId)).toEqual(["item-1", "item-4"]); + for (const rows of [[], current.visibleTurnItems]) { + const hydrated = mergeOlderHistoryIntoProjection(current, rows, { + nodes: [older], + checkpoints: [], + runs: [], + attempts: [], + }); + expect(hydrated.nodes).toEqual([older, node]); + expect(hydrated.visibleTurnItems).toBe(current.visibleTurnItems); + } + }); it("fills activity around partial messages in canonical order and preserves live edits", () => { const initial = { ...v2Projection, turnItems: [row(4).item], visibleTurnItems: [row(4)] }; const preview = [row(1), row(3)]; diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index ef8bd219b9ea9..bd1e664519b63 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -1,6 +1,7 @@ import type { OrchestrationV2ProjectedTurnItem, OrchestrationV2ThreadProjection, + OrchestrationV2ThreadHistoryPage, } from "@t3tools/contracts"; import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline"; @@ -67,10 +68,19 @@ function renumberVisible( export function mergeOlderHistoryIntoProjection( projection: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, + support?: OrchestrationV2ThreadHistoryPage["support"], ): OrchestrationV2ThreadProjection { - if (olderItems.length === 0) { - return projection; - } + const withSupport = + support === undefined + ? projection + : { + ...projection, + nodes: mergeHistoryEntities(projection.nodes, support.nodes), + checkpoints: mergeHistoryEntities(projection.checkpoints, support.checkpoints), + runs: mergeHistoryEntities(projection.runs, support.runs), + attempts: mergeHistoryEntities(projection.attempts, support.attempts), + }; + if (olderItems.length === 0) return withSupport; const currentByKey = new Map( projection.visibleTurnItems.map((row) => [projectedItemKey(row), row]), @@ -111,7 +121,7 @@ export function mergeOlderHistoryIntoProjection( ) turnItemById.set(String(row.sourceItemId), row.item); } - if (page.length === 0) return projection; + if (page.length === 0) return withSupport; // Server page order fills gaps between rows from a partial conversation load. // Existing rows supply live values; rows outside this range keep their order. const visible = [ @@ -122,14 +132,24 @@ export function mergeOlderHistoryIntoProjection( visible.length === projection.visibleTurnItems.length && visible.every((row, index) => row === projection.visibleTurnItems[index]) ) - return projection; + return withSupport; return { - ...projection, + ...withSupport, turnItems: [...turnItemById.values()], visibleTurnItems: renumberVisible(visible), }; } +/** Historical pages fill missing entities without replacing newer live values. */ +function mergeHistoryEntities( + current: ReadonlyArray, + older: ReadonlyArray, +): ReadonlyArray { + const ids = new Set(current.map((value) => value.id)); + const missing = older.filter((value) => !ids.has(value.id)); + return missing.length === 0 ? current : [...missing, ...current]; +} + export function applyHistoryPageMeta( current: ThreadHistoryMeta, page: { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 74e88fe4f4b6b..e95d26280eede 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -720,7 +720,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make { _tag: "loaded" }, { ...latest, - data: Option.some(mergeOlderHistoryIntoProjection(latest.data.value, page.items)), + data: Option.some( + mergeOlderHistoryIntoProjection(latest.data.value, page.items, page.support), + ), status: complete && waiting ? "synchronizing" : latest.status, history: complete ? applyHistoryPageMeta(latest.history, page) @@ -893,6 +895,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const, acceptCompactCheckpointItems: true as const, + acceptViewportWindow: true as const, } : {}), }; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a2d10a029540f..4805238b73b1b 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -592,12 +592,14 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ compactTurnItems: Schema.optionalKey(Schema.String), compactCheckpointItems: Schema.optionalKey(Schema.String), + viewportWindow: Schema.optionalKey(Schema.String), }); const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, throughEntryId: Schema.optional(TrimmedNonEmptyString), view: Schema.optional(Schema.Literals(["conversation", "activity"])), + includeSupport: Schema.optionalKey(Schema.String), }); const EnvironmentOrchestrationThreadHistoryErrors = [ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index dafc3826acd54..10d662c18263c 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3328,6 +3328,8 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ * file list their checkpoint already carries. See `checkpointFilesOmittedItemIds`. */ acceptCompactCheckpointItems: Schema.optionalKey(Schema.Boolean), + /** Starts with a small row window; history pages hydrate older control metadata. */ + acceptViewportWindow: Schema.optionalKey(Schema.Boolean), /** Accepts compact read-watermark and pull-request-link events. */ acceptThreadFieldEvents: Schema.optionalKey(Schema.Boolean), }); @@ -3397,6 +3399,15 @@ export const OrchestrationV2ThreadHistoryPage = Schema.Struct({ ), nextCursor: Schema.NullOr(TrimmedNonEmptyString), hasMoreHistory: Schema.Boolean, + /** Present only when requested by clients that hydrate older history metadata. */ + support: Schema.optionalKey( + Schema.Struct({ + nodes: Schema.Array(OrchestrationV2ExecutionNode), + checkpoints: Schema.Array(OrchestrationV2Checkpoint), + runs: Schema.Array(OrchestrationV2Run), + attempts: Schema.Array(OrchestrationV2RunAttempt), + }), + ), }); export type OrchestrationV2ThreadHistoryPage = typeof OrchestrationV2ThreadHistoryPage.Type; From 52f269b29fb555278dcc70ad91d9d52cfe074a2a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 03:40:26 +0000 Subject: [PATCH 015/215] perf(web): reuse markdown parses and observer row measurements --- apps/web/src/components/ChatMarkdown.tsx | 9 +- apps/web/src/markdown-incremental.test.tsx | 16 ++- apps/web/src/markdown-incremental.ts | 48 +++++++ patches/@legendapp__list@3.3.5.patch | 152 ++++++++++++++++++--- pnpm-lock.yaml | 10 +- 5 files changed, 209 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d339cf9ee67c8..970ee9e6dc9e6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -103,7 +103,10 @@ import type { } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { toHtml } from "hast-util-to-html"; -import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; +import { + createCachedMarkdownPlugin, + createIncrementalMarkdownPlugin, +} from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; @@ -3458,13 +3461,15 @@ function ChatMarkdown({ props.isStreaming === true && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); + const cachedParsing = props.isStreaming !== true && extraRemarkPlugins.length === 0; const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), ...extraRemarkPlugins, ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), + ...(cachedParsing ? [createCachedMarkdownPlugin()] : []), ], - [extraRemarkPlugins, incrementalParsing, lineBreaks], + [extraRemarkPlugins, incrementalParsing, cachedParsing, lineBreaks], ); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx index e8b3158fa23b2..9771e07418fcc 100644 --- a/apps/web/src/markdown-incremental.test.tsx +++ b/apps/web/src/markdown-incremental.test.tsx @@ -8,7 +8,10 @@ import { describe, expect, it } from "vite-plus/test"; import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives"; import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; -import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; +import { + createCachedMarkdownPlugin, + createIncrementalMarkdownPlugin, +} from "./markdown-incremental"; import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; const CHAT_MARKDOWN_REHYPE_PLUGINS = chatMarkdownRehypePlugins(await loadRehypeRaw()); @@ -49,6 +52,17 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n"; describe("incremental Markdown parsing", () => { + it("reuses a parsed document across mounts without retaining mutated transforms", () => { + const source = "> [!NOTE]\n> cached alert\n\n- a\n\n[link]: /target"; + const expected = render(source); + expect(render(source, createCachedMarkdownPlugin())).toEqual(expected); + const parsedSources: string[] = []; + expect(render(source, createCachedMarkdownPlugin(), parsedSources)).toEqual(expected); + expect(parsedSources).not.toContain(source); + const edited = source + "\n\n[link]"; + expect(render(edited, createCachedMarkdownPlugin())).toEqual(render(edited)); + expect(render(source, createCachedMarkdownPlugin())).toEqual(expected); + }); it("keeps the document prefix cached when list recovery parses contain fences", () => { const source = prefix + diff --git a/apps/web/src/markdown-incremental.ts b/apps/web/src/markdown-incremental.ts index 3fffa35d3adb6..8467b69b1b107 100644 --- a/apps/web/src/markdown-incremental.ts +++ b/apps/web/src/markdown-incremental.ts @@ -3,6 +3,54 @@ import type { Parser as UnifiedParser, Plugin } from "unified"; type Parser = UnifiedParser; +const parsedDocuments = new Map(); +let parsedDocumentBytes = 0; +const MAX_PARSED_DOCUMENT_BYTES = 8 * 1024 * 1024; + +/** Share pristine parse trees across virtualized row mounts. Transforms receive + * their own tree, so alerts, directives and list recovery cannot mutate the cache. + * Only the standard parser configuration uses this cache. + */ +export function createCachedMarkdownPlugin(): Plugin<[], Root> { + return function () { + const original = this.parser; + if (!original) return; + let documentParsed = false; + this.parser = (source, file) => { + if (documentParsed) return original(source, file); + documentParsed = true; + let hash = 2166136261; + for (let index = 0; index < source.length; index++) { + hash = Math.imul(hash ^ source.charCodeAt(index), 16777619); + } + const cached = parsedDocuments.get(hash); + if (cached?.source === source) { + parsedDocuments.delete(hash); + parsedDocuments.set(hash, cached); + return structuredClone(cached.root); + } + const root = original(source, file) as Root; + // Account for positions and AST nodes as well as source text, and cap + // both bytes and entry count. Oversized documents are never retained. + const bytes = source.length * 16 + 1024; + if (bytes <= MAX_PARSED_DOCUMENT_BYTES) { + if (cached) { + parsedDocumentBytes -= cached.bytes; + parsedDocuments.delete(hash); + } + parsedDocuments.set(hash, { source, root: structuredClone(root), bytes }); + parsedDocumentBytes += bytes; + while (parsedDocumentBytes > MAX_PARSED_DOCUMENT_BYTES || parsedDocuments.size > 128) { + const oldest = parsedDocuments.keys().next().value!; + parsedDocumentBytes -= parsedDocuments.get(oldest)!.bytes; + parsedDocuments.delete(oldest); + } + } + return root; + }; + }; +} + interface ParsedPrefix { source: string; offset: number; diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 88b739824b01e..efff3fc5171bb 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1377,10 +1377,39 @@ index 40e87cd..93aac74 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/react-native.web.js b/react-native.web.js -index 914d2da..8b88b09 100644 --- a/react-native.web.js +++ b/react-native.web.js -@@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { +@@ -5348,7 +5348,7 @@ + top: entry.contentRect.top, + width: borderBoxSize.inlineSize + } : (_a4 = target == null ? void 0 : target.getBoundingClientRect()) != null ? _a4 : entry.contentRect; +- const previousRect = prevRect != null ? prevRect : getContainerLayoutBaseline(element); ++ const previousRect = prevRect; + const didSizeChange = previousRect === void 0 || rectObserved.width !== previousRect.width || rectObserved.height !== previousRect.height; + prevRect = rectObserved; + const shouldResyncLayout = !!(webLayoutResync == null ? void 0 : webLayoutResync()); +@@ -5415,11 +5415,6 @@ + scheduleWebShrinkMeasurement(state, () => { + var _a4; + if (state.itemKey === currentItemKey) { +- const element = ref.current; +- const rect = (_a4 = element == null ? void 0 : element.getBoundingClientRect) == null ? void 0 : _a4.call(element); +- if (rect) { +- layout = { height: rect.height, width: rect.width }; +- } + applyLayout(); + } + }); +@@ -5474,7 +5469,7 @@ + }); + const { onLayout } = useOnLayoutSync( + { +- measureInLayoutEffect: !IsNewArchitecture, ++ measureInLayoutEffect: false, + onLayoutChange, + ref, + webLayoutResync: () => isInMVCPActiveMode(ctx.state) +@@ -5651,6 +5646,18 @@ }); // src/utils/reordering.ts @@ -1399,7 +1428,7 @@ index 914d2da..8b88b09 100644 function sortDOMElements(container, indexByElement) { const elements = Array.from(container.children); if (elements.length <= 1) return elements; -@@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) { +@@ -5690,9 +5697,9 @@ } } if (nextStableElement) { @@ -1412,10 +1441,39 @@ index 914d2da..8b88b09 100644 } } diff --git a/react-native.web.mjs b/react-native.web.mjs -index 95465f2..d7cbe1c 100644 --- a/react-native.web.mjs +++ b/react-native.web.mjs -@@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { +@@ -5327,7 +5327,7 @@ + top: entry.contentRect.top, + width: borderBoxSize.inlineSize + } : (_a4 = target == null ? void 0 : target.getBoundingClientRect()) != null ? _a4 : entry.contentRect; +- const previousRect = prevRect != null ? prevRect : getContainerLayoutBaseline(element); ++ const previousRect = prevRect; + const didSizeChange = previousRect === void 0 || rectObserved.width !== previousRect.width || rectObserved.height !== previousRect.height; + prevRect = rectObserved; + const shouldResyncLayout = !!(webLayoutResync == null ? void 0 : webLayoutResync()); +@@ -5394,11 +5394,6 @@ + scheduleWebShrinkMeasurement(state, () => { + var _a4; + if (state.itemKey === currentItemKey) { +- const element = ref.current; +- const rect = (_a4 = element == null ? void 0 : element.getBoundingClientRect) == null ? void 0 : _a4.call(element); +- if (rect) { +- layout = { height: rect.height, width: rect.width }; +- } + applyLayout(); + } + }); +@@ -5453,7 +5448,7 @@ + }); + const { onLayout } = useOnLayoutSync( + { +- measureInLayoutEffect: !IsNewArchitecture, ++ measureInLayoutEffect: false, + onLayoutChange, + ref, + webLayoutResync: () => isInMVCPActiveMode(ctx.state) +@@ -5630,6 +5625,18 @@ }); // src/utils/reordering.ts @@ -1434,7 +1492,7 @@ index 95465f2..d7cbe1c 100644 function sortDOMElements(container, indexByElement) { const elements = Array.from(container.children); if (elements.length <= 1) return elements; -@@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) { +@@ -5669,9 +5676,9 @@ } } if (nextStableElement) { @@ -1447,10 +1505,9 @@ index 95465f2..d7cbe1c 100644 } } diff --git a/react.js b/react.js -index 914d2da..0c2917d 100644 --- a/react.js +++ b/react.js -@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4702,7 +4702,8 @@ contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -1460,7 +1517,7 @@ index 914d2da..0c2917d 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4722,6 +4723,12 @@ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -1473,7 +1530,37 @@ index 914d2da..0c2917d 100644 } return nextSize; } -@@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { +@@ -5348,7 +5355,7 @@ + top: entry.contentRect.top, + width: borderBoxSize.inlineSize + } : (_a4 = target == null ? void 0 : target.getBoundingClientRect()) != null ? _a4 : entry.contentRect; +- const previousRect = prevRect != null ? prevRect : getContainerLayoutBaseline(element); ++ const previousRect = prevRect; + const didSizeChange = previousRect === void 0 || rectObserved.width !== previousRect.width || rectObserved.height !== previousRect.height; + prevRect = rectObserved; + const shouldResyncLayout = !!(webLayoutResync == null ? void 0 : webLayoutResync()); +@@ -5415,11 +5422,6 @@ + scheduleWebShrinkMeasurement(state, () => { + var _a4; + if (state.itemKey === currentItemKey) { +- const element = ref.current; +- const rect = (_a4 = element == null ? void 0 : element.getBoundingClientRect) == null ? void 0 : _a4.call(element); +- if (rect) { +- layout = { height: rect.height, width: rect.width }; +- } + applyLayout(); + } + }); +@@ -5474,7 +5476,7 @@ + }); + const { onLayout } = useOnLayoutSync( + { +- measureInLayoutEffect: !IsNewArchitecture, ++ measureInLayoutEffect: false, + onLayoutChange, + ref, + webLayoutResync: () => isInMVCPActiveMode(ctx.state) +@@ -5651,6 +5653,18 @@ }); // src/utils/reordering.ts @@ -1492,7 +1579,7 @@ index 914d2da..0c2917d 100644 function sortDOMElements(container, indexByElement) { const elements = Array.from(container.children); if (elements.length <= 1) return elements; -@@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) { +@@ -5690,9 +5704,9 @@ } } if (nextStableElement) { @@ -1504,7 +1591,7 @@ index 914d2da..0c2917d 100644 } } } -@@ -6446,8 +6465,8 @@ function ScrollAdjust() { +@@ -6446,8 +6460,8 @@ window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1515,10 +1602,9 @@ index 914d2da..0c2917d 100644 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/react.mjs b/react.mjs -index 95465f2..b73bfdf 100644 --- a/react.mjs +++ b/react.mjs -@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4681,7 +4681,8 @@ contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -1528,7 +1614,7 @@ index 95465f2..b73bfdf 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4701,6 +4702,12 @@ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -1541,7 +1627,37 @@ index 95465f2..b73bfdf 100644 } return nextSize; } -@@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { +@@ -5327,7 +5334,7 @@ + top: entry.contentRect.top, + width: borderBoxSize.inlineSize + } : (_a4 = target == null ? void 0 : target.getBoundingClientRect()) != null ? _a4 : entry.contentRect; +- const previousRect = prevRect != null ? prevRect : getContainerLayoutBaseline(element); ++ const previousRect = prevRect; + const didSizeChange = previousRect === void 0 || rectObserved.width !== previousRect.width || rectObserved.height !== previousRect.height; + prevRect = rectObserved; + const shouldResyncLayout = !!(webLayoutResync == null ? void 0 : webLayoutResync()); +@@ -5394,11 +5401,6 @@ + scheduleWebShrinkMeasurement(state, () => { + var _a4; + if (state.itemKey === currentItemKey) { +- const element = ref.current; +- const rect = (_a4 = element == null ? void 0 : element.getBoundingClientRect) == null ? void 0 : _a4.call(element); +- if (rect) { +- layout = { height: rect.height, width: rect.width }; +- } + applyLayout(); + } + }); +@@ -5453,7 +5455,7 @@ + }); + const { onLayout } = useOnLayoutSync( + { +- measureInLayoutEffect: !IsNewArchitecture, ++ measureInLayoutEffect: false, + onLayoutChange, + ref, + webLayoutResync: () => isInMVCPActiveMode(ctx.state) +@@ -5630,6 +5632,18 @@ }); // src/utils/reordering.ts @@ -1560,7 +1676,7 @@ index 95465f2..b73bfdf 100644 function sortDOMElements(container, indexByElement) { const elements = Array.from(container.children); if (elements.length <= 1) return elements; -@@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) { +@@ -5669,9 +5683,9 @@ } } if (nextStableElement) { @@ -1572,7 +1688,7 @@ index 95465f2..b73bfdf 100644 } } } -@@ -6425,8 +6444,8 @@ function ScrollAdjust() { +@@ -6425,8 +6439,8 @@ window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1faa0c6fe125..f1d1068806ece 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,7 +148,7 @@ patchedDependencies: '@effect/vitest@4.0.2': 595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9 '@expo/metro-config@58.0.6': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 - '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 + '@legendapp/list@3.3.5': 70069ede322d8584fc3799d33372173ee79de580c2114f6024bb0ac959af0cca '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59 @@ -323,7 +323,7 @@ importers: version: 58.0.11(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 3.3.5(patch_hash=70069ede322d8584fc3799d33372173ee79de580c2114f6024bb0ac959af0cca)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@material/material-color-utilities': specifier: 0.3.0 version: 0.3.0 @@ -778,7 +778,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + version: 3.3.5(patch_hash=70069ede322d8584fc3799d33372173ee79de580c2114f6024bb0ac959af0cca)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@noble/hashes': specifier: 'catalog:' version: 1.8.0 @@ -14981,7 +14981,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=70069ede322d8584fc3799d33372173ee79de580c2114f6024bb0ac959af0cca)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -14989,7 +14989,7 @@ snapshots: react-dom: 19.2.6(react@19.2.6) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)': + '@legendapp/list@3.3.5(patch_hash=70069ede322d8584fc3799d33372173ee79de580c2114f6024bb0ac959af0cca)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: react: 19.3.0 use-sync-external-store: 1.6.0(react@19.3.0) From 9ee41d61cc8841313d74919b28d340c480601840 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 03:40:37 +0000 Subject: [PATCH 016/215] perf(web): render streamed tokens in the assistant row --- apps/web/src/components/ChatView.tsx | 10 +-- .../src/components/chat/MessagesTimeline.tsx | 69 ++++++++++++++++- .../chat/ThreadRelationshipsControl.tsx | 15 ++-- .../src/hooks/useChatThreadPresentation.ts | 39 ++++++++++ apps/web/src/session-logic.test.ts | 41 ++++++++++ apps/web/src/session-logic.ts | 76 ++++++++++++++++++- 6 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/hooks/useChatThreadPresentation.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bb7286b6391f6..d70ac792a0c2e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -434,15 +434,14 @@ import { useEnvironmentShellBootstrapped, useProject, useProjects, - useThreadProjection, useThreadStatus, useThreadHistory, useThreadShell, useChildThreadInputs, useThreadRefs, - useThreadVisibleTurnItems, waitForThreadShell, } from "../state/entities"; +import { useChatThreadPresentation } from "../hooks/useChatThreadPresentation"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; @@ -1825,8 +1824,9 @@ export default function ChatView(props: ChatViewProps) { shellExists: serverThread !== null, waitForShell: draftThread !== null, }); - const serverThreadProjection = useThreadProjection(routeThreadDetailRef); - const serverProjection = serverThreadProjection?.projection ?? null; + const [isThreadFindActive, setIsThreadFindActive] = useState(false); + const { projection: serverProjection, visibleTurnItems: serverVisibleTurnItems } = + useChatThreadPresentation(routeThreadDetailRef, isThreadFindActive); const reportedModelSelection = serverProjection ? deriveReportedModelSelection(serverProjection) : null; @@ -1843,7 +1843,6 @@ export default function ChatView(props: ChatViewProps) { () => latestProviderTurnTokenUsage(serverProjection?.providerTurns ?? []), [serverProjection?.providerTurns], ); - const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef); const serverThreadHistory = useThreadHistory(routeThreadDetailRef); const threadHistoryControls = useMemo(() => { if (routeThreadDetailRef === null || !shouldShowLoadEarlierControl(serverThreadHistory)) { @@ -8166,7 +8165,6 @@ export default function ChatView(props: ChatViewProps) { ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS; const threadFindControlsRef = useRef(null); - const [isThreadFindActive, setIsThreadFindActive] = useState(false); const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []); const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c5623c8bfe573..e39dbc7c9a87b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -37,6 +37,7 @@ import { import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { useAtomValue } from "@effect/atom-react"; import { environmentThreadDetails } from "../../state/threads"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { Link } from "@tanstack/react-router"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; @@ -779,6 +780,26 @@ const ConversationTimeline = memo(function ConversationTimeline({ return disclosureAnchorKey === null || row.id === disclosureAnchorKey; }, []); + useEffect(() => { + if (!historyControls?.hasMoreHistory || historyControls.loading || historyControls.error) + return; + // A row window can start inside a folded turn. Hydrate its missing activity + // only when the reader opens it, stopping once its user message is present. + for (const runId of paintedExpandedRunIds) { + if ( + !timelineEntries.some( + (entry) => + entry.kind === "message" && + entry.message.role === "user" && + entry.message.runId === runId, + ) + ) { + historyControls.onLoadEarlier(runId); + break; + } + } + }, [historyControls, paintedExpandedRunIds, timelineEntries]); + const onToggleTurnFold = useCallback( (runId: RunId) => { suspendEndScrollMaintenanceForDisclosure(`turn-fold:${runId}`); @@ -2800,7 +2821,8 @@ function AttemptFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const message = useStreamingFrameMessage(row.message, ctx.threadRef); + const messageText = message.text || (message.streaming ? "" : "(empty response)"); return ( <> @@ -2818,7 +2840,7 @@ function AssistantTimelineRow({ row }: { row: Extract @@ -2848,6 +2870,47 @@ function AssistantTimelineRow({ row }: { row: Extract { + if (!message.streaming || threadRef === null) return; + const atom = environmentThreadDetails.visibleTurnItemsAtom(threadRef); + let frame: number | undefined; + const paint = () => { + const item = appAtomRegistry + .get(atom) + .find( + (row) => row.item.type === "assistant_message" && row.item.messageId === message.id, + )?.item; + if (item?.type === "assistant_message") { + setPainted({ + ...message, + text: item.text, + updatedAt: DateTime.formatIso(item.updatedAt), + streaming: item.streaming, + }); + } + }; + const unsubscribe = appAtomRegistry.subscribe(atom, () => { + if (frame !== undefined) return; + frame = requestAnimationFrame(() => { + frame = undefined; + paint(); + }); + }); + paint(); + return () => { + unsubscribe(); + if (frame !== undefined) cancelAnimationFrame(frame); + }; + }, [message, threadRef]); + return message.streaming && painted.id === message.id ? painted : message; +} + function AssistantForkButton({ projectedItem, }: { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index d7f6471eea3c6..71094448aebb2 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -49,13 +49,9 @@ import { useMemo, useState, type ReactNode } from "react"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { buildThreadRouteParams } from "../../threadRoutes"; -import { - useProjects, - useServerConfigs, - useThreadProjection, - useThreadShells, -} from "../../state/entities"; +import { useProjects, useServerConfigs, useThreadShells } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; +import { useChatThreadPresentation } from "../../hooks/useChatThreadPresentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { AgentElapsed } from "./AgentElapsed"; import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; @@ -214,8 +210,11 @@ export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; }) { - const ref = scopeThreadRef(props.environmentId, props.threadId); - const projection = useThreadProjection(ref)?.projection ?? null; + const ref = useMemo( + () => scopeThreadRef(props.environmentId, props.threadId), + [props.environmentId, props.threadId], + ); + const { projection } = useChatThreadPresentation(ref, false); const providers = useServerConfigs().get(props.environmentId)?.providers; const subagentsByThreadId = useMemo( () => diff --git a/apps/web/src/hooks/useChatThreadPresentation.ts b/apps/web/src/hooks/useChatThreadPresentation.ts new file mode 100644 index 0000000000000..a61adf1110c7f --- /dev/null +++ b/apps/web/src/hooks/useChatThreadPresentation.ts @@ -0,0 +1,39 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; +import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { useMemo } from "react"; +import { isStreamingProjectionTextUpdate, isStreamingTurnItemTextUpdate } from "../session-logic"; +import { environmentThreadDetails } from "../state/threads"; + +/** Token text belongs to the assistant row; lifecycle and metadata still update the chat view. */ +export function useChatThreadPresentation(ref: ScopedThreadRef | null, searching: boolean) { + const presentation = useMemo(() => { + let previous: EnvironmentThread | null = null; + let previousVisible: OrchestrationV2ThreadProjection["visibleTurnItems"] = []; + let value: { + projection: OrchestrationV2ThreadProjection | null; + visibleTurnItems: OrchestrationV2ThreadProjection["visibleTurnItems"]; + } = { projection: null, visibleTurnItems: [] }; + return Atom.make((get) => { + const next = ref === null ? null : get(environmentThreadDetails.threadAtom(ref)); + const visible = ref === null ? [] : get(environmentThreadDetails.visibleTurnItemsAtom(ref)); + const textOnly = + !searching && + previous !== null && + next !== null && + isStreamingProjectionTextUpdate(previous.projection, next.projection) && + visible.length === previousVisible.length && + visible.every( + (row, index) => + row === previousVisible[index] || + isStreamingTurnItemTextUpdate(previousVisible[index]!, row), + ); + previous = next; + previousVisible = visible; + if (!textOnly) value = { projection: next?.projection ?? null, visibleTurnItems: visible }; + return value; + }); + }, [ref, searching]); + return useAtomValue(presentation); +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f7d61b8f65a70..741992eb0bebd 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -31,6 +31,7 @@ import { derivePhase, findLatestProposedPlan, isLatestRunSettled, + isStreamingProjectionTextUpdate, selectHandoffImageResources, selectMessageImageResources, createMessageAttachmentPreviewProjector, @@ -1416,6 +1417,46 @@ describe("work-log failure policy (#7999/#7893)", () => { }); describe("incremental v2 timeline entries", () => { + it("keeps token text in its row and exposes completion, metadata, and control changes", () => { + const fixture = makeStreamingTimelineFixture("Partial"); + const rows = fixture.visibleTurnItems; + const last = rows.at(-1)!; + if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); + const before = { + ...makeThreadProjectionFixture(), + turnItems: rows.map((row) => row.item), + visibleTurnItems: rows, + }; + const token = { + ...last, + item: { ...last.item, text: "Next token", updatedAt: DateTime.makeUnsafe(fixture.time(8)) }, + }; + const after = { + ...before, + turnItems: [...before.turnItems.slice(0, -1), token.item], + visibleTurnItems: [...rows.slice(0, -1), token], + }; + expect(isStreamingProjectionTextUpdate(before, after)).toBe(true); + for (const changed of [ + { ...token, item: { ...token.item, streaming: false } }, + { ...token, item: { ...token.item, runId: fixture.historyRunId } }, + { ...token, sourceThreadId: ThreadId.make("inherited") }, + ]) { + expect( + isStreamingProjectionTextUpdate(before, { + ...after, + turnItems: [...before.turnItems.slice(0, -1), changed.item], + visibleTurnItems: [...rows.slice(0, -1), changed], + }), + ).toBe(false); + } + expect(isStreamingProjectionTextUpdate(before, { ...after, runs: [...before.runs] })).toBe( + false, + ); + expect( + isStreamingProjectionTextUpdate(before, { ...after, visibleTurnItems: rows.slice(1) }), + ).toBe(false); + }); it("retains history and attachment previews through decoded streaming updates", () => { const fixture = makeStreamingTimelineFixture("Partial"); const attachment = { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a1554683a5648..572256dba4cc2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -591,9 +591,14 @@ export interface TimelineEntriesProjection { export function deriveTimelineEntriesFromVisibleTurnItems( input: TimelineEntriesInput, + previous: TimelineEntriesProjection | null = null, ): TimelineEntry[] { const committedMessageIds = new Set(); const entries: TimelineEntry[] = []; + const previousById = new Map(previous?.entries.map((entry) => [entry.id, entry])); + const canReuse = + previous !== null && shallow(input.attachmentUrlById, previous.input.attachmentUrlById); + const previousRows = new Set(previous?.input.visibleTurnItems); const attemptByRootNodeId = new Map( (input.attempts ?? []).map((attempt) => [attempt.rootNodeId, attempt] as const), ); @@ -632,6 +637,20 @@ export function deriveTimelineEntriesFromVisibleTurnItems( if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; const createdAt = projectedItemCreatedAt(row); const attempt = resolveAttempt(item); + const old = previousById.get( + item.type === "user_message" || item.type === "assistant_message" ? item.messageId : item.id, + ); + if ( + canReuse && + previousRows.has(row) && + old !== undefined && + old.attempt === attempt && + (item.type !== "proposed_plan" || input.plans === previous?.input.plans) + ) { + entries.push(old); + if (old.kind === "message") committedMessageIds.add(old.message.id); + continue; + } const attemptMetadata = attempt === undefined ? {} : { attempt }; if (item.type === "notification") { entries.push({ @@ -928,6 +947,61 @@ export function isStreamingTurnItemTextUpdate( return Equal.equals(previousMetadata, nextMetadata); } +/** A text-only token event can bypass the chat shell while its row reads live text. */ +export function isStreamingProjectionTextUpdate( + previous: OrchestrationV2ThreadProjection, + next: OrchestrationV2ThreadProjection, +): boolean { + if (previous === next) return true; + const { + turnItems: beforeItems, + visibleTurnItems: beforeRows, + messages: beforeMessages, + updatedAt: _beforeTime, + ...before + } = previous; + const { + turnItems: afterItems, + visibleTurnItems: afterRows, + messages: afterMessages, + updatedAt: _afterTime, + ...after + } = next; + if ( + !shallow(before, after) || + beforeItems.length !== afterItems.length || + beforeRows.length !== afterRows.length + ) + return false; + if (beforeMessages !== afterMessages) { + if (beforeMessages.length !== afterMessages.length) return false; + for (let index = 0; index < beforeMessages.length; index++) { + const old = beforeMessages[index]!; + const message = afterMessages[index]!; + if (old === message) continue; + if ( + old.role !== "assistant" || + message.role !== "assistant" || + !old.streaming || + !message.streaming + ) + return false; + const { text: _oldText, updatedAt: _oldUpdated, ...oldMetadata } = old; + const { text: _text, updatedAt: _updated, ...metadata } = message; + if (!Equal.equals(oldMetadata, metadata)) return false; + } + } + const changedItems = new Set(); + for (let index = 0; index < beforeRows.length; index++) { + const old = beforeRows[index]!; + const row = afterRows[index]!; + if (old === row) continue; + if (!isStreamingTurnItemTextUpdate(old, row)) return false; + changedItems.add(row.item); + } + return afterItems.every((item, index) => item === beforeItems[index] || changedItems.has(item)); +} + function reuseTimelineEntries( input: TimelineEntriesInput, previous: TimelineEntriesProjection, @@ -1010,7 +1084,7 @@ export function deriveTimelineEntriesFromVisibleTurnItemsWithState( ): TimelineEntriesProjection { const reused = previous === null ? null : reuseTimelineEntries(input, previous); if (reused !== null) return { input, entries: reused }; - const entries = deriveTimelineEntriesFromVisibleTurnItems(input); + const entries = deriveTimelineEntriesFromVisibleTurnItems(input, previous); if (previous === null || !shallow(input.attachmentUrlById, previous.input.attachmentUrlById)) { return { input, entries }; } From 5204ffbdd19d5f2e5512847723df7bad6c75abc5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:02:20 +0000 Subject: [PATCH 017/215] perf(web): isolate sidebar row updates and viewport content --- apps/web/src/components/BranchToolbar.tsx | 34 +- apps/web/src/components/RightPanelTabs.tsx | 29 +- apps/web/src/components/Sidebar.motion.ts | 123 ++++++-- apps/web/src/components/Sidebar.shells.ts | 15 + apps/web/src/components/Sidebar.tooltip.tsx | 59 ++++ apps/web/src/components/Sidebar.tsx | 325 +++++++++++++------- apps/web/src/components/Sidebar.viewport.ts | 46 +++ apps/web/src/hooks/useNowMinute.ts | 11 +- 8 files changed, 502 insertions(+), 140 deletions(-) create mode 100644 apps/web/src/components/Sidebar.shells.ts create mode 100644 apps/web/src/components/Sidebar.tooltip.tsx create mode 100644 apps/web/src/components/Sidebar.viewport.ts diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 709510c153b5f..6295e4123f5b2 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -531,9 +531,12 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - for (const [label, previousRect] of previousRects) { - if (!label.isConnected) continue; - const nextWidth = label.getBoundingClientRect().width; + const widths = [...previousRects].flatMap(([label, previousRect]) => + label.isConnected + ? [{ label, previousRect, nextWidth: label.getBoundingClientRect().width }] + : [], + ); + for (const { label, previousRect, nextWidth } of widths) { if (Math.abs(previousRect.width - nextWidth) < 0.5) continue; // Animate the space occupied by each label so flex layout keeps the @@ -572,19 +575,36 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { [], ); - // Label widths can change without the strip box moving (font family or - // size preferences), so re-measure on every render as well as on resize - // and font loads. useLayoutEffect(() => { measure(); - }); + }, [element, measure]); useEffect(() => { if (!element) return; const stopObserving = observeResize(element, measure); + let frame: number | null = null; + const schedule = () => { + if (frame !== null) return; + frame = requestAnimationFrame(() => { + frame = null; + measure(); + }); + }; + // Child labels and hosted controls may resize while the strip itself + // stays fixed. Observe those changes instead of measuring every render. + const mutations = new MutationObserver(schedule); + mutations.observe(element, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["class", "data-composer-label"], + }); document.fonts.addEventListener("loadingdone", measure); return () => { stopObserving(); + mutations.disconnect(); + if (frame !== null) cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); }; }, [element, measure]); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f4558fed87b8b..9f68a70231739 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,9 @@ import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { useProjects, useServerConfigs } from "~/state/entities"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; +import { environmentThreadShells } from "~/state/threads"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, @@ -753,7 +756,6 @@ function PullRequestSurfaceIcon({ const resolvedEnvironmentId = (surface.environmentId as EnvironmentId | undefined) ?? environmentId; const projects = useProjects(); - const threads = useThreadShells(); const project = projects.find( (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, ); @@ -768,10 +770,25 @@ function PullRequestSurfaceIcon({ resolvedEnvironmentId === null ? undefined : configs.get(resolvedEnvironmentId)?.environment.capabilities; - const linkedSnapshot = - capabilities?.threadPullRequests === true - ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) - : null; + const linksSupported = capabilities?.threadPullRequests === true; + const linkedSnapshotAtom = useMemo( + () => + Atom.make((get) => + linksSupported + ? (resolvePullRequestTabLink( + get(environmentThreadShells.threadShellsAtom), + resolvedEnvironmentId, + host, + { + repository: surface.repository, + number: surface.number, + }, + )?.snapshot ?? null) + : null, + ), + [linksSupported, resolvedEnvironmentId, host, surface.repository, surface.number], + ); + const linkedSnapshot = useAtomValue(linkedSnapshotAtom); const detail = useEnvironmentQuery( resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null ? null diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 14b211fa1df9a..349f39e2c126c 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,3 +1,5 @@ +import { observeResize } from "../lib/observeResize"; + const motionTiming = { duration: 150, easing: "ease-out" }; // Rows normally ride their displaced neighbour's travel. Absent a moving // neighbour, a row still travels on its own, clamped so a tall card does not @@ -10,7 +12,14 @@ const rowTravel = (height: number) => Math.min(height, 40); // so only the fade count decides whether an update animates. const MAX_FADED_ROWS_PER_UPDATE = 40; -type RowPosition = { top: number; left: number; width: number; height: number }; +type RowPosition = { + top: number; + left: number; + width: number; + height: number; + key: string | HTMLElement; + shape: string; +}; function progress(animation: Animation) { return animation.playState === "finished" @@ -37,18 +46,78 @@ export function createSidebarListMotion(parent: HTMLUListElement) { Array.from(parent.children).filter( (node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node), ); - const measure = () => - new Map( - rows().map((node) => [ + const rowKey = (node: HTMLElement) => node.getAttribute?.("data-thread-item") ?? node; + const rowShape = (node: HTMLElement) => + node.getAttribute?.("data-sidebar-row-variant") ?? node.className ?? ""; + const measure = (selective = false) => { + const nodes = rows(); + const previous = selective && positions ? [...positions.values()] : []; + const byKey = new Map(previous.map((position) => [position.key, position])); + let start = 0; + while ( + start < nodes.length && + start < previous.length && + rowKey(nodes[start]!) === previous[start]!.key && + rowShape(nodes[start]!) === previous[start]!.shape + ) + start++; + let suffix = nodes.length; + let oldSuffix = previous.length; + while ( + suffix > start && + oldSuffix > start && + rowKey(nodes[suffix - 1]!) === previous[oldSuffix - 1]!.key && + rowShape(nodes[suffix - 1]!) === previous[oldSuffix - 1]!.shape + ) { + suffix--; + oldSuffix--; + } + const next = new Map(); + let suffixDelta = 0; + let prefixDelta = 0; + // Read the changed range and the first retained suffix row before any + // animation or clone writes. Fixed row shapes let the unchanged prefix + // and the rest of the suffix reuse their boxes, even as content virtualizes. + for (const [index, node] of nodes.entries()) { + const key = rowKey(node); + const shape = rowShape(node); + const old = byKey.get(key); + if (index < start && old && typeof key === "string") { + next.set(node, { ...old, top: old.top + prefixDelta }); + continue; + } + if (index > suffix && old && typeof key === "string") { + next.set(node, { ...old, top: old.top + suffixDelta }); + continue; + } + const top = node.offsetTop; + if (old) { + if (index < start) prefixDelta = top - old.top; + if (index >= suffix) suffixDelta = top - old.top; + } + next.set( node, - { - top: node.offsetTop, - left: node.offsetLeft, - width: node.offsetWidth, - height: node.offsetHeight, - }, - ]), - ); + old?.shape === shape + ? { ...old, top } + : { + top, + left: node.offsetLeft, + width: node.offsetWidth, + height: node.offsetHeight, + key, + shape, + }, + ); + } + if (selective && positions) { + // A virtual slot and its rendered row share identity for motion. + const nodesByKey = new Map(nodes.map((node) => [rowKey(node), node])); + positions = new Map( + [...positions].map(([node, old]) => [nodesByKey.get(old.key) ?? node, old]), + ); + } + return next; + }; const cancelBaseline = () => { if (baselineFrame !== null) view?.cancelAnimationFrame(baselineFrame); baselineFrame = null; @@ -68,6 +137,16 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!disposed && positions === null) positions = measure(); }); }; + let observedWidth: number | null = null; + const stopObserving = + typeof ResizeObserver === "undefined" + ? null + : observeResize(parent, ([entry]) => { + if (!entry) return; + const width = entry.contentRect.width; + if (observedWidth !== null && observedWidth !== width) scheduleBaseline(); + observedWidth = width; + }); const remainingOffset = (node: HTMLElement) => { const current = running.get(node); @@ -176,7 +255,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { return; } cancelBaseline(); - const next = measure(); + const next = measure(true); let fadeCount = 0; if (positions !== null) { for (const [node, position] of positions) { @@ -187,6 +266,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } } const shouldAnimate = + released === null && animate && positions !== null && !reducedMotion?.matches && @@ -276,21 +356,28 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } positions = next; }, - /** Called on drag release, before the commit that clears dnd-kit's - * transforms. Takes every row's visual top, including the lifted row - * under the pointer and the peers holding the label gaps open, so the - * next update glides each of them into its committed slot. */ + /** Capture transformed rows before dnd-kit clears its preview. Retain + * layout boxes so the release commit reads only the changed range. */ release() { + const baseline = positions; suspend(); + positions = baseline; const origin = parent.getBoundingClientRect().top; released = new Map( Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) + .filter( + (node): node is HTMLElement => + node instanceof HTMLElement && + !exiting.has(node) && + (typeof rowKey(node) !== "string" || + (node.style.transform !== "" && node.style.transform !== "none")), + ) .map((node) => [node, node.getBoundingClientRect().top - origin]), ); }, suspend, dispose() { + stopObserving?.(); suspend(); disposed = true; }, diff --git a/apps/web/src/components/Sidebar.shells.ts b/apps/web/src/components/Sidebar.shells.ts new file mode 100644 index 0000000000000..5552b72789a0b --- /dev/null +++ b/apps/web/src/components/Sidebar.shells.ts @@ -0,0 +1,15 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import * as Option from "effect/Option"; +import { Atom } from "effect/reactivity"; +import { environmentThreadShells } from "../state/threads"; +import { filterSidebarV2VisibleThreads } from "./Sidebar.logic"; + +/** Hidden child and archived shell updates do not invalidate the sidebar roster. */ +export const sidebarThreadShellsAtom = Atom.make((get): readonly EnvironmentThreadShell[] => { + const next = filterSidebarV2VisibleThreads(get(environmentThreadShells.threadShellsAtom), null); + const previous = Option.getOrUndefined(get.self()); + return previous?.length === next.length && + next.every((thread, index) => thread === previous[index]) + ? previous + : next; +}); diff --git a/apps/web/src/components/Sidebar.tooltip.tsx b/apps/web/src/components/Sidebar.tooltip.tsx new file mode 100644 index 0000000000000..f5b8e5ceb07bd --- /dev/null +++ b/apps/web/src/components/Sidebar.tooltip.tsx @@ -0,0 +1,59 @@ +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { + Children, + cloneElement, + createContext, + isValidElement, + use, + useEffect, + useState, + type ReactNode, + type ComponentProps, +} from "react"; +import { Tooltip, TooltipTrigger, TooltipProvider } from "./ui/tooltip"; + +const SidebarTooltipHandle = createContext | null>(null); + +/** One floating popup serves every row and its quick actions. */ +export function SidebarTooltipScope({ + children, + ...props +}: ComponentProps) { + const [handle] = useState(() => TooltipPrimitive.createHandle()); + useEffect(() => { + const close = () => { + const focused = document.activeElement; + if (focused?.matches('[data-slot="tooltip-trigger"][data-popup-open]')) return; + handle.close(); + }; + document.addEventListener("scroll", close, true); + return () => document.removeEventListener("scroll", close, true); + }, [handle]); + return ( + + + {children} + {({ payload }) => payload} + + + ); +} + +export function SidebarTooltip({ + children, + disabled = false, +}: { + children: ReactNode; + disabled?: boolean | undefined; +}) { + const handle = use(SidebarTooltipHandle); + useEffect(() => { + if (disabled) handle?.close(); + }, [disabled, handle]); + const parts = Children.toArray(children); + const trigger = parts.find((child) => isValidElement(child) && child.type === TooltipTrigger); + if (handle === null || !isValidElement>(trigger)) + return null; + const payload = parts.filter((child) => child !== trigger); + return cloneElement(trigger, { handle, payload, disabled }); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 73c3348d4e4e6..1e379cf983379 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -145,7 +145,10 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useNowMinute } from "../hooks/useNowMinute"; +import { useMinuteValue } from "../hooks/useNowMinute"; +import { sidebarThreadShellsAtom } from "./Sidebar.shells"; +import { SidebarTooltip, SidebarTooltipScope } from "./Sidebar.tooltip"; +import { Atom } from "effect/reactivity"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, @@ -157,7 +160,6 @@ import { useAllEnvironmentProjectSnapshotsReady, useAllEnvironmentShellsBootstrapped, useProjects, - useThreadShells, } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; @@ -237,6 +239,8 @@ import { } from "./Sidebar.drag"; import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; +import { useSidebarRowViewport } from "./Sidebar.viewport"; +import { observeResize } from "../lib/observeResize"; import { ThreadPullRequestBadgeControl, ThreadPullRequestsMiniList, @@ -280,7 +284,7 @@ import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { composerDraftHasUserContent, @@ -293,6 +297,10 @@ import { // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. +const sidebarChangeRequestSnapshotAtom = Atom.family((key: string) => + Atom.make((get) => get(threadChangeRequestSnapshotsAtom).get(key) ?? null), +); + const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); // Collapsed shelves share one empty list so a route change alone does not // give the sidebar list a new identity. @@ -334,17 +342,23 @@ function compactSidebarTimeLabel(label: string): string { return label.endsWith(" ago") ? label.slice(0, -4) : label; } -function threadTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; - return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +function SidebarTimeLabel(props: { thread: SidebarThreadSummary; settled?: boolean }) { + const timestamp = props.settled + ? resolveSettledThreadTimestamp(props.thread) + : (props.thread.latestUserMessageAt ?? props.thread.updatedAt); + const read = useCallback( + () => (timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp))), + [timestamp], + ); + return useMinuteValue(read); } -// Settled rows read "how long ago did this wrap up", matching their sort -// key: both go through resolveSettledThreadTimestamp so label and order can't -// disagree. -function settledTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = resolveSettledThreadTimestamp(thread); - return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +function SidebarWakeLabel(props: { until: string }) { + const read = useCallback( + () => snoozeWakeLabel(props.until, { now: new Date().toISOString() }), + [props.until], + ); + return useMinuteValue(read); } // Floats at the row's right edge, vertically centered, while the jump @@ -590,7 +604,7 @@ function SnoozeMenuButton(props: { ); return ( - + Snooze thread - + {presets.map((preset) => ( (null); + const [visible, setVisible] = useSidebarRowViewport(node, props.initiallyVisible); + const focusedRef = useRef(false); + const attach = useCallback( + (element: HTMLElement | null) => { + setNodeRef(element); + setNode(element); + }, + [setNodeRef], + ); + useLayoutEffect(() => { + if (visible && focusedRef.current) { + node?.querySelector('[tabindex="0"]')?.focus(); + focusedRef.current = false; + } + }, [visible, node]); // dnd-kit memoizes each field but not the bag, so the memoized row would // rerender on every shell update without this. const bag = useMemo( () => ({ listeners, - setNodeRef, + setNodeRef: attach, transform: props.contextDrag ? null : transform, // The lifted row normally follows the pointer without a transition. // When it becomes a context ghost, glide its sidebar copy back home. transition: props.contextDrag && isDragging ? "transform 150ms ease-out" : transition, isDragging: isDragging && !props.contextDrag, }), - [listeners, setNodeRef, transform, transition, isDragging, props.contextDrag], + [listeners, attach, transform, transition, isDragging, props.contextDrag], ); + if (!visible && !props.keepMounted && !isDragging && !node?.contains(document.activeElement)) { + return ( +
  • +
    { + focusedRef.current = true; + setVisible(true); + }} + /> +
  • + ); + } return props.children(bag); } @@ -928,7 +988,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { {props.projectDisplayName} - + Discard draft - +
    @@ -1137,7 +1197,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // The action this row will take when the sweep is released. sweepAction: SidebarSweepAction | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. - snoozeWakeLabelText: string | null; + snoozedUntil: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until // the user visits the thread. wokeAt: string | null; @@ -1173,7 +1233,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; - changeRequestSnapshot: ThreadChangeRequestSnapshot | null; onChangeRequestSnapshot: ( threadKey: string, snapshot: ThreadChangeRequestSnapshot | null, @@ -1181,7 +1240,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }) { const { isRenaming, - changeRequestSnapshot, onChangeRequestSnapshot, onCancelRename, onCommitRename, @@ -1209,6 +1267,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const changeRequestSnapshot = useAtomValue(sidebarChangeRequestSnapshotAtom(threadKey)); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); useEffect(() => { if (!canOperateThread && isRenaming) onCancelRename(); @@ -1740,7 +1799,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Same pen the new-thread draft rows lead with, so both kinds of unsent // work read the same way in the list. const draftIndicator = hasUnsentDraft ? ( - + Unsent draft - + ) : null; const showPin = props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); const pinIndicator = showPin ? ( props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( - + Unpin thread - + ) : ( - + - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + {variantAction === "unsnooze" && props.snoozedUntil !== null ? ( // Snoozed rows show when they come BACK, not when they were // last touched — the return ticket is the row's whole story. - {props.snoozeWakeLabelText} + ) : isWoke ? ( // A wake can land straight in the settled tail (e.g. PR // merged while snoozed); the signal must survive the trip. - + Dismiss Woke notification - + ) : ( - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} + )} @@ -1913,7 +1971,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - + Un-settle thread - + ) : ( diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 1fa5c2c10fb0c..6f137a226136b 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -6,6 +6,7 @@ import { useState, type ComponentProps, type CSSProperties, + type RefObject, } from "react"; import { useElementState } from "../../hooks/useElementState"; import { ChatCanvasContext } from "./ChatCanvasContext"; @@ -18,12 +19,12 @@ import { observeResize } from "../../lib/observeResize"; * canvas decides when chat moves over to make room for them. */ export function ChatCanvas({ - composerOverlayElement, + composerOverlayRef, detailsCardTopInset = 0, children, ...props }: Omit, "className" | "style" | "ref"> & { - composerOverlayElement: HTMLElement | null; + composerOverlayRef: RefObject | null; detailsCardTopInset?: number; }) { const elementRef = useRef(null); @@ -72,6 +73,7 @@ export function ChatCanvas({ useLayoutEffect(() => { const element = elementRef.current; const probe = widthProbeRef.current; + const composerOverlayElement = composerOverlayRef?.current; if (!element || !probe) return; const findBar = detailsCardTopInset > 0 ? element.querySelector("[data-thread-find-bar]") : null; @@ -107,7 +109,7 @@ export function ChatCanvas({ if (composerOverlayElement) observed.push(composerOverlayElement); if (timelineElement) observed.push(timelineElement); return observeResize(observed, measure); - }, [composerOverlayElement, timelineElement, detailsCardTopInset]); + }, [composerOverlayRef, timelineElement, detailsCardTopInset]); const context = useMemo(() => { const container = { width: measurements.width, height: measurements.height }; const findBar = diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 697facdc3bf99..d3a51e89264aa 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -54,7 +54,7 @@ import { projectFaviconCache } from "../assets/projectFaviconCache"; import { layer as browserCryptoLayer } from "../cloud/dpop"; import { forgetBootEnvironment, - readBootSnapshot, + readBootCacheSnapshot, recordBootServerConfig, recordBootShell, } from "../bootSnapshot"; @@ -68,23 +68,27 @@ const SERVER_CONFIG_STORE_NAME = "server-config"; const VCS_REFS_STORE_NAME = "vcs-refs"; const CATALOG_KEY = "document"; const StoredShellSnapshot = StoredOrchestrationShellSnapshot; -const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); +const StoredShellSnapshotJson = Schema.suspend(() => Schema.fromJsonString(StoredShellSnapshot)); const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; -const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); +const StoredThreadSnapshotJson = Schema.suspend(() => Schema.fromJsonString(StoredThreadSnapshot)); const StoredServerConfig = Schema.Struct({ schemaVersion: Schema.Literal(1), environmentId: EnvironmentId, config: ServerConfig, }); -const StoredServerConfigJson = Schema.fromJsonString(StoredServerConfig); +const StoredServerConfigJson = Schema.suspend(() => Schema.fromJsonString(StoredServerConfig)); const StoredVcsRefs = Schema.Struct({ schemaVersion: Schema.Literal(1), environmentId: EnvironmentId, cwd: Schema.String, refs: VcsListRefsResult, }); -const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs); -const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const StoredVcsRefsJson = Schema.suspend(() => Schema.fromJsonString(StoredVcsRefs)); +// Compile a cache's codec only when it is read or written. A same-build boot +// snapshot needs neither the shell nor config decoder before first paint. +const ConnectionCatalogDocumentJson = Schema.suspend(() => + Schema.fromJsonString(ConnectionCatalogDocument), +); const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); @@ -525,11 +529,11 @@ const bootServerConfigsTaken = new Set(); function takeBootValue( taken: Set, environmentId: EnvironmentId, - read: (snapshot: NonNullable>) => A | undefined, + read: (snapshot: NonNullable>) => A | undefined, ): A | undefined { if (taken.has(environmentId)) return undefined; taken.add(environmentId); - const snapshot = readBootSnapshot(); + const snapshot = readBootCacheSnapshot(); return snapshot === null ? undefined : read(snapshot); } diff --git a/apps/web/src/environments/primary/httpClient.ts b/apps/web/src/environments/primary/httpClient.ts index b86a534c70bff..e58b6d0aebb25 100644 --- a/apps/web/src/environments/primary/httpClient.ts +++ b/apps/web/src/environments/primary/httpClient.ts @@ -1,13 +1,11 @@ -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import { makeEnvironmentHttpApiGroupClient } from "@t3tools/client-runtime/rpc"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { resolvePrimaryEnvironmentHttpUrl } from "./target"; -type PrimaryEnvironmentHttpApiClient = Effect.Success< - ReturnType ->; +type PrimaryEnvironmentHttpApiClient = Effect.Success; export interface PrimaryEnvironmentHttpClient { readonly PrimaryEnvironmentHttpClient: unique symbol; @@ -18,8 +16,15 @@ export const PrimaryEnvironmentHttpClient: Context.Service< PrimaryEnvironmentHttpApiClient > = Context.Service("@t3tools/web/environments/primary/httpClient/PrimaryEnvironmentHttpClient"); -const make = Effect.suspend(() => - makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")), -); +// Primary HTTP uses only these groups. Building the whole API compiles codecs +// for orchestration, projects, pull requests and webhooks during the auth check. +const make = Effect.suspend(() => { + const url = resolvePrimaryEnvironmentHttpUrl("/"); + return Effect.all({ + auth: makeEnvironmentHttpApiGroupClient(url, "auth"), + metadata: makeEnvironmentHttpApiGroupClient(url, "metadata"), + mcpOAuth: makeEnvironmentHttpApiGroupClient(url, "mcpOAuth"), + }); +}); export const layer = Layer.effect(PrimaryEnvironmentHttpClient, make); diff --git a/apps/web/src/hooks/usePreviewPanelInlineSize.ts b/apps/web/src/hooks/usePreviewPanelInlineSize.ts index 1f91c53ccb1f7..61b446dab8d14 100644 --- a/apps/web/src/hooks/usePreviewPanelInlineSize.ts +++ b/apps/web/src/hooks/usePreviewPanelInlineSize.ts @@ -30,10 +30,14 @@ export function usePreviewPanelInlineSize( readonly widthStorageKey?: string | undefined; readonly defaultWidth?: number | undefined; /** Measure this row instead of the panel's parent. */ - readonly container?: HTMLElement | null | undefined; + readonly containerRef?: RefObject | undefined; } = {}, ): PreviewPanelInlineSize { - const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true, options.container); + const maxWidth = useViewportClampedMaxWidth( + hostRef, + options.enabled ?? true, + options.containerRef, + ); return useResizableWidth({ storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, @@ -51,14 +55,14 @@ export function usePreviewPanelInlineSize( function useViewportClampedMaxWidth( hostRef: RefObject | undefined, enabled: boolean, - container: HTMLElement | null | undefined, + containerRef: RefObject | undefined, ): number { const [maxWidth, setMaxWidth] = useState(() => getPreviewPanelMaxWidth(typeof window === "undefined" ? 1280 : window.innerWidth), ); useLayoutEffect(() => { if (typeof window === "undefined") return; - const row = container ?? (enabled ? hostRef?.current?.parentElement : null) ?? null; + const row = containerRef?.current ?? (enabled ? hostRef?.current?.parentElement : null) ?? null; // Measure before first paint: the persisted width must be clamped against // the row on the initial render, not one observer tick later (the panel // would flash over-wide on every mount). Only the derived cap is stored: the @@ -83,7 +87,7 @@ function useViewportClampedMaxWidth( if (frame !== 0) window.cancelAnimationFrame(frame); stopObserving?.(); }; - }, [container, hostRef, enabled]); + }, [containerRef, hostRef, enabled]); return maxWidth; } export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { From 5a0c1e44989be4319fdba2147c35c29f4ada7424 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:06:04 +0000 Subject: [PATCH 021/215] perf(bundle): remove duplicate themes and redundant dependencies --- apps/desktop/package.json | 1 - apps/web/package.json | 1 - apps/web/src/components/ChatView.tsx | 8 +- .../PullRequestThreadDialog.test.ts | 3 - .../components/PullRequestThreadDialog.tsx | 15 +- apps/web/src/lib/debounce.ts | 27 ++++ apps/web/src/lib/storage.ts | 15 +- apps/web/src/uiStateStore.ts | 6 +- pnpm-lock.yaml | 144 ++++++------------ pnpm-workspace.yaml | 2 + 10 files changed, 98 insertions(+), 124 deletions(-) create mode 100644 apps/web/src/lib/debounce.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 551aa17e962ec..43b7ee8635865 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,7 +25,6 @@ "dbus-next": "0.10.2", "effect": "catalog:", "electron": "44.4.5", - "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", "ffi-rs": "1.3.2", diff --git a/apps/web/package.json b/apps/web/package.json index 1d2d93ed36a93..6a08636c4421a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,7 +36,6 @@ "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", - "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3e085c871a554..eb8918e81a067 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -145,7 +145,7 @@ import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { Debouncer } from "@tanstack/react-pacer"; +import { createDebouncer } from "~/lib/debounce"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { @@ -6515,9 +6515,7 @@ export default function ChatView(props: ChatViewProps) { // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during // thread switches. LegendList fires scroll events with isAtEnd=false while // initialScrollAtEnd is settling; hiding is always immediate. - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), - ); + const showScrollDebouncer = useRef(createDebouncer(() => setShowScrollToBottom(true), 150)); const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); const timelineScrollModeRef = useRef("following-end"); // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd @@ -6969,7 +6967,7 @@ export default function ChatView(props: ChatViewProps) { } else { timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); + showScrollDebouncer.current.schedule(); } }, []); diff --git a/apps/web/src/components/PullRequestThreadDialog.test.ts b/apps/web/src/components/PullRequestThreadDialog.test.ts index c852a853da39c..8349c02478c51 100644 --- a/apps/web/src/components/PullRequestThreadDialog.test.ts +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -37,9 +37,6 @@ vi.mock("react", async (importOriginal) => ({ useRef: (current: unknown) => ({ current }), useEffect: () => {}, })); -vi.mock("@tanstack/react-pacer", () => ({ - useDebouncedValue: (value: unknown) => [value, { state: { isPending: false } }], -})); vi.mock("~/state/session", () => ({ useEnvironmentScope: (_environmentId: unknown, scope: string) => scope === AuthOrchestrationOperateScope diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 47e4b361aaeeb..3f3bb7ed652fc 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -7,7 +7,6 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -58,11 +57,13 @@ export function PullRequestThreadDialog({ const [referenceDirty, setReferenceDirty] = useState(false); const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); const [prepareErrorMessage, setPrepareErrorMessage] = useState(null); - const [debouncedReference, referenceDebouncer] = useDebouncedValue( - reference, - { wait: 450 }, - (debouncerState) => ({ isPending: debouncerState.isPending }), - ); + const [debouncedReference, setDebouncedReference] = useState(reference); + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedReference(reference); + }, 450); + return () => window.clearTimeout(timer); + }, [reference]); const { data: gitStatus } = useEnvironmentQuery( cwd === null ? null @@ -122,7 +123,7 @@ export function PullRequestThreadDialog({ open && parsedReference !== null && resolvedPullRequest === null && - (referenceDebouncer.state.isPending || + (reference !== debouncedReference || parsedReference !== parsedDebouncedReference || pullRequestResolution.isPending || pullRequestResolution.isFetching); diff --git a/apps/web/src/lib/debounce.ts b/apps/web/src/lib/debounce.ts new file mode 100644 index 0000000000000..c55c2fd44489e --- /dev/null +++ b/apps/web/src/lib/debounce.ts @@ -0,0 +1,27 @@ +/** Keep only the latest call; cancellation and execution release its arguments. */ +export function createDebouncer( + callback: (...args: Args) => void, + wait: number, +) { + let timer: ReturnType | undefined; + let pending: Args | undefined; + const cancel = () => { + clearTimeout(timer); + timer = undefined; + pending = undefined; + }; + const flush = () => { + const args = pending; + cancel(); + if (args !== undefined) callback(...args); + }; + return { + schedule: (...args: Args) => { + cancel(); + pending = args; + timer = setTimeout(flush, wait); + }, + cancel, + flush, + }; +} diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 4c409a3b1ca19..55ab5731fd0e0 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -1,4 +1,4 @@ -import { Debouncer } from "@tanstack/react-pacer"; +import { createDebouncer } from "./debounce"; export interface StateStorage { getItem: (name: string) => string | null | Promise; @@ -49,22 +49,17 @@ export function createDeferredStorage( debounceMs: number = 300, ): DeferredStorage { const resolvedStorage = resolveStorage(baseStorage); - const debouncedSetItem = new Debouncer( - (name: string, value: TValue) => { - resolvedStorage.setItem(name, serialize(value)); - }, - { wait: debounceMs }, - ); + const debouncedSetItem = createDebouncer((name: string, value: TValue) => { + resolvedStorage.setItem(name, serialize(value)); + }, debounceMs); return { getItem: (name) => resolvedStorage.getItem(name), setItem: (name, value) => { - debouncedSetItem.maybeExecute(name, value); + debouncedSetItem.schedule(name, value); }, removeItem: (name) => { debouncedSetItem.cancel(); - // cancel() leaves the captured value in Pacer's lastArgs. - debouncedSetItem.reset(); resolvedStorage.removeItem(name); }, flush: () => { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index e82c86f26404d..79d2cd2c7b518 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,4 +1,4 @@ -import { Debouncer } from "@tanstack/react-pacer"; +import { createDebouncer } from "./lib/debounce"; import type { PullRequestMergeMethod } from "@t3tools/contracts"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; @@ -245,7 +245,7 @@ export function persistState(state: UiState): void { } } -const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); +const debouncedPersistState = createDebouncer(persistState, 500); export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState { const visitedAtMs = Date.parse(visitedAt); @@ -459,7 +459,7 @@ export const useUiStateStore = create((set) => ({ ), })); -useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); +useUiStateStore.subscribe((state) => debouncedPersistState.schedule(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1d1068806ece..d442366fe923c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,7 @@ overrides: expo-sharing>@expo/config-plugins: 58.0.4 expo-sharing>@expo/config-types: 58.0.2 '@pierre/diffs>@shikijs/transformers': ^4.2.0 + '@shikijs/themes': 4.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 '@tailwindcss/vite': 4.3.3 @@ -238,9 +239,6 @@ importers: electron: specifier: 44.4.5 version: 44.4.5 - electron-store: - specifier: ^8.2.0 - version: 8.2.0 electron-updater: specifier: ^6.8.9 version: 6.8.9 @@ -784,10 +782,10 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.6 - version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -818,9 +816,6 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared - '@tanstack/react-pacer': - specifier: ^0.19.4 - version: 0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -5234,7 +5229,7 @@ packages: resolution: {integrity: sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g==} peerDependencies: '@pierre/theme': ^1.1.0 - '@shikijs/themes': ^3.0.0 || ^4.0.0 + '@shikijs/themes': 4.2.0 react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 shiki: ^3.0.0 || ^4.0.0 @@ -5254,7 +5249,7 @@ packages: resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==} peerDependencies: '@pierre/theme': ^1.1.0 || ^2.0.0 - '@shikijs/themes': ^3.0.0 || ^4.0.0 + '@shikijs/themes': 4.2.0 react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 shiki: ^3.0.0 || ^4.0.0 @@ -5710,10 +5705,6 @@ packages: resolution: {integrity: sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==} engines: {node: '>=20'} - '@shikijs/themes@4.3.0': - resolution: {integrity: sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==} - engines: {node: '>=20'} - '@shikijs/transformers@4.2.0': resolution: {integrity: sha512-pKrYVNUr1oPjJvs76gkPPirDySx3GKG9O88P2Y3AQ+7AjSFws9Y+Ry/Q/6Yg6QpyigzjdQ6H5JAMNAvLXZ63dw==} engines: {node: '>=20'} @@ -5918,29 +5909,13 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/devtools-event-client@0.4.3': - resolution: {integrity: sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==} - engines: {node: '>=18'} - hasBin: true - '@tanstack/history@1.162.0': resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} - '@tanstack/pacer@0.18.0': - resolution: {integrity: sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg==} - engines: {node: '>=18'} - '@tanstack/query-core@5.102.8': resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} - '@tanstack/react-pacer@0.19.4': - resolution: {integrity: sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q==} - engines: {node: '>=18'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - '@tanstack/react-router@1.170.10': resolution: {integrity: sha512-gVmWYq0ucWr+OB97Nud0YhKa9NOipB7/QrWI7wRZJJWEL0qUS8WPqAs0vA1f3IBXZpXmf8xxzf/tl5cmo4tlmA==} engines: {node: '>=20.19'} @@ -5948,12 +5923,6 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.8.1': - resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-store@0.9.3': resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: @@ -5993,9 +5962,6 @@ packages: resolution: {integrity: sha512-62layyTGmclHDQS/eidwKRfN1hhCKwViG7iEBcVmL0MXgcAB3OOucWCEcDDGd9Cu11H6b4QQ5oOo47MWIqwz0A==} engines: {node: '>=20.19'} - '@tanstack/store@0.8.1': - resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} - '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} @@ -15727,33 +15693,33 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 2.0.0 - '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 shiki: 4.2.0 optionalDependencies: - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.5.2(patch_hash=298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@pierre/theme': 2.0.0 - '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 shiki: 4.2.0 optionalDependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - '@shikijs/themes' @@ -15762,10 +15728,10 @@ snapshots: '@pierre/theme@2.0.0': {} - '@pierre/theming@1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + '@pierre/theming@1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 1.1.0 - '@shikijs/themes': 4.3.0 + '@shikijs/themes': 4.2.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) shiki: 4.2.0 @@ -15778,25 +15744,25 @@ snapshots: react-dom: 19.3.0(react@19.3.0) shiki: 4.2.0 - '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 2.0.0 '@shikijs/themes': 4.2.0 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) shiki: 4.2.0 - '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 2.0.0 - '@shikijs/themes': 4.3.0 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@shikijs/themes': 4.2.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) shiki: 4.2.0 - '@pierre/trees@1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + '@pierre/trees@1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': dependencies: - '@pierre/theming': 1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + '@pierre/theming': 1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) preact: 11.0.0-beta.0 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) react: 19.2.6 @@ -16225,11 +16191,6 @@ snapshots: dependencies: '@shikijs/types': 4.2.0 - '@shikijs/themes@4.3.0': - dependencies: - '@shikijs/types': 4.3.0 - optional: true - '@shikijs/transformers@4.2.0': dependencies: '@shikijs/core': 4.2.0 @@ -16419,24 +16380,10 @@ snapshots: tailwindcss: 4.3.3 vite: '@voidzero-dev/vite-plus-core@1.0.0(@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)' - '@tanstack/devtools-event-client@0.4.3': {} - '@tanstack/history@1.162.0': {} - '@tanstack/pacer@0.18.0': - dependencies: - '@tanstack/devtools-event-client': 0.4.3 - '@tanstack/store': 0.8.1 - '@tanstack/query-core@5.102.8': {} - '@tanstack/react-pacer@0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@tanstack/pacer': 0.18.0 - '@tanstack/react-store': 0.8.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - '@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/history': 1.162.0 @@ -16446,13 +16393,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@tanstack/react-store@0.8.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@tanstack/store': 0.8.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - use-sync-external-store: 1.6.0(react@19.2.6) - '@tanstack/react-store@0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/store': 0.9.3 @@ -16515,8 +16455,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/store@0.8.1': {} - '@tanstack/store@0.9.3': {} '@tanstack/virtual-file-routes@1.162.0': {} @@ -17801,7 +17739,8 @@ snapshots: at-least-node@1.0.0: {} - atomically@1.7.0: {} + atomically@1.7.0: + optional: true await-lock@2.2.2: {} @@ -18262,6 +18201,7 @@ snapshots: onetime: 5.1.2 pkg-up: 3.1.0 semver: 7.8.5 + optional: true connect@3.7.0: dependencies: @@ -18585,6 +18525,7 @@ snapshots: debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 + optional: true debug@2.6.9: dependencies: @@ -18714,6 +18655,7 @@ snapshots: dot-prop@6.0.1: dependencies: is-obj: 2.0.0 + optional: true dotenv-expand@11.0.7: dependencies: @@ -18810,6 +18752,7 @@ snapshots: dependencies: conf: 10.2.0 type-fest: 2.19.0 + optional: true electron-to-chromium@1.5.430: {} @@ -19573,6 +19516,7 @@ snapshots: find-up@3.0.0: dependencies: locate-path: 3.0.0 + optional: true flatted@3.4.4: {} @@ -20040,7 +19984,8 @@ snapshots: is-number@7.0.0: {} - is-obj@2.0.0: {} + is-obj@2.0.0: + optional: true is-plain-obj@4.1.0: {} @@ -20164,7 +20109,8 @@ snapshots: json-schema-traverse@1.0.0: {} - json-schema-typed@7.0.3: {} + json-schema-typed@7.0.3: + optional: true json-schema-typed@8.0.2: {} @@ -20318,6 +20264,7 @@ snapshots: dependencies: p-locate: 3.0.0 path-exists: 3.0.0 + optional: true lodash-es@4.18.1: {} @@ -21015,9 +20962,11 @@ snapshots: mimic-fn@1.2.0: {} - mimic-fn@2.1.0: {} + mimic-fn@2.1.0: + optional: true - mimic-fn@3.1.0: {} + mimic-fn@3.1.0: + optional: true mimic-function@5.0.1: {} @@ -21303,6 +21252,7 @@ snapshots: onetime@5.1.2: dependencies: mimic-fn: 2.1.0 + optional: true onetime@7.0.0: dependencies: @@ -21530,6 +21480,7 @@ snapshots: p-limit@2.3.0: dependencies: p-try: 2.2.0 + optional: true p-limit@3.1.0: dependencies: @@ -21542,6 +21493,7 @@ snapshots: p-locate@3.0.0: dependencies: p-limit: 2.3.0 + optional: true p-queue@9.3.3: dependencies: @@ -21550,7 +21502,8 @@ snapshots: p-timeout@7.0.2: {} - p-try@2.2.0: {} + p-try@2.2.0: + optional: true package-manager-detector@1.6.0: {} @@ -21586,7 +21539,8 @@ snapshots: path-data-parser@0.1.0: {} - path-exists@3.0.0: {} + path-exists@3.0.0: + optional: true path-expression-matcher@1.6.2: optional: true @@ -21675,6 +21629,7 @@ snapshots: pkg-up@3.1.0: dependencies: find-up: 3.0.0 + optional: true pkijs@3.4.0: dependencies: @@ -23037,7 +22992,8 @@ snapshots: type-fest@0.7.1: {} - type-fest@2.19.0: {} + type-fest@2.19.0: + optional: true type-fest@5.7.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 73be846320a1b..15a366c297dce 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -240,6 +240,8 @@ overrides: "expo-sharing>@expo/config-plugins": 58.0.4 "expo-sharing>@expo/config-types": 58.0.2 "@pierre/diffs>@shikijs/transformers": ^4.2.0 + # Trees and diffs otherwise ship two byte-identical sets of all 65 themes. + "@shikijs/themes": 4.2.0 # Keep one copy of these native-binary packages. lightningcss ships 11 platform binaries per # version, and the workspaces' tailwindcss ^4 ranges drift into two 4.3.x copies of the family. "@tailwindcss/node": "catalog:" From bfd3540467b8ded1395976dab413bd82abf9e680 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:06:13 +0000 Subject: [PATCH 022/215] perf(desktop): use native fetch in the main process --- apps/desktop/src/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 82b0c6e198021..b8ba089ce619e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,7 +5,7 @@ for (const stream of [process.stdout, process.stderr]) { }); } -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; // @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. @@ -239,7 +239,7 @@ const layerDesktopClerk = DesktopClerk.layer.pipe( const layerDesktopApplicationRuntime = layerDesktopApplication.pipe( Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(NetService.layer), Layer.provideMerge(layerElectron), ); From 59041984e68a96edf2d4cef587566b6d3a3a9d83 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:06:14 +0000 Subject: [PATCH 023/215] chore(cursor): remove a missing keychain token entry point --- packages/provider-cursor/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index af0808d97db9c..1a9e35848f1b0 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -46,10 +46,6 @@ "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" - }, - "./server/keychainToken": { - "types": "./src/server/keychainToken.ts", - "import": "./src/server/keychainToken.ts" } }, "scripts": { From 9dd6545f9f798d5a0d6e14b70f577060c8bf41b1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 00:56:04 +0000 Subject: [PATCH 024/215] perf(desktop): split main evaluation and defer the updater sdk --- apps/desktop/scripts/dev-electron.mjs | 6 +- .../scripts/main-process-bundle.test.mjs | 15 +- apps/desktop/src/application.ts | 260 +++++++++++++++++ apps/desktop/src/electron/ElectronUpdater.ts | 62 ++-- apps/desktop/src/main.ts | 269 +----------------- apps/desktop/vite.config.ts | 6 +- 6 files changed, 328 insertions(+), 290 deletions(-) create mode 100644 apps/desktop/src/application.ts diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 22475fb26c161..bb510008e1503 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -23,6 +23,7 @@ if (!Number.isInteger(port) || port <= 0) { const requiredFiles = [ "dist-electron/main.cjs", + "dist-electron/application.cjs", "dist-electron/electron/WindowsForegroundFocusWorker.cjs", "dist-electron/preload.cjs", "dist-electron/snapShot/GlobalShiftShortcutWorker.cjs", @@ -208,7 +209,10 @@ function startWatchers() { NodePath.join(desktopDir, directory), { persistent: true }, (_eventType, filename) => { - if (typeof filename !== "string" || !files.has(filename)) { + if ( + typeof filename !== "string" || + !(files.has(filename) || (directory === "dist-electron" && filename.endsWith(".cjs"))) + ) { return; } diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 678b62f58aaca..9183764d952b9 100644 --- a/apps/desktop/scripts/main-process-bundle.test.mjs +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -22,11 +22,17 @@ it("keeps lazy Linux imports and worker bundles from executing desktop startup t NodeFSP.mkdir(NodePath.join(directory, "src/snapShot"), { recursive: true }), ]); await Promise.all([ - NodeFSP.writeFile( + NodeFSP.copyFile( + new URL("../src/main.ts", import.meta.url), NodePath.join(directory, "src/main.ts"), + ), + NodeFSP.writeFile( + NodePath.join(directory, "src/application.ts"), `import { shared } from "./shared.ts"; -process.emit("startup", shared.value); -void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, +export function run() { + process.emit("startup", shared.value); + void import("./linux.ts").then(({ result }) => process.emit("ready", result)); +}`, ), NodeFSP.writeFile( NodePath.join(directory, "src/shared.ts"), @@ -44,7 +50,7 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, ), ]); assert.ok(Array.isArray(desktopConfig.pack)); - const fixtureEntries = new Set(["src/main.ts", ...workerEntries]); + const fixtureEntries = new Set(["src/main.ts", "src/application.ts", ...workerEntries]); for (const packConfig of desktopConfig.pack) { if (!Array.isArray(packConfig.entry)) continue; if (!packConfig.entry.some((entry) => fixtureEntries.has(entry))) continue; @@ -87,6 +93,7 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, module, require: (specifier) => load(NodePath.resolve(NodePath.dirname(filename), specifier)), process: { + env: {}, emit: (event, value) => { if (event === "startup") startups.push(value); if (event === "worker") workers.push(value); diff --git a/apps/desktop/src/application.ts b/apps/desktop/src/application.ts new file mode 100644 index 0000000000000..0184a491e18b5 --- /dev/null +++ b/apps/desktop/src/application.ts @@ -0,0 +1,260 @@ +import * as MacPermissions from "./permissions/MacPermissions.ts"; +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EPIPE") throw err; + }); +} + +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +import * as NetService from "@t3tools/shared/Net"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import serverPackageJson from "../../server/package.json" with { type: "json" }; + +import * as DesktopIpc from "./ipc/DesktopIpc.ts"; +import * as ElectronApp from "./electron/ElectronApp.ts"; +import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; +import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; +import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronShell from "./electron/ElectronShell.ts"; +import * as ElectronTheme from "./electron/ElectronTheme.ts"; +import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; +import * as ElectronWindow from "./electron/ElectronWindow.ts"; +import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; +import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; +import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; +import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; +import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; +import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; +import * as DesktopAssets from "./app/DesktopAssets.ts"; +import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; +import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; +import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; +import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; +import * as DesktopShutdown from "./app/DesktopShutdown.ts"; +import * as DesktopObservability from "./app/DesktopObservability.ts"; +import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; +import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; +import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; +import * as DesktopSnapShot from "./snapShot/DesktopSnapShot.ts"; +import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import * as DesktopPreReadyFileSystem from "./app/DesktopPreReadyFileSystem.ts"; +import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; +import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopState from "./app/DesktopState.ts"; +import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; +import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; +import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; +import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; +import * as BrowserSession from "./preview/BrowserSession.ts"; +import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts"; +import * as PreviewManager from "./preview/Manager.ts"; +import * as PreviewPasskeys from "./preview/Passkeys.ts"; +import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; + +if (process.argv.includes("--version")) { + try { + NodeFS.writeSync(process.stdout.fd, `${Electron.app.getVersion()}\n`); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error; + } + Electron.app.exit(0); +} + +const layerDesktopEnvironment = Layer.unwrap( + Effect.gen(function* () { + const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( + Effect.flatMap((app) => app.metadata), + ); + const platform = yield* HostProcess.Platform; + const processArch = yield* HostProcess.Architecture; + return DesktopEnvironment.layer({ + dirname: __dirname, + homeDirectory: NodeOS.homedir(), + platform, + processArch, + ...metadata, + }); + }), +); + +// The remote runs the exact release this app is on, from its self-contained +// archive, so it needs neither Node nor npm. Development points the remote at +// a source checkout instead so the two sides can be iterated together. +const resolveDesktopSshCliRunner = ( + environment: DesktopEnvironment.DesktopEnvironment["Service"], +): RemoteT3RunnerOptions => { + const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); + if (environment.isDevelopment && devRemoteEntryPath !== undefined) { + return { + nodeScriptPath: devRemoteEntryPath, + nodeEngineRange: serverPackageJson.engines.node, + }; + } + return { archiveVersion: environment.appVersion }; +}; + +const layerDesktopSshEnvironment = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return DesktopSshEnvironment.layer({ + resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), + }); + }), +); + +const layerElectron = Layer.mergeAll( + ElectronApp.layer, + ElectronDialog.layer, + ElectronMenu.layer, + ElectronPowerMonitor.layer, + ElectronProtocol.layer, + ElectronSafeStorage.layer, + ElectronShell.layer, + ElectronTheme.layer, + ElectronUpdater.layer, + ElectronWindow.layer, + DesktopIpc.layer(Electron.ipcMain), +); + +const layerDesktopFoundation = Layer.mergeAll( + MacPermissions.layer, + DesktopState.layer, + DesktopShutdown.layer, + DesktopLegacyLocalStorage.layer, + DesktopAppSettings.layer, + DesktopClientSettings.layer, + DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), + DesktopAssets.layer, + DesktopObservability.layer, + DesktopRendererHistory.layer, +).pipe(Layer.provideMerge(layerDesktopEnvironment)); + +const layerDesktopSsh = layerDesktopSshEnvironment.pipe( + Layer.provideMerge(DesktopSshPasswordPrompts.layer()), +); + +const layerDesktopServerExposure = DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopNetworkInterfaces.layer), + Layer.provideMerge(layerDesktopFoundation), +); + +const layerDesktopPreview = PreviewManager.layer.pipe( + Layer.provideMerge(DesktopBrowserHost.layer), + // Merged rather than provided so the IPC handlers can reach the import + // service alongside the manager; both sit on the same BrowserSession. + Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), + Layer.provideMerge(BrowserSession.layer), + Layer.provideMerge(PreviewPasskeys.layer), + Layer.provideMerge(layerDesktopFoundation), +); + +const layerDesktopWindow = DesktopWindow.layer.pipe( + Layer.provideMerge(layerDesktopServerExposure), + Layer.provideMerge(layerDesktopPreview), +); + +const layerDesktopSnapShot = DesktopSnapShot.layer.pipe( + Layer.provideMerge(layerDesktopWindow), + Layer.provideMerge(layerDesktopFoundation), +); +const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( + Layer.provide(layerDesktopWindow), +); + +// Pool layer instantiates the backend factory once for the Windows +// primary instance and exposes it via pool.primary. Consumers go through +// the pool now; the legacy DesktopBackendManager service is gone. The +// WSL second instance gets registered later in the migration. See +// DesktopBackendPool.ts header for the full rollout plan. +const layerDesktopBackend = DesktopBackendPool.layer.pipe( + Layer.provideMerge(DesktopAppIdentity.layer), + Layer.provideMerge(DesktopShellEnvironment.layer), + Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(DesktopWslEnvironment.layer), + Layer.provideMerge(DesktopWslServerTree.layer), + Layer.provideMerge(DesktopTelemetryPublisher.layer), + Layer.provideMerge(layerDesktopWindow), +); + +// WSL orchestrator hangs off the backend layer because it needs the +// pool + configuration + serverExposure; it pulls NetService and the +// foundation services through the same provideMerge chain. +const layerDesktopWslBackend = DesktopWslBackend.layer.pipe( + Layer.provideMerge(layerDesktopBackend), +); + +const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provideMerge(layerDesktopBackend), +); + +const layerDesktopApplication = Layer.mergeAll( + DesktopLifecycle.layer, + layerDesktopAppActivation, + DesktopWebLinks.layer, + DesktopApplicationMenu.layer, + DesktopLinuxUrlHandler.layer, + DesktopCliCommand.layer, + layerDesktopSsh, +).pipe( + Layer.provideMerge(layerDesktopSnapShot), + Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(layerDesktopWslBackend), + Layer.provideMerge(layerDesktopLocalEnvironmentAuth), +); + +// Clerk resolves userData before Electron is ready, so it gets the synchronous FileSystem. +const layerDesktopClerk = DesktopClerk.layer.pipe( + Layer.provide(DesktopPreReadyFileSystem.layer), + Layer.provideMerge(ElectronShell.layer), + Layer.provideMerge(layerDesktopEnvironment), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ElectronApp.layer), +); + +const layerDesktopApplicationRuntime = layerDesktopApplication.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(layerElectron), +); + +// Acquire strict pre-ready setup before Clerk. Nothing before the Clerk bridge +// may yield, or Electron can emit ready before Clerk registers its scheme. +const layerDesktopRuntime = layerDesktopClerk.pipe( + Layer.flatMap((clerkContext) => + layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), + ), + Layer.provideMerge(DesktopPreReadyPlatform.layer), +); + +// The entry calls this once, after module evaluation. Lazy chunks may import +// application exports without launching a second desktop instance. +export function run(): void { + DesktopApp.program.pipe(Effect.provide(layerDesktopRuntime), NodeRuntime.runMain); +} diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab4..0fc583adfd405 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -4,10 +4,17 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { autoUpdater } from "electron-updater"; +import type { autoUpdater } from "electron-updater"; type AutoUpdater = typeof autoUpdater; +// Update initialization runs after the window opens. Keep its SDK out of the +// main module's evaluation; Node retains the instance for every later call. +let updater: Promise | undefined; +const loadUpdater = Effect.promise( + () => (updater ??= import("electron-updater").then((module) => module.autoUpdater)), +); + export type ElectronUpdaterFeedUrl = Parameters[0]; export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedError()( @@ -83,54 +90,54 @@ export class ElectronUpdater extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = ElectronUpdater.of({ setFeedURL: (options) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.setFeedURL(options); return Effect.void; }), setAutoDownload: (value) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.autoDownload = value; return Effect.void; }), setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.autoInstallOnAppQuit = value; return Effect.void; }), setChannel: (channel) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.channel = channel; return Effect.void; }), setAllowPrerelease: (value) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.allowPrerelease = value; return Effect.void; }), - allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), + allowDowngrade: Effect.map(loadUpdater, (autoUpdater) => autoUpdater.allowDowngrade), setAllowDowngrade: (value) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.allowDowngrade = value; return Effect.void; }), setFullChangelog: (value) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.fullChangelog = value; return Effect.void; }), setDisableDifferentialDownload: (value) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { autoUpdater.disableDifferentialDownload = value; return Effect.void; }), - checkForUpdates: Effect.suspend(() => { + checkForUpdates: Effect.flatMap(loadUpdater, (autoUpdater) => { const channel = autoUpdater.channel; return Effect.tryPromise({ try: () => autoUpdater.checkForUpdates(), catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), }).pipe(Effect.asVoid); }), - downloadUpdate: Effect.suspend(() => { + downloadUpdate: Effect.flatMap(loadUpdater, (autoUpdater) => { const channel = autoUpdater.channel; return Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(), @@ -138,7 +145,7 @@ export const make = ElectronUpdater.of({ }).pipe(Effect.asVoid); }), quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { + Effect.flatMap(loadUpdater, (autoUpdater) => { const channel = autoUpdater.channel; return Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), @@ -151,22 +158,23 @@ export const make = ElectronUpdater.of({ }), }); }), - on: (eventName, listener) => { - const eventTarget = autoUpdater as unknown as { - on: (eventName: string, listener: (...args: Array) => void) => void; - removeListener: (eventName: string, listener: (...args: Array) => void) => void; - }; - const untypedListener = listener as unknown as (...args: Array) => void; - return Effect.acquireRelease( - Effect.sync(() => { - eventTarget.on(eventName, untypedListener); - }), - () => + on: (eventName, listener) => + Effect.flatMap(loadUpdater, (autoUpdater) => { + const eventTarget = autoUpdater as unknown as { + on: (eventName: string, listener: (...args: Array) => void) => void; + removeListener: (eventName: string, listener: (...args: Array) => void) => void; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( Effect.sync(() => { - eventTarget.removeListener(eventName, untypedListener); + eventTarget.on(eventName, untypedListener); }), - ).pipe(Effect.asVoid); - }, + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); + }), }); export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b8ba089ce619e..dd7564668e462 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,256 +1,15 @@ -import * as MacPermissions from "./permissions/MacPermissions.ts"; -for (const stream of [process.stdout, process.stderr]) { - stream.on("error", (err: NodeJS.ErrnoException) => { - if (err.code !== "EPIPE") throw err; - }); +// Electron's entry stays separate from the code shared with lazy chunks. +// Effect's definition-site stacks add work for every schema/service import; +// restore runtime stacks before acquiring services or starting the app. +const stackTraceLimit = Error.stackTraceLimit; +let application: typeof import("./application.ts"); +try { + if (!process.env.VITE_DEV_SERVER_URL) Error.stackTraceLimit = 0; + application = require("./application.cjs"); +} catch (cause) { + Error.stackTraceLimit = stackTraceLimit; + throw new Error("Desktop module initialization failed", { cause }); +} finally { + Error.stackTraceLimit = stackTraceLimit; } - -import * as FetchHttpClient from "effect/http/FetchHttpClient"; -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -// @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as Electron from "electron"; - -import * as NetService from "@t3tools/shared/Net"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; -import serverPackageJson from "../../server/package.json" with { type: "json" }; - -import * as DesktopIpc from "./ipc/DesktopIpc.ts"; -import * as ElectronApp from "./electron/ElectronApp.ts"; -import * as ElectronDialog from "./electron/ElectronDialog.ts"; -import * as ElectronMenu from "./electron/ElectronMenu.ts"; -import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; -import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; -import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; -import * as ElectronShell from "./electron/ElectronShell.ts"; -import * as ElectronTheme from "./electron/ElectronTheme.ts"; -import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; -import * as ElectronWindow from "./electron/ElectronWindow.ts"; -import * as DesktopApp from "./app/DesktopApp.ts"; -import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; -import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; -import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; -import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; -import * as DesktopClerk from "./app/DesktopClerk.ts"; -import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; -import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; -import * as DesktopAssets from "./app/DesktopAssets.ts"; -import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; -import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; -import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; -import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; -import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; -import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; -import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; -import * as DesktopShutdown from "./app/DesktopShutdown.ts"; -import * as DesktopObservability from "./app/DesktopObservability.ts"; -import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; -import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; -import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; -import * as DesktopSnapShot from "./snapShot/DesktopSnapShot.ts"; -import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; -import * as DesktopPreReadyFileSystem from "./app/DesktopPreReadyFileSystem.ts"; -import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; -import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; -import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; -import * as DesktopState from "./app/DesktopState.ts"; -import * as DesktopLegacyLocalStorage from "./app/DesktopLegacyLocalStorage.ts"; -import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; -import * as DesktopRendererHistory from "./telemetry/DesktopRendererHistory.ts"; -import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; -import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; -import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; -import * as BrowserSession from "./preview/BrowserSession.ts"; -import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts"; -import * as PreviewManager from "./preview/Manager.ts"; -import * as PreviewPasskeys from "./preview/Passkeys.ts"; -import * as DesktopWindow from "./window/DesktopWindow.ts"; -import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; -import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; -import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; - -if (process.argv.includes("--version")) { - try { - NodeFS.writeSync(process.stdout.fd, `${Electron.app.getVersion()}\n`); - } catch (error) { - if (!(error instanceof Error) || !("code" in error) || error.code !== "EPIPE") throw error; - } - Electron.app.exit(0); -} - -const layerDesktopEnvironment = Layer.unwrap( - Effect.gen(function* () { - const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( - Effect.flatMap((app) => app.metadata), - ); - const platform = yield* HostProcess.Platform; - const processArch = yield* HostProcess.Architecture; - return DesktopEnvironment.layer({ - dirname: __dirname, - homeDirectory: NodeOS.homedir(), - platform, - processArch, - ...metadata, - }); - }), -); - -// The remote runs the exact release this app is on, from its self-contained -// archive, so it needs neither Node nor npm. Development points the remote at -// a source checkout instead so the two sides can be iterated together. -const resolveDesktopSshCliRunner = ( - environment: DesktopEnvironment.DesktopEnvironment["Service"], -): RemoteT3RunnerOptions => { - const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); - if (environment.isDevelopment && devRemoteEntryPath !== undefined) { - return { - nodeScriptPath: devRemoteEntryPath, - nodeEngineRange: serverPackageJson.engines.node, - }; - } - return { archiveVersion: environment.appVersion }; -}; - -const layerDesktopSshEnvironment = Layer.unwrap( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return DesktopSshEnvironment.layer({ - resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), - }); - }), -); - -const layerElectron = Layer.mergeAll( - ElectronApp.layer, - ElectronDialog.layer, - ElectronMenu.layer, - ElectronPowerMonitor.layer, - ElectronProtocol.layer, - ElectronSafeStorage.layer, - ElectronShell.layer, - ElectronTheme.layer, - ElectronUpdater.layer, - ElectronWindow.layer, - DesktopIpc.layer(Electron.ipcMain), -); - -const layerDesktopFoundation = Layer.mergeAll( - MacPermissions.layer, - DesktopState.layer, - DesktopShutdown.layer, - DesktopLegacyLocalStorage.layer, - DesktopAppSettings.layer, - DesktopClientSettings.layer, - DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), - DesktopAssets.layer, - DesktopObservability.layer, - DesktopRendererHistory.layer, -).pipe(Layer.provideMerge(layerDesktopEnvironment)); - -const layerDesktopSsh = layerDesktopSshEnvironment.pipe( - Layer.provideMerge(DesktopSshPasswordPrompts.layer()), -); - -const layerDesktopServerExposure = DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopNetworkInterfaces.layer), - Layer.provideMerge(layerDesktopFoundation), -); - -const layerDesktopPreview = PreviewManager.layer.pipe( - Layer.provideMerge(DesktopBrowserHost.layer), - // Merged rather than provided so the IPC handlers can reach the import - // service alongside the manager; both sit on the same BrowserSession. - Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), - Layer.provideMerge(BrowserSession.layer), - Layer.provideMerge(PreviewPasskeys.layer), - Layer.provideMerge(layerDesktopFoundation), -); - -const layerDesktopWindow = DesktopWindow.layer.pipe( - Layer.provideMerge(layerDesktopServerExposure), - Layer.provideMerge(layerDesktopPreview), -); - -const layerDesktopSnapShot = DesktopSnapShot.layer.pipe( - Layer.provideMerge(layerDesktopWindow), - Layer.provideMerge(layerDesktopFoundation), -); -const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( - Layer.provide(layerDesktopWindow), -); - -// Pool layer instantiates the backend factory once for the Windows -// primary instance and exposes it via pool.primary. Consumers go through -// the pool now; the legacy DesktopBackendManager service is gone. The -// WSL second instance gets registered later in the migration. See -// DesktopBackendPool.ts header for the full rollout plan. -const layerDesktopBackend = DesktopBackendPool.layer.pipe( - Layer.provideMerge(DesktopAppIdentity.layer), - Layer.provideMerge(DesktopShellEnvironment.layer), - Layer.provideMerge(DesktopBackendConfiguration.layer), - Layer.provideMerge(DesktopWslEnvironment.layer), - Layer.provideMerge(DesktopWslServerTree.layer), - Layer.provideMerge(DesktopTelemetryPublisher.layer), - Layer.provideMerge(layerDesktopWindow), -); - -// WSL orchestrator hangs off the backend layer because it needs the -// pool + configuration + serverExposure; it pulls NetService and the -// foundation services through the same provideMerge chain. -const layerDesktopWslBackend = DesktopWslBackend.layer.pipe( - Layer.provideMerge(layerDesktopBackend), -); - -const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provideMerge(layerDesktopBackend), -); - -const layerDesktopApplication = Layer.mergeAll( - DesktopLifecycle.layer, - layerDesktopAppActivation, - DesktopWebLinks.layer, - DesktopApplicationMenu.layer, - DesktopLinuxUrlHandler.layer, - DesktopCliCommand.layer, - layerDesktopSsh, -).pipe( - Layer.provideMerge(layerDesktopSnapShot), - Layer.provideMerge(DesktopUpdates.layer), - Layer.provideMerge(layerDesktopWslBackend), - Layer.provideMerge(layerDesktopLocalEnvironmentAuth), -); - -// Clerk resolves userData before Electron is ready, so it gets the synchronous FileSystem. -const layerDesktopClerk = DesktopClerk.layer.pipe( - Layer.provide(DesktopPreReadyFileSystem.layer), - Layer.provideMerge(ElectronShell.layer), - Layer.provideMerge(layerDesktopEnvironment), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(ElectronApp.layer), -); - -const layerDesktopApplicationRuntime = layerDesktopApplication.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(layerElectron), -); - -// Acquire strict pre-ready setup before Clerk. Nothing before the Clerk bridge -// may yield, or Electron can emit ready before Clerk registers its scheme. -const layerDesktopRuntime = layerDesktopClerk.pipe( - Layer.flatMap((clerkContext) => - layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), - ), - Layer.provideMerge(DesktopPreReadyPlatform.layer), -); - -DesktopApp.program.pipe(Effect.provide(layerDesktopRuntime), NodeRuntime.runMain); +application.run(); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 17ce805ffe164..c619a4ae81a67 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -55,12 +55,12 @@ export default defineConfig({ sourcemap: true, outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, - outputOptions: { codeSplitting: false }, - entry: ["src/main.ts"], + outputOptions: { codeSplitting: true }, + entry: ["src/main.ts", "src/application.ts"], clean: true, deps: { alwaysBundle: (id) => !id.startsWith("node:") && !isMainProcessExternal(id), - neverBundle: isMainProcessExternal, + neverBundle: (id) => id === "./application.cjs" || isMainProcessExternal(id), onlyBundle: false, }, ...(shouldLaunchElectronAfterPack ? { onSuccess: "node scripts/dev-electron.mjs" } : {}), From ecd490f9d43d3c6283d0913946ef5a5e21100e29 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 00:56:15 +0000 Subject: [PATCH 025/215] perf(desktop): render the cached composer without the session wait --- apps/web/src/components/ThreadRouteView.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 6eef52d3f6f22..774b34cfd7886 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -43,6 +43,7 @@ import { environmentShell } from "../state/shell"; import { environmentThreadDetails } from "../state/threads"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentSession } from "../state/session"; +import { isElectron } from "../env"; import { isBootHeld, releaseBootHoldAfterPaint, retainBootHold } from "../connection/catalog"; import { buildThreadRouteParams, @@ -122,8 +123,8 @@ function whenQuietAndIdle(callback: () => void, initialDelayMs: number): () => v let bootChatViewDeferred = false; // How long the chat view waits for the session before rendering from the -// snapshot. A local server answers well within it; a starting desktop backend -// or a slow tunnel does not, and the composer appears without it. +// snapshot. Desktop skips this wait because its backend is still starting. +// A slow tunnel eventually renders the composer without its session. const BOOT_SESSION_WAIT_MS = 750; /** @@ -131,7 +132,8 @@ const BOOT_SESSION_WAIT_MS = 750; * this view's frame, and the chat view renders after it, so its large first * render does not hold back that frame. * - * If the environment's session answers within BOOT_SESSION_WAIT_MS, the + * Desktop renders the cached chat view after that first frame. For web, if + * the environment's session answers within BOOT_SESSION_WAIT_MS, the * screen switches to live data and the chat view renders with it in one * task, with nothing left to redo. Otherwise it renders from the snapshot as * a transition, which yields so the connection keeps making progress, and the @@ -171,6 +173,9 @@ function useBootDeferredChatView(environmentId: EnvironmentId | null): boolean { const configAtom = sessionConfigAtom(); if (configAtom === null) return renderFromSnapshot(); if (appAtomRegistry.get(configAtom) !== null) return renderLive(); + // A desktop backend is still booting. Let the user type from the cached + // screen immediately; sending still waits for the authenticated session. + if (isElectron) return renderFromSnapshot(); // A task later, so the requests the session sends as it starts go out // before this render instead of queuing behind it. const unsubscribe = appAtomRegistry.subscribe(configAtom, (config) => { From d29111886dee62633f9c1fd61ee9dc51579f851c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:59:03 +0000 Subject: [PATCH 026/215] perf(review): reuse previews until the workspace changes --- apps/server/src/review/ReviewService.test.ts | 43 ++++++++++++ apps/server/src/review/ReviewService.ts | 34 +++++++++- apps/web/src/components/DiffPanel.tsx | 65 ++++++++++++++----- .../files/useFileSaveCoordinator.test.tsx | 5 ++ .../files/useFileSaveCoordinator.ts | 7 +- apps/web/src/state/review.ts | 17 +++++ packages/contracts/src/review.ts | 2 + 7 files changed, 152 insertions(+), 21 deletions(-) diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 384b8ad40d127..820ca24f67d9b 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -67,6 +67,49 @@ function layer(input: { } describe("ReviewService", () => { + it.effect("reads fresh for legacy clients without an epoch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const detectCalls: Array<{ readonly cwd: string }> = []; + yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + const results = yield* Effect.all( + [ + review.getDiffPreview({ cwd: workspaceRoot }), + review.getDiffPreview({ cwd: workspaceRoot, ignoreWhitespace: false }), + ], + { concurrency: "unbounded" }, + ); + assert.strictEqual(detectCalls.length, 2); + assert.deepStrictEqual(results[0], results[1]); + yield* review.getDiffPreview({ cwd: workspaceRoot }); + assert.strictEqual(detectCalls.length, 3); + }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reuses an epoch and refreshes when the epoch or comparison changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const detectCalls: Array<{ readonly cwd: string }> = []; + yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + const input = { cwd: workspaceRoot, cacheKey: "mutation-1" }; + yield* review.getDiffPreview(input); + yield* review.getDiffPreview(input); + assert.strictEqual(detectCalls.length, 1); + yield* review.getDiffPreview({ ...input, cacheKey: "mutation-2" }); + yield* review.getDiffPreview({ ...input, ignoreWhitespace: true }); + yield* review.getDiffPreview({ ...input, baseRef: "main" }); + assert.strictEqual(detectCalls.length, 4); + }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("rejects diff preview cwd outside the configured workspace roots", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 1bde38b589b47..dac944b1042c3 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -1,6 +1,9 @@ +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -122,11 +125,9 @@ export const make = Effect.gen(function* () { }); }); - const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( + const readDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( "ReviewService.getDiffPreview", )(function* (input) { - yield* assertWorkspaceBoundCwd("ReviewService.getDiffPreview", input.cwd); - const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); if (!handle) { return { @@ -151,6 +152,33 @@ export const make = Effect.gen(function* () { return yield* getDriverDiffPreview(input); }); + // Clients with a freshness epoch can reuse a preview across surface remounts. + // Legacy callers still read fresh; failures are never retained. + const previews = yield* Cache.makeWith( + (key: string) => readDiffPreview(JSON.parse(key) as ReviewDiffPreviewInput), + { + capacity: 32, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.seconds(5) : Duration.zero), + }, + ); + const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = (input) => + assertWorkspaceBoundCwd("ReviewService.getDiffPreview", input.cwd).pipe( + Effect.andThen( + input.cacheKey === undefined + ? readDiffPreview(input) + : Cache.get( + previews, + JSON.stringify({ + cwd: input.cwd, + ...(input.cacheKey === undefined ? {} : { cacheKey: input.cacheKey }), + ...(input.baseRef ? { baseRef: input.baseRef } : {}), + ignoreWhitespace: input.ignoreWhitespace ?? false, + ...(input.file ? { file: input.file } : {}), + }), + ), + ), + ); + const getDiffFileContents: ReviewService["Service"]["getDiffFileContents"] = Effect.fn( "ReviewService.getDiffFileContents", )(function* (input) { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 02b50152348d0..114b5809ffec4 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -44,7 +44,6 @@ import { import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { useProject, useThreadProjection, useThreadShell } from "../state/entities"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; @@ -81,7 +80,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { serverEnvironment } from "../state/server"; -import { reviewEnvironment } from "../state/review"; +import { reviewEnvironment, reviewWorkspaceRevision } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; @@ -185,6 +184,9 @@ function DiffFileHeaderSuffix({ ); } +// Preserve explicit refresh epochs across short panel remounts. +const diffRefreshVersions = new Map(); + interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; @@ -339,29 +341,68 @@ export default function DiffPanel({ }, { enabled: isGitRepo && selectedTurn !== undefined }, ); - const branchDiffPreview = useEnvironmentQuery( + const reviewScope = JSON.stringify([ + activeThread?.environmentId, + activeCwd, + selectedBaseRef, + diffIgnoreWhitespace, + ]); + const [refreshVersion, setRefreshVersion] = useState({ + scope: reviewScope, + version: diffRefreshVersions.get(reviewScope) ?? 0, + }); + const version = + refreshVersion.scope === reviewScope + ? refreshVersion.version + : (diffRefreshVersions.get(reviewScope) ?? 0); + const savedFileRevision = useAtomValue( + reviewWorkspaceRevision(activeThread?.environmentId, activeCwd ?? undefined), + ); + const reviewCacheKey = JSON.stringify([workspaceMutationId, savedFileRevision, version]); + const refreshBranchDiffPreview = useCallback(() => { + const next = Math.max(Date.now(), (diffRefreshVersions.get(reviewScope) ?? 0) + 1); + if (diffRefreshVersions.size >= 64 && !diffRefreshVersions.has(reviewScope)) + diffRefreshVersions.delete(diffRefreshVersions.keys().next().value!); + diffRefreshVersions.set(reviewScope, next); + setRefreshVersion({ scope: reviewScope, version: next }); + }, [reviewScope, setRefreshVersion]); + const requestedBranchDiffPreview = useEnvironmentQuery( canReadFiles && selectedRunId === null && activeThread && activeCwd ? reviewEnvironment.diffPreview({ environmentId: activeThread.environmentId, input: { cwd: activeCwd, + cacheKey: reviewCacheKey, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), ignoreWhitespace: diffIgnoreWhitespace, }, }) : null, ); + const [previousPreview, setPreviousPreview] = useState({ + scope: reviewScope, + data: requestedBranchDiffPreview.data, + }); + if ( + requestedBranchDiffPreview.data !== null && + (previousPreview.scope !== reviewScope || + previousPreview.data !== requestedBranchDiffPreview.data) + ) { + setPreviousPreview({ scope: reviewScope, data: requestedBranchDiffPreview.data }); + } + const branchDiffPreview = { + ...requestedBranchDiffPreview, + data: + requestedBranchDiffPreview.data ?? + (previousPreview.scope === reviewScope ? previousPreview.data : null), + }; const canRefreshGitDiff = isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null; - const activeThreadRefreshKey = routeThreadRef - ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}` - : null; const selectedGitSource = branchDiffPreview.data?.sources.find( (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), ); - const refreshPreviewQuery = branchDiffPreview.refresh; - const refreshDiffFromUserAction = refreshPreviewQuery; + const refreshDiffFromUserAction = refreshBranchDiffPreview; const currentLoadDiffFiles = useMemo(() => { const preview = branchDiffPreview.data; @@ -490,7 +531,6 @@ export default function DiffPanel({ : undefined, preview: renderablePatch, }); - const refreshBranchDiffPreview = refreshPreviewQuery; useEffect(() => { if (!canRefreshGitDiff) return; @@ -499,13 +539,6 @@ export default function DiffPanel({ return () => window.removeEventListener("focus", refreshOnFocus); }, [canRefreshGitDiff, refreshBranchDiffPreview]); - useWorkspaceMutationRefresh({ - enabled: canRefreshGitDiff, - mutationId: workspaceMutationId, - refresh: refreshBranchDiffPreview, - resourceKey: `diff:${activeThreadRefreshKey ?? ""}`, - }); - const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending; const renderableFileEntries = useMemo( () => renderableFiles.map(getCachedFileEntry), diff --git a/apps/web/src/components/files/useFileSaveCoordinator.test.tsx b/apps/web/src/components/files/useFileSaveCoordinator.test.tsx index 733c2c5f52d75..7f0029fec88fe 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.test.tsx +++ b/apps/web/src/components/files/useFileSaveCoordinator.test.tsx @@ -23,6 +23,8 @@ vi.mock("./projectFilesQueryState", () => ({ import { setMarkdownTaskChecked } from "./filePreviewMode"; import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { reviewWorkspaceRevision } from "~/state/review"; const environmentId = EnvironmentId.make("save-lifecycle-audit"); const onPendingChange = vi.fn(); @@ -76,6 +78,8 @@ afterEach(async () => { describe("file-save React lifecycle", () => { it("persists editor model changes after StrictMode setup replay", async () => { + const revision = reviewWorkspaceRevision(environmentId, defaultProps.cwd); + const before = appAtomRegistry.get(revision); mount(); changeHandler()("AUDIT7907NATIVE\n"); expect(onPendingChange).toHaveBeenCalledWith("file.txt", true); @@ -91,6 +95,7 @@ describe("file-save React lifecycle", () => { "AUDIT7907NATIVE\n", ); expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); + expect(appAtomRegistry.get(revision)).toBe(before + 1); }); it("persists rendered Markdown task changes after StrictMode setup replay", async () => { diff --git a/apps/web/src/components/files/useFileSaveCoordinator.ts b/apps/web/src/components/files/useFileSaveCoordinator.ts index 2ac6ee67263b7..fd43ef409e0d0 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.ts +++ b/apps/web/src/components/files/useFileSaveCoordinator.ts @@ -2,6 +2,7 @@ import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts import { createRef, useEffect, useMemo } from "react"; import { projectEnvironment } from "~/state/projects"; +import { invalidateReviewWorkspace } from "~/state/review"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -42,8 +43,10 @@ export function useFileSaveCoordinator({ environmentId, input: { cwd, relativePath, contents: nextContents }, }), - onConfirmed: (confirmedContents) => - confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents), + onConfirmed: (confirmedContents) => { + confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); + invalidateReviewWorkspace(environmentId, cwd); + }, }); coordinatorRef.current = coordinator; return () => { diff --git a/apps/web/src/state/review.ts b/apps/web/src/state/review.ts index e4289d1f1d50e..2ec3e6beb8133 100644 --- a/apps/web/src/state/review.ts +++ b/apps/web/src/state/review.ts @@ -1,5 +1,22 @@ import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); + +const workspaceRevisions = Atom.family((key: string) => + Atom.make(0).pipe(Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`review-workspace:${key}`)), +); + +export const reviewWorkspaceRevision = ( + environmentId: EnvironmentId | undefined, + cwd: string | undefined, +) => workspaceRevisions(JSON.stringify([environmentId, cwd])); + +/** Confirmed editor saves invalidate previews even while the diff is closed. */ +export function invalidateReviewWorkspace(environmentId: EnvironmentId, cwd: string) { + appAtomRegistry.update(reviewWorkspaceRevision(environmentId, cwd), (revision) => revision + 1); +} diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index eee17de4e6f7d..52a0154b18e2a 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -5,6 +5,8 @@ import { VcsError } from "./vcs.ts"; export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, + /** Freshness epoch supplied by clients that invalidate on mutation and explicit refresh. */ + cacheKey: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(1024))), baseRef: Schema.optional(TrimmedNonEmptyString), ignoreWhitespace: Schema.optionalKey(Schema.Boolean), file: Schema.optionalKey( From 35d437ecf7fb5fb4af51182e95c16d145573c337 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:59:03 +0000 Subject: [PATCH 027/215] perf(web): warm file viewer assets while picking files --- .../src/components/DiffWorkerPoolProvider.tsx | 53 +++++++++++-------- .../src/components/files/FileBrowserPanel.tsx | 5 ++ .../src/components/files/FilePreviewPanel.tsx | 8 ++- .../components/files/ProjectFilePicker.tsx | 11 +++- 4 files changed, 52 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index b0cf48ede57e4..1c85132db1f69 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -2,14 +2,7 @@ import { WorkerPoolContext, useWorkerPool } from "@pierre/diffs/react"; import { WorkerPoolManager } from "@pierre/diffs/worker"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import * as Schema from "effect/Schema"; -import { - useCallback, - useEffect, - useMemo, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; @@ -33,7 +26,13 @@ let sharedWorkerPool: } | undefined; -/** Create workers after a viewer commits, then reuse them across short panel closures. */ +function diffWorkerPoolSize() { + const cores = + typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); + return Math.max(2, Math.min(6, Math.floor(cores / 2))); +} + +/** Reuse workers across viewer intent and short panel closures. */ function acquireDiffWorkerPool(themeName: DiffThemeName, poolSize: number) { const entry = (sharedWorkerPool ??= { pool: new WorkerPoolManager( @@ -64,6 +63,26 @@ function acquireDiffWorkerPool(themeName: DiffThemeName, poolSize: number) { return entry; } +function releaseDiffWorkerPool(entry: NonNullable) { + entry.consumers -= 1; + if (entry.consumers !== 0) return; + entry.idleTimer = setTimeout(() => { + entry.idleTimer = undefined; + entry.pool.terminate(); + if (sharedWorkerPool === entry) sharedWorkerPool = undefined; + }, DIFF_WORKER_IDLE_TTL_MS); +} + +/** File picking is viewer intent; overlap worker assets with the file search. */ +export async function preloadDiffWorkerPool(themeName: DiffThemeName) { + const entry = acquireDiffWorkerPool(themeName, diffWorkerPoolSize()); + try { + await entry.pool.initialize(); + } finally { + releaseDiffWorkerPool(entry); + } +} + function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); @@ -131,26 +150,14 @@ function DiffWorkerReady({ children }: { children?: ReactNode }) { export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); - const workerPoolSize = useMemo(() => { - const cores = - typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); - return Math.max(2, Math.min(6, Math.floor(cores / 2))); - }, []); + const workerPoolSize = diffWorkerPoolSize(); const workerPool = useSyncExternalStore( useCallback( (onStoreChange) => { if (typeof window === "undefined") return () => {}; const entry = acquireDiffWorkerPool(diffThemeName, workerPoolSize); onStoreChange(); - return () => { - entry.consumers -= 1; - if (entry.consumers !== 0) return; - entry.idleTimer = setTimeout(() => { - entry.idleTimer = undefined; - entry.pool.terminate(); - if (sharedWorkerPool === entry) sharedWorkerPool = undefined; - }, DIFF_WORKER_IDLE_TTL_MS); - }; + return () => releaseDiffWorkerPool(entry); }, [diffThemeName, workerPoolSize], ), diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index d9722303ab72e..8b0ac082d4d58 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -105,6 +105,11 @@ export default function FileBrowserPanel({ onRefreshSelectedFile, workspaceMutationId, }: FileBrowserPanelProps) { + useEffect(() => { + void import("./FilePreviewPanel") + .then(({ preloadFileViewer }) => preloadFileViewer()) + .catch(() => undefined); + }, []); const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index efd7a7fbc90fb..431bf6c77a8c4 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -30,7 +30,7 @@ import { } from "@pierre/diffs/edit"; import type { WorkerPoolManager } from "@pierre/diffs/worker"; import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; -import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; +import { DiffWorkerPoolProvider, preloadDiffWorkerPool } from "../DiffWorkerPoolProvider"; import { useFilesystemReadAccess } from "~/state/filesystem"; import { isAtomCommandInterrupted, @@ -116,6 +116,12 @@ import { useProjectFileQuery, } from "./projectFilesQueryState"; +export function preloadFileViewer() { + return preloadDiffWorkerPool( + resolveDiffThemeName(document.documentElement.classList.contains("dark") ? "dark" : "light"), + ); +} + interface FilePreviewPanelProps { environmentId: EnvironmentId; cwd: string; diff --git a/apps/web/src/components/files/ProjectFilePicker.tsx b/apps/web/src/components/files/ProjectFilePicker.tsx index 7de429624e953..427a459430c4b 100644 --- a/apps/web/src/components/files/ProjectFilePicker.tsx +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useMemo, useState, type ReactNode } from "react"; +import { useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; import { useTheme } from "~/hooks/useTheme"; @@ -72,7 +72,16 @@ function EmptyProjectFilePicker() { function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveProjectTarget }) { const { target } = props; + // Opening the picker is file-viewer intent; overlap the viewer chunk with search. + useEffect(() => { + void import("./FilePreviewPanel") + .then(({ preloadFileViewer }) => preloadFileViewer()) + .catch(() => undefined); + }, []); const [query, setQuery] = useState(props.initialQuery ?? ""); + useLayoutEffect(() => { + setQuery(props.initialQuery ?? ""); + }, [props.initialQuery]); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const result = useProjectFilePickerQuery( target.environmentId, From 38bc8e272be4f26dab36de153f16c0428c7ae44c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:59:03 +0000 Subject: [PATCH 028/215] perf(web): reuse palette search work and defer focus --- .../src/components/CommandPalette.logic.ts | 24 ++++++--- apps/web/src/components/CommandPalette.tsx | 32 +++++++---- .../src/components/CommandPaletteContent.tsx | 11 ++-- .../web/src/components/CommandPaletteHost.tsx | 39 ++++++++++---- .../src/components/CommandPaletteResults.tsx | 53 +++++++++++-------- .../search/ProjectContentSearchDialog.tsx | 5 +- 6 files changed, 110 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 915700770aa96..b88e5edead80a 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -404,27 +404,37 @@ export function buildThreadActionItems, { fields: string[]; haystack: string }>(); const normalizedTermCache = new Map(); function normalizeSearchTerm(term: string): string { let normalized = normalizedTermCache.get(term); if (normalized === undefined) { - if (normalizedTermCache.size >= NORMALIZED_TERM_CACHE_LIMIT) normalizedTermCache.clear(); + if (normalizedTermCache.size >= 10_000) normalizedTermCache.clear(); normalized = normalizeSearchText(term); normalizedTermCache.set(term, normalized); } return normalized; } +function indexedSearchTerms(terms: ReadonlyArray) { + let indexed = searchIndex.get(terms); + if (indexed === undefined) { + const fields = terms.filter((term) => term.length > 0).map(normalizeSearchTerm); + indexed = { fields, haystack: fields.join(" ") }; + searchIndex.set(terms, indexed); + } + return indexed; +} + function rankSearchFieldMatch( field: string, normalizedQuery: string, queryTokens: ReadonlyArray, ): number { - const normalizedField = normalizeSearchTerm(field); + const normalizedField = field; if ( normalizedField.length === 0 || !queryTokens.every((token) => normalizedField.includes(token)) @@ -448,7 +458,7 @@ function rankCommandPaletteItemMatch( normalizedQuery: string, queryTokens: ReadonlyArray, ): number { - const terms = item.searchTerms.filter((term) => term.length > 0); + const terms = indexedSearchTerms(item.searchTerms).fields; if (terms.length === 0) { return 0; } @@ -521,7 +531,7 @@ export function filterCommandPaletteGroups(input: { return searchableGroups.flatMap((group) => { const items = Arr.filterMap(group.items, (item, index) => { - const haystack = item.searchTerms.map(normalizeSearchTerm).join(" "); + const haystack = indexedSearchTerms(item.searchTerms).haystack; if (!queryTokens.every((token) => haystack.includes(token))) { return Result.failVoid; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8ffc7a09f6d49..f86c3953bb6b2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -54,7 +54,7 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, @@ -355,6 +355,7 @@ export function CommandPaletteDialog(props: { return ( void; }) { const navigate = useNavigate(); + const router = useRouter(); const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); @@ -446,6 +448,14 @@ function OpenCommandPaletteDialog(props: { const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const availableSettingsSearchItems = useAvailableSettingsSearchItems(); + const settingsIntent = + highlightedItemValue === "action:settings" + ? "/settings/general" + : availableSettingsSearchItems.find((item) => `setting:${item.id}` === highlightedItemValue) + ?.to; + useEffect(() => { + if (settingsIntent) void router.preloadRoute({ to: settingsIntent }).catch(() => undefined); + }, [router, settingsIntent]); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -565,13 +575,7 @@ function OpenCommandPaletteDialog(props: { [threadSearch.matches], ); const [browseGeneration, setBrowseGeneration] = useState(0); - const browseNavigationRef = useRef | null>( - null, - ); - if (browseNavigationRef.current === null) { - browseNavigationRef.current = createBrowseNavigationCoordinator(); - } - const browseNavigation = browseNavigationRef.current; + const [browseNavigation] = useState(createBrowseNavigationCoordinator); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, ); @@ -1603,7 +1607,9 @@ function OpenCommandPaletteDialog(props: { setViewStack([]); setLinkedThreadSearch(openIntent); setQuery(openIntent.query); - clearOpenIntent(); + if (document.activeElement?.matches("#t3-command-palette input[role=combobox]")) { + clearOpenIntent(); + } }, [browseNavigation, clearOpenIntent, openIntent]); useLayoutEffect(() => { @@ -3149,6 +3155,7 @@ function OpenCommandPaletteDialog(props: { handleAddProjectForEnvironment, isPickingProjectFolder, primaryEnvironmentId, + setIsPickingProjectFolder, ]); const inputAccessory = @@ -3258,7 +3265,9 @@ function OpenCommandPaletteDialog(props: { // One callback for the palette's lifetime, so result rows whose item did not // change skip re-rendering on every keystroke. const executeItemRef = useRef(executeItem); - executeItemRef.current = executeItem; + useLayoutEffect(() => { + executeItemRef.current = executeItem; + }); const executeLatestItem = useCallback( (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => executeItemRef.current(item), [], @@ -3326,6 +3335,9 @@ function OpenCommandPaletteDialog(props: { ? { startAddon: } : {}), onKeyDown: handleKeyDown, + onFocus: () => { + if (openIntent?.kind === "search") clearOpenIntent(); + }, }} mode="none" items={resultRows.itemValues} diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index 8732c6ef926f7..4c05bface1021 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react"; +import { type ComponentProps, type ReactNode, useEffect, useRef } from "react"; import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -42,15 +42,18 @@ export function CommandPaletteContent({ // Direct-open flows replace the initial palette view after the dialog has // already moved focus. Reclaim it when the replacement input mounts so // typing cannot continue in the composer behind the modal. - useLayoutEffect(() => { - inputRef.current?.focus(); + useEffect(() => { + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true })); + }); + return () => cancelAnimationFrame(frame); }, []); return (
    - + {inputAccessory}
    >; /** Whether the open view has rendered its text input, which then takes the keys. */ -function paletteTextInputMounted(): boolean { +function paletteTextInputMounted(mode: SearchOverlayMode): boolean { const palette = document.getElementById(COMMAND_PALETTE_ELEMENT_ID); - return palette?.querySelector("input, textarea, [contenteditable='true']") != null; + return ( + palette?.dataset.paletteMode === mode && + palette?.contains(document.activeElement) === true && + document.activeElement?.matches("input, textarea, [contenteditable='true']") === true + ); } /** @@ -106,6 +110,10 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { mode: "command", openIntent: null, }); + const requestedModeRef = useRef("command"); + useLayoutEffect(() => { + requestedModeRef.current = state.mode; + }, [state.mode]); // Stays mounted after the first open so closing still animates out. const [CommandPaletteDialog, setCommandPaletteDialog] = useState dispatch({ _tag: "ToggleMode", mode }), - [], - ); + const toggleMode = useCallback((mode: SearchOverlayMode) => { + // Keys can arrive before React replaces the old mode's focused input. + requestedModeRef.current = mode; + dispatch({ _tag: "ToggleMode", mode }); + }, []); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); @@ -179,12 +188,19 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { : null; useLayoutEffect(() => { if (!state.open || loadingQuery === null) return; + let query = loadingQuery; + let queryMode = state.mode; const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (paletteTextInputMounted() || event.isComposing) return; + const mode = requestedModeRef.current; + if (paletteTextInputMounted(mode) || event.isComposing) return; + if (queryMode !== mode) { + queryMode = mode; + query = ""; + } const text = typedText(event); if (text === null && event.key !== "Backspace" && event.key !== "Escape") return; if (event.key === "Escape") { - if (state.mode !== "command") return; + if (mode !== "command") return; event.preventDefault(); event.stopPropagation(); setOpen(false); @@ -192,7 +208,12 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { } event.preventDefault(); event.stopPropagation(); - const query = text === null ? loadingQuery.slice(0, -1) : loadingQuery + text; + const palette = document.getElementById(COMMAND_PALETTE_ELEMENT_ID); + // Replacement views can carry an initial query while waiting for focus. + if (query === "" && palette?.dataset.paletteMode === mode) { + query = palette.querySelector('input[role="combobox"]')?.value ?? ""; + } + query = text === null ? query.slice(0, -1) : query + text; dispatch({ _tag: "TypeAhead", query }); }; window.addEventListener("keydown", onKeyDown, true); diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 8a00d8f435c25..81631bdfbfe3c 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -1,7 +1,7 @@ import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronRightIcon } from "lucide-react"; -import { type RefObject, useCallback, useState } from "react"; +import { type RefObject, memo, useCallback, useState } from "react"; import { cn } from "~/lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; import { @@ -23,6 +23,9 @@ import { import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; +const paletteRowKey = (row: CommandPaletteRow) => row.key; +const paletteRowType = (row: CommandPaletteRow) => row.kind; + interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; @@ -112,6 +115,26 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize ); }, [listRef]); + const renderItem = useCallback( + ({ item: row }: { item: CommandPaletteRow }) => + row.kind === "label" ? ( +
    + {row.label} +
    + ) : row.itemIndex === null ? ( + + ) : ( + + ), + [props.highlightedItemValue, props.keybindings, props.onExecuteItem], + ); + if (props.rows.length === 0) { return ; } @@ -121,28 +144,12 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize ref={listRef} data={props.rows} - keyExtractor={(row) => row.key} - getItemType={(row) => row.kind} + keyExtractor={paletteRowKey} + getItemType={paletteRowType} extraData={props.highlightedItemValue} - renderItem={({ item: row }) => - row.kind === "label" ? ( -
    - {row.label} -
    - ) : row.itemIndex === null ? ( - - ) : ( - - ) - } + renderItem={renderItem} estimatedItemSize={40} - drawDistance={400} + drawDistance={120} onLayout={updateScrollFade} onScroll={updateScrollFade} contentContainerClassName="px-2" @@ -187,7 +194,7 @@ function DisabledCommandPaletteResultRow(props: { ); } -function CommandPaletteResultRow(props: { +const CommandPaletteResultRow = memo(function CommandPaletteResultRow(props: { index?: number; item: CommandPaletteActionItem | CommandPaletteSubmenuItem; isActive: boolean; @@ -244,4 +251,4 @@ function CommandPaletteResultRow(props: { ) : null} ); -} +}); diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 082d09ea2f4af..457fce1d74018 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,7 +1,7 @@ import { Spinner } from "~/components/ui/spinner"; import { AuthFilesystemReadScope, type ProjectContentMatch } from "@t3tools/contracts"; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; import { useTheme } from "~/hooks/useTheme"; @@ -112,6 +112,9 @@ function OpenContentSearchDialog(props: { const { target } = props; const { resolvedTheme } = useTheme(); const [query, setQuery] = useState(props.initialQuery ?? ""); + useLayoutEffect(() => { + setQuery(props.initialQuery ?? ""); + }, [props.initialQuery]); const [caseSensitive, setCaseSensitive] = useState(false); const [wholeWord, setWholeWord] = useState(false); const [useRegex, setUseRegex] = useState(false); From 3bdabb8c308eceecd253f479abec69fa254f4e71 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:59:03 +0000 Subject: [PATCH 029/215] perf(web): defer settings surfaces and batch row mounts --- .../settings/KeybindingsSettings.tsx | 10 +- .../settings/OpenSourceLicenses.tsx | 21 +- .../settings/SettingsFontPreviewContents.tsx | 273 ++++++++++++++++ .../settings/SettingsFontPreviews.tsx | 309 ++---------------- .../settings/SettingsSidebarNav.tsx | 9 +- .../components/settings/ThemeEditorPanel.tsx | 2 + apps/web/src/hooks/useProgressiveRowLimit.ts | 18 +- apps/web/src/hooks/useTheme.test.ts | 29 +- apps/web/src/hooks/useTheme.ts | 40 +-- 9 files changed, 386 insertions(+), 325 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsFontPreviewContents.tsx diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 5880496a69b7f..f5cb2b7e879d8 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -19,6 +19,7 @@ import { useCallback, useEffect, useMemo, + memo, useReducer, useRef, useState, @@ -994,7 +995,7 @@ function KeybindingHoverRowMenu(props: { } /** One binding as a settings row: pills flush right, actions fading in beside them on hover. */ -function KeybindingSettingsRow(props: KeybindingRowProps) { +const KeybindingSettingsRow = memo(function KeybindingSettingsRow(props: KeybindingRowProps) { const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props; const editor = useKeybindingRowEditor({ row, allRows, onSave }); @@ -1023,7 +1024,7 @@ function KeybindingSettingsRow(props: KeybindingRowProps) { } /> ); -} +}); /** Draft state for a binding that does not exist yet. */ function useNewKeybindingDraft({ @@ -1244,7 +1245,10 @@ interface KeybindingsGroupsProps extends KeybindingRowActions { /** One titled section per command area, each holding its binding rows. */ function KeybindingsGroups(props: KeybindingsGroupsProps) { const { groups, anchorIds, savingCommand, ...rowActions } = props; - const rowLimit = useProgressiveRowLimit(INITIAL_KEYBINDING_ROWS); + const rowLimit = useProgressiveRowLimit( + INITIAL_KEYBINDING_ROWS, + groups.reduce((total, group) => total + group.rows.length, 0), + ); const shownGroups: Array<{ group: KeybindingGroup; rows: KeybindingGroup["rows"] }> = []; let budget = rowLimit; for (const group of groups) { diff --git a/apps/web/src/components/settings/OpenSourceLicenses.tsx b/apps/web/src/components/settings/OpenSourceLicenses.tsx index 5e15cd3f96f2d..bdb04015b4cb6 100644 --- a/apps/web/src/components/settings/OpenSourceLicenses.tsx +++ b/apps/web/src/components/settings/OpenSourceLicenses.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon, ExternalLinkIcon, SearchIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { decodeThirdPartyLicenseManifest, @@ -18,7 +18,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; /** Enough rows to fill a tall window; the rest render right after the first paint. */ -const INITIAL_LICENSE_ROWS = 40; +const INITIAL_LICENSE_ROWS = 20; type LicenseManifestState = | { readonly status: "loading" } @@ -56,17 +56,20 @@ function loadLicenseManifest(): Promise { return licenseManifestPromise; } -function LicenseNoticeRow({ +const LicenseNoticeRow = memo(function LicenseNoticeRow({ entry, open, - onOpenChange, + onSelectEntry, }: { readonly entry: ThirdPartyLicenseEntry; readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; + readonly onSelectEntry: (key: string | null) => void; }) { return ( - + onSelectEntry(nextOpen ? thirdPartyLicenseEntryKey(entry) : null)} + > {/* Rows out of view skip style and layout until scrolled to. */}
    @@ -110,7 +113,7 @@ function LicenseNoticeRow({
    ); -} +}); function LicenseCount({ filteredCount, @@ -248,7 +251,7 @@ export function OpenSourceLicensesPanel() { [entries, query], ); const retry = useCallback(() => setRequestVersion((value) => value + 1), []); - const rowLimit = useProgressiveRowLimit(INITIAL_LICENSE_ROWS); + const rowLimit = useProgressiveRowLimit(INITIAL_LICENSE_ROWS, filteredEntries.length); const shownEntries = filteredEntries.length > rowLimit ? filteredEntries.slice(0, rowLimit) : filteredEntries; @@ -279,7 +282,7 @@ export function OpenSourceLicensesPanel() { key={entryKey} entry={entry} open={openEntryKey === entryKey} - onOpenChange={(open) => setOpenEntryKey(open ? entryKey : null)} + onSelectEntry={setOpenEntryKey} /> ); }) diff --git a/apps/web/src/components/settings/SettingsFontPreviewContents.tsx b/apps/web/src/components/settings/SettingsFontPreviewContents.tsx new file mode 100644 index 0000000000000..8a4f18d330cfb --- /dev/null +++ b/apps/web/src/components/settings/SettingsFontPreviewContents.tsx @@ -0,0 +1,273 @@ +import { preloadPatchFile } from "@pierre/diffs/ssr"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; +import { EMPTY_COMPOSER_CONTEXT_RECORDS } from "../composerContextPresentation"; +import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; +import { useTheme } from "../../hooks/useTheme"; +import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; +import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; +import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; + +// The font previews are the real surfaces, not lookalikes: the composer's +// Tiptap editor, the diff panel's file diff, and the Ghostty canvas +// renderer. Each already consumes the appearance font tokens (or, for the +// terminal, the settings passed down as props), so what the row shows is +// exactly what the app renders. + +const EMPTY_SKILLS: ReadonlyArray = []; + +// Serialized the way the composer stores inline tokens: the $skill and the +// markdown-style file links render as chips, so the preview shows prompt +// text and pills exactly as the real composer draws them. +const PROMPT_PREVIEW_TEXT = + "Use $frontend-design to fix the flaky test in " + + "[surface.test.ts](apps/web/src/terminal/ghostty/surface.test.ts) and align the header with " + + "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping."; + +function noop() {} + +/** A live composer editor: type in it to feel the family and size. */ +export function PromptFontPreview() { + const editorRef = useRef(null); + const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT); + const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length); + const onChange = useCallback((nextValue: string, nextCursor: number) => { + setPrompt(nextValue); + setCursor(nextCursor); + }, []); + return ( +
    + +
    + ); +} + +const DIFF_PREVIEW_PATCH = [ + "diff --git a/src/formatUser.ts b/src/formatUser.ts", + "--- a/src/formatUser.ts", + "+++ b/src/formatUser.ts", + "@@ -1,3 +1,3 @@", + " export function formatUser(user: User) {", + "- return user.name.toUpperCase();", + "+ return `${user.name} <${user.email}>`; // 0O 1lI", + " }", + "", +].join("\n"); + +// Rendered once per theme through the SSR pipeline, which always awaits the +// shared highlighter before producing HTML. The interactive FileDiff's mount +// lifecycle can race that highlighter when the typography views remount it +// (toggling Advanced) and lock in an unhighlighted frame; a static preview +// needs none of that lifecycle, so it uses the deterministic renderer and +// injects the finished HTML into a shadow root, exactly as FileDiff would. +const diffPreviewHtmlByTheme = new Map>(); + +function loadDiffPreviewHtml(theme: DiffThemeName): Promise { + let promise = diffPreviewHtmlByTheme.get(theme); + if (promise === undefined) { + promise = preloadPatchFile({ + patch: DIFF_PREVIEW_PATCH, + options: { diffStyle: "unified", theme, preferredHighlighter: PREFERRED_HIGHLIGHTER }, + }).then((results) => results.map((result) => result.prerenderedHTML)); + diffPreviewHtmlByTheme.set(theme, promise); + } + return promise; +} + +// Pierre's prerendered stylesheet bakes its own light/dark surface colors +// into the shadow root's @layer rules. These unlayered rules win the cascade +// without !important and re-point the surfaces at the app's code tokens +// (custom properties inherit across the shadow boundary), so the preview +// follows the active theme exactly like the real diff panel does. +const DIFF_PREVIEW_THEME_BRIDGE = ` + :host { + color: var(--code-foreground); + background-color: var(--code-background); + --diffs-fg: var(--code-foreground); + --diffs-bg: var(--code-background); + --diffs-light-bg: var(--code-background); + --diffs-dark-bg: var(--code-background); + } + [data-diffs-header] { + background-color: var(--code-background); + color: var(--code-foreground); + } +`; + +function StaticDiffHtml({ html }: { html: string }) { + const hostRef = useRef(null); + useEffect(() => { + const host = hostRef.current; + if (host === null) return; + const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); + shadow.innerHTML = html; + const bridge = document.createElement("style"); + bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; + shadow.append(bridge); + }, [html]); + return
    ; +} + +/** The diff panel's file diff, statically rendered by its real pipeline. */ +export function CodeFontPreview() { + const { resolvedTheme } = useTheme(); + const themeName = resolveDiffThemeName(resolvedTheme); + const [htmlByFile, setHtmlByFile] = useState(null); + useEffect(() => { + let cancelled = false; + void loadDiffPreviewHtml(themeName).then((html) => { + if (!cancelled) setHtmlByFile(html); + }); + return () => { + cancelled = true; + }; + }, [themeName]); + if (htmlByFile === null) return
    ; + return ( +
    + {htmlByFile.map((html) => ( + + ))} +
    + ); +} + +// A zsh-style prompt: green arrow, cyan project, blue/red git segment, +// yellow dirty marker. It doubles as the echo loop's fresh-line prompt. +const TERMINAL_PROMPT = + "\x1b[1;32m→\x1b[0m \x1b[1;36mt3code\x1b[0m \x1b[1;34mgit:(\x1b[1;31mmain\x1b[1;34m)\x1b[0m \x1b[1;33m✗\x1b[0m "; +// A dev-server startup: brand line, addresses, a test summary, and a READY +// badge. Together the lines cover bold, dim, underline, the six accent +// colors, and a background cell, so a font choice shows every SGR the +// terminal actually renders. +const TERMINAL_PREVIEW_TRANSCRIPT = + `${TERMINAL_PROMPT}vpr dev\r\n` + + "\r\n" + + " \x1b[1;32mVITE\x1b[0m \x1b[32mv7.1.1\x1b[0m \x1b[2mready in\x1b[0m \x1b[1m1.24s\x1b[0m\r\n" + + "\r\n" + + " \x1b[32m→\x1b[0m \x1b[2mLocal:\x1b[0m \x1b[4;36mhttp://127.0.0.1:5173/\x1b[0m\r\n" + + " \x1b[32m→\x1b[0m \x1b[2mNetwork:\x1b[0m \x1b[4;36mhttp://192.168.1.24:5173/\x1b[0m\r\n" + + "\r\n" + + " \x1b[32m✓ 85 passed\x1b[0m \x1b[33m△ 2 warnings\x1b[0m \x1b[31m✗ 0 failed\x1b[0m\r\n" + + "\r\n" + + " \x1b[42;30m READY \x1b[0m \x1b[2mwatching for changes — press\x1b[0m \x1b[1mq\x1b[0m \x1b[2mto quit\x1b[0m\r\n" + + "\r\n" + + TERMINAL_PROMPT; + +/** The surface treats an omitted family or size as "use the built-in default". */ +function previewTerminalFont(family: string, size: number): { family?: string; size: number } { + const trimmed = family.trim(); + return trimmed.length > 0 ? { family: trimmed, size } : { size }; +} + +/** + * The real Ghostty canvas renderer against a local echo loop instead of a + * PTY: keys print, Enter starts a new prompt line, Backspace erases. That + * exercises the same glyph atlas, cell metrics, and monospace gate the + * terminal drawer uses. + */ +export function TerminalFontPreview({ family, size }: { family: string; size: number }) { + const mountRef = useRef(null); + const surfaceRef = useRef(null); + const fontRef = useRef({ family, size }); + const { theme, resolvedTheme } = useTheme(); + + useEffect(() => { + const current = fontRef.current; + if (current.family === family && current.size === size) return; + fontRef.current = { family, size }; + void surfaceRef.current?.setFont(previewTerminalFont(family, size)); + }, [family, size]); + + // Re-read the terminal tokens on any theme change — switching between two + // palettes can leave resolvedTheme (light/dark) untouched. + useEffect(() => { + const mount = mountRef.current; + const surface = surfaceRef.current; + if (!mount || !surface) return; + surface.setTheme(terminalThemeFromApp(mount)); + }, [theme, resolvedTheme]); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + let cancelled = false; + // Column of the caret on the current input line, so Backspace stops at + // the prompt instead of eating it. + let lineLength = 0; + + const echo = (data: string) => { + const surface = surfaceRef.current; + if (!surface) return; + if (data === "\r") { + surface.write(`\r\n${TERMINAL_PROMPT}`); + lineLength = 0; + return; + } + if (data === "\x7f" || data === "\b") { + if (lineLength > 0) { + surface.write("\b \b"); + lineLength -= 1; + } + return; + } + // Arrow keys and other escape reports have no cursor to move here. + if (data.startsWith("\x1b")) return; + const printable = [...data] + .filter((character) => character >= " " && character !== "\x7f") + .join(""); + if (printable.length === 0) return; + surface.write(printable); + lineLength += printable.length; + }; + + void GhosttyTerminalSurface.create(mount, { + theme: terminalThemeFromApp(mount), + font: previewTerminalFont(fontRef.current.family, fontRef.current.size), + onData: echo, + onResize: noop, + onSelectionChange: noop, + // Tab keeps walking the settings page instead of feeding the echo loop. + beforeKey: (event) => event.key !== "Tab", + onLinkActivate: noop, + }).then((surface) => { + if (cancelled) { + surface.dispose(); + return; + } + surfaceRef.current = surface; + // The theme and font may both have changed while the WASM surface loaded. + surface.setTheme(terminalThemeFromApp(mount)); + const font = fontRef.current; + void surface.setFont(previewTerminalFont(font.family, font.size)); + surface.write(TERMINAL_PREVIEW_TRANSCRIPT); + }); + + return () => { + cancelled = true; + surfaceRef.current?.dispose(); + surfaceRef.current = null; + }; + }, []); + + return ( +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 5a0f827c4cc66..d0eff6acfab5b 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -1,302 +1,55 @@ -import { preloadPatchFile } from "@pierre/diffs/ssr"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; -import { EMPTY_COMPOSER_CONTEXT_RECORDS } from "../composerContextPresentation"; -import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; -import { useTheme } from "../../hooks/useTheme"; -import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; -import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; -import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; - -// The font previews are the real surfaces, not lookalikes: the composer's -// Tiptap editor, the diff panel's file diff, and the Ghostty canvas -// renderer. Each already consumes the appearance font tokens (or, for the -// terminal, the settings passed down as props), so what the row shows is -// exactly what the app renders. - -const EMPTY_SKILLS: ReadonlyArray = []; - -/** - * True once the element has scrolled into view. The diff and terminal - * previews pull megabytes of grammars, fonts, and WASM, and sit below the - * fold, so they load when a reader reaches them rather than with the page. - */ -function useRevealed(ref: RefObject): boolean { - const [revealed, setRevealed] = useState(false); +import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; + +const PromptPreview = lazy(() => + import("./SettingsFontPreviewContents").then((m) => ({ default: m.PromptFontPreview })), +); +const CodePreview = lazy(() => + import("./SettingsFontPreviewContents").then((m) => ({ default: m.CodeFontPreview })), +); +const TerminalPreview = lazy(() => + import("./SettingsFontPreviewContents").then((m) => ({ default: m.TerminalFontPreview })), +); + +/** Load and mount real preview surfaces once their row enters the viewport. */ +function VisiblePreview({ children }: { children: ReactNode }) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); useEffect(() => { const element = ref.current; - if (revealed || element === null) return; + if (!element) return; if (typeof IntersectionObserver === "undefined") { - setRevealed(true); + setVisible(true); return; } const observer = new IntersectionObserver((entries) => { if (!entries.some((entry) => entry.isIntersecting)) return; observer.disconnect(); - setRevealed(true); + setVisible(true); }); observer.observe(element); return () => observer.disconnect(); - }, [ref, revealed]); - return revealed; + }, []); + return
    {visible ? {children} : null}
    ; } -// Serialized the way the composer stores inline tokens: the $skill and the -// markdown-style file links render as chips, so the preview shows prompt -// text and pills exactly as the real composer draws them. -const PROMPT_PREVIEW_TEXT = - "Use $frontend-design to fix the flaky test in " + - "[surface.test.ts](apps/web/src/terminal/ghostty/surface.test.ts) and align the header with " + - "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping."; - -function noop() {} - -/** A live composer editor: type in it to feel the family and size. */ export function PromptFontPreview() { - const editorRef = useRef(null); - const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT); - const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length); - const onChange = useCallback((nextValue: string, nextCursor: number) => { - setPrompt(nextValue); - setCursor(nextCursor); - }, []); return ( -
    - -
    + + + ); } - -const DIFF_PREVIEW_PATCH = [ - "diff --git a/src/formatUser.ts b/src/formatUser.ts", - "--- a/src/formatUser.ts", - "+++ b/src/formatUser.ts", - "@@ -1,3 +1,3 @@", - " export function formatUser(user: User) {", - "- return user.name.toUpperCase();", - "+ return `${user.name} <${user.email}>`; // 0O 1lI", - " }", - "", -].join("\n"); - -// Rendered once per theme through the SSR pipeline, which always awaits the -// shared highlighter before producing HTML. The interactive FileDiff's mount -// lifecycle can race that highlighter when the typography views remount it -// (toggling Advanced) and lock in an unhighlighted frame; a static preview -// needs none of that lifecycle, so it uses the deterministic renderer and -// injects the finished HTML into a shadow root, exactly as FileDiff would. -const diffPreviewHtmlByTheme = new Map>(); - -function loadDiffPreviewHtml(theme: DiffThemeName): Promise { - let promise = diffPreviewHtmlByTheme.get(theme); - if (promise === undefined) { - promise = preloadPatchFile({ - patch: DIFF_PREVIEW_PATCH, - options: { diffStyle: "unified", theme, preferredHighlighter: PREFERRED_HIGHLIGHTER }, - }).then((results) => results.map((result) => result.prerenderedHTML)); - diffPreviewHtmlByTheme.set(theme, promise); - } - return promise; -} - -// Pierre's prerendered stylesheet bakes its own light/dark surface colors -// into the shadow root's @layer rules. These unlayered rules win the cascade -// without !important and re-point the surfaces at the app's code tokens -// (custom properties inherit across the shadow boundary), so the preview -// follows the active theme exactly like the real diff panel does. -const DIFF_PREVIEW_THEME_BRIDGE = ` - :host { - color: var(--code-foreground); - background-color: var(--code-background); - --diffs-fg: var(--code-foreground); - --diffs-bg: var(--code-background); - --diffs-light-bg: var(--code-background); - --diffs-dark-bg: var(--code-background); - } - [data-diffs-header] { - background-color: var(--code-background); - color: var(--code-foreground); - } -`; - -function StaticDiffHtml({ html }: { html: string }) { - const hostRef = useRef(null); - useEffect(() => { - const host = hostRef.current; - if (host === null) return; - const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); - shadow.innerHTML = html; - const bridge = document.createElement("style"); - bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; - shadow.append(bridge); - }, [html]); - return
    ; -} - -/** The diff panel's file diff, statically rendered by its real pipeline. */ export function CodeFontPreview() { - const { resolvedTheme } = useTheme(); - const themeName = resolveDiffThemeName(resolvedTheme); - const placeholderRef = useRef(null); - const revealed = useRevealed(placeholderRef); - const [htmlByFile, setHtmlByFile] = useState(null); - useEffect(() => { - if (!revealed) return; - let cancelled = false; - void loadDiffPreviewHtml(themeName).then((html) => { - if (!cancelled) setHtmlByFile(html); - }); - return () => { - cancelled = true; - }; - }, [revealed, themeName]); - if (htmlByFile === null) return
    ; return ( -
    - {htmlByFile.map((html) => ( - - ))} -
    + + + ); } - -// A zsh-style prompt: green arrow, cyan project, blue/red git segment, -// yellow dirty marker. It doubles as the echo loop's fresh-line prompt. -const TERMINAL_PROMPT = - "\x1b[1;32m→\x1b[0m \x1b[1;36mt3code\x1b[0m \x1b[1;34mgit:(\x1b[1;31mmain\x1b[1;34m)\x1b[0m \x1b[1;33m✗\x1b[0m "; -// A dev-server startup: brand line, addresses, a test summary, and a READY -// badge. Together the lines cover bold, dim, underline, the six accent -// colors, and a background cell, so a font choice shows every SGR the -// terminal actually renders. -const TERMINAL_PREVIEW_TRANSCRIPT = - `${TERMINAL_PROMPT}vpr dev\r\n` + - "\r\n" + - " \x1b[1;32mVITE\x1b[0m \x1b[32mv7.1.1\x1b[0m \x1b[2mready in\x1b[0m \x1b[1m1.24s\x1b[0m\r\n" + - "\r\n" + - " \x1b[32m→\x1b[0m \x1b[2mLocal:\x1b[0m \x1b[4;36mhttp://127.0.0.1:5173/\x1b[0m\r\n" + - " \x1b[32m→\x1b[0m \x1b[2mNetwork:\x1b[0m \x1b[4;36mhttp://192.168.1.24:5173/\x1b[0m\r\n" + - "\r\n" + - " \x1b[32m✓ 85 passed\x1b[0m \x1b[33m△ 2 warnings\x1b[0m \x1b[31m✗ 0 failed\x1b[0m\r\n" + - "\r\n" + - " \x1b[42;30m READY \x1b[0m \x1b[2mwatching for changes — press\x1b[0m \x1b[1mq\x1b[0m \x1b[2mto quit\x1b[0m\r\n" + - "\r\n" + - TERMINAL_PROMPT; - -/** The surface treats an omitted family or size as "use the built-in default". */ -function previewTerminalFont(family: string, size: number): { family?: string; size: number } { - const trimmed = family.trim(); - return trimmed.length > 0 ? { family: trimmed, size } : { size }; -} - -/** - * The real Ghostty canvas renderer against a local echo loop instead of a - * PTY: keys print, Enter starts a new prompt line, Backspace erases. That - * exercises the same glyph atlas, cell metrics, and monospace gate the - * terminal drawer uses. - */ -export function TerminalFontPreview({ family, size }: { family: string; size: number }) { - const mountRef = useRef(null); - const surfaceRef = useRef(null); - const fontRef = useRef({ family, size }); - const { theme, resolvedTheme } = useTheme(); - const revealed = useRevealed(mountRef); - - useEffect(() => { - const current = fontRef.current; - if (current.family === family && current.size === size) return; - fontRef.current = { family, size }; - void surfaceRef.current?.setFont(previewTerminalFont(family, size)); - }, [family, size]); - - // Re-read the terminal tokens on any theme change — switching between two - // palettes can leave resolvedTheme (light/dark) untouched. - useEffect(() => { - const mount = mountRef.current; - const surface = surfaceRef.current; - if (!mount || !surface) return; - surface.setTheme(terminalThemeFromApp(mount)); - }, [theme, resolvedTheme]); - - useEffect(() => { - const mount = mountRef.current; - if (!mount || !revealed) return; - let cancelled = false; - // Column of the caret on the current input line, so Backspace stops at - // the prompt instead of eating it. - let lineLength = 0; - - const echo = (data: string) => { - const surface = surfaceRef.current; - if (!surface) return; - if (data === "\r") { - surface.write(`\r\n${TERMINAL_PROMPT}`); - lineLength = 0; - return; - } - if (data === "\x7f" || data === "\b") { - if (lineLength > 0) { - surface.write("\b \b"); - lineLength -= 1; - } - return; - } - // Arrow keys and other escape reports have no cursor to move here. - if (data.startsWith("\x1b")) return; - const printable = [...data] - .filter((character) => character >= " " && character !== "\x7f") - .join(""); - if (printable.length === 0) return; - surface.write(printable); - lineLength += printable.length; - }; - - void GhosttyTerminalSurface.create(mount, { - theme: terminalThemeFromApp(mount), - font: previewTerminalFont(fontRef.current.family, fontRef.current.size), - onData: echo, - onResize: noop, - onSelectionChange: noop, - // Tab keeps walking the settings page instead of feeding the echo loop. - beforeKey: (event) => event.key !== "Tab", - onLinkActivate: noop, - }).then((surface) => { - if (cancelled) { - surface.dispose(); - return; - } - surfaceRef.current = surface; - // The theme and font may both have changed while the WASM surface loaded. - surface.setTheme(terminalThemeFromApp(mount)); - const font = fontRef.current; - void surface.setFont(previewTerminalFont(font.family, font.size)); - surface.write(TERMINAL_PREVIEW_TRANSCRIPT); - }); - - return () => { - cancelled = true; - surfaceRef.current?.dispose(); - surfaceRef.current = null; - }; - }, [revealed]); - +export function TerminalFontPreview(props: { family: string; size: number }) { return ( -
    + + + ); } diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index d10abed376bac..407ea8c18fc9c 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -25,7 +25,7 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; @@ -111,6 +111,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); + const router = useRouter(); const currentHash = useLocation({ select: (location) => location.hash }); const currentSearch = useLocation({ select: (location) => location.search }); const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]); @@ -339,6 +340,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { + void router.preloadRoute({ to: item.to }).catch(() => undefined) + } + onFocus={() => + void router.preloadRoute({ to: item.to }).catch(() => undefined) + } onClick={() => handleSectionClick(item.to)} > diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 63482a22a1058..0df954f035434 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -29,6 +29,7 @@ import { type ThemeDefinition, } from "../../themePalette"; import { cn } from "../../lib/utils"; +import { syncBrowserChromeTheme } from "../../hooks/useTheme"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; import { Input } from "../ui/input"; @@ -485,6 +486,7 @@ export function ThemeEditorPanel({ useEffect(() => { if (!open || !isDraftSeeded) return; applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); + syncBrowserChromeTheme(); }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); useEffect(() => { diff --git a/apps/web/src/hooks/useProgressiveRowLimit.ts b/apps/web/src/hooks/useProgressiveRowLimit.ts index 9ea5569b88e7b..905a9a47bd8c4 100644 --- a/apps/web/src/hooks/useProgressiveRowLimit.ts +++ b/apps/web/src/hooks/useProgressiveRowLimit.ts @@ -2,17 +2,19 @@ import { startTransition, useEffect, useState } from "react"; /** * How many rows of a long list to render. The first paint gets `initial` - * rows and the rest follow in a transition, so opening the page paints at - * once and rendering the remainder never blocks input. + * rows and the rest follow in small transitions between paints. A single + * transition for the whole remainder still commits hundreds of controls at once. */ -export function useProgressiveRowLimit(initial: number): number { +export function useProgressiveRowLimit(initial: number, total: number): number { const [limit, setLimit] = useState(initial); useEffect(() => { - if (limit === Number.POSITIVE_INFINITY) return; - const frame = requestAnimationFrame(() => - startTransition(() => setLimit(Number.POSITIVE_INFINITY)), - ); + if (limit >= total) return; + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => + startTransition(() => setLimit((current) => Math.min(total, current + initial))), + ); + }); return () => cancelAnimationFrame(frame); - }, [limit]); + }, [initial, limit, total]); return limit; } diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index ab87388ff2981..652ab3bf4d27e 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -27,6 +27,29 @@ afterEach(() => { }); describe("theme failure handling", () => { + it("keeps browser chrome aligned with a draft and the restored default", async () => { + const meta = { setAttribute: vi.fn() }; + const root = { + dataset: { themeId: "__preview" } as { themeId?: string }, + style: { backgroundColor: "", getPropertyValue: () => "#123456" }, + classList: { contains: () => false }, + }; + const body = { style: { backgroundColor: "" } }; + vi.stubGlobal("document", { documentElement: root, body, querySelectorAll: () => [meta] }); + const { syncBrowserChromeTheme } = await import("./useTheme"); + + syncBrowserChromeTheme(); + expect(root.style.backgroundColor).toBe("#123456"); + expect(body.style.backgroundColor).toBe("#123456"); + expect(meta.setAttribute).toHaveBeenLastCalledWith("content", "#123456"); + + delete root.dataset.themeId; + syncBrowserChromeTheme(); + expect(root.style.backgroundColor).toBe("#fcfcfc"); + expect(body.style.backgroundColor).toBe("#fcfcfc"); + expect(meta.setAttribute).toHaveBeenLastCalledWith("content", "#fcfcfc"); + }); + it("preserves exact storage causes and operation context", async () => { const readCause = new Error("storage read blocked"); const writeCause = new Error("storage quota exceeded"); @@ -95,8 +118,12 @@ describe("theme failure handling", () => { }); vi.stubGlobal("document", { documentElement: { - classList: { toggle: vi.fn() }, + dataset: {}, + style: { removeProperty: vi.fn(), setProperty: vi.fn(), getPropertyValue: () => "" }, + classList: { toggle: vi.fn(), contains: () => false }, }, + body: { style: {} }, + querySelectorAll: () => [{ setAttribute: vi.fn() }], }); await expect(import("./useTheme")).resolves.toBeDefined(); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 1d93255b3aba8..70272b44f71bb 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,3 +1,7 @@ +import { + T3_CODE_DARK_THEME_COLORS, + T3_CODE_LIGHT_THEME_COLORS, +} from "@t3tools/shared/themePalettes"; import type { DesktopBridge } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import * as Schema from "effect/Schema"; @@ -283,25 +287,15 @@ function normalizeThemeColor(value: string | null | undefined): string | null { return value?.trim() ?? null; } -function resolveBrowserChromeSurface(): HTMLElement { - return ( - document.querySelector("main[data-slot='sidebar-inset']") ?? - document.querySelector("[data-slot='sidebar-inner']") ?? - document.body - ); -} - export function syncBrowserChromeTheme() { - if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; - const rootStyles = getComputedStyle(document.documentElement); - const themeChromeColor = document.documentElement.dataset.themeId - ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) - : null; - const surfaceColor = normalizeThemeColor( - getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, - ); - const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; + if (typeof document === "undefined") return; + const root = document.documentElement; + // The applied palette already owns this color, including an editor draft. + // Reading computed styles here forces a second document-wide style pass. + const backgroundColor = root.dataset.themeId + ? normalizeThemeColor(root.style.getPropertyValue("--app-theme-chrome")) + : (root.classList.contains("dark") ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS) + .chrome; if (!backgroundColor) return; document.documentElement.style.backgroundColor = backgroundColor; @@ -355,18 +349,15 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncDesktopTheme(theme, followSystem, appearanceMode); - if (suppressTransitions) { - settleThemeAfterPaint(); - } else { - syncBrowserChromeTheme(); - } + syncBrowserChromeTheme(); + if (suppressTransitions) settleThemeAfterPaint(); } let themeSettleGeneration = 0; /** * Finish a transition-suppressed theme change once the next frame has painted - * it. Reading computed colors or removing `no-transitions` any earlier forces + * it. Removing `no-transitions` any earlier forces * an extra full-document style recalc before the new theme reaches the screen. * The frame itself computes styles with transitions off, so nothing animates. * Another change before then restarts the wait so it paints without transitions too. @@ -376,7 +367,6 @@ function settleThemeAfterPaint() { requestAnimationFrame(() => { requestAnimationFrame(() => { if (generation !== themeSettleGeneration) return; - syncBrowserChromeTheme(); document.documentElement.classList.remove("no-transitions"); }); }); From b9f404b095f5893cafac441a7e7af8b6ad3cbf47 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 01:59:03 +0000 Subject: [PATCH 030/215] perf(web): avoid panel layout reads and stream terminal wasm --- apps/web/src/components/RightPanelTabs.tsx | 18 ++++++----- .../src/components/chat/ThreadDetailsCard.tsx | 1 - apps/web/src/terminal/ghostty/runtime.ts | 30 ++++++++++++++----- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 9f68a70231739..3e1545586da7a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -76,7 +76,6 @@ import { MenuTrigger, } from "~/components/ui/menu"; import { useBrowserDefaults } from "~/browser/browserDefaults"; -import { ScrollArea } from "~/components/ui/scroll-area"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { useTheme } from "~/hooks/useTheme"; @@ -219,7 +218,7 @@ const TAB_SCROLL_EDGE_TOLERANCE = 1; const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { - return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; + return root; } /** @@ -1197,12 +1196,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { )} data-right-panel-tabbar > -
    @@ -1432,7 +1434,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
    ) : null} - + {tabScrollState.hasOverflow ? (
    { +async function fetchWasm(url: string, label: string): Promise { const response = await fetch(url); if (!response.ok) { throw new Error(`Unable to load ${label} (${response.status})`); } + return response; +} + +async function compileWasm(url: string, label: string): Promise { + const response = await fetchWasm(url, label); if ( typeof WebAssembly.compileStreaming === "function" && response.headers.get("content-type")?.split(";")[0]?.trim() === "application/wasm" @@ -62,12 +67,6 @@ export class GhosttyRuntime { } static async load(): Promise { - // Both modules download and compile in parallel; the trampoline is only - // instantiated once the main module's memory and table exist. - const [module, trampolineModule] = await Promise.all([ - compileWasm(ghosttyWasmUrl, "libghostty-vt"), - compileWasm(ghosttyWritePtyWasmUrl, "the libghostty-vt PTY trampoline"), - ]); let instance: WebAssembly.Instance | undefined; const imports = { env: { @@ -80,7 +79,22 @@ export class GhosttyRuntime { }, }, }; - instance = await WebAssembly.instantiate(module, imports); + const main = (async () => { + const response = await fetchWasm(ghosttyWasmUrl, "libghostty-vt"); + if ( + typeof WebAssembly.instantiateStreaming === "function" && + response.headers.get("content-type")?.split(";")[0]?.trim() === "application/wasm" + ) { + return (await WebAssembly.instantiateStreaming(response, imports)).instance; + } + return (await WebAssembly.instantiate(await response.arrayBuffer(), imports)).instance; + })(); + // Instantiate the main module while the trampoline downloads and compiles. + const [mainInstance, trampolineModule] = await Promise.all([ + main, + compileWasm(ghosttyWritePtyWasmUrl, "the libghostty-vt PTY trampoline"), + ]); + instance = mainInstance; const runtime = new GhosttyRuntime(instance); await runtime.installWritePtyTrampoline(trampolineModule); return runtime; From 37c86c130d640b826f20a195d820ccfa583dfa35 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 03:20:19 +0000 Subject: [PATCH 031/215] fix(review): scope preview freshness to each client --- apps/web/src/components/DiffPanel.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 114b5809ffec4..7feb477ff03c9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -28,7 +28,7 @@ import { useFileContextMenuHandler } from "../fileContextMenu"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { cn } from "~/lib/utils"; +import { cn, randomUUID } from "~/lib/utils"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useTheme } from "../hooks/useTheme"; @@ -184,6 +184,8 @@ function DiffFileHeaderSuffix({ ); } +// Local save revisions must not alias another client's revision on the server. +const diffCacheClientId = randomUUID(); // Preserve explicit refresh epochs across short panel remounts. const diffRefreshVersions = new Map(); @@ -358,7 +360,12 @@ export default function DiffPanel({ const savedFileRevision = useAtomValue( reviewWorkspaceRevision(activeThread?.environmentId, activeCwd ?? undefined), ); - const reviewCacheKey = JSON.stringify([workspaceMutationId, savedFileRevision, version]); + const reviewCacheKey = JSON.stringify([ + diffCacheClientId, + workspaceMutationId, + savedFileRevision, + version, + ]); const refreshBranchDiffPreview = useCallback(() => { const next = Math.max(Date.now(), (diffRefreshVersions.get(reviewScope) ?? 0) + 1); if (diffRefreshVersions.size >= 64 && !diffRefreshVersions.has(reviewScope)) From 907929d32962de51d9ad3777b2609fac6729047a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 05:48:51 +0000 Subject: [PATCH 032/215] test(server): the MCP credential channel test reads the runner service from its module --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 72273b4787b89..1212a1d35a0c3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1973,7 +1973,7 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { }; const openWith = (cli: ReturnType) => Effect.gen(function* () { - const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner; + const runner = yield* ClaudeAgentSdkQueryRunnerService.ClaudeAgentSdkQueryRunner; return yield* runner.open({ threadId: ThreadId.make("thread-claude-mcp-channel"), providerSessionId: ProviderSessionId.make("provider-session-claude-mcp-channel"), From b70737cc83d3628ecc279cd3cb3502605227bcfc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 05:48:51 +0000 Subject: [PATCH 033/215] docs(desktop): the updater layers are wired in application.ts --- docs/operations/release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operations/release.md b/docs/operations/release.md index 6be0d8fd90fea..9f620a928f659 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -396,7 +396,7 @@ available. - Updater runtime: `apps/desktop/src/updates/DesktopUpdates.ts`. - `electron-updater` adapter: `apps/desktop/src/electron/ElectronUpdater.ts`. -- `apps/desktop/src/main.ts` only wires the updater layers into the desktop runtime. +- `apps/desktop/src/application.ts` only wires the updater layers into the desktop runtime. - Update UX: - Background checks run on startup delay + interval. - No automatic download or install. From 655db457378ba7f1e51ea07962feeb68a707839e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 05:50:42 +0000 Subject: [PATCH 034/215] test(web): the find bar canvas test passes the composer overlay as a ref --- apps/web/src/components/chat/ChatCanvas.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatCanvas.test.tsx b/apps/web/src/components/chat/ChatCanvas.test.tsx index 5ab43b81cb2e1..7359f066684f7 100644 --- a/apps/web/src/components/chat/ChatCanvas.test.tsx +++ b/apps/web/src/components/chat/ChatCanvas.test.tsx @@ -78,7 +78,7 @@ afterEach(async () => { async function renderFind(open: boolean, previewX = 1548) { await act(() => root.render( - + {open ?
    : null} , From 938d5cbdd5bec26f798294d69ed9a30c0df4e078 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 00:58:15 +0000 Subject: [PATCH 035/215] perf(web): keep composer typing out of frame renders --- .../components/ComposerPromptEditorTiptap.tsx | 10 +- apps/web/src/components/chat/ChatComposer.tsx | 222 ++++++++++++++---- apps/web/src/components/chat/TraitsPicker.tsx | 13 +- .../components/chat/composerProviderState.tsx | 3 + .../chat/useComposerTriggerState.test.tsx | 39 +++ .../components/chat/useComposerTypingGuard.ts | 33 ++- apps/web/src/composerDraftStore.test.ts | 57 +++++ apps/web/src/composerDraftStore.ts | 82 +++++-- 8 files changed, 386 insertions(+), 73 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 6d6aff5da2c66..a6277b679d6f2 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -990,6 +990,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { return; } + latestValueRef.current = nextValue; snapshotRef.current = { value: nextValue, cursor: nextCursor, @@ -1480,11 +1481,10 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; }, }, - onUpdate: ({ editor: updated }) => { - handleEditorChange(updated); - }, - onSelectionUpdate: ({ editor: updated }) => { - handleEditorChange(updated); + onTransaction: ({ editor: updated, transaction, appendedTransactions }) => { + if ([transaction, ...appendedTransactions].some((tr) => tr.docChanged || tr.selectionSet)) { + handleEditorChange(updated); + } }, }, [], diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0207a5c1042d0..98875534b80a2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,4 @@ +import { useShallow } from "zustand/react/shallow"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; @@ -119,7 +120,8 @@ import { composerTargetKey, hydrateImagesFromPersisted, useComposerDraftStore, - useComposerThreadDraft, + useComposerThreadDraftSelector, + type ComposerThreadTarget, useEffectiveComposerModelState, } from "../../composerDraftStore"; import { @@ -205,6 +207,7 @@ import { measureRestingComposerControls } from "./restingComposerControlsMeasure import { type ComposerCitationCommentRequest, type ComposerPromptEditorHandle, + type ComposerPromptEditorProps, ComposerPromptEditor, } from "../ComposerPromptEditor"; import { @@ -1443,6 +1446,81 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( // Handle exposed to ChatView // -------------------------------------------------------------------------- +const ComposerDraftEditor = memo(function ComposerDraftEditor({ + target, + promptRef, + readCursor, + value: overriddenValue, + ...props +}: Omit & { + target: ComposerThreadTarget; + promptRef: React.RefObject; + readCursor: () => number; + value: string | undefined; +}) { + "use no memo"; // The caret is imperative; caching readCursor() would rewind typed text. + const draftPrompt = useComposerDraftStore.getState().getComposerDraft(target)?.prompt ?? ""; + const locallyEditedPrompt = useRef(null); + const externalPrompt = useRef(draftPrompt); + // Local transactions have already painted in ProseMirror. Only external rewrites + // (recall, chips, reload, send) need to send a controlled value through React. + useComposerThreadDraftSelector(target, (draft) => { + if (draft.prompt !== locallyEditedPrompt.current) externalPrompt.current = draft.prompt; + return externalPrompt.current; + }); + const value = overriddenValue ?? draftPrompt; + const onChange = useCallback( + (...args) => { + locallyEditedPrompt.current = args[0]; + props.onChange(...args); + }, + [props.onChange], + ); + useLayoutEffect(() => { + promptRef.current = value; + }, [promptRef, value]); + return ( + + ); +}); + +const ComposerDraftPromptText = memo(function ComposerDraftPromptText({ + target, + placeholder, +}: { + target: ComposerThreadTarget; + placeholder: string; +}) { + const prompt = useComposerThreadDraftSelector(target, (draft) => draft.prompt); + return prompt.trim() || placeholder; +}); + +const ComposerDraftTraits = memo(function ComposerDraftTraits({ + target, + menu = false, + ...input +}: Omit[0], "prompt"> & { + target: ComposerThreadTarget; + menu?: boolean; +}) { + "use no memo"; + useComposerThreadDraftSelector( + target, + useShallow((draft) => [ + getComposerPromptInjectionState(draft.prompt), + getComposerPromptInjectionState(draft.prompt.replace(/^Ultrathink:\s*/i, "")), + ]), + ); + const getPrompt = useCallback( + () => useComposerDraftStore.getState().getComposerDraft(target)?.prompt ?? "", + [target], + ); + const prompt = getPrompt(); + return menu + ? renderProviderTraitsMenuContent({ ...input, prompt, getPrompt }) + : renderProviderTraitsPicker({ ...input, prompt, getPrompt }); +}); + export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; @@ -1843,7 +1921,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ - const composerDraft = useComposerThreadDraft(composerDraftTarget); + // Text belongs to the editor. The frame observes only changes to its presentation. + const composerDraft = useComposerThreadDraftSelector( + composerDraftTarget, + useShallow(({ prompt, ...draft }) => ({ + ...draft, + hasText: prompt.trim().length > 0, + hasMultilineText: prompt.includes("\n"), + promptInjection: getComposerPromptInjectionState(prompt), + inlineContextKey: collectInlineContextIds(prompt).join("\n"), + promptValidation: getComposerPromptLengthValidationMessage(prompt), + hasSendableText: deriveComposerSendState({ + prompt, + imageCount: 0, + terminalContexts: [], + }).hasSendableContent, + })), + ); // Live target key, for async flows that must notice a thread switch that // happened while they awaited. const composerDraftTargetKeyRef = useRef(""); @@ -1858,14 +1952,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null; const attachmentDraftTarget = questionAttachmentTarget ?? composerDraftTarget; - const attachmentDraft = useComposerThreadDraft(attachmentDraftTarget); + const attachmentDraft = useComposerThreadDraftSelector( + attachmentDraftTarget, + useShallow((draft) => ({ + images: draft.images, + files: draft.files, + nonPersistedImageIds: draft.nonPersistedImageIds, + })), + ); const attachmentTargetKey = composerTargetKey(attachmentDraftTarget); // An import that finishes after a draft change must compare against the draft open *now*, not // the one captured in the closure that started it. const attachmentTargetKeyRef = useRef(attachmentTargetKey); attachmentTargetKeyRef.current = attachmentTargetKey; const questionPreparations = useQuestionAttachmentPreparation((state) => state.counts); - const prompt = composerDraft.prompt; + const prompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; const composerImages = attachmentDraft.images; const composerFiles = attachmentDraft.files; // A question answer has no chips: its files live in the question draft and show in the @@ -2427,10 +2529,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ - const [composerCursor, setComposerCursor] = useState(() => { + const [composerCursor, updateComposerCursor] = useState(() => { const value = isLiteralPendingAnswer ? (activePendingProgress?.customAnswer ?? prompt) : prompt; return collapseExpandedComposerCursor(value, value.length, isLiteralPendingAnswer); }); + const composerCursorRef = useRef(composerCursor); + const setComposerCursor = useCallback((cursor: number | ((current: number) => number)) => { + const next = typeof cursor === "function" ? cursor(composerCursorRef.current) : cursor; + composerCursorRef.current = next; + updateComposerCursor(next); + }, []); + const readComposerCursor = useCallback(() => composerCursorRef.current, []); const { trigger: composerTrigger, setTrigger: setComposerTrigger, @@ -2466,7 +2575,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const [composerMenuAnchor, setComposerMenuAnchor] = useElementState(); const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor); - const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; + const hasMultilinePrompt = composerDraft.hasMultilineText || hasWrappedPrompt; const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -2983,19 +3092,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ], ); - const providerTraitsMenuContent = renderProviderTraitsMenuContent({ - provider: selectedProvider, - instanceId: selectedInstanceId, - ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), - ...(routeKind === "draft" && draftId ? { draftId } : {}), - model: selectedModel, - models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedInstanceId], - reportedModelSelection, - prompt, - onPromptChange: setPromptFromTraits, - planModeEnabled: settings.planModeEnabled, - }); + const providerTraitsMenuContent = ( + + ); const providerTraitsPickerInput = { provider: selectedProvider, instanceId: selectedInstanceId, @@ -3005,12 +3119,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedInstanceId], reportedModelSelection, - prompt, onPromptChange: setPromptFromTraits, planModeEnabled: settings.planModeEnabled, isComposerOwned: true, - } satisfies Parameters[0]; - const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); + } satisfies Omit[0], "prompt">; + const providerTraitsPicker = ( + + ); const [inlineRestingControlsHost, setInlineRestingControlsHost] = useElementState(); const { @@ -3045,7 +3160,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ], ); const showResumeAction = - canResume && !composerDraftHasUserContent(composerDraft) && !isEditingQueuedMessage; + canResume && + !composerDraftHasUserContent({ ...composerDraft, prompt }) && + !isEditingQueuedMessage; const collapsedComposerPrimaryActionDisabled = phase === "running" || isSendBusy || @@ -3740,6 +3857,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } onTypingGuardDraftChange(); promptRef.current = nextPrompt; + composerCursorRef.current = nextCursor; setPrompt(nextPrompt); // Any edit ends browsing, even one later undone by hand: typing a // character and deleting it leaves the text equal to the recall, and @@ -3837,10 +3955,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (attachmentChanges.filesToRestore.length > 0) { addComposerDraftFiles(attachmentDraftTarget, attachmentChanges.filesToRestore); } - setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); + setIsStashMenuOpen(false); + setProviderInputSubmissionError(null); + setComposerSubmissionError((current) => + current === null ? null : getComposerPromptLengthValidationMessage(nextPrompt), + ); }, [ activePendingProgress?.activeQuestion, @@ -3979,10 +4101,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return { value: promptRef.current, - cursor: composerCursor, + cursor: composerCursorRef.current, expandedCursor: expandCollapsedComposerCursor( promptRef.current, - composerCursor, + composerCursorRef.current, isLiteralPendingAnswer, ), contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current), @@ -5461,11 +5583,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const iconOnlyBlockCount = composerControlsCollapsed ? restingControlsIconOnlyBlockCount : expandedControlsLayout.iconOnlyBlockCount; - const restingProviderTraitsPicker = renderProviderTraitsPicker({ - ...providerTraitsPickerInput, - size: composerControlsCollapsed ? "xs" : "sm", - hidden: composerControlsHidden || restingHiddenBlockCount > 1, - }); + const restingProviderTraitsPicker = ( + 1, + }} + /> + ); const restingBlockDefs = [ ...(providerTraitsPicker ? [ @@ -6969,15 +7096,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) disabled={isChoiceOnlyPendingQuestion} aria-label="Expand composer" > - {activePendingProgress - ? isChoiceOnlyPendingQuestion - ? "Choose an option above" - : activePendingProgress.customAnswer || - "Type your own answer, or leave this blank to use the selected option" - : prompt.trim() || - (showProviderUnavailable - ? "Enable a provider in Settings" - : "Ask anything...")} + {activePendingProgress ? ( + isChoiceOnlyPendingQuestion ? ( + "Choose an option above" + ) : ( + activePendingProgress.customAnswer || + "Type your own answer, or leave this blank to use the selected option" + ) + ) : ( + + )} {collapsedComposerImagePreviews}