From 623ad18a44ebca0afcf776ab5f8de99cad26749c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:39:21 +0000 Subject: [PATCH 001/143] perf(server): start the Claude CLI while the baseline checkpoint is captured The CLI used to spawn only after the baseline checkpoint, so a new thread waited for git and then for the CLI boot in turn. Adapters can now start native work in prepareTurn without delivering the prompt. Claude opens its query there, and startTurn adopts that process when it would open the same one. The prompt is still sent only after the baseline exists. --- .../Adapters/ClaudeAdapterV2.test.ts | 102 ++++++++ .../Adapters/ClaudeAdapterV2.ts | 222 +++++++++++++----- .../ProviderSessionManager.ts | 9 + .../orchestration-v2/RunExecutionService.ts | 93 +++++--- .../src/server/ProviderAdapter.ts | 11 + 5 files changed, 339 insertions(+), 98 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9c4e336815f76..d7d08b67b8f93 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1045,6 +1045,108 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); }); +describe("ClaudeAdapterV2 prepareTurn", () => { + const runPrepareScenario = (input: { readonly startModel: string }) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-prepare-turn-", + }); + const queries: Array<{ offers: number; closed: boolean; model: string | undefined }> = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + crypto: yield* Crypto.Crypto, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-prepare-turn"), + open: (openInput) => + Effect.sync(() => { + const query = { offers: 0, closed: false, model: openInput.options.model }; + queries.push(query); + return { + messages: Stream.never, + offer: () => + Effect.sync(() => { + query.offers += 1; + }), + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Effect.sync(() => { + query.closed = true; + }), + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-prepare-turn"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-prepare-turn"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const now = yield* DateTime.now; + const turnInput = (model: string) => + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-prepare-turn"), + text: "Reply with ok.", + attachments: [], + modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model }, + }); + assert.isFunction(runtime.prepareTurn); + yield* runtime.prepareTurn!(turnInput(CLAUDE_TEST_MODEL_SELECTION.model)); + // The CLI is started, but nothing reaches it before the turn starts. + assert.deepEqual( + queries.map((query) => query.offers), + [0], + ); + yield* runtime.startTurn(turnInput(input.startModel)); + return queries; + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("starts the turn on the process it started before the prompt", () => + Effect.gen(function* () { + const queries = yield* runPrepareScenario({ startModel: CLAUDE_TEST_MODEL_SELECTION.model }); + assert.equal(queries.length, 1); + assert.equal(queries[0]?.offers, 1); + }), + ); + + it.effect("replaces a prepared process the turn would not have opened", () => + Effect.gen(function* () { + const queries = yield* runPrepareScenario({ startModel: "claude-haiku-4-5" }); + assert.equal(queries.length, 2); + assert.isTrue(queries[0]?.closed); + assert.equal(queries[0]?.offers, 0); + assert.equal(queries[1]?.offers, 1); + }), + ); +}); + describe("ClaudeAdapterV2 approval cancellation", () => { it.effect("observes an approval signal that was already aborted", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c7b1fb0d58190..5953c54405529 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1647,6 +1647,19 @@ export function claudeEffectiveQueryPolicyKey( }); } +// Identifies how a CLI process was opened, so a process started before its +// prompt is reused only by a turn that would open it the same way. +function claudeQueryOpenKey(input: { + readonly nativeThreadId: string; + readonly queryPolicyKey: string; + readonly selectionKey: string; + readonly shouldResume: boolean; + readonly resumeSessionAt: string | null; + readonly cwd: string | null; +}): string { + return JSON.stringify(input); +} + type ClaudeToolItemType = Extract< OrchestrationV2TurnItem["type"], "command_execution" | "file_change" | "dynamic_tool" | "web_search" @@ -3079,6 +3092,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const interruptedTurns = yield* Ref.make(new Set()); const steeredTurns = yield* Ref.make(new Set()); const queryContext = yield* Ref.make(null); + // A CLI started by `prepareTurn` before its first prompt exists. The + // next `openQuery` with the same open key adopts it; any other closes it. + const prewarmedQuery = yield* Ref.make<{ + readonly key: string; + readonly query: ClaudeAgentSdkQuerySession; + } | null>(null); const openedNativeThreads = yield* Ref.make(new Set()); const latestPlanByKind = yield* Ref.make(new Map()); const planIdsByNativeItem = yield* Ref.make( @@ -7257,7 +7276,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; }); - const openQuery = Effect.fnUntraced(function* ( + // Everything a new CLI process for this turn is opened with. `openKey` + // covers every input that varies by turn, so a prewarmed process is + // adopted only when it was opened exactly as this turn would open one. + const planQueryOpen = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, ) { @@ -7273,7 +7295,75 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); + const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); + // openedNativeThreads is per session instance and is lost when the + // provider session is idle-released. A prior turn on this native id + // requires resume; sessionId would fail with "already in use". + // A fresh-session fallback keeps provider-thread history but binds + // a new native id, which must be created before it can be resumed. + const hasPersistedProviderTurn = + turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; + const shouldResume = + resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const queryOptions = makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + attachmentsDir, + settings: adapterOptions.settings, + environment: { ...adapterOptions.environment, ...mcpOverrides.mcpEnvironment }, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...(mcpOverrides.allowedTools === undefined + ? {} + : { allowedTools: mcpOverrides.allowedTools }), + ...(mcpOverrides.mcpServers === undefined + ? {} + : { mcpServers: mcpOverrides.mcpServers }), + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], + }); + return { + queryPolicyKey, + selectionKey: compiledSelection.queryIdentity, + queryOptions, + openKey: claudeQueryOpenKey({ + nativeThreadId, + queryPolicyKey, + selectionKey: compiledSelection.queryIdentity, + shouldResume, + resumeSessionAt: resumeSessionAt ?? null, + cwd: turnInput.runtimePolicy.cwd ?? null, + }), + }; + }); + + const closePrewarmedQuery = Ref.getAndSet(prewarmedQuery, null).pipe( + Effect.flatMap((prewarmed) => + prewarmed === null ? Effect.void : prewarmed.query.close.pipe(Effect.ignore), + ), + ); + + const openQuery = Effect.fnUntraced(function* ( + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + nativeThreadId: string, + ) { + const plan = yield* planQueryOpen(turnInput, nativeThreadId); + const { queryPolicyKey } = plan; const existing = yield* Ref.get(queryContext); + if (existing !== null) { + // `prepareTurn` only prewarms while no process is live, so a + // prewarmed process here is stale whatever this turn does next. + yield* closePrewarmedQuery; + } // A continuation prompts nothing: it drains output the live process // already produced, so it keeps that process whatever its selection. if ( @@ -7287,7 +7377,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === compiledSelection.queryIdentity + existing.selectionKey === plan.selectionKey ) { // Claude can switch its own mode mid-session (EnterPlanMode), and // a denied ExitPlanMode leaves it there. Put the live process back @@ -7329,65 +7419,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } - const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); - // openedNativeThreads is per session instance and is lost when the - // provider session is idle-released. A prior turn on this native id - // requires resume; sessionId would fail with "already in use". - // A fresh-session fallback keeps provider-thread history but binds - // a new native id, which must be created before it can be resumed. - const hasPersistedProviderTurn = - turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; - const shouldResume = - resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; - const queryOptions = makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - attachmentsDir, - settings: adapterOptions.settings, - environment: { ...adapterOptions.environment, ...mcpOverrides.mcpEnvironment }, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...(mcpOverrides.allowedTools === undefined - ? {} - : { allowedTools: mcpOverrides.allowedTools }), - ...(mcpOverrides.mcpServers === undefined - ? {} - : { mcpServers: mcpOverrides.mcpServers }), - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { - allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, - }), - canUseTool, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }); - const querySession = yield* queryRunner - .open({ - threadId: turnInput.threadId, - providerSessionId: input.providerSessionId, - options: queryOptions, - }) - .pipe( - // An interrupted open leaves the old process just as dead. - Effect.onError(() => - // Same-native-thread replacement: the old process is already - // dead, so its process-scoped roster is not authoritative. - // First-ever failed open (no prior live query) must not invent - // native-session reset events. - closedExistingNativeThreadId === nativeThreadId - ? Effect.gen(function* () { - yield* clearWakeStateForNativeThread(nativeThreadId); - yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { - status: "idle", - }); - }) - : Effect.void, - ), - ); + const { queryOptions } = plan; + const prewarmed = yield* Ref.getAndSet(prewarmedQuery, null); + if (prewarmed !== null && prewarmed.key !== plan.openKey) { + yield* prewarmed.query.close.pipe(Effect.ignore); + } + const querySession = yield* ( + prewarmed !== null && prewarmed.key === plan.openKey + ? Effect.succeed(prewarmed.query) + : queryRunner.open({ + threadId: turnInput.threadId, + providerSessionId: input.providerSessionId, + options: queryOptions, + }) + ).pipe( + // An interrupted open leaves the old process just as dead. + Effect.onError(() => + // Same-native-thread replacement: the old process is already + // dead, so its process-scoped roster is not authoritative. + // First-ever failed open (no prior live query) must not invent + // native-session reset events. + closedExistingNativeThreadId === nativeThreadId + ? Effect.gen(function* () { + yield* clearWakeStateForNativeThread(nativeThreadId); + yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { + status: "idle", + }); + }) + : Effect.void, + ), + ); // Marked only after a successful open: a failed create must not // leave the runtime believing the native session exists, or the // retry would resume a session that was never created. @@ -7414,7 +7475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId, query: querySession, queryPolicyKey, - selectionKey: compiledSelection.queryIdentity, + selectionKey: plan.selectionKey, closed, promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, @@ -7472,6 +7533,41 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return context; }); + // Starts the CLI process for a turn that will need a new one, without a + // prompt. The SDK spawns the CLI when the query opens and the CLI emits + // nothing until it reads a prompt, so its ~1.3 s boot overlaps the + // baseline checkpoint. `startTurn` adopts it through `openQuery`. + const prepareTurn = Effect.fn("ClaudeAdapterV2.prepareTurn")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + if ((yield* Ref.get(queryContext)) !== null || (yield* Ref.get(activeTurn)) !== null) { + return; + } + const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); + const plan = yield* planQueryOpen(turnInput, nativeThreadId); + if ((yield* Ref.get(prewarmedQuery))?.key === plan.openKey) return; + const query = yield* queryRunner.open({ + threadId: turnInput.threadId, + providerSessionId: input.providerSessionId, + options: plan.queryOptions, + }); + const replaced = yield* Ref.getAndSet(prewarmedQuery, { key: plan.openKey, query }); + if (replaced !== null) yield* replaced.query.close.pipe(Effect.ignore); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: CLAUDE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ); + const startTurn = Effect.fn("ClaudeAdapterV2.startTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { const startedAt = yield* DateTime.now; @@ -7809,6 +7905,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (existing !== null) { yield* existing.query.close.pipe(Effect.ignore); } + yield* closePrewarmedQuery; yield* Effect.yieldNow; yield* queryRunner.assertComplete.pipe( Effect.catchCause((cause) => @@ -7949,6 +8046,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ), ), + prepareTurn, startTurn, compactThread: (turnInput) => startTurn({ diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 53c1c06f13ef0..caab93c765ddb 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1669,6 +1669,7 @@ export const layerWithOptions = ( >, ): ProviderAdapter.ProviderAdapterV2SessionRuntime => { const providerSessionId = runtime.providerSessionId; + const prepareTurn = runtime.prepareTurn; const subscribeEvents = makeEventSubscription(eventSubscribers); // Every provider's turn operations pass through here, so this is where they are // counted. Only turn starts are timed: until the provider accepts the turn. @@ -1828,6 +1829,14 @@ export const layerWithOptions = ( ), ), ), + ...(prepareTurn === undefined + ? {} + : { + prepareTurn: (input: ProviderAdapter.ProviderAdapterV2TurnInput) => + observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( + Effect.andThen(prepareTurn(input)), + ), + }), steerTurn: (input) => observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( Effect.andThen(runtime.steerTurn(input).pipe(turnMetrics("steer"))), diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a316ce0ccd621..1b30ba23a9e51 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -830,6 +830,27 @@ export const layer: Layer.Layer< failure, threadDisposition: "reusable", }); + const baseTurnInput: ProviderAdapter.ProviderAdapterV2TurnInput = { + appThread: input.appThread, + threadId: input.run.threadId, + runId: input.run.id, + runOrdinal: input.run.ordinal, + providerTurnOrdinal: input.providerTurnOrdinal, + ...(input.nativeThreadHasTurns === undefined + ? {} + : { nativeThreadHasTurns: input.nativeThreadHasTurns }), + ...(input.run.restartContinuationOfRunId === undefined + ? {} + : { + restartContinuationOfRunId: input.run.restartContinuationOfRunId, + }), + attemptId: input.attemptId, + rootNodeId: input.rootNode.id, + providerThread: input.providerThread, + message: input.message, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + }; const responseStreamingMode = yield* Effect.gen(function* () { const responseStreamingMode = yield* serverSettings.getSettings.pipe( Effect.map( @@ -838,21 +859,41 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); - 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 }, + // The prompt is delivered only after the baseline exists. Native + // startup that cannot act on the workspace (a CLI boot) runs beside it. + const prepareTurn = input.session.prepareTurn; + yield* Effect.all( + [ + 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 }, + ), + ), + ), + prepareTurn === undefined + ? Effect.void + : prepareTurn(baseTurnInput).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning( + "orchestration V2 provider turn preparation failed; starting it with the turn", + { runId: input.run.id, cause: Cause.pretty(cause) }, + ), ), - ), - ); + ), + ], + { concurrency: "unbounded", discard: true }, + ); if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) @@ -1374,28 +1415,8 @@ export const layer: Layer.Layer< key: `mcp_app_${entry.itemId.replace(/[^\w.-]/g, "_")}`, text: entry.text, })); - const turnInput = { - appThread: input.appThread, - threadId: input.run.threadId, - runId: input.run.id, - runOrdinal: input.run.ordinal, - providerTurnOrdinal: input.providerTurnOrdinal, - ...(input.nativeThreadHasTurns === undefined - ? {} - : { nativeThreadHasTurns: input.nativeThreadHasTurns }), - ...(input.run.restartContinuationOfRunId === undefined - ? {} - : { - restartContinuationOfRunId: input.run.restartContinuationOfRunId, - }), - attemptId: input.attemptId, - rootNodeId: input.rootNode.id, - providerThread: input.providerThread, - message: input.message, - modelSelection: input.modelSelection, - runtimePolicy: input.runtimePolicy, - ...(appContext.length === 0 ? {} : { appContext }), - }; + const turnInput = + appContext.length === 0 ? baseTurnInput : { ...baseTurnInput, appContext }; const compact = input.message.attachments.length === 0 && input.message.text.trim().toLowerCase() === "/compact"; diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 086300e21878e..24952b03fbb08 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -584,6 +584,17 @@ export interface ProviderAdapterV2SessionRuntime { readonly providerThread: OrchestrationV2ProviderThread; }, ) => Effect.Effect; + /** + * Starts native work the coming `startTurn` would wait for (such as spawning + * a CLI process) without sending the prompt, so it overlaps the baseline + * checkpoint. It must not deliver the prompt, so the agent cannot act before + * the baseline exists. `startTurn` with the same input reuses what it + * started. Best effort: a + * failure leaves `startTurn` to do the work itself. + */ + readonly prepareTurn?: ( + input: ProviderAdapterV2TurnInput, + ) => Effect.Effect; readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; From e9f2861e648b528d1760b6ef5578fd39e1a250bf Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:39:30 +0000 Subject: [PATCH 002/143] perf(server): fewer sequential git processes per checkpoint Repository detection runs one rev-parse instead of three, and auto and git detection share one cache entry, so a checkpoint no longer re-detects a repository the status poll already knows. Checkpoint capture reads the common dir, HEAD, sparse config and index path in one parallel round, and the post-turn capture checks the previous ref while it writes the new one. --- .../src/orchestration-v2/CheckpointService.ts | 63 ++++++++++-------- apps/server/src/vcs/GitVcsDriver.ts | 65 +++++++++++++++---- apps/server/src/vcs/VcsDriverRegistry.test.ts | 22 +++++-- apps/server/src/vcs/VcsDriverRegistry.ts | 8 ++- 4 files changed, 110 insertions(+), 48 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 913e53658e2c4..d2a45af08f66f 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -394,21 +394,42 @@ export const layer: Layer.Layer< }); } - const captured = yield* checkpointStore - .captureCheckpoint({ - cwd: input.scope.cwd, - checkpointRef, - }) - .pipe( - Effect.as(true), - Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint capture failed", { - scopeId: input.scope.id, + // The previous ref is read while this one is written, so the lookup + // costs no extra round of Git processes on the way to "done". + const [captured, previousExists] = yield* Effect.all( + [ + checkpointStore + .captureCheckpoint({ + cwd: input.scope.cwd, checkpointRef, - cause: String(cause), - }).pipe(Effect.as(false)), - ), - ); + }) + .pipe( + Effect.as(true), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint capture failed", { + scopeId: input.scope.id, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as(false)), + ), + ), + checkpointStore + .hasCheckpointRef({ + cwd: input.scope.cwd, + checkpointRef: previousCheckpointRef, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration V2 previous checkpoint ref lookup failed", { + scopeId: input.scope.id, + checkpointRef: previousCheckpointRef, + cause: String(cause), + }).pipe(Effect.as(false)), + ), + ), + ], + { concurrency: "unbounded" }, + ); if (!captured) { return makeCheckpoint({ @@ -426,20 +447,6 @@ export const layer: Layer.Layer< }); } - const previousExists = yield* checkpointStore - .hasCheckpointRef({ - cwd: input.scope.cwd, - checkpointRef: previousCheckpointRef, - }) - .pipe( - Effect.catch((cause) => - Effect.logWarning("orchestration V2 previous checkpoint ref lookup failed", { - scopeId: input.scope.id, - checkpointRef: previousCheckpointRef, - cause: String(cause), - }).pipe(Effect.as(false)), - ), - ); const files = previousExists ? yield* checkpointStore .diffCheckpoints({ diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dcb..bfc80b022b29c 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -580,9 +580,36 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { - if (!(yield* isInsideWorkTree(cwd))) { + // One process answers the usual work tree. `--is-inside-work-tree` prints + // first, so anything but "true" means no work tree. Output that does not + // parse as one path per line takes the separate probes below. + const combined = yield* gitCommand( + vcsProcess, + "GitVcsDriver.detectRepository", + cwd, + ["rev-parse", "--is-inside-work-tree", "--show-toplevel", "--git-common-dir"], + { allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 64 * 1024 }, + ); + const [insideWorkTree, rootPath, metadataPath, ...rest] = combined.stdout + .replace(/\n$/, "") + .split("\n"); + if (insideWorkTree?.trim() !== "true") { return null; } + if ( + combined.exitCode === 0 && + rootPath !== undefined && + rootPath.trim().length > 0 && + metadataPath !== undefined && + rest.length === 0 + ) { + return { + kind: "git" as const, + rootPath: rootPath.trim(), + metadataPath: metadataPath.trim() || null, + freshness: yield* nowFreshness(), + }; + } const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ "rev-parse", @@ -816,7 +843,27 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-c", "sparse.expectFilesOutsideOfPatterns=false", ]; - const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + // 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( + [ + resolveGitCommonDir(input.cwd), + hasHeadCommit(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 = { @@ -836,13 +883,6 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ); yield* Effect.gen(function* () { - const headExists = yield* hasHeadCommit(input.cwd); - const sparseConfig = yield* execute({ - operation, - cwd: input.cwd, - args: ["config", "--bool", "core.sparseCheckout"], - allowNonZeroExit: true, - }); let sparseCheckout = sparseConfig.stdout.trim() === "true"; if (sparseCheckout) { const help = yield* execute({ @@ -855,11 +895,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } if (headExists) { const reusedIndex = yield* Effect.gen(function* () { - const indexPath = yield* execute({ - operation, - cwd: input.cwd, - args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], - }); + if (Option.isNone(sourceIndexPath)) return false; + const indexPath = sourceIndexPath.value; const { mtime } = 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. diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index d302ef106ac17..46b08cfcc385e 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -74,6 +74,10 @@ describe("VcsDriverRegistry", () => { stderr: "fatal: not a git repository", }; } + if (command === "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir") { + const commonDir = path.relative(input.cwd, path.join(repoDir, ".git")); + return processOutput(`true\n${repoDir}\n${commonDir}\n`); + } if (command === "rev-parse --is-inside-work-tree") return processOutput("true\n"); if (command === "rev-parse --show-toplevel") return processOutput(`${repoDir}\n`); if (command === "rev-parse --git-common-dir") { @@ -97,13 +101,15 @@ describe("VcsDriverRegistry", () => { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const first = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); const second = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); + // Auto detection resolves to git, so it reuses the same entry. + const auto = yield* registry.resolve({ cwd: repoDir }); assert.equal(first.repository.rootPath, repoDir); assert.equal(second.repository.rootPath, repoDir); + assert.equal(auto.repository.rootPath, repoDir); + assert.equal(first.repository.metadataPath, ".git"); assert.deepStrictEqual(calls, [ - "rev-parse --is-inside-work-tree", - "rev-parse --show-toplevel", - "rev-parse --git-common-dir", + "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir", ]); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), @@ -124,7 +130,10 @@ describe("VcsDriverRegistry", () => { yield* fs.remove(path.join(repoDir, ".git"), { recursive: true }); assert.equal(yield* registry.detect({ cwd: repoDir }), null); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal( + calls.filter((call) => call.startsWith("rev-parse --is-inside-work-tree")).length, + 2, + ); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -189,7 +198,10 @@ describe("VcsDriverRegistry", () => { yield* fs.makeDirectory(path.join(repoDir, ".git")); assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal( + calls.filter((call) => call.startsWith("rev-parse --is-inside-work-tree")).length, + 2, + ); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 35275d819a2e5..fae48a30ee639 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -45,7 +45,13 @@ function detectionCacheKey(input: { readonly cwd: string; readonly requestedKind: VcsDriverKind | "auto"; }): string { - return `${input.requestedKind}\0${input.cwd}`; + // Auto detection only tries git (see `detectResolvedKind`), so it shares the + // git entry: status polls ask for "auto" and checkpoints for "git". + const kind = + input.requestedKind === "auto" || input.requestedKind === "unknown" + ? "git" + : input.requestedKind; + return `${kind}\0${input.cwd}`; } function parseDetectionCacheKey(key: string): { From 240ebab04b79ee0fa3788dc3db59524d1c18096f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:39:30 +0000 Subject: [PATCH 003/143] perf(server): refresh VCS status after a turn without holding the thread lane The run is completed once its checkpoint commits. The workspace and VCS status refresh that followed took about two seconds on the thread's effect lane, which delayed the next turn's start. It now runs beside the lane and logs its failures. --- .../RunFinalizationService.test.ts | 18 ++++++++++++- .../RunFinalizationService.ts | 26 +++++++++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 9e43c7ddd30ab..ab5923c8362eb 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -5,6 +5,7 @@ import { ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -20,7 +21,16 @@ it.effect("refreshes workspace after checkpoint capture without reading history" const runId = RunId.make("run_finalize"); const scopeId = CheckpointScopeId.make("scope_finalize"); const capture = vi.fn(() => Effect.void); - const refresh = vi.fn(() => Effect.void); + const refreshStarted = Deferred.makeUnsafe(); + const releaseRefresh = Deferred.makeUnsafe(); + const refreshFinished = Deferred.makeUnsafe(); + const refresh = vi.fn(() => + Deferred.succeed(refreshStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRefresh)), + Effect.andThen(Deferred.succeed(refreshFinished, undefined)), + Effect.asVoid, + ), + ); const checkpointContext = { runs: [], checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], @@ -44,9 +54,15 @@ it.effect("refreshes workspace after checkpoint capture without reading history" ); return Effect.gen(function* () { const service = yield* RunFinalization.RunFinalizationService; + // Finalize returns while the refresh is still blocked: the thread's effect + // lane is free for the next turn as soon as the checkpoint commits. yield* service.finalize({ threadId, runId, scopeId }); assert.equal(capture.mock.calls.length, 1); + yield* Deferred.await(refreshStarted); assert.deepEqual(refresh.mock.calls[0], [{ cwd: "/repo", threadId, runId }]); + assert.isFalse(yield* Deferred.isDone(refreshFinished)); + yield* Deferred.succeed(releaseRefresh, undefined); + yield* Deferred.await(refreshFinished); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index d56ae20dc9ef2..0cceb064aa113 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -1,4 +1,5 @@ import { CheckpointScopeId, ProjectId, RunId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -52,6 +53,7 @@ const make = Effect.gen(function* () { const checkpointCapture = yield* CheckpointCapture.CheckpointCaptureServiceV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const observer = yield* RunFinalizationObserver; + const refreshScope = yield* Effect.scope; const finalize: RunFinalizationService["Service"]["finalize"] = Effect.fn( "RunFinalizationService.finalize", @@ -72,14 +74,22 @@ const make = Effect.gen(function* () { ); const cwd = projection.checkpointScopes.find((scope) => scope.id === input.scopeId)?.cwd; if (cwd !== undefined) { - yield* observer - .refresh({ cwd, threadId: input.threadId, runId: input.runId }) - .pipe( - Effect.mapError( - (cause) => - new RunFinalizationError({ ...input, operation: "refresh-workspace", cause }), - ), - ); + // The run is already completed and its checkpoint committed. The VCS and + // file-list refresh only updates client caches, so it runs beside the + // thread's effect lane instead of holding the next turn back for seconds. + yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning("failed to refresh workspace after run completion", { + threadId: input.threadId, + runId: input.runId, + cwd, + cause: Cause.pretty(cause), + }), + ), + Effect.forkIn(refreshScope), + ); } }); return RunFinalizationService.of({ finalize }); From d8fefcffdc583de40777e1fb570e84b8d281862f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:39:30 +0000 Subject: [PATCH 004/143] perf(server): show an item's first streamed text without the 50 ms hold Text and reasoning deltas are coalesced into one update per 50 ms. The first delta of an item now flushes on the next tick, so the first token is not delayed; later deltas still coalesce. --- .../Adapters/CodexAdapterV2.test.ts | 30 +++++++++++++++++++ .../Adapters/ProviderTextDeltaCoalescer.ts | 26 ++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 5b18f436c26e3..14e9e8f4ba7d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -224,6 +224,36 @@ describe("CodexAdapterV2 assistant message streaming", () => { }), ); + it.effect("shows an item's first text without waiting for the flush interval", () => + Effect.gen(function* () { + const updates = yield* Ref.make>([]); + const coalescer = yield* makeProviderTextDeltaCoalescer({ + flushIntervalMs: 50, + emit: (update) => Ref.update(updates, (current) => [...current, update]), + }); + + yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "first" }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.deepEqual( + (yield* Ref.get(updates)).map((update) => update.text), + ["first"], + ); + + // Later deltas of the same item wait for the interval and coalesce. + yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " second" }); + yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " third" }); + yield* Effect.yieldNow; + assert.equal((yield* Ref.get(updates)).length, 1); + yield* TestClock.adjust("50 millis"); + yield* Effect.yieldNow; + assert.deepEqual( + (yield* Ref.get(updates)).map((update) => update.text), + ["first", "first second third"], + ); + }), + ); + it.effect("coalesces multiple token deltas into one assistant update per interval", () => Effect.gen(function* () { const updates = yield* Ref.make>([]); diff --git a/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts b/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts index 3729cb7073f9e..63eca9377d767 100644 --- a/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts +++ b/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts @@ -102,9 +102,9 @@ export const makeProviderTextDeltaCoalescer = Effect.fn("makeProviderTextDeltaCo ? Effect.void : Effect.uninterruptible( Effect.gen(function* () { - const shouldSchedule = yield* flushLock.withPermit( + const [shouldSchedule, firstDelta] = yield* flushLock.withPermit( Effect.gen(function* () { - yield* Ref.update(buffered, (current) => { + const firstDelta = yield* Ref.modify(buffered, (current) => { const key = providerTextBufferKey(turnId, itemId); const existing = current.get(key); const next = new Map(current); @@ -114,17 +114,25 @@ export const makeProviderTextDeltaCoalescer = Effect.fn("makeProviderTextDeltaCo text: `${existing?.text ?? ""}${delta}`, dirty: true, }); - return next; + return [existing === undefined, next] as const; }); - return yield* Ref.modify(flushScheduled, (scheduled) => [!scheduled, true]); + const shouldSchedule = yield* Ref.modify(flushScheduled, (scheduled) => [ + !scheduled, + true, + ]); + return [shouldSchedule, firstDelta] as const; }), ); if (shouldSchedule) { - yield* Effect.sleep(Duration.millis(Math.max(1, input.flushIntervalMs))).pipe( - Effect.andThen(flushDirty), - Effect.interruptible, - Effect.forkIn(coalescerScope), - ); + // An item's first text flushes on the next tick, so the first + // token is not held for the interval; later deltas coalesce. + yield* ( + firstDelta + ? flushDirty + : Effect.sleep(Duration.millis(Math.max(1, input.flushIntervalMs))).pipe( + Effect.andThen(flushDirty), + ) + ).pipe(Effect.interruptible, Effect.forkIn(coalescerScope)); } }), ), From 6ad355d36f4339f1795550f1b701e3886a14fda3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:39:30 +0000 Subject: [PATCH 005/143] perf(server): write a fraction of the trace bytes per minute Startup phase and server fields were inherited span annotations, so every span of the workers forked during startup carried them. They now sit on the startup spans only. The per-link PR sync span and the enrichment cache-read spans are untraced, query text is whitespace-collapsed before clamping, and the trace sink encodes each batch once instead of each record to count bytes. --- .../PullRequestSyncReactor.ts | 4 +- .../src/project/ProjectEnrichmentService.ts | 82 ++++++++++--------- apps/server/src/serverRuntimeStartup.ts | 24 ++++-- packages/shared/src/observability.test.ts | 12 +++ packages/shared/src/observability.ts | 70 ++++++++++------ 5 files changed, 117 insertions(+), 75 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 5f1fcd8269acd..aed8d15f03329 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -215,7 +215,9 @@ export const make = Effect.gen(function* () { const linkedThisSweep = new Set(); const persistence = yield* Semaphore.make(1); - const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + // One call per thread link per sweep (thousands a minute on large installs). + // Its rare dispatches stay traced under the group span. + const syncEntry = Effect.fnUntraced(function* ( entry: LinkEntry, fields: SnapshotFields, fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 1477733d66bd3..36f4b4c461a4e 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -239,48 +239,50 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( } }); - const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fn( - "ProjectEnrichmentService.peek", - )(function* (workspaceRoot) { - const [repositoryIdentity, faviconPath] = yield* Effect.all( - [ - Cache.getSuccess(repositoryIdentityCache, workspaceRoot), - Cache.getSuccess(faviconCache, workspaceRoot), - ] as const, - { concurrency: "unbounded" }, - ); - return { - repositoryIdentity: availableValue(repositoryIdentity), - faviconPath: availableValue(faviconPath), - repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), - }; - }); + // peek, request and getAvailable are cache reads called per project and per + // thread on every sweep. Spans live on the lanes that do the actual work. + const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fnUntraced( + function* (workspaceRoot) { + const [repositoryIdentity, faviconPath] = yield* Effect.all( + [ + Cache.getSuccess(repositoryIdentityCache, workspaceRoot), + Cache.getSuccess(faviconCache, workspaceRoot), + ] as const, + { concurrency: "unbounded" }, + ); + return { + repositoryIdentity: availableValue(repositoryIdentity), + faviconPath: availableValue(faviconPath), + repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), + }; + }, + ); - const request: ProjectEnrichmentService["Service"]["request"] = Effect.fn( - "ProjectEnrichmentService.request", - )(function* (workspaceRoot) { - const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( - [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], - { concurrency: "unbounded" }, - ); - yield* Effect.all( - [ - hasRepositoryIdentity - ? Effect.void - : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), - hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), - ], - { concurrency: "unbounded", discard: true }, - ); - }); + const request: ProjectEnrichmentService["Service"]["request"] = Effect.fnUntraced( + function* (workspaceRoot) { + const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( + [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], + { concurrency: "unbounded" }, + ); + yield* Effect.all( + [ + hasRepositoryIdentity + ? Effect.void + : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), + hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), + ], + { concurrency: "unbounded", discard: true }, + ); + }, + ); - const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fn( - "ProjectEnrichmentService.getAvailable", - )(function* (workspaceRoot) { - const available = yield* peek(workspaceRoot); - yield* request(workspaceRoot); - return available; - }); + const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fnUntraced( + function* (workspaceRoot) { + const available = yield* peek(workspaceRoot); + yield* request(workspaceRoot); + return available; + }, + ); const invalidate: ProjectEnrichmentService["Service"]["invalidate"] = Effect.fn( "ProjectEnrichmentService.invalidate", diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 88aa4298c5961..1ca44979b246c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -344,10 +344,12 @@ const maybeOpenBrowser = (target: string) => ); }); +// Phase and server fields go on the startup spans only. As inherited span +// annotations they were copied onto every span of the workers forked during +// startup (the effect worker, PR sync), which is most of the trace file. const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( - Effect.annotateSpans({ "startup.phase": phase }), - Effect.withSpan(`server.startup.${phase}`), + Effect.withSpan(`server.startup.${phase}`, { attributes: { "startup.phase": phase } }), ); interface StartupOptions { @@ -581,8 +583,9 @@ const make = (options?: StartupOptions) => Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), + Effect.withSpan("server.startup.heartbeat.record", { + attributes: { "startup.phase": "heartbeat.record" }, + }), Effect.ignoreCause({ log: true }), ); if (serverConfig.startupPresentation === "headless") { @@ -653,12 +656,15 @@ const make = (options?: StartupOptions) => yield* Effect.logDebug("startup phase: complete"); yield* flushCompileCache; }).pipe( - Effect.annotateSpans({ - "server.mode": serverConfig.mode, - "server.port": serverConfig.port, - "server.host": serverConfig.host ?? "default", + Effect.withSpan("server.startup", { + kind: "server", + root: true, + attributes: { + "server.mode": serverConfig.mode, + "server.port": serverConfig.port, + "server.host": serverConfig.host ?? "default", + }, }), - Effect.withSpan("server.startup", { kind: "server", root: true }), ); yield* Effect.forkScoped( diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 8959f7787e76d..817a7b9ba989f 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -142,6 +142,18 @@ describe("truncateTraceAttributes", () => { const attributes = { short: "ok", nested: { fine: "also ok" } }; assert.equal(truncateTraceAttributes(attributes), attributes); }); + + it("collapses whitespace in query text before clamping it", () => { + const query = `SELECT payload_json\n FROM orchestration_v2_projection_runs\n WHERE thread_id = ?`; + assert.equal( + truncateTraceAttributes({ "db.query.text": query })["db.query.text"], + "SELECT payload_json FROM orchestration_v2_projection_runs WHERE thread_id = ?", + ); + const long = `SELECT ${"column,\n ".repeat(100)}`; + const clamped = truncateTraceAttributes({ "db.query.text": long })["db.query.text"] as string; + assert.isTrue(clamped.startsWith("SELECT column, column,")); + assert.equal(clamped.length, 200 + "…[truncated]".length); + }); }); describe("decodeOtlpTraceRecords", () => { diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index a8cdb8059f5a0..69a87082c9309 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -37,7 +37,6 @@ export const DEFAULT_SIGNAL_EXPORT: SignalExport = { }; const FLUSH_BUFFER_THRESHOLD = 256; -const textEncoder = new TextEncoder(); export type TraceAttributes = Readonly>; @@ -317,10 +316,17 @@ export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttri let truncated: Record | undefined; for (const [key, value] of Object.entries(attributes)) { if (typeof value === "string" && ALWAYS_TRUNCATED_TRACE_ATTRIBUTES.has(key)) { - if (value.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH) continue; + // Query text is indented source; collapsing whitespace keeps more of the + // statement in fewer bytes on one of the most frequent spans. + const head = value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH * 8); + const compact = head.replace(/\s+/g, " ").trim(); + const fits = + head.length === value.length && compact.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH; + if (fits && compact === value) continue; truncated ??= { ...attributes }; - truncated[key] = - `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + truncated[key] = fits + ? compact + : `${compact.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; continue; } const next = truncateNestedValue(value); @@ -391,10 +397,40 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac const records = buffer; buffer = []; - let persistedCount = 0; + // Writes one chunk of `count` records. A failing disk (ENOSPC, EACCES, EIO) + // drops the rest of the batch: retrying it would grow the backlog, and + // every later push would retry all of it. + const writeChunk = (chunk: Buffer, count: number, remaining: number) => { + const startedAt = performance.now(); + try { + sink.write(chunk); + } catch { + writeFailing = true; + droppedCount += remaining; + return false; + } + writeFailing = false; + pendingFlushStats = { + logicalWriteBytes: pendingFlushStats.logicalWriteBytes + chunk.length, + count: pendingFlushStats.count + count, + durationMs: pendingFlushStats.durationMs + Math.max(0, performance.now() - startedAt), + }; + return true; + }; + + // UTF-8 needs at most three bytes per UTF-16 code unit. A batch whose bound + // fits in one file, the usual case, is encoded once instead of per record. + let lengthBound = 0; + for (const record of records) lengthBound += record.length; + if (lengthBound * 3 <= options.maxBytes) { + writeChunk(Buffer.from(records.join(""), "utf8"), records.length, records.length); + return; + } + + let persistedCount = 0; while (persistedCount < records.length) { - const firstRecordBytes = textEncoder.encode(records[persistedCount]).byteLength; + const firstRecordBytes = Buffer.byteLength(records[persistedCount]!, "utf8"); if (firstRecordBytes > options.maxBytes) { persistedCount += 1; continue; @@ -403,30 +439,14 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac let nextIndex = persistedCount + 1; let chunkBytes = firstRecordBytes; while (nextIndex < records.length) { - const nextRecordBytes = textEncoder.encode(records[nextIndex]).byteLength; + const nextRecordBytes = Buffer.byteLength(records[nextIndex]!, "utf8"); if (chunkBytes + nextRecordBytes > options.maxBytes) break; chunkBytes += nextRecordBytes; nextIndex += 1; } - const chunk = records.slice(persistedCount, nextIndex).join(""); - const startedAt = performance.now(); - try { - sink.write(chunk); - } catch { - // A failing disk (ENOSPC, EACCES, EIO) drops the rest of the batch. - // Retrying it would grow the backlog, and every later push would - // retry all of it. - writeFailing = true; - droppedCount += records.length - persistedCount; - return; - } - writeFailing = false; - pendingFlushStats = { - logicalWriteBytes: pendingFlushStats.logicalWriteBytes + chunkBytes, - count: pendingFlushStats.count + nextIndex - persistedCount, - durationMs: pendingFlushStats.durationMs + Math.max(0, performance.now() - startedAt), - }; + const chunk = Buffer.from(records.slice(persistedCount, nextIndex).join(""), "utf8"); + if (!writeChunk(chunk, nextIndex - persistedCount, records.length - persistedCount)) return; persistedCount = nextIndex; } }; From aa3192920ad88715e1d120bda2832092258824e8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:46:28 +0000 Subject: [PATCH 006/143] perf(server): send each checkpoint's file list once in bounded snapshots A checkpoint turn item repeats its checkpoint's file list. On the largest thread that list had 13k files, so the bounded snapshot carried 1.7 MB twice. Clients that opt in (compactCheckpointItems over HTTP, acceptCompactCheckpointItems over the socket) get those items with empty files plus the ids to refill from projection.checkpoints. Older clients and older servers keep the unchanged shape. --- .../src/orchestration-v2/ThreadStream.ts | 18 +++- .../boundedSnapshotTransport.test.ts | 97 +++++++++++++++++++ apps/server/src/orchestration-v2/http.ts | 1 + apps/server/src/ws.ts | 2 + .../state/boundedThreadSnapshotHttp.test.ts | 1 + .../src/state/boundedThreadSnapshotHttp.ts | 15 ++- .../src/state/threads-sync.test.ts | 7 ++ packages/client-runtime/src/state/threads.ts | 6 +- packages/contracts/src/environmentHttp.ts | 6 +- packages/contracts/src/orchestrationV2.ts | 14 +++ .../orchestrationV2BoundedSnapshot.test.ts | 65 +++++++++++++ .../src/orchestrationV2BoundedSnapshot.ts | 94 +++++++++++++++++- 12 files changed, 314 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index fad28abf677ab..47e944d1711fe 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -3,7 +3,10 @@ import type { OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; -import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; +import { + omitCheckpointItemFiles, + omitLocalVisibleTurnItems, +} from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import { buildBoundedThreadProjection, @@ -52,18 +55,27 @@ type ThreadSnapshotStreamItem = Extract< export function boundedSnapshotResponseFields(input: { readonly bounded: BoundedProjectionResult; readonly compactTurnItems: boolean; + readonly compactCheckpointItems?: boolean; }) { const { bounded } = input; const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(bounded.projection) : null; + // After the turn-item compaction, which matches rows to items by identity. + const compactCheckpoints = + input.compactCheckpointItems === true + ? omitCheckpointItemFiles(compactProjection ?? bounded.projection) + : null; return { - projection: compactProjection ?? bounded.projection, + projection: compactCheckpoints?.projection ?? compactProjection ?? bounded.projection, historyCursor: bounded.historyCursor, hasMoreHistory: bounded.hasMoreHistory, latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, payloadBudgetExceeded: bounded.payloadBudgetExceeded, ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), + ...(compactCheckpoints === null + ? {} + : { checkpointFilesOmittedItemIds: compactCheckpoints.itemIds }), }; } @@ -72,6 +84,7 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; readonly compactTurnItems?: boolean; + readonly compactCheckpointItems?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -83,6 +96,7 @@ export function buildBoundedThreadStreamSnapshot(input: { ...boundedSnapshotResponseFields({ bounded, compactTurnItems: input.compactTurnItems === true, + compactCheckpointItems: input.compactCheckpointItems === true, }), }; } diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index a86b19be95f7e..58bb29bede2e2 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -2,6 +2,9 @@ import { assert, describe, it } from "@effect/vitest"; import { AuthOrchestrationReadScope, AuthSessionId, + CheckpointId, + CheckpointRef, + CheckpointScopeId, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi, @@ -201,6 +204,68 @@ function itemUpdated( }; } +const forkCheckpointId = CheckpointId.make("checkpoint:compact-transport:fork"); +const forkCheckpointItemId = TurnItemId.make(`turn-item:${FORK}:checkpoint`); +const checkpointFiles = [ + { path: "src/a.ts", kind: "modified", additions: 3, deletions: 1 }, + { path: "src/b.ts", kind: "added", additions: 10, deletions: 0 }, +]; + +// A captured checkpoint and its turn item, which repeats the checkpoint's files. +function forkCheckpoint(at: DateTime.Utc): ReadonlyArray { + const scopeId = CheckpointScopeId.make(`checkpoint-scope:${FORK}`); + const nodeId = NodeId.make(`node:${FORK}:checkpoint`); + return [ + { + id: EventId.make(`event:${FORK}:checkpoint-captured`), + type: "checkpoint.captured", + threadId: FORK, + nodeId, + occurredAt: at, + payload: { + id: forkCheckpointId, + threadId: FORK, + scopeId, + runId: null, + nodeId, + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: null, + ref: CheckpointRef.make("refs/t3/compact-transport/1"), + status: "ready", + files: checkpointFiles, + capturedAt: at, + }, + }, + { + id: EventId.make(`event:${FORK}:checkpoint-item`), + type: "turn-item.updated", + threadId: FORK, + occurredAt: at, + payload: { + id: forkCheckpointItemId, + threadId: FORK, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 5, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "checkpoint", + checkpointId: forkCheckpointId, + scopeId, + files: checkpointFiles, + }, + }, + ]; +} + const seed = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const at = yield* DateTime.now; @@ -210,6 +275,7 @@ const seed = Effect.gen(function* () { ...[1, 2, 3].map((n) => itemUpdated(PARENT, parentRun, `parent-${n}`, n, at)), threadCreated(FORK, at, parentRun), ...[1, 2, 3, 4].map((n) => itemUpdated(FORK, null, `fork-${n}`, n, at)), + ...forkCheckpoint(at), threadCreated(LONG, at), runCreated(LONG, longRun, at), // The request sits far outside the recent window; its result is inside it. @@ -303,6 +369,20 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { const { projection: _legacyProjection, ...legacyRest } = legacy; assert.deepStrictEqual(compactRest, legacyRest); assert.deepStrictEqual(boundedSnapshotProjection(compact), legacy.projection); + + // Checkpoint items are compacted only when that opt-in is exact too. + assert.notInclude(compactBody, "checkpointFilesOmittedItemIds"); + const bothBody = await ( + await get(`${base}?compactTurnItems=1&compactCheckpointItems=1`) + ).text(); + const both = decodeBounded(bothBody); + assert.deepStrictEqual(boundedSnapshotProjection(both), legacy.projection); + if (threadId === FORK) { + assert.deepStrictEqual(both.checkpointFilesOmittedItemIds, [forkCheckpointItemId]); + assert.isBelow(bothBody.length, compactBody.length); + } else { + assert.isUndefined(both.checkpointFilesOmittedItemIds); + } return { legacy, compact }; }).pipe( Effect.tap(({ legacy }) => @@ -318,6 +398,7 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { "local", "local", "local", + "local", ]); } else { const ids = itemIds(legacy.projection); @@ -364,6 +445,22 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { }, { ...legacy.decoded, turnItemsOmitLocalVisible: undefined }, ); + + const compactCheckpoints = yield* firstSnapshot({ + threadId, + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + acceptCompactCheckpointItems: true, + }); + assert.isUndefined(compact.decoded.checkpointFilesOmittedItemIds); + assert.deepStrictEqual( + compactCheckpoints.decoded.checkpointFilesOmittedItemIds, + threadId === FORK ? [forkCheckpointItemId] : undefined, + ); + assert.deepStrictEqual( + boundedSnapshotProjection(compactCheckpoints.decoded), + legacy.decoded.projection, + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 8f67002333ba9..d5a924c9d9d99 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -194,6 +194,7 @@ export const layer = HttpApiBuilder.group( ...boundedSnapshotResponseFields({ bounded, compactTurnItems: args.query.compactTurnItems === "1", + compactCheckpointItems: args.query.compactCheckpointItems === "1", }), }; }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a035d6971d454..1c90d94c50eef 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -751,6 +751,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; + readonly acceptCompactCheckpointItems?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -847,6 +848,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? buildBoundedThreadStreamSnapshot({ ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, + compactCheckpointItems: input.acceptCompactCheckpointItems === true, }) : { kind: "snapshot" as const, diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 906f06fb95aa2..aea869aefe458 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -134,6 +134,7 @@ describe("boundedThreadSnapshotLoader", () => { const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; const result = yield* loader.load(PREPARED, THREAD_ID); expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); + expect(new URL(urls[0]!).searchParams.get("compactCheckpointItems")).toBe("1"); expect(result._tag).toBe("present"); if (result._tag !== "present") return; expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index a049a6b148634..86a1516d780fc 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -21,7 +21,8 @@ const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; /** * Load a bounded recent-window thread snapshot over HTTP. Opts into compact - * turnItems and restores them, so callers always see the full bounded shape. + * turnItems and checkpoint items and restores them, so callers always see the + * full bounded shape. * Older servers ignore the query and send the full shape. */ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( @@ -45,14 +46,18 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( request: ({ client, headers }) => client.threadBoundedSnapshot({ params: { threadId: input.threadId }, - query: { compactTurnItems: "1" }, + query: { compactTurnItems: "1", compactCheckpointItems: "1" }, headers: withOrchestrationProtocolHeader(headers), }), }).pipe( - // Drop the marker with the restore so nothing can restore twice. - Effect.map(({ turnItemsOmitLocalVisible, ...snapshot }) => ({ + // Drop the markers with the restore so nothing can restore twice. + Effect.map(({ turnItemsOmitLocalVisible, checkpointFilesOmittedItemIds, ...snapshot }) => ({ ...snapshot, - projection: boundedSnapshotProjection({ ...snapshot, turnItemsOmitLocalVisible }), + projection: boundedSnapshotProjection({ + ...snapshot, + turnItemsOmitLocalVisible, + checkpointFilesOmittedItemIds, + }), })), ); }); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index b78377b2b1eca..0d9f9f59b14ff 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -123,6 +123,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const lastRequestCompletionMarker = yield* Ref.make(false); const lastAcceptBoundedSnapshot = yield* Ref.make(undefined); const lastAcceptCompactTurnItems = yield* Ref.make(undefined); + const lastAcceptCompactCheckpointItems = yield* Ref.make(undefined); const wakeups = yield* Queue.unbounded(); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); @@ -141,6 +142,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly requestCompletionMarker?: true; readonly acceptBoundedSnapshot?: true; readonly acceptCompactTurnItems?: true; + readonly acceptCompactCheckpointItems?: true; }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( @@ -150,6 +152,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), Effect.andThen(Ref.set(lastAcceptBoundedSnapshot, input.acceptBoundedSnapshot)), Effect.andThen(Ref.set(lastAcceptCompactTurnItems, input.acceptCompactTurnItems)), + Effect.andThen( + Ref.set(lastAcceptCompactCheckpointItems, input.acceptCompactCheckpointItems), + ), Effect.as(streamFrom(inputs)), ), ), @@ -266,6 +271,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o lastRequestCompletionMarker, lastAcceptBoundedSnapshot, lastAcceptCompactTurnItems, + lastAcceptCompactCheckpointItems, supervisorState, supervisorSession, savedThreads, @@ -1049,6 +1055,7 @@ describe("EnvironmentThreads", () => { expect(Option.getOrThrow(seeded.data).turnItems).toEqual([...local, retainedRequest]); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); expect(yield* Ref.get(harness.lastAcceptCompactTurnItems)).toBe(true); + expect(yield* Ref.get(harness.lastAcceptCompactCheckpointItems)).toBe(true); // Live updates reduce against the restored list, not the compact one. const updated = { ...local[1]!, output: "updated", ordinal: 2 }; diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a6e31cf40c4a8..c724e06ff575f 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -888,7 +888,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), ...(acceptBoundedSnapshot - ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const } + ? { + acceptBoundedSnapshot: true as const, + acceptCompactTurnItems: true as const, + acceptCompactCheckpointItems: true as const, + } : {}), }; }), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e60db4527a91e..76e9d0a2ffba5 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -585,11 +585,13 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ }); /** - * `compactTurnItems=1` opts into `turnItemsOmitLocalVisible`. Other values are - * ignored, and older servers ignore the whole query. + * `compactTurnItems=1` opts into `turnItemsOmitLocalVisible`, and + * `compactCheckpointItems=1` into `checkpointFilesOmittedItemIds`. Other values + * are ignored, and older servers ignore the whole query. */ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ compactTurnItems: Schema.optionalKey(Schema.String), + compactCheckpointItems: Schema.optionalKey(Schema.String), }); const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 7a1e56b6d5e32..5a7128666880c 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3254,6 +3254,11 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ * that repeat local visible rows. See `turnItemsOmitLocalVisible`. */ acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), + /** + * Allows bounded snapshot fallbacks to send checkpoint turn items without the + * file list their checkpoint already carries. See `checkpointFilesOmittedItemIds`. + */ + acceptCompactCheckpointItems: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; @@ -3299,6 +3304,13 @@ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ * with `boundedSnapshotProjection` before using the projection. */ turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), + /** + * Set only for clients that opted in: these checkpoint turn items are sent + * with empty `files`, which equal their checkpoint's `files` in + * `projection.checkpoints`. Clients must restore them with + * `boundedSnapshotProjection` before using the projection. + */ + checkpointFilesOmittedItemIds: Schema.optionalKey(Schema.Array(TurnItemId)), }); export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type; @@ -3381,6 +3393,8 @@ export const OrchestrationV2ThreadStreamItem = Schema.Union([ payloadBudgetExceeded: Schema.optionalKey(Schema.Boolean), /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), + /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ + checkpointFilesOmittedItemIds: Schema.optionalKey(Schema.Array(TurnItemId)), }), Schema.Struct({ kind: Schema.Literal("event"), diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts index cf231c0617ef8..6bc52c2f2aea0 100644 --- a/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { boundedSnapshotProjection, + omitCheckpointItemFiles, omitLocalVisibleTurnItems, restoreLocalVisibleTurnItems, } from "./orchestrationV2BoundedSnapshot.ts"; @@ -145,3 +146,67 @@ describe("compact bounded snapshot turnItems", () => { expect(boundedSnapshotProjection({ projection: full })).toBe(full); }); }); + +describe("compact bounded snapshot checkpoint items", () => { + const files = [ + { path: "a.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "b.ts", kind: "modified", additions: 0, deletions: 4 }, + ]; + function checkpointItem(id: string, checkpointId: string, itemFiles: typeof files) { + return { + ...item(CHILD, id, 1), + type: "checkpoint", + checkpointId, + scopeId: "scope", + files: itemFiles, + } as unknown as OrchestrationV2TurnItem; + } + function withCheckpoints( + value: OrchestrationV2ThreadProjection, + checkpoints: ReadonlyArray<{ readonly id: string; readonly files: typeof files }>, + ): OrchestrationV2ThreadProjection { + return { ...value, checkpoints } as unknown as OrchestrationV2ThreadProjection; + } + + it("sends each checkpoint's files once and restores both lists exactly", () => { + const repeated = checkpointItem("checkpoint-item", "checkpoint-1", files); + // The checkpoint entity changed after its item was written, so the item keeps its own list. + const diverged = checkpointItem("diverged-item", "checkpoint-2", [files[0]!]); + const full = withCheckpoints( + projection({ + visible: [row("local", CHILD, repeated), row("local", CHILD, diverged)], + turnItems: [repeated, diverged], + }), + [ + { id: "checkpoint-1", files }, + { id: "checkpoint-2", files }, + ], + ); + + const compact = omitCheckpointItemFiles(omitLocalVisibleTurnItems(full)!)!; + expect(compact.itemIds).toEqual([TurnItemId.make("checkpoint-item")]); + expect( + compact.projection.visibleTurnItems.map( + (value) => (value.item as unknown as { files: unknown[] }).files.length, + ), + ).toEqual([0, 1]); + + const wire = JSON.parse(JSON.stringify(compact.projection)); + expect( + boundedSnapshotProjection({ + projection: wire, + turnItemsOmitLocalVisible: true, + checkpointFilesOmittedItemIds: compact.itemIds, + }), + ).toEqual(JSON.parse(JSON.stringify(full))); + }); + + it("sends nothing compact when no item repeats its checkpoint", () => { + const empty = checkpointItem("empty-item", "checkpoint-1", []); + const full = withCheckpoints( + projection({ visible: [row("local", CHILD, empty)], turnItems: [empty] }), + [{ id: "checkpoint-1", files }], + ); + expect(omitCheckpointItemFiles(full)).toBe(null); + }); +}); diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.ts index 117306819cd26..036c1df902935 100644 --- a/packages/shared/src/orchestrationV2BoundedSnapshot.ts +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.ts @@ -1,6 +1,8 @@ import type { OrchestrationV2ProjectedTurnItem, OrchestrationV2ThreadProjection, + OrchestrationV2TurnItem, + TurnItemId, } from "@t3tools/contracts"; /** Local timeline rows also carry their item in `projection.turnItems`. */ @@ -47,12 +49,100 @@ export function restoreLocalVisibleTurnItems( return { ...projection, turnItems: [...local, ...projection.turnItems] }; } +type CheckpointFiles = OrchestrationV2ThreadProjection["checkpoints"][number]["files"]; + +function sameCheckpointFiles(left: CheckpointFiles, right: CheckpointFiles): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const a = left[index]!; + const b = right[index]!; + if ( + a.path !== b.path || + a.kind !== b.kind || + a.additions !== b.additions || + a.deletions !== b.deletions || + Object.keys(a).length !== Object.keys(b).length + ) { + return false; + } + } + return true; +} + +/** + * Server half of the opt-in checkpoint compaction. A checkpoint turn item + * repeats its checkpoint's file list, which can hold thousands of entries. + * Items whose files equal those of their checkpoint in `projection.checkpoints` + * are sent with empty `files`; the returned ids tell the client which ones to + * refill with `restoreCheckpointItemFiles`. Returns null when nothing would be + * omitted. + */ +export function omitCheckpointItemFiles(projection: OrchestrationV2ThreadProjection): { + readonly projection: OrchestrationV2ThreadProjection; + readonly itemIds: ReadonlyArray; +} | null { + const checkpointFiles = new Map(); + for (const checkpoint of projection.checkpoints) { + if (checkpoint.files.length > 0) checkpointFiles.set(String(checkpoint.id), checkpoint.files); + } + if (checkpointFiles.size === 0) return null; + const itemIds = new Set(); + const compact = (item: OrchestrationV2TurnItem): OrchestrationV2TurnItem => { + if (item.type !== "checkpoint" || item.files.length === 0) return item; + const files = checkpointFiles.get(String(item.checkpointId)); + if (files === undefined || !sameCheckpointFiles(files, item.files)) return item; + itemIds.add(item.id); + return { ...item, files: [] }; + }; + const turnItems = projection.turnItems.map(compact); + const visibleTurnItems = projection.visibleTurnItems.map((row) => { + const item = compact(row.item); + return item === row.item ? row : { ...row, item }; + }); + if (itemIds.size === 0) return null; + return { + projection: { ...projection, turnItems, visibleTurnItems }, + itemIds: [...itemIds], + }; +} + +/** Client half of `omitCheckpointItemFiles`, before reducers or caches see the projection. */ +export function restoreCheckpointItemFiles( + projection: OrchestrationV2ThreadProjection, + itemIds: ReadonlyArray, +): OrchestrationV2ThreadProjection { + if (itemIds.length === 0) return projection; + const omitted = new Set(itemIds.map(String)); + const checkpointFiles = new Map( + projection.checkpoints.map((checkpoint) => [String(checkpoint.id), checkpoint.files]), + ); + const restore = (item: OrchestrationV2TurnItem): OrchestrationV2TurnItem => { + if (item.type !== "checkpoint" || !omitted.has(String(item.id))) return item; + const files = checkpointFiles.get(String(item.checkpointId)); + return files === undefined ? item : { ...item, files }; + }; + return { + ...projection, + turnItems: projection.turnItems.map(restore), + visibleTurnItems: projection.visibleTurnItems.map((row) => { + const item = restore(row.item); + return item === row.item ? row : { ...row, item }; + }), + }; +} + /** The usable projection of a decoded bounded snapshot, compact or not. */ export function boundedSnapshotProjection(snapshot: { readonly projection: OrchestrationV2ThreadProjection; readonly turnItemsOmitLocalVisible?: true | undefined; + readonly checkpointFilesOmittedItemIds?: ReadonlyArray | undefined; }): OrchestrationV2ThreadProjection { + // Files first: restoring local items copies the visible rows' items. + const projection = + snapshot.checkpointFilesOmittedItemIds === undefined + ? snapshot.projection + : restoreCheckpointItemFiles(snapshot.projection, snapshot.checkpointFilesOmittedItemIds); return snapshot.turnItemsOmitLocalVisible === true - ? restoreLocalVisibleTurnItems(snapshot.projection) - : snapshot.projection; + ? restoreLocalVisibleTurnItems(projection) + : projection; } From f5067fae71a4b7a01906c28dc07f91f42fbaafb3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:26:50 +0000 Subject: [PATCH 007/143] perf(server): resolve checkpoint metadata in one git process --- apps/server/src/vcs/GitVcsDriver.ts | 33 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index bfc80b022b29c..d90d7faf0f971 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -812,15 +812,33 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ); - const resolveGitCommonDir = (cwd: string) => + // One process for both: `--verify` applies to HEAD only, so an unborn branch + // still prints the common dir and exits 1, the same answer `hasHeadCommit` gives. + const resolveGitCommonDirAndHead = (cwd: string) => Effect.gen(function* () { + const operation = "GitVcsDriver.checkpoints.resolveGitCommonDir"; const result = yield* execute({ - operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", + operation, cwd, - args: ["rev-parse", "--git-common-dir"], + args: ["rev-parse", "--git-common-dir", "--verify", "--quiet", "HEAD"], + allowNonZeroExit: true, }); - const gitCommonDir = result.stdout.trim(); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + const gitCommonDir = result.stdout.split("\n")[0]?.trim() ?? ""; + if (gitCommonDir.length === 0 || (result.exitCode !== 0 && result.exitCode !== 1)) { + return yield* new VcsProcessExitError({ + operation, + command: "git rev-parse", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git rev-parse --git-common-dir failed", + }); + } + return { + gitCommonDir: path.isAbsolute(gitCommonDir) + ? gitCommonDir + : path.resolve(cwd, gitCommonDir), + headExists: result.exitCode === 0, + }; }); // Git renames loose objects and refs into place without fsync by default, so @@ -846,10 +864,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( // 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( + const [{ gitCommonDir, headExists }, sparseConfig, sourceIndexPath] = yield* Effect.all( [ - resolveGitCommonDir(input.cwd), - hasHeadCommit(input.cwd), + resolveGitCommonDirAndHead(input.cwd), execute({ operation, cwd: input.cwd, From 8903e82645241d54d47c39a322590a43e7c3e025 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:27:18 +0000 Subject: [PATCH 008/143] perf(desktop): load the renderer while the local backend starts --- apps/desktop/src/app/DesktopApp.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 08318bcd8da53..5a43de71ca5f6 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -251,6 +251,13 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + // The renderer loads bundled assets and can connect while the backend + // boots. Starting first makes its endpoint and bootstrap credential + // available before the preload reads them. WSL-only retains its splash + // until the primary has a usable endpoint. + if (!(settings.wslOnly === true && settings.wslBackendEnabled === true)) { + yield* desktopWindow.ensureMain; + } yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), From 7ef5da729915ba4e6a07fd55bf23c92b8d7e532a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:29:11 +0000 Subject: [PATCH 009/143] perf(web): reduce repeated chat and panel work --- apps/web/src/commandPaletteBus.ts | 5 +- apps/web/src/components/ChatView.logic.ts | 32 ++++ apps/web/src/components/ChatView.tsx | 176 +++++++++++------- .../src/components/CommandPalette.logic.ts | 19 +- apps/web/src/components/CommandPalette.tsx | 151 +++++++++------ apps/web/src/components/GitActionsControl.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 3 +- .../src/components/ThreadTerminalDrawer.tsx | 38 ++-- .../src/components/chat/MessagesTimeline.tsx | 13 +- .../components/chat/draftHeroTransition.ts | 31 +-- .../settings/SettingsFontPreviews.tsx | 39 +++- .../src/components/sidebar/SidebarChrome.tsx | 27 ++- apps/web/src/composer-rich-text-doc.ts | 16 +- apps/web/src/hooks/useMediaQuery.ts | 17 +- apps/web/src/hooks/useTheme.ts | 25 ++- apps/web/src/keybindings.ts | 68 +++++-- apps/web/src/lib/turnDiffTree.ts | 19 +- apps/web/src/terminal/ghostty/runtime.ts | 45 +++-- apps/web/src/terminal/ghostty/surface.ts | 16 +- 19 files changed, 525 insertions(+), 219 deletions(-) diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 7bf122c468177..d72602172e35c 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -31,9 +31,12 @@ export function onOpenCommandPalette( return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); } +/** Element id of the mounted palette popup. An id lookup is O(1); keydown handlers call this per key. */ +export const COMMAND_PALETTE_ELEMENT_ID = "t3-command-palette"; + /** Read at event time so consumers do not subscribe to transient dialog state. */ export function isCommandPaletteOpen(): boolean { return ( - typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null + typeof document !== "undefined" && document.getElementById(COMMAND_PALETTE_ELEMENT_ID) !== null ); } diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1e79964caa0a9..086303a58dc9f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -23,6 +23,7 @@ import { type ThreadId, type ThreadLinkedPullRequest, type RunId, + type RuntimeMode, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; @@ -481,6 +482,37 @@ export function startNewThreadForProject( return true; } +/** The thread settings a send writes before its turn starts, or null when none changed. */ +export function resolveThreadSettingsUpdatesForNextTurn( + thread: { + readonly modelSelection: ModelSelection; + readonly branch: string | null; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, + input: { + readonly branch?: string; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, +): { + readonly metadataUpdate: ReturnType; + readonly runtimeMode: RuntimeMode | null; + readonly interactionMode: ProviderInteractionMode | null; +} | null { + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: thread.modelSelection, + currentBranch: thread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), + }); + const runtimeMode = input.runtimeMode !== thread.runtimeMode ? input.runtimeMode : null; + const interactionMode = + input.interactionMode !== thread.interactionMode ? input.interactionMode : null; + return metadataUpdate === null && runtimeMode === null && interactionMode === null + ? null + : { metadataUpdate, runtimeMode, interactionMode }; +} + export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dbee50a28ac78..ba824ef6f28ba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -502,6 +502,7 @@ import { DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, + isMobileComposerTransitionEnabled, runMobileComposerTransition, } from "./chat/draftHeroTransition"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -544,7 +545,7 @@ import { getAntigravitySendBlockReason, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, - resolveThreadMetadataUpdateForNextTurn, + resolveThreadSettingsUpdatesForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -6272,16 +6273,15 @@ export default function ChatView(props: ChatViewProps) { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }): Promise> => { - if (!serverThread) { + const updates = serverThread + ? resolveThreadSettingsUpdatesForNextTurn(serverThread, input) + : null; + if (!updates) { return AsyncResult.success(undefined); } let result: AtomCommandResult = AsyncResult.success(undefined); - const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: serverThread.modelSelection, - currentBranch: serverThread.branch, - ...(input.branch ? { nextBranch: input.branch } : {}), - }); + const { metadataUpdate } = updates; if (metadataUpdate) { result = mapAtomCommandResult( await updateThreadMetadata({ @@ -6298,13 +6298,13 @@ export default function ChatView(props: ChatViewProps) { } } - if (input.runtimeMode !== serverThread.runtimeMode) { + if (updates.runtimeMode !== null) { result = mapAtomCommandResult( await setThreadRuntimeMode({ environmentId, input: { threadId: input.threadId, - runtimeMode: input.runtimeMode, + runtimeMode: updates.runtimeMode, createdAt: input.createdAt, }, }), @@ -6315,13 +6315,13 @@ export default function ChatView(props: ChatViewProps) { } } - if (input.interactionMode !== serverThread.interactionMode) { + if (updates.interactionMode !== null) { result = mapAtomCommandResult( await setThreadInteractionMode({ environmentId, input: { threadId: input.threadId, - interactionMode: input.interactionMode, + interactionMode: updates.interactionMode, createdAt: input.createdAt, }, }), @@ -9380,11 +9380,33 @@ export default function ChatView(props: ChatViewProps) { } } - if ( + const dockDraftHeroThreadKey = multipleModelSelections === null && - shouldDockDraftHeroForSubmission({ isDraftHeroState, activeThreadKey, submissionIntent }) && - activeThreadKey - ) { + shouldDockDraftHeroForSubmission({ isDraftHeroState, activeThreadKey, submissionIntent }) + ? activeThreadKey + : null; + // A plain send can dispatch before the dock and optimistic render. + // Mobile view transitions still snapshot the hero before it changes. + const dispatchBeforeRender = + multipleModelSelections === null && + submissionIntent !== "background" && + composerAttachmentsSnapshot.length === 0 && + !compactBeforeSend && + (!isServerThread || + serverThread === null || + resolveThreadSettingsUpdatesForNextTurn(serverThread, { + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: sendInteractionMode, + }) === null) && + !(dockDraftHeroThreadKey && isMobileComposerTransitionEnabled(panelAnimationsActive)); + if (dockDraftHeroThreadKey && dispatchBeforeRender) { + // Batched with the optimistic turn below; the rect is read before React renders any of it. + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(dockDraftHeroThreadKey); + } else if (dockDraftHeroThreadKey) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; @@ -9393,7 +9415,7 @@ export default function ChatView(props: ChatViewProps) { () => { flushSync(() => { captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); + setDockedDraftHeroThreadKey(dockDraftHeroThreadKey); }); resolveDockStarted?.(); }, @@ -9827,54 +9849,7 @@ export default function ChatView(props: ChatViewProps) { ctxSelectedModelSelection.options, ); - let failure: AtomCommandResult | null = null; - - if (failure === null && isServerThread) { - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode: sendInteractionMode, - }); - if (settingsResult._tag === "Failure") { - failure = settingsResult; - } - } - - const turnAttachmentsResult = await settlePromise(async () => { - const turnAttachments = await turnAttachmentsPromise; - const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; - if (liveFileBlockReason !== null) { - throw new Error(liveFileBlockReason); - } - return turnAttachments; - }); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } - - if (failure === null && compactBeforeSend) { - const compactResult = await startThreadTurn({ - environmentId, - input: { - threadId: threadIdForSend, - message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, - modelSelection: ctxSelectedModelSelection, - runtimeMode, - interactionMode: sendInteractionMode, - }, - }); - if (compactResult._tag === "Failure") { - failure = compactResult; - } - } - - let backgroundDraftOpened = false; - let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { + const startTurnForSend = (turnAttachments: Awaited) => { const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { @@ -9904,12 +9879,7 @@ export default function ChatView(props: ChatViewProps) { : {}), } : undefined; - const backgroundThreadRef = - submissionIntent === "background" && isLocalDraftThread - ? scopeThreadRef(environmentId, threadIdForSend) - : null; - if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); - const startPromise = startThreadTurn({ + return startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -9917,10 +9887,10 @@ export default function ChatView(props: ChatViewProps) { messageId: messageIdForSend, role: "user", text: outgoingMessageText, - attachments: turnAttachmentsResult.value, + attachments: turnAttachments, ...(() => { const context = buildOutgoingMessageContext( - turnAttachmentsResult.value.map((attachment, index) => + turnAttachments.map((attachment, index) => "id" in attachment && attachment.id !== undefined ? attachment.id : composerAttachmentsSnapshot[index]!.id, @@ -9954,6 +9924,67 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); + }; + // Nothing above awaited, so React has not rendered the state queued for + // this send yet and the command reaches the socket first. + const earlyTurnStart = dispatchBeforeRender ? startTurnForSend([]) : null; + + let failure: AtomCommandResult | null = null; + + if (failure === null && isServerThread && !dispatchBeforeRender) { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: sendInteractionMode, + }); + if (settingsResult._tag === "Failure") { + failure = settingsResult; + } + } + + const turnAttachmentsResult = dispatchBeforeRender + ? AsyncResult.success>([]) + : await settlePromise(async () => { + const turnAttachments = await turnAttachmentsPromise; + const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; + if (liveFileBlockReason !== null) { + throw new Error(liveFileBlockReason); + } + return turnAttachments; + }); + if (failure === null && turnAttachmentsResult._tag === "Failure") { + failure = turnAttachmentsResult; + } + + if (failure === null && compactBeforeSend) { + const compactResult = await startThreadTurn({ + environmentId, + input: { + threadId: threadIdForSend, + message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, + modelSelection: ctxSelectedModelSelection, + runtimeMode, + interactionMode: sendInteractionMode, + }, + }); + if (compactResult._tag === "Failure") { + failure = compactResult; + } + } + + let backgroundDraftOpened = false; + let turnStartSucceeded = false; + if (failure === null && turnAttachmentsResult._tag === "Success") { + const backgroundThreadRef = + submissionIntent === "background" && isLocalDraftThread + ? scopeThreadRef(environmentId, threadIdForSend) + : null; + if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); + const startPromise = earlyTurnStart ?? startTurnForSend(turnAttachmentsResult.value); if (backgroundThreadRef) { markPromotedDraftThreadByRef(backgroundThreadRef); try { @@ -11428,6 +11459,7 @@ export default function ChatView(props: ChatViewProps) { } routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} + paintOnly={paintOnlyDisplayedTimeline} onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff} onOpenThread={onOpenRelatedThread} parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 49e963f68313a..f052221645261 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -390,12 +390,27 @@ export function buildThreadActionItems(); + +function normalizeSearchTerm(term: string): string { + let normalized = normalizedTermCache.get(term); + if (normalized === undefined) { + if (normalizedTermCache.size >= NORMALIZED_TERM_CACHE_LIMIT) normalizedTermCache.clear(); + normalized = normalizeSearchText(term); + normalizedTermCache.set(term, normalized); + } + return normalized; +} + function rankSearchFieldMatch( field: string, normalizedQuery: string, queryTokens: ReadonlyArray, ): number { - const normalizedField = normalizeSearchText(field); + const normalizedField = normalizeSearchTerm(field); if ( normalizedField.length === 0 || !queryTokens.every((token) => normalizedField.includes(token)) @@ -492,7 +507,7 @@ export function filterCommandPaletteGroups(input: { return searchableGroups.flatMap((group) => { const items = Arr.filterMap(group.items, (item, index) => { - const haystack = normalizeSearchText(item.searchTerms.join(" ")); + const haystack = item.searchTerms.map(normalizeSearchTerm).join(" "); 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 80aedc344fb14..0e9ea387cb170 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -129,7 +129,7 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; -import { onOpenCommandPalette } from "../commandPaletteBus"; +import { COMMAND_PALETTE_ELEMENT_ID, onOpenCommandPalette } from "../commandPaletteBus"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { @@ -222,7 +222,7 @@ import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, } from "../sidebarProjectGrouping"; -import type { Project } from "../types"; +import type { Project, SidebarThreadSummary } from "../types"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; @@ -663,6 +663,7 @@ function CommandPaletteDialog(props: { : "Command palette" } className={cn("overflow-hidden", props.mode === "content" && "h-105")} + id={COMMAND_PALETTE_ELEMENT_ID} data-command-palette="true" data-palette-mode={props.mode} data-testid="command-palette" @@ -1418,42 +1419,82 @@ function OpenCommandPaletteDialog(props: { startScratchThread, ]); - const allThreadItems = useMemo( - () => + const threadItemOptions = useMemo( + () => ({ + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: clientSettings.sidebarThreadSortOrder, + icon: , + renderLeadingContent: (thread: (typeof threads)[number]) => ( + + ), + renderTrailingContent: (thread: (typeof threads)[number]) => ( + + ), + renderDescription: ( + thread: (typeof threads)[number], + { projectTitle }: { projectTitle: string | undefined }, + ) => { + const modelInstanceId = + thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = + providerEntryByEnvironmentAndInstanceId.get( + `${thread.environmentId}:${modelInstanceId}`, + ) ?? null; + return ( + + ); + }, + runThread: async (thread: Pick) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }), + [ + activeThreadId, + clientSettings.sidebarThreadSortOrder, + navigate, + projectByKey, + projectEnvironmentLocationById, + projectTitleById, + providerEntryByEnvironmentAndInstanceId, + ], + ); + const baseThreadItems = useMemo( + () => buildThreadActionItems({ threads, ...threadItemOptions }), + [threadItemOptions, threads], + ); + // Message matches arrive per keystroke. Only the matched threads get new + // items; the rest keep their identity, so their rows skip re-rendering. + const allThreadItems = useMemo(() => { + if (threadContentMatchByKey.size === 0) return baseThreadItems; + const matchedThreads = threads.filter((thread) => + threadContentMatchByKey.has( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ), + ); + if (matchedThreads.length === 0) return baseThreadItems; + const matchedItems = new Map( buildThreadActionItems({ - threads, - ...(activeThreadId ? { activeThreadId } : {}), - projectTitleById, - sortOrder: clientSettings.sidebarThreadSortOrder, - icon: , - renderLeadingContent: (thread) => , - renderTrailingContent: (thread) => , - renderDescription: (thread, { projectTitle }) => { - const modelInstanceId = - thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = - providerEntryByEnvironmentAndInstanceId.get( - `${thread.environmentId}:${modelInstanceId}`, - ) ?? null; - return ( - - ); - }, + threads: matchedThreads, + ...threadItemOptions, getContentMatch: (thread) => { const match = threadContentMatchByKey.get( threadSearchMatchKey({ @@ -1469,27 +1510,10 @@ function OpenCommandPaletteDialog(props: { } : undefined; }, - runThread: async (thread) => { - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, - }), - [ - activeThreadId, - clientSettings.sidebarThreadSortOrder, - navigate, - projectCwdById, - projectByKey, - projectEnvironmentLocationById, - projectTitleById, - providerEntryByEnvironmentAndInstanceId, - threadContentMatchByKey, - threadSearch.query, - threads, - ], - ); + }).map((item) => [item.value, item]), + ); + return baseThreadItems.map((item) => matchedItems.get(item.value) ?? item); + }, [baseThreadItems, threadContentMatchByKey, threadItemOptions, threadSearch.query, threads]); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); const pushPaletteView = useCallback( @@ -3440,6 +3464,15 @@ function OpenCommandPaletteDialog(props: { ) : null; + // 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; + const executeLatestItem = useCallback( + (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => executeItemRef.current(item), + [], + ); + const footerActionLabel = newProjectFlow !== null ? highlightedItemValue === null @@ -3566,7 +3599,7 @@ function OpenCommandPaletteDialog(props: { highlightedItemValue={highlightedItemValue} isActionsOnly={isActionsOnly} keybindings={keybindings} - onExecuteItem={executeItem} + onExecuteItem={executeLatestItem} {...(addProjectCloneFlow?.step === "repository" ? { emptyStateMessage: diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6e3aff08484e2..a174c4b8a92e6 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1103,8 +1103,10 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); + // The projection is only a fallback for a thread without a shell. Reading it + // while the shell exists re-rendered this control on every streamed delta. const activeServerThreadProjection = useThreadProjection( - activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef, + activeDraftThread === null && activeServerThreadShell === null ? activeThreadRef : null, ); const activeServerThread = activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null; diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6869dd7d1ec2b..5f1fd7dd4737f 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1051,11 +1051,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (!viewport) return; const content = viewport.firstElementChild; + // The observer's first callback reports the initial sizes once layout has + // run, so there is no synchronous read here forcing an early layout. const resizeObserver = new ResizeObserver(updateTabScrollState); resizeObserver.observe(viewport); if (content) resizeObserver.observe(content); viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); - updateTabScrollState(); return () => { resizeObserver.disconnect(); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1a82bae029008..44dfbd0b26bae 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1421,23 +1421,24 @@ export default function ThreadTerminalDrawer({ }; }, []); - const handleResizePointerMove = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - setDrawerHeight(clampedHeight); - }, - [setDrawerHeight], - ); + const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + const clampedHeight = clampDrawerHeight( + resizeState.startHeight + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + // Size the drawer directly while dragging; re-rendering it on every move + // dropped frames. The surfaces refit through their own resize observers, + // and the height is committed to state when the drag ends. + const drawer = event.currentTarget.parentElement; + if (drawer) drawer.style.height = `${clampedHeight}px`; + }, []); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent) => { @@ -1450,10 +1451,11 @@ export default function ThreadTerminalDrawer({ if (!didResizeDuringDragRef.current) { return; } + setDrawerHeight(drawerHeightRef.current); syncHeight(drawerHeightRef.current); setResizeEpoch((value) => value + 1); }, - [syncHeight], + [setDrawerHeight, syncHeight], ); useEffect(() => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22cea..70d849b38b084 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -479,6 +479,8 @@ interface MessagesTimelineProps { turnDiffSummaries: ReadonlyArray; routeThreadKey: string; displayThreadKey?: string; + /** Another thread's held snapshot stands in while this one loads; its scroll is not remembered. */ + paintOnly?: boolean; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; parentThreadLink?: { @@ -622,6 +624,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onIsAtEndChange, onContentOverflowChange, liveFollowEnabled, + paintOnly = false, onToolOutputCollapsedAtEnd, onManualNavigation, cancelPositionRestoreRef, @@ -1180,7 +1183,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (restoringThreadPosition || state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; - if (position && state && isAtEnd !== undefined) { + if (position && state && isAtEnd !== undefined && !paintOnly) { const index = state.indexByKey(position.rowId); const row = index === undefined ? undefined : state.elementAtIndex(index); const element = listRef.current?.getScrollableNode(); @@ -1190,7 +1193,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ // DOM geometry includes the header and the virtualizer's layout adjustment. offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, scrollOffset: element.scrollTop, - atEnd: isAtEnd, + // Live follow only ends on a user scroll gesture. Layout changes + // (the composer inset, a thread switch) can still move the list off + // the end without the reader leaving it, and a revisit must land + // at the end then rather than at that transient offset. + atEnd: isAtEnd || liveFollowEnabled, disclosures: { runs: paintedExpandedRunIds, workGroups: paintedExpandedWorkGroupIds, @@ -1249,6 +1256,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ workGroupViewState, rows, listIdentityKey, + liveFollowEnabled, + paintOnly, restoringThreadPosition, listRef, minimapItems, diff --git a/apps/web/src/components/chat/draftHeroTransition.ts b/apps/web/src/components/chat/draftHeroTransition.ts index 958b7374c1a84..afc73298a483d 100644 --- a/apps/web/src/components/chat/draftHeroTransition.ts +++ b/apps/web/src/components/chat/draftHeroTransition.ts @@ -37,25 +37,34 @@ export async function waitForDraftHeroTransition(): Promise { ]); } +/** + * Whether `runMobileComposerTransition` runs its update inside a view + * transition. Otherwise it runs the update synchronously. + */ +export function isMobileComposerTransitionEnabled(active: boolean): boolean { + if (!active || typeof document === "undefined" || typeof window === "undefined") return false; + const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; + const prefersReducedMotion = + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + return ( + mobileViewport && + !prefersReducedMotion && + Boolean((document as ComposerViewTransitionDocument).startViewTransition) + ); +} + export async function runMobileComposerTransition( update: () => void | Promise, options: { active: boolean; durationMs: number }, ): Promise { - if (typeof document === "undefined" || typeof window === "undefined") { + if (!isMobileComposerTransitionEnabled(options.active)) { await update(); return; } const transitionDocument = document as ComposerViewTransitionDocument; - const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; - const prefersReducedMotion = - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; - if ( - !options.active || - !mobileViewport || - prefersReducedMotion || - !transitionDocument.startViewTransition - ) { + const startViewTransition = transitionDocument.startViewTransition; + if (!startViewTransition) { await update(); return; } @@ -73,7 +82,7 @@ export async function runMobileComposerTransition( ); transitionDocument.documentElement.dataset.mobileComposerRouteTransition = "true"; try { - const transition = transitionDocument.startViewTransition(runUpdate); + const transition = startViewTransition.call(transitionDocument, runUpdate); transitionFinished = transition.finished.catch(() => undefined); activeMobileComposerTransition = transitionFinished; try { diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 4715583e4005c..5a0f827c4cc66 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -1,5 +1,5 @@ import { preloadPatchFile } from "@pierre/diffs/ssr"; -import { useCallback, useEffect, useRef, useState } from "react"; +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"; @@ -17,6 +17,31 @@ import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; 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); + useEffect(() => { + const element = ref.current; + if (revealed || element === null) return; + if (typeof IntersectionObserver === "undefined") { + setRevealed(true); + return; + } + const observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + observer.disconnect(); + setRevealed(true); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref, revealed]); + return revealed; +} + // 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. @@ -125,8 +150,11 @@ function StaticDiffHtml({ html }: { html: string }) { 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); @@ -134,8 +162,8 @@ export function CodeFontPreview() { return () => { cancelled = true; }; - }, [themeName]); - if (htmlByFile === null) return null; + }, [revealed, themeName]); + if (htmlByFile === null) return
; return (
{htmlByFile.map((html) => ( @@ -184,6 +212,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu const surfaceRef = useRef(null); const fontRef = useRef({ family, size }); const { theme, resolvedTheme } = useTheme(); + const revealed = useRevealed(mountRef); useEffect(() => { const current = fontRef.current; @@ -203,7 +232,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu useEffect(() => { const mount = mountRef.current; - if (!mount) return; + 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. @@ -261,7 +290,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu surfaceRef.current?.dispose(); surfaceRef.current = null; }; - }, []); + }, [revealed]); return (
void; + /** Hover or focus: start loading the destination before the click. */ + onIntent?: () => void; }) { return ( + {icon} } @@ -188,6 +197,18 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { closeMobileSidebar(); void navigate({ to: "/settings" }); }, [closeMobileSidebar, navigate]); + // These are buttons, not Links, so the router's hover preloading misses + // them. Opening a page renders synchronously before its chunks are fetched; + // fetching on intent takes the download off that path. + const router = useRouter(); + const preloadSettings = useCallback(() => { + void router.preloadRoute({ to: "/settings/general" }).catch(() => undefined); + }, [router]); + const preloadPullRequests = useCallback(() => { + void router + .preloadRoute({ to: "/pull-requests", search: readPullRequestListPreferences() }) + .catch(() => undefined); + }, [router]); const handleUsageClick = useCallback(() => { if (isMobile) { @@ -216,12 +237,14 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { icon={} label="Settings" onClick={handleSettingsClick} + onIntent={preloadSettings} /> {pullRequestsSupported ? ( } label="Pull Requests" onClick={handlePullRequestsClick} + onIntent={preloadPullRequests} /> ) : null} (); + +/** The returned map is shared between callers and must not be mutated. */ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { + const cached = serializedDocs.get(doc); + if (cached) return cached; + const map = serializeEditorDocUncached(doc); + serializedDocs.set(doc, map); + return map; +} + +function serializeEditorDocUncached(doc: ProseMirrorNode): RichDocMap { const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; doc.content.forEach((node) => { diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index e7e007b0b7c6d..cd5f1af198d89 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -61,13 +61,26 @@ export type MediaQueryInput = { pointer?: "coarse" | "fine"; }; +// A MediaQueryList stays live, so one per query serves every render. +// matchMedia() on each render was a measurable share of a keystroke. +const mediaQueryLists = new Map(); + +function mediaQueryList(mediaQuery: string): MediaQueryList { + let mql = mediaQueryLists.get(mediaQuery); + if (!mql) { + mql = window.matchMedia(mediaQuery); + mediaQueryLists.set(mediaQuery, mql); + } + return mql; +} + export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): boolean { const mediaQuery = parseQuery(query); const subscribe = useCallback( (callback: () => void) => { if (typeof window === "undefined") return () => {}; - const mql = window.matchMedia(mediaQuery); + const mql = mediaQueryList(mediaQuery); mql.addEventListener("change", callback); return () => mql.removeEventListener("change", callback); }, @@ -76,7 +89,7 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string const getSnapshot = useCallback(() => { if (typeof window === "undefined") return false; - return window.matchMedia(mediaQuery).matches; + return mediaQueryList(mediaQuery).matches; }, [mediaQuery]); return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 7a750fd7434a8..02a6a2ee596e1 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -354,15 +354,32 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; - syncBrowserChromeTheme(); syncDesktopTheme(theme, followSystem, appearanceMode); if (suppressTransitions) { - // Force a reflow so the no-transitions class takes effect before removal - void document.documentElement.offsetHeight; + settleThemeAfterPaint(); + } else { + syncBrowserChromeTheme(); + } +} + +let themeSettlePending = false; + +/** + * Finish a transition-suppressed theme change once the next frame has painted + * it. Reading computed colors or 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. + */ +function settleThemeAfterPaint() { + if (themeSettlePending) return; + themeSettlePending = true; + requestAnimationFrame(() => { requestAnimationFrame(() => { + themeSettlePending = false; + syncBrowserChromeTheme(); document.documentElement.classList.remove("no-transitions"); }); - } + }); } export async function syncDesktopThemePreference( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 92affc39e5250..5babb6a9669bb 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -106,16 +106,43 @@ export function shortcutConflictKey( ].join("|"); } -export function effectiveShortcutsForCommand( +type EffectiveShortcutsByCommand = ReadonlyMap; + +/** + * Resolved configs are replaced, never mutated, so the effective shortcuts for + * one config, platform and context never change. Labels resolve on every + * render and keydown, so they read this index instead of walking every binding. + */ +const effectiveShortcutIndex = new WeakMap< + ResolvedKeybindingsConfig, + Map +>(); + +function effectiveShortcutIndexKey(platform: string, context: ShortcutMatchContext): string { + // `when` clauses only test truthiness, so the truthy names identify the context. + let key = platform; + for (const name of Object.keys(context).sort()) { + if (context[name]) key += `|${name}`; + } + return key; +} + +function effectiveShortcutsByCommand( keybindings: ResolvedKeybindingsConfig, - command: KeybindingCommand, - options?: ShortcutMatchOptions, -): KeybindingShortcut[] { - const platform = resolvePlatform(options); - const context = resolveContext(options); - const claimedShortcuts = new Set(); - const effective: KeybindingShortcut[] = []; + platform: string, + context: ShortcutMatchContext, +): EffectiveShortcutsByCommand { + let byContext = effectiveShortcutIndex.get(keybindings); + if (!byContext) { + byContext = new Map(); + effectiveShortcutIndex.set(keybindings, byContext); + } + const key = effectiveShortcutIndexKey(platform, context); + const cached = byContext.get(key); + if (cached) return cached; + const claimedShortcuts = new Set(); + const byCommand = new Map(); for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; if (!binding) continue; @@ -127,12 +154,22 @@ export function effectiveShortcutsForCommand( } claimedShortcuts.add(conflictKey); - if (binding.command === command) { - effective.push(binding.shortcut); - } + const shortcuts = byCommand.get(binding.command); + if (shortcuts) shortcuts.push(binding.shortcut); + else byCommand.set(binding.command, [binding.shortcut]); } + byContext.set(key, byCommand); + return byCommand; +} - return effective; +export function effectiveShortcutsForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): KeybindingShortcut[] { + const platform = resolvePlatform(options); + const context = resolveContext(options); + return [...(effectiveShortcutsByCommand(keybindings, platform, context).get(command) ?? [])]; } function findEffectiveShortcutForCommand( @@ -140,7 +177,12 @@ function findEffectiveShortcutForCommand( command: KeybindingCommand, options?: ShortcutMatchOptions, ): KeybindingShortcut | null { - return effectiveShortcutsForCommand(keybindings, command, options)[0] ?? null; + const shortcuts = effectiveShortcutsByCommand( + keybindings, + resolvePlatform(options), + resolveContext(options), + ).get(command); + return shortcuts?.[0] ?? null; } function matchesCommandShortcut( diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index cd9bfc831fbb3..884cc67ec0d31 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -30,7 +30,9 @@ interface MutableDirectoryNode { files: TurnDiffTreeFileNode[]; } -const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" }; +// One collator for every comparison: localeCompare with options builds a new +// collator per call, which dominated building a large turn tree. +const NAME_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); function normalizePathSegments(pathValue: string): string[] { return pathValue @@ -40,7 +42,7 @@ function normalizePathSegments(pathValue: string): string[] { } function compareByName(a: { name: string }, b: { name: string }): number { - return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS); + return NAME_COLLATOR.compare(a.name, b.name); } function readStat(file: TurnDiffFileChange): TurnDiffStat | null { @@ -110,7 +112,20 @@ export function summarizeTurnDiffStats(files: ReadonlyArray) ); } +// A turn's file list is immutable, and its tree card remounts whenever the +// timeline virtualizes it back into view, so the tree is built once per list. +const treeByFiles = new WeakMap, TurnDiffTreeNode[]>(); + +/** The returned nodes are shared between callers and must not be mutated. */ export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { + const cached = treeByFiles.get(files); + if (cached) return cached; + const tree = buildTurnDiffTreeUncached(files); + treeByFiles.set(files, tree); + return tree; +} + +function buildTurnDiffTreeUncached(files: ReadonlyArray): TurnDiffTreeNode[] { const root: MutableDirectoryNode = { name: "", path: "", diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index ad06389def55c..b74ac2d502165 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -19,6 +19,24 @@ type TypeLayouts = Readonly>; const textDecoder = new TextDecoder(); +/** + * Compile while the bytes download when the server labels the file as WASM; + * otherwise (some static hosts and custom protocols) fall back to buffering. + */ +async function compileWasm(url: string, label: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Unable to load ${label} (${response.status})`); + } + if ( + typeof WebAssembly.compileStreaming === "function" && + response.headers.get("content-type")?.split(";")[0]?.trim() === "application/wasm" + ) { + return WebAssembly.compileStreaming(response); + } + return WebAssembly.compile(await response.arrayBuffer()); +} + export class GhosttyRuntime { readonly memory: WebAssembly.Memory; readonly layouts: TypeLayouts; @@ -44,10 +62,12 @@ export class GhosttyRuntime { } static async load(): Promise { - const response = await fetch(ghosttyWasmUrl); - if (!response.ok) { - throw new Error(`Unable to load libghostty-vt (${response.status})`); - } + // 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: { @@ -60,10 +80,9 @@ export class GhosttyRuntime { }, }, }; - const result = await WebAssembly.instantiate(await response.arrayBuffer(), imports); - instance = result.instance; - const runtime = new GhosttyRuntime(result.instance); - await runtime.installWritePtyTrampoline(); + instance = await WebAssembly.instantiate(module, imports); + const runtime = new GhosttyRuntime(instance); + await runtime.installWritePtyTrampoline(trampolineModule); return runtime; } @@ -193,12 +212,8 @@ export class GhosttyRuntime { } } - private async installWritePtyTrampoline(): Promise { - const response = await fetch(ghosttyWritePtyWasmUrl); - if (!response.ok) { - throw new Error(`Unable to load the libghostty-vt PTY trampoline (${response.status})`); - } - const result = await WebAssembly.instantiate(await response.arrayBuffer(), { + private async installWritePtyTrampoline(module: WebAssembly.Module): Promise { + const trampolineInstance = await WebAssembly.instantiate(module, { env: { t3_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => { const writer = this.ptyWriters.get(userdata); @@ -207,7 +222,7 @@ export class GhosttyRuntime { }, }, }); - const trampoline = result.instance.exports.ghostty_write_pty; + const trampoline = trampolineInstance.exports.ghostty_write_pty; const table = this.exports.__indirect_function_table; if (typeof trampoline !== "function" || !(table instanceof WebAssembly.Table)) { throw new Error("libghostty-vt did not expose its callback table"); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 5ea27cea08c98..574edd65ce38b 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -14,6 +14,7 @@ import { type GhosttyCellRange, type GhosttyCellMetrics, } from "./renderer"; +import { loadGhosttyRuntime } from "./runtime"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; @@ -713,14 +714,13 @@ export class GhosttyTerminalSurface { context.fillStyle = `rgb(${options.theme.background.r}, ${options.theme.background.g}, ${options.theme.background.b})`; context.fillRect(0, 0, canvas.width, canvas.height); const fontSize = terminalFontSize(options.font?.size); - try { - // Cell metrics must come from the faces that will render; measuring before - // the bundled webfonts load would size the grid from a fallback font. - await ensureTerminalSymbolsFont(); - } catch { - // Metrics fall back to whichever faces are already available. - } - const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize); + // Cell metrics must come from the faces that will render; measuring before + // the bundled webfonts load would size the grid from a fallback font. + const [, fontFamily] = await Promise.all([ + ensureTerminalSymbolsFont(), + loadTerminalFontFamily(options.font?.family, fontSize), + loadGhosttyRuntime(), + ]); const metrics = measureGhosttyCell(context, fontSize, fontFamily); const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); const core = await GhosttyTerminalCore.create( From 31967f8678afd22fb7a73cf395a4bf4f74fee617 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:30:47 +0000 Subject: [PATCH 010/143] fix(client-runtime): preserve encoded thread urls during snapshot compaction --- .../src/state/boundedThreadSnapshotHttp.ts | 5 ++- .../src/state/environmentHttpAuth.test.ts | 45 +++++++++++++++++++ .../src/state/threadHistoryHttp.ts | 5 ++- .../src/state/threadSnapshotHttp.ts | 5 ++- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index 86a1516d780fc..b070c8ae0c4a5 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -41,7 +41,10 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( group: "orchestration", method: "GET", url: (httpBaseUrl) => - environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}/bounded`), + environmentEndpointUrl( + httpBaseUrl, + `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/bounded`, + ), timeoutMs: input.timeoutMs ?? DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS, request: ({ client, headers }) => client.threadBoundedSnapshot({ diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 61504668427ec..0a2df6f936a51 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -4,6 +4,7 @@ import { ORCHESTRATION_PROTOCOL_HEADER, ORCHESTRATION_PROTOCOL_VERSION_TEXT, ProjectId, + ThreadId, type AuthSessionState, type OrchestrationV2ShellSnapshot, OrchestrationV2ThreadDetailSnapshot, @@ -307,6 +308,50 @@ describe("authenticated environment HTTP requests", () => { }), ); + // MCP-created thread ids contain ":", which the request path percent-encodes. + // The DPoP proof must sign the URL that is actually sent, or the environment + // rejects it as a URL mismatch. + const MCP_THREAD_ID = ThreadId.make("mcp:3534bc83-1c17-4a1e-9118-601c2766d355"); + const MCP_THREAD_LOADERS: ReadonlyArray< + Pick<(typeof LOADERS)[number], "name" | "response" | "load"> + > = [ + { + name: "thread snapshot", + response: encodeThreadSnapshot(THREAD), + load: (input: HttpInput) => + ThreadSnapshotLoader.fetchEnvironmentThreadSnapshot({ ...input, threadId: MCP_THREAD_ID }), + }, + { + name: "bounded thread snapshot", + response: encodeBoundedSnapshot(BOUNDED_THREAD), + load: (input: HttpInput) => + fetchEnvironmentBoundedThreadSnapshot({ ...input, threadId: MCP_THREAD_ID }), + }, + { + name: "older thread history", + response: THREAD_HISTORY, + load: (input: HttpInput) => + fetchEnvironmentThreadHistoryPage({ + ...input, + threadId: MCP_THREAD_ID, + cursor: "older-page", + }), + }, + ]; + it.effect.each(MCP_THREAD_LOADERS)( + "signs the sent URL for a $name of a thread id that needs encoding", + (loader) => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json(loader.response)); + yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer)); + + const sent = new URL(harness.calls[0]!.url); + expect(sent.pathname).toContain("/mcp%3A3534bc83-"); + sent.search = ""; + expect(harness.proofs.map((proof) => proof.url)).toEqual([sent.toString()]); + }), + ); + it.effect("retries a rejected diff once with a new token, endpoint, and proof", () => Effect.gen(function* () { const harness = makeHarness((requestNumber) => diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index b83cb6f20c356..316e0567305a6 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -30,7 +30,10 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( group: "orchestration", method: "GET", url: (httpBaseUrl) => - environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}/history`), + environmentEndpointUrl( + httpBaseUrl, + `/api/orchestration/threads/${encodeURIComponent(input.threadId)}/history`, + ), timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_HISTORY_TIMEOUT_MS, request: ({ client, headers }) => client.threadHistoryPage({ diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 8b31e4c60687f..905d8872d589b 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -66,7 +66,10 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( group: "orchestration", method: "GET", url: (httpBaseUrl) => - environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`), + environmentEndpointUrl( + httpBaseUrl, + `/api/orchestration/threads/${encodeURIComponent(input.threadId)}`, + ), timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, request: ({ client, headers }) => client.threadSnapshot({ From 16239118841d208d2259137fefd9fb7932357da9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:33:10 +0000 Subject: [PATCH 011/143] perf(server): defer startup maintenance and parallelize editor discovery --- .../server/src/httpResponseErrorGuard.test.ts | 53 ++++++++++- apps/server/src/httpResponseErrorGuard.ts | 30 ++++++ .../PullRequestSyncReactor.ts | 4 +- .../ThreadPullRequestService.ts | 4 +- .../ThreadSettlementService.ts | 4 +- apps/server/src/process/externalLauncher.ts | 32 +++---- apps/server/src/provider/ProviderHostLive.ts | 5 + .../src/pullRequest/PullRequestReadCache.ts | 6 +- apps/server/src/server.ts | 17 +++- apps/server/src/serverActivation.test.ts | 45 +++++++++ apps/server/src/serverActivation.ts | 92 +++++++++++++++++++ .../src/serverRuntimeStartup.autoPull.test.ts | 3 + apps/server/src/serverRuntimeStartup.ts | 28 +++--- apps/server/src/storageCleanup.ts | 4 +- apps/server/src/telemetry/AnalyticsService.ts | 13 ++- apps/server/src/ws.ts | 2 + .../provider-core/src/server/ProviderHost.ts | 6 ++ .../src/server/managedProvider.ts | 4 +- 18 files changed, 303 insertions(+), 49 deletions(-) diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index 983547b5bcc0d..fe61e62ae614f 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -3,7 +3,10 @@ import * as NodeHttp from "node:http"; import * as NodeNet from "node:net"; import { afterEach, describe, expect, it } from "vite-plus/test"; -import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; +import { + guardHttpResponseWriteErrors, + holdRequestsUntilServing, +} from "./httpResponseErrorGuard.ts"; const servers: NodeHttp.Server[] = []; @@ -102,3 +105,51 @@ describe("guardHttpResponseWriteErrors", () => { expect(writeErrors).toEqual([]); }); }); + +describe("holdRequestsUntilServing", () => { + it("answers requests and upgrades accepted before the handlers attach", async () => { + const server = holdRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())); + const port = await listen(server); + const requestsParked = Promise.withResolvers(); + let parsedRequests = 0; + server.on("connection", (socket) => + socket.once("data", () => { + if (++parsedRequests === 2) requestsParked.resolve(); + }), + ); + + const response = fetchStatus(port, "/early"); + const upgradeResponse = new Promise((resolve, reject) => { + const client = NodeNet.connect(port, "127.0.0.1", () => { + client.write( + [ + "GET /ws HTTP/1.1", + "Host: 127.0.0.1", + "Connection: Upgrade", + "Upgrade: websocket", + "", + "", + ].join("\r\n"), + ); + }); + client.once("data", (data) => { + resolve(data.toString().split("\r\n")[0] ?? ""); + client.destroy(); + }); + client.on("error", reject); + }); + // Node's HTTP parser handles data before this observer. + await requestsParked.promise; + + server.on("request", (_request, response) => { + response.writeHead(204); + response.end(); + }); + server.on("upgrade", (_request, socket) => { + socket.end("HTTP/1.1 101 Switching Protocols\r\n\r\n"); + }); + + await expect(response).resolves.toBe(204); + await expect(upgradeResponse).resolves.toBe("HTTP/1.1 101 Switching Protocols"); + }); +}); diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts index d673eaa42bec5..8924f3d3373bf 100644 --- a/apps/server/src/httpResponseErrorGuard.ts +++ b/apps/server/src/httpResponseErrorGuard.ts @@ -37,3 +37,33 @@ export function guardHttpResponseWriteErrors( }); return server; } + +/** + * `NodeHttpServer` listens while its layer builds but attaches its request + * and upgrade handlers only when `HttpRouter.serve` runs, after the whole + * runtime graph: about a second at startup. Node accepts connections in that + * window, and with no handler they were never answered, so a client that + * reconnected into it waited out its socket timeout. Park those requests and + * hand them to the first real handler, which answers them like any request + * that arrived a moment later. + */ +export function holdRequestsUntilServing(server: T): T { + for (const event of ["request", "upgrade"] as const) { + const parked: Array> = []; + const park = (...args: ReadonlyArray) => { + parked.push(args); + }; + const onNewListener = (name: string | symbol) => { + if (name !== event) return; + server.off("newListener", onNewListener); + server.off(event, park); + // "newListener" fires before the listener is added. + setImmediate(() => { + for (const args of parked.splice(0)) server.emit(event, ...args); + }); + }; + server.on(event, park); + server.on("newListener", onNewListener); + } + return server; +} diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index aed8d15f03329..31a3764e81d36 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -31,7 +31,7 @@ import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground, forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { isTerminalRunStatus } from "./ThreadManagementService.ts"; @@ -472,7 +472,7 @@ export const make = Effect.gen(function* () { } }).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), ); - yield* forkParked( + yield* forkBackground( Effect.gen(function* () { yield* worker.enqueue("all"); yield* worker.drain; diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index cd88d93977768..e858c10804da9 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -26,7 +26,7 @@ import * as GitManager from "../git/GitManager.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground, forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; class ThreadPullRequestServiceV2 extends Context.Service< @@ -390,7 +390,7 @@ export const make = Effect.gen(function* () { "ThreadPullRequestServiceV2.start", )(function* () { yield* forkParked(Stream.runForEach(orchestrator.streamDomainEvents, processEvent)); - yield* forkParked( + yield* forkBackground( Effect.gen(function* () { yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); yield* worker.drain; diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 8b55e25cdc393..e2df108c065c6 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -23,7 +23,7 @@ import * as GitManager from "../git/GitManager.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground, forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -632,7 +632,7 @@ export const make = Effect.gen(function* () { const events = orchestrator.streamDomainEvents; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); - yield* forkParked( + yield* forkBackground( Effect.gen(function* () { yield* worker.enqueue(undefined); yield* worker.drain; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 6215f8653ffca..50500cdaa8673 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -419,23 +419,20 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { - const available: EditorId[] = []; - - for (const editor of EDITORS) { - if (editor.commands === null) { - if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { - available.push(editor.id); - } - continue; - } - - const command = yield* resolveEditorCommand(editor, env); - if (Option.isSome(command)) { - available.push(editor.id); - } - } - - return available; + // Each check is a few PATH lookups over shared directory listings, plus one + // `xdg-mime` spawn for the Linux file manager, so they run together. The + // result keeps EDITORS order. + const found = yield* Effect.forEach( + EDITORS, + (editor) => + editor.commands === null + ? resolveUsableFileManagerCommand(platform, env).pipe( + Effect.map((command) => command !== undefined), + ) + : resolveEditorCommand(editor, env).pipe(Effect.map(Option.isSome)), + { concurrency: "unbounded" }, + ); + return EDITORS.flatMap((editor, index) => (found[index] ? [editor.id] : [])); }); const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( @@ -815,7 +812,6 @@ export const make = Effect.gen(function* () { return scan; }).pipe(Effect.uninterruptible); const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); - return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, resolveFileManagerRevealKind: () => diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 14d08de50ee97..e957e85fc03bf 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -14,6 +14,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerActivation from "../serverActivation.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; @@ -25,6 +26,7 @@ export const layer = Layer.effect( const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; + const backgroundStart = yield* ServerActivation.ServerBackgroundStart; return ProviderHost.ProviderHost.of({ paths: { cwd: config.cwd, @@ -40,6 +42,9 @@ export const layer = Layer.effect( subscribe: serverSettings.subscribeChanges, }, shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, + ...(backgroundStart === undefined + ? {} + : { awaitStartupProbe: backgroundStart.awaitFirstClient }), resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), credentials: (namespace, bindingId) => diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index a749a14031fd3..cfffff91bc8e4 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -21,7 +21,7 @@ import * as Persistable from "effect/persistence/Persistable"; import * as PersistedCache from "effect/persistence/PersistedCache"; import * as Persistence from "effect/persistence/Persistence"; import * as ServerConfig from "../config.ts"; -import { forkParked } from "../serverActivation.ts"; +import { forkBackground } from "../serverActivation.ts"; const CONCURRENT_READS = 512; // Persistence prefixes every entry key with the store id, so each entry file @@ -220,9 +220,9 @@ export const layer = Layer.unwrap( return Layer.effect(PullRequestReadCache, make).pipe( Layer.provide( KeyValueStore.layerFileSystem(directory).pipe( - // Prunes once the server is active, then every hour. + // Prunes once startup work may run, then every hour. Layer.tap(() => - forkParked( + forkBackground( pruneExpiredEntryFiles(directory).pipe( Effect.catch((cause) => Effect.logWarning("Failed to prune PR cache files", { cause }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1bbe647dcd0cf..133e204be59fe 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -28,7 +28,10 @@ import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; -import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; +import { + guardHttpResponseWriteErrors, + holdRequestsUntilServing, +} from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -276,7 +279,9 @@ const layerRelayClient = Layer.unwrap( const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), { + const createServer = () => + holdRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())); + return NodeHttpServer.layer(createServer, { host: config.host ?? "127.0.0.1", port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, @@ -747,7 +752,13 @@ const layerMakeServer = Layer.unwrap( const config = yield* ServerConfig.ServerConfig; const activation = yield* Deferred.make(); const awaitActivation = Deferred.await(activation); - const layerActivation = Layer.succeed(ServerActivation.ServerActivation, awaitActivation); + const layerActivation = Layer.merge( + Layer.succeed(ServerActivation.ServerActivation, awaitActivation), + Layer.effect( + ServerActivation.ServerBackgroundStart, + ServerActivation.makeBackgroundStart(awaitActivation), + ), + ); const runtimeStateParked = yield* Deferred.make(); const tailscaleParked = yield* Deferred.make(); const cloudLinkParked = yield* Deferred.make(); diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts index dc41b884ab36c..be2d23d0a3580 100644 --- a/apps/server/src/serverActivation.test.ts +++ b/apps/server/src/serverActivation.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { TestClock } from "effect/testing"; import * as ServerActivation from "./serverActivation.ts"; @@ -21,3 +23,46 @@ it.effect("proves a root is parked before returning and releases it with one gat }), ), ); + +it.effect("defers maintenance until the client loads and staggers its startup work", () => + Effect.scoped( + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const firstRan = yield* Deferred.make(); + const secondRan = yield* Deferred.make(); + const background = yield* ServerActivation.makeBackgroundStart(Deferred.await(activation)); + yield* ServerActivation.forkBackground(Deferred.succeed(firstRan, undefined)).pipe( + Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), + Effect.provideService(ServerActivation.ServerBackgroundStart, background), + ); + yield* ServerActivation.forkBackground(Deferred.succeed(secondRan, undefined)).pipe( + Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), + Effect.provideService(ServerActivation.ServerBackgroundStart, background), + ); + yield* Deferred.succeed(activation, undefined); + yield* TestClock.adjust("10 seconds"); + expect(yield* Deferred.isDone(firstRan)).toBe(false); + expect(yield* Deferred.isDone(secondRan)).toBe(false); + yield* background.markClientServed; + yield* Deferred.await(firstRan); + expect(yield* Deferred.isDone(secondRan)).toBe(false); + yield* TestClock.adjust("2 seconds"); + yield* Deferred.await(secondRan); + }), + ), +); + +it.effect("eventually starts probes and maintenance even when no client connects", () => + Effect.scoped( + Effect.gen(function* () { + const background = yield* ServerActivation.makeBackgroundStart(Effect.void); + const firstClient = yield* background.awaitFirstClient.pipe(Effect.forkScoped); + const maintenance = yield* background.awaitTurn.pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(firstClient); + expect(maintenance.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(maintenance); + }), + ), +); diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts index 21326973538db..44c5195f09753 100644 --- a/apps/server/src/serverActivation.ts +++ b/apps/server/src/serverActivation.ts @@ -1,5 +1,7 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import type * as Fiber from "effect/Fiber"; import type * as Scope from "effect/Scope"; @@ -30,3 +32,93 @@ export const forkParkedFiber = ( export const forkParked = ( effect: Effect.Effect, ): Effect.Effect => forkParkedFiber(effect).pipe(Effect.asVoid); + +export interface BackgroundStart { + /** Opens once the first client has its config, or a while after activation without one. */ + readonly awaitFirstClient: Effect.Effect; + /** Waits for this caller's staggered start once startup has gone quiet. */ + readonly awaitTurn: Effect.Effect; + readonly markClientServed: Effect.Effect; +} + +/** + * Gate for startup work no client waits on: settlement and pull request + * sweeps, cache prunes, first provider probes, the boot heartbeat. Started at + * activation, that work spawned over a hundred processes and kept the event + * loop busy for seconds while the first client loaded. Unset (tests, CLI + * tools) means start now. + */ +export class ServerBackgroundStart extends Context.Reference( + "t3/serverBackgroundStart", + { defaultValue: () => undefined }, +) {} + +const FIRST_CLIENT_WAIT = Duration.seconds(10); +const QUIET_AFTER_ACTIVATION = Duration.seconds(10); +const QUIET_MAX_WAIT = Duration.seconds(30); +const TURN_STAGGER_MILLIS = 2_000; + +/** + * Provider probes start once the first client has its config. Maintenance + * starts once that client is served and the server has been active for + * `QUIET_AFTER_ACTIVATION` (at most `QUIET_MAX_WAIT` after activation), one + * job every `TURN_STAGGER_MILLIS`. Callers that arrive later start at once. + */ +export const makeBackgroundStart = (activation: Effect.Effect) => + Effect.gen(function* () { + const clientServed = yield* Deferred.make(); + const firstClient = yield* Deferred.make(); + const quiet = yield* Deferred.make(); + const awaitClientServed = Deferred.await(clientServed); + yield* activation.pipe( + Effect.andThen( + Effect.all( + [ + awaitClientServed.pipe( + Effect.timeoutOption(FIRST_CLIENT_WAIT), + Effect.andThen(Deferred.succeed(firstClient, undefined)), + ), + Effect.all([Effect.sleep(QUIET_AFTER_ACTIVATION), awaitClientServed], { + concurrency: "unbounded", + }).pipe( + Effect.timeoutOption(QUIET_MAX_WAIT), + Effect.andThen(Deferred.succeed(quiet, undefined)), + ), + ], + { concurrency: "unbounded" }, + ), + ), + Effect.forkScoped, + ); + let nextStartAt = 0; + return { + awaitFirstClient: Deferred.await(firstClient), + awaitTurn: Deferred.await(quiet).pipe( + Effect.andThen(Clock.currentTimeMillis), + Effect.flatMap((now) => { + const startAt = Math.max(now, nextStartAt); + nextStartAt = startAt + TURN_STAGGER_MILLIS; + return startAt > now ? Effect.sleep(Duration.millis(startAt - now)) : Effect.void; + }), + ), + markClientServed: Deferred.succeed(clientServed, undefined).pipe(Effect.asVoid), + } satisfies BackgroundStart; + }); + +/** See `BackgroundStart.awaitTurn`. */ +export const awaitBackgroundTurn: Effect.Effect = Effect.gen(function* () { + const gate = yield* ServerBackgroundStart; + if (gate !== undefined) yield* gate.awaitTurn; +}); + +/** See `BackgroundStart.markClientServed`. */ +export const markClientServed: Effect.Effect = Effect.gen(function* () { + const gate = yield* ServerBackgroundStart; + if (gate !== undefined) yield* gate.markClientServed; +}); + +/** `forkParked` for deferrable startup work: it also waits for its background turn. */ +export const forkBackground = ( + effect: Effect.Effect, +): Effect.Effect => + forkParked(Effect.andThen(awaitBackgroundTurn, effect)); diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 8313d33dc220d..030f36e404969 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -1,6 +1,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Deferred from "effect/Deferred"; @@ -170,6 +171,8 @@ it.effect("parks automatic pull until activation without delaying command readin address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773), }), Path.layer, + // The boot heartbeat counts threads; an empty database is enough here. + NodeSqliteClient.layer({ filename: ":memory:" }).pipe(Layer.orDie), ); yield* Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1ca44979b246c..429fbf8686619 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -24,6 +24,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "./config.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; @@ -43,7 +44,7 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; -import { forkParked, forkParkedFiber } from "./serverActivation.ts"; +import { forkBackground, forkParked, forkParkedFiber } from "./serverActivation.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -146,15 +147,18 @@ export const makeCommandGate = Effect.gen(function* () { const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; const projects = yield* ProjectService.ProjectService; - const threads = yield* ThreadManagement.ThreadManagementService; + const sql = yield* SqlClient.SqlClient; const { threadCount, projectCount } = yield* Effect.all({ projects: projects.snapshot, - threads: threads.getShellSnapshot(), + // The threads the shell snapshot lists, counted without building it. + threads: sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL + `, }).pipe( - Effect.map(({ projects: projectSnapshot, threads: shellSnapshot }) => ({ + Effect.map(({ projects: projectSnapshot, threads: rows }) => ({ projectCount: projectSnapshot.projects.length, - threadCount: shellSnapshot.threads.length + shellSnapshot.archivedThreads.length, + threadCount: rows[0]?.count ?? 0, })), Effect.catch((cause) => Effect.logWarning("failed to gather V2 startup counts for telemetry", { @@ -579,15 +583,15 @@ const make = (options?: StartupOptions) => : importPendingTranscripts ).pipe(forkParked); + yield* forkBackground( + recordStartupHeartbeat.pipe( + Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), + Effect.withSpan("server.startup.heartbeat.record"), + Effect.ignoreCause({ log: true }), + ), + ); yield* forkParked( Effect.gen(function* () { - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* recordStartupHeartbeat.pipe( - Effect.withSpan("server.startup.heartbeat.record", { - attributes: { "startup.phase": "heartbeat.record" }, - }), - Effect.ignoreCause({ log: true }), - ); if (serverConfig.startupPresentation === "headless") { yield* Effect.logDebug("startup phase: headless access info"); const accessInfo = yield* issueHeadlessServeAccessInfo(); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d823e84e45ddc..780fd569ea209 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -32,7 +32,7 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; -import { forkParked } from "./serverActivation.ts"; +import { forkBackground, forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; @@ -516,7 +516,7 @@ export const make = Effect.gen(function* () { const changes = yield* settingsService.subscribeChanges; const events = engine.streamDomainEvents; let lastSettings = yield* settingsService.getSettings.pipe(Effect.orDie); - yield* forkParked( + yield* forkBackground( worker .enqueue(undefined) .pipe( diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 3d10e93a26d6a..2fee7d3185fc3 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -29,6 +29,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import { awaitBackgroundTurn } from "../serverActivation.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; interface BufferedAnalyticsEvent { @@ -276,9 +277,15 @@ export const make = Effect.gen(function* () { }, ); - yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { - disableYield: true, - }).pipe(Effect.forkScoped); + // A starting server sends its first batch once startup work may run. + yield* awaitBackgroundTurn.pipe( + Effect.andThen( + Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { + disableYield: true, + }), + ), + Effect.forkScoped, + ); yield* Effect.addFinalizer(() => flush); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1c90d94c50eef..99efa9002dd53 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -173,6 +173,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as ServerActivation from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -2954,6 +2955,7 @@ const layerWsRpc = ( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; const config = yield* loadServerConfig({ usageLimitsCommand }); + yield* ServerActivation.markClientServed; const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 858a7c1e22d1a..7c1dd51baa9c6 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -62,6 +62,12 @@ export class ProviderHost extends Context.Service< }; /** Whether background work for `scope` should run now (client demand and host power). */ readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; + /** + * Resolves when a provider's first status probe may run. A starting server + * holds it until its first client has loaded; hosts without a startup + * phase omit it. + */ + readonly awaitStartupProbe?: Effect.Effect; /** * Absolute path of a stored chat attachment, or `null` when its id does not * resolve inside the attachments directory. diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index b74788a38bada..c5bca887adbeb 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -276,7 +276,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), ).pipe(Effect.forkScoped); - yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( + // Until it lands, clients see the cached or pending snapshot. + yield* (host.awaitStartupProbe ?? Effect.void).pipe( + Effect.andThen(refreshSnapshot()), Effect.ignoreCause({ log: true }), Effect.forkScoped, ); From 777b5e9a42e8b06e5f5e0a3222e006c95ef635ce Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:33:10 +0000 Subject: [PATCH 012/143] perf(server): compact thread sync events and index background sweep fields --- .../src/orchestration-v2/Orchestrator.ts | 67 +++++++++---- .../orchestration-v2/ProjectionStore.test.ts | 63 +++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 96 +++++++++++++++---- .../ThreadSettlementService.ts | 1 + .../boundedSnapshotTransport.test.ts | 49 ++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 30 ++++++ apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 41 +++++++- .../061_ProjectionThreadSweepColumns.ts | 69 +++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 2 + apps/server/src/ws.ts | 47 ++++++++- .../state/orchestrationV2Projection.test.ts | 55 +++++++++++ .../src/state/orchestrationV2Projection.ts | 8 ++ packages/contracts/src/orchestrationV2.ts | 44 +++++++++ packages/shared/src/threadPullRequests.ts | 36 +++++++ 15 files changed, 566 insertions(+), 44 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 977b225f52566..beae11b1f06c6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2293,20 +2293,59 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const movesForward = thread.lastVisitedAt === null || DateTime.toEpochMillis(visitedAt.value) > DateTime.toEpochMillis(thread.lastVisitedAt); - // Viewing a thread changes read state only. Loading its transcript (or - // bumping updatedAt) makes a routine read receipt scale with its history. + // Viewing a thread changes read state only. Loading its transcript, bumping + // updatedAt, or recording the whole thread makes a routine read receipt + // scale with its history. yield* emit( events, command, )({ - type: "thread.visited", + type: "thread.visit-recorded", threadId: command.threadId, providerInstanceId: thread.providerInstanceId, occurredAt: yield* DateTime.now, - payload: movesForward ? { ...thread, lastVisitedAt: visitedAt.value } : thread, + payload: { lastVisitedAt: movesForward ? visitedAt.value : thread.lastVisitedAt }, }); }); + // The sync reactor writes one of these per linked thread whenever a pull request changes on + // its host, so the event records only that link's snapshot, not the thread. + const dispatchPullRequestLinkSync = Effect.fn("orchestrationV2.dispatch.pullRequestLinkSync")( + function* ( + command: Extract, + events: Ref.Ref>, + ) { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is deleted.`, + }); + } + yield* emit( + events, + command, + )({ + type: "thread.pull-request-link-synced", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: { + ...normalizeThreadPullRequestKey(command), + snapshot: command.snapshot, + stack: command.stack, + }, + }); + }, + ); + // Checked under the thread lock: the watch or the thread can change while the host is read. // The watch is recorded first so the wake's own thread events carry it. const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( @@ -2384,7 +2423,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.metadata.update" | "thread.pull-request.link" | "thread.pull-request.unlink" - | "thread.pull-request-link.sync" | "thread.pull-request.watch" | "thread.pull-request-watch.sync" | "thread.pull-request.sync" @@ -2977,8 +3015,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } case "thread.pull-request.link": - case "thread.pull-request.unlink": - case "thread.pull-request-link.sync": { + case "thread.pull-request.unlink": { const key = normalizeThreadPullRequestKey(command); const links = threadPullRequestsOf(thread); const existing = links.find((link) => threadPullRequestKeysEqual(link, key)); @@ -3022,7 +3059,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...links.filter((entry) => !threadPullRequestKeysEqual(entry, key)), link, ]; - } else if (command.type === "thread.pull-request.unlink") { + } else { if (!existing) return thread; const belongsToStack = existing.source === "stack" || @@ -3043,13 +3080,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : link, ) : links.filter((link) => link !== existing); - } else { - if (!existing) return thread; - pullRequests = links.map((link) => - link === existing - ? { ...link, snapshot: command.snapshot, stack: command.stack } - : link, - ); } return { ...thread, @@ -3066,7 +3096,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) ? thread.linkedPullRequest : null, - updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now, + updatedAt: now, }; } case "thread.pull-request.watch": @@ -3212,7 +3242,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.metadata-updated" as const; case "thread.pull-request.link": case "thread.pull-request.unlink": - case "thread.pull-request-link.sync": case "thread.pull-request.watch": case "thread.pull-request-watch.sync": case "thread.pull-request.sync": @@ -10232,7 +10261,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.metadata.update": case "thread.pull-request.link": case "thread.pull-request.unlink": - case "thread.pull-request-link.sync": case "thread.pull-request.watch": case "thread.pull-request.sync": case "thread.title.regeneration.complete": @@ -10242,6 +10270,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider.switch": yield* dispatchThreadMutation(command, events, effects); break; + case "thread.pull-request-link.sync": + yield* dispatchPullRequestLinkSync(command, events); + break; case "thread.pull-request-watch.sync": yield* dispatchPullRequestWatchSync(command, events, effects); break; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index e8a3c1bcfc08e..fc0a809c7191b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1939,6 +1939,18 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.deepEqual(visited.thread.lastVisitedAt, createdAt); assert.deepEqual(visited.thread.updatedAt, createdAt); + yield* projectionStore.apply({ + id: EventId.make("event:projection-read-state:visit-recorded"), + type: "thread.visit-recorded", + threadId, + occurredAt: markedUnreadOccurredAt, + payload: { lastVisitedAt: visitedOccurredAt }, + }); + const recorded = yield* projectionStore.getThreadProjection(threadId); + assert.deepEqual(recorded.thread.lastVisitedAt, visitedOccurredAt); + assert.deepEqual(recorded.thread.updatedAt, createdAt); + assert.equal(recorded.thread.title, thread.title); + yield* projectionStore.apply({ id: EventId.make("event:projection-read-state:marked-unread"), type: "thread.marked-unread", @@ -2632,7 +2644,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ["settledOverride", "settled"], ]) { yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = json_set(payload_json, ${`$.${field}`}, ${value}) + SET payload_json = json_set(payload_json, ${`$.${field}`}, ${value}), + archived_at = CASE WHEN ${field} = 'archivedAt' THEN ${value} ELSE archived_at END, + settled_override = CASE WHEN ${field} = 'settledOverride' THEN ${value} ELSE settled_override END WHERE thread_id = ${threadId}`; assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ now, autoResume: true, snooze: false })).find( @@ -2640,7 +2654,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), ); yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = ${originalRow!.payload_json} WHERE thread_id = ${threadId}`; + SET payload_json = ${originalRow!.payload_json}, archived_at = NULL, settled_override = NULL + WHERE thread_id = ${threadId}`; } yield* sql`UPDATE orchestration_v2_projection_threads SET deleted_at = ${DateTime.formatIso(now)} WHERE thread_id = ${threadId}`; assert.isUndefined( @@ -4811,6 +4826,50 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ]); assert.equal(yield* phase, "running"); + const snapshot = { + state: "open" as const, + title: "Updated pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(at), + syncedAt: DateTime.formatIso(at), + }; + yield* store.apply({ + id: EventId.make("event:watched-pull-request:link-synced"), + type: "thread.pull-request-link-synced", + threadId, + occurredAt: DateTime.add(at, { seconds: 1 }), + payload: { + host: link.host, + repository: link.repository, + number: link.number, + snapshot, + stack: null, + }, + }); + const synced = yield* store.getThread(threadId); + assert.deepEqual(synced.pullRequests?.[0]?.snapshot, snapshot); + assert.isDefined(synced.pullRequests?.[0]?.watch); + assert.deepEqual(synced.updatedAt, at); + assert.equal(yield* phase, "running"); + assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); + + yield* store.apply({ + id: EventId.make("event:watched-pull-request:missing-link-synced"), + type: "thread.pull-request-link-synced", + threadId, + occurredAt: DateTime.add(at, { seconds: 2 }), + payload: { + host: link.host, + repository: link.repository, + number: 999, + snapshot, + stack: null, + }, + }); + assert.deepEqual((yield* store.getThread(threadId)).pullRequests, synced.pullRequests); + yield* syncPullRequests("unwatched", [link]); assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, []); assert.equal(yield* phase, "completed"); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 8c33faef808b2..1c007807135a7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -12,7 +12,10 @@ import { threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { + applyThreadPullRequestLinkSync, + threadPullRequestsOf, +} from "@t3tools/shared/threadPullRequests"; import type { OrchestrationV2AppThread, OrchestrationV2CheckpointScope, @@ -716,6 +719,11 @@ export function applyToProjection( ...base, thread: event.payload, }; + case "thread.pull-request-link-synced": + return { + ...base, + thread: applyThreadPullRequestLinkSync(projection.thread, event.payload), + }; // Visited tracking is read state, not activity: skip the updatedAt bump so // viewing a thread does not surface it as recently active. case "thread.visited": @@ -724,6 +732,11 @@ export function applyToProjection( ...projection, thread: event.payload, }; + case "thread.visit-recorded": + return { + ...projection, + thread: { ...projection.thread, lastVisitedAt: event.payload.lastVisitedAt }, + }; case "run.created": case "run.updated": return withLocalVisibleTurnItems({ @@ -1835,6 +1848,12 @@ export const layer: Layer.Layer = updated_at, archived_at, deleted_at, + settled_at, + settled_override, + pinned_at, + auto_settle_disabled_at, + forked_from_run_thread_id, + pull_request_count, payload_json ) VALUES ( @@ -1850,6 +1869,12 @@ export const layer: Layer.Layer = ${stringField(payload, "updatedAt")}, ${nullableStringField(payload, "archivedAt")}, ${nullableStringField(payload, "deletedAt")}, + ${nullableStringField(payload, "settledAt")}, + ${nullableStringField(payload, "settledOverride")}, + ${nullableStringField(payload, "pinnedAt")}, + ${nullableStringField(payload, "autoSettleDisabledAt")}, + ${event.payload.forkedFrom?.type === "run" ? event.payload.forkedFrom.threadId : null}, + ${event.payload.pullRequests?.length ?? 0}, ${payloadJson} ) ON CONFLICT(thread_id) @@ -1865,10 +1890,44 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, archived_at = excluded.archived_at, deleted_at = excluded.deleted_at, + settled_at = excluded.settled_at, + settled_override = excluded.settled_override, + pinned_at = excluded.pinned_at, + auto_settle_disabled_at = excluded.auto_settle_disabled_at, + forked_from_run_thread_id = excluded.forked_from_run_thread_id, + pull_request_count = excluded.pull_request_count, payload_json = excluded.payload_json `; break; } + // 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": { + 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) 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 payloadJson = yield* encodeThreadPayload(updatedThread); + yield* sql` + UPDATE orchestration_v2_projection_threads + SET + updated_at = ${stringField(parseEncodedPayload(payloadJson), "updatedAt")}, + pull_request_count = ${updatedThread.pullRequests?.length ?? 0}, + payload_json = ${payloadJson} + WHERE thread_id = ${event.threadId} + `; + break; + } case "run.created": case "run.updated": { const payloadJson = yield* encodeRunPayload(event.payload); @@ -2643,6 +2702,8 @@ export const layer: Layer.Layer = event.type !== "thread.unpinned" && event.type !== "thread.pin-reordered" && event.type !== "thread.visited" && + event.type !== "thread.visit-recorded" && + event.type !== "thread.pull-request-link-synced" && event.type !== "thread.marked-unread" && event.type !== "thread.metadata-updated" && event.type !== "thread.runtime-mode-updated" && @@ -3485,11 +3546,14 @@ export const layer: Layer.Layer = LIMIT 1 ) WHERE t.deleted_at IS NULL - AND json_extract(t.payload_json, '$.archivedAt') IS NULL - AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled' + AND t.archived_at IS NULL + AND t.settled_override IS NOT 'settled' + AND t.thread_id IN ( + SELECT thread_id FROM orchestration_v2_projection_runs WHERE status = 'failed' + ) AND json_extract(item.payload_json, '$.failure.class') = 'usage_limit' AND json_extract(item.payload_json, '$.failure.resetAt') IS NOT NULL - AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) > julianday(COALESCE(r.completed_at, json_extract(t.payload_json, '$.updatedAt'))) + AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) > julianday(COALESCE(r.completed_at, t.updated_at)) AND ( ( json_extract(t.payload_json, '$.limitRecovery.runId') IS r.run_id @@ -5120,11 +5184,7 @@ export const layer: Layer.Layer = SELECT t.thread_id, t.payload_json, - CASE - WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' - THEN json_extract(t.payload_json, '$.forkedFrom.threadId') - ELSE NULL - END AS forked_from_run_source_thread_id, + t.forked_from_run_thread_id AS forked_from_run_source_thread_id, presented.run_id AS latest_run_id, presented.status AS latest_run_status, presented.requested_at AS latest_run_requested_at, @@ -5291,13 +5351,13 @@ export const layer: Layer.Layer = ) AND blocked.status = 'failed' WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}${ location === "active" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + ? sql` AND t.archived_at IS NULL` : location === "archive" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NOT NULL` + ? sql` AND t.archived_at IS NOT NULL` : sql`` }${ unsettledOnly - ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` + ? sql` AND t.settled_at IS NULL AND t.settled_override IS NOT 'settled'` : sql`` } ORDER BY t.updated_at ASC, t.thread_id ASC @@ -5462,10 +5522,10 @@ export const layer: Layer.Layer = LIMIT 1 ) WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} - AND json_extract(t.payload_json, '$.archivedAt') IS NULL - AND json_extract(t.payload_json, '$.settledOverride') IS NULL - AND json_extract(t.payload_json, '$.pinnedAt') IS NULL - AND json_extract(t.payload_json, '$.autoSettleDisabledAt') IS NULL + AND t.archived_at IS NULL + AND t.settled_override IS NULL + AND t.pinned_at IS NULL + AND t.auto_settle_disabled_at IS NULL AND NOT EXISTS ( SELECT 1 FROM orchestration_v2_projection_runs active WHERE active.thread_id = t.thread_id @@ -5548,8 +5608,8 @@ export const layer: Layer.Layer = SELECT payload_json FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} - AND json_extract(payload_json, '$.archivedAt') IS NULL - AND json_array_length(payload_json, '$.pullRequests') > 0 + AND archived_at IS NULL + AND pull_request_count > 0 ORDER BY updated_at ASC, thread_id ASC `; return yield* Effect.forEach(rows, (row) => diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index e2df108c065c6..8c599b23231c7 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -607,6 +607,7 @@ export const make = Effect.gen(function* () { case "thread.settled": return cleanUpSettledThread(event.threadId); case "thread.pull-request-synced": + case "thread.pull-request-link-synced": case "provider-session.detached": return worker.enqueue(event.threadId); case "provider-session.updated": diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index 58bb29bede2e2..b35971777fab7 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -344,6 +344,55 @@ function itemIds(projection: OrchestrationV2ThreadProjection): string[] { } it.layer(TestLayer)("compact bounded snapshot transport", (it) => { + it.effect("keeps compact visits current for legacy and opted-in thread subscribers", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const at = yield* DateTime.now; + const event: OrchestrationV2DomainEvent = { + id: EventId.make("event:compact-transport:visit"), + type: "thread.visit-recorded", + threadId: PARENT, + occurredAt: at, + payload: { lastVisitedAt: at }, + }; + yield* projections.apply(event); + const liveManagement = Layer.mock(ThreadManagementService.ThreadManagementService)({ + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: (id) => projections.getThreadSnapshot(id).pipe(Effect.orDie), + getThreadSnapshotWindow: (id, options) => + projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), + getThreadRecords: (id, fields, filter) => + projections.getThreadRecords(id, fields, filter).pipe(Effect.orDie), + streamStoredEventsFrom: () => Stream.make({ sequence: 1, commandId: null, event }), + }); + for (const acceptThreadFieldEvents of [false, true]) { + const items = yield* subscribeOrchestrationV2Thread({ + threadId: PARENT, + acceptThreadFieldEvents, + }).pipe( + Effect.flatMap((stream) => Stream.take(stream, 2).pipe(Stream.runCollect)), + Effect.provide(liveManagement), + ); + const live = items[1]; + assert.equal(live?.kind, "event"); + if (live?.kind !== "event") return; + const decoded = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(live)))); + assert.equal(decoded.kind, "event"); + if (decoded.kind !== "event") return; + assert.equal( + decoded.event.type, + acceptThreadFieldEvents ? "thread.visit-recorded" : "thread.visited", + ); + if ( + decoded.event.type === "thread.visited" || + decoded.event.type === "thread.visit-recorded" + ) { + assert.deepEqual(decoded.event.payload.lastVisitedAt, at); + } + } + }), + ); + describe.each([ ["fork", FORK], ["long run with a retained interrupt request", LONG], diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b11184f446326..3f0c8c509b3c3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2094,6 +2094,36 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { number === 3 ? [1, 2, 3] : [1, 2], ); } + const beforeSync = (yield* orchestrator.getThreadRecords(threadId, [])).thread; + const syncedSnapshot = { + state: "open" as const, + title: "Updated host title", + headBranch: "feature/pr-links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-07-24T00:00:00.000Z", + syncedAt: "2026-07-24T00:00:00.000Z", + }; + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make("branch-pr-sync"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 2, + snapshot: syncedSnapshot, + stack: null, + }); + const afterSync = (yield* orchestrator.getThreadRecords(threadId, [])).thread; + assert.deepEqual( + afterSync.pullRequests?.map((link) => link.number), + [1, 2, 3], + ); + assert.deepEqual( + afterSync.pullRequests?.find((link) => link.number === 2)?.snapshot, + syncedSnapshot, + ); + assert.deepEqual(afterSync.updatedAt, beforeSync.updatedAt); yield* orchestrator.dispatch({ type: "thread.pull-request.unlink", commandId: CommandId.make("branch-pr-unlink"), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 35c1a3fdce4bb..15af266b217fc 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,6 +74,7 @@ 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_ProjectionThreadSweepColumns.ts"; /** * Migration loader with all migrations defined inline. @@ -148,6 +149,7 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], + [61, "ProjectionThreadSweepColumns", Migration0061], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 5e45ed05d3024..a032d941891cb 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 60 }, (_, index) => index + 1), + Array.from({ length: 61 }, (_, index) => index + 1), ); }), ); @@ -32,6 +32,7 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "ProjectionThreadSweepColumns"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -58,6 +59,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, + { migration_id: 61, name: "ProjectionThreadSweepColumns" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -131,4 +133,41 @@ layer("055_OrchestrationV2", (it) => { ); }), ); + + it.effect("preserves existing thread filters when adding sweep columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 60 }); + const payload = JSON.stringify({ + settledAt: "2026-07-24T00:00:00.000Z", + settledOverride: "settled", + pinnedAt: "2026-07-23T00:00:00.000Z", + autoSettleDisabledAt: "2026-07-22T00:00:00.000Z", + forkedFrom: { type: "run", threadId: "source-thread" }, + pullRequests: [{ number: 1 }, { number: 2 }], + }); + yield* sql` + INSERT INTO orchestration_v2_projection_threads ( + thread_id, project_id, title, default_provider, provider_instance_id, + runtime_mode, interaction_mode, created_at, updated_at, payload_json + ) VALUES ( + 'sweep-thread', 'sweep-project', 'Sweep thread', 'codex', 'codex', + 'full-access', 'default', '2026-07-24T00:00:00.000Z', + '2026-07-24T00:00:00.000Z', ${payload} + ) + `; + yield* runMigrations(); + const rows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id FROM orchestration_v2_projection_threads + WHERE deleted_at IS NULL AND archived_at IS NULL + AND settled_at = '2026-07-24T00:00:00.000Z' + AND settled_override = 'settled' + AND pinned_at = '2026-07-23T00:00:00.000Z' + AND auto_settle_disabled_at = '2026-07-22T00:00:00.000Z' + AND forked_from_run_thread_id = 'source-thread' + AND pull_request_count = 2 + `; + assert.deepStrictEqual(rows, [{ thread_id: "sweep-thread" }]); + }), + ); }); diff --git a/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts b/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts new file mode 100644 index 0000000000000..d1b77d15b9b01 --- /dev/null +++ b/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts @@ -0,0 +1,69 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +/** + * Background sweeps (settlement, pull request sync, the shell reads behind pull request + * discovery) filtered every thread with `json_extract` on its payload, which holds every linked + * pull request's snapshot: tens of KB parsed per thread per sweep. These columns copy the + * fields they filter on out of the payload. ProjectionStore's thread upsert keeps them current. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(orchestration_v2_projection_threads) + `; + const existing = new Set(columns.map((column) => column.name)); + for (const [name, definition] of [ + ["settled_at", "TEXT"], + ["settled_override", "TEXT"], + ["pinned_at", "TEXT"], + ["auto_settle_disabled_at", "TEXT"], + ["forked_from_run_thread_id", "TEXT"], + ["pull_request_count", "INTEGER NOT NULL DEFAULT 0"], + ] as const) { + if (!existing.has(name)) { + yield* sql.unsafe( + `ALTER TABLE orchestration_v2_projection_threads ADD COLUMN ${name} ${definition}`, + ).unprepared; + } + } + yield* sql` + UPDATE orchestration_v2_projection_threads + SET + settled_at = json_extract(payload_json, '$.settledAt'), + settled_override = json_extract(payload_json, '$.settledOverride'), + pinned_at = json_extract(payload_json, '$.pinnedAt'), + auto_settle_disabled_at = json_extract(payload_json, '$.autoSettleDisabledAt'), + forked_from_run_thread_id = CASE + WHEN json_extract(payload_json, '$.forkedFrom.type') = 'run' + THEN json_extract(payload_json, '$.forkedFrom.threadId') + ELSE NULL + END, + pull_request_count = COALESCE(json_array_length(payload_json, '$.pullRequests'), 0) + `; + // Covers the sweeps' filters and order without touching the payload. Columns added by + // ALTER TABLE sit after the payload in each row, so reading them from the table walks the + // payload's overflow pages. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_active_idx + ON orchestration_v2_projection_threads( + updated_at, + thread_id, + settled_at, + settled_override, + pinned_at, + auto_settle_disabled_at, + pull_request_count, + forked_from_run_thread_id, + provider_instance_id + ) + WHERE deleted_at IS NULL AND archived_at IS NULL + `; + // Usage-limit recovery only concerns threads whose latest run failed; this finds the few + // threads with any failed run without reading the rest. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_failed_idx + ON orchestration_v2_projection_runs(thread_id) + WHERE status = 'failed' + `; +}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f4d3e7b577238..10221c49ecd07 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -98,6 +98,7 @@ export function shouldPublishAgentAwarenessEvent( case "thread.deleted": case "thread.metadata-updated": case "thread.pull-request-synced": + case "thread.pull-request-link-synced": case "thread.model-selection-updated": case "thread.provider-switched": case "run.created": @@ -116,6 +117,7 @@ export function shouldPublishAgentAwarenessEvent( case "thread.pin-reordered": case "thread.active-reordered": case "thread.visited": + case "thread.visit-recorded": case "thread.marked-unread": case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 99efa9002dd53..8108cfa6a670b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -753,6 +753,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; readonly acceptCompactCheckpointItems?: boolean; + readonly acceptThreadFieldEvents?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -778,11 +779,37 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs afterSequence, }) .pipe( - Stream.map((stored) => ({ - kind: "event" as const, - sequence: stored.sequence, - event: projectDomainEventForWire(stored.event), - })), + Stream.mapEffect((stored) => + Effect.gen(function* () { + const event = stored.event; + if ( + input.acceptThreadFieldEvents !== true && + (event.type === "thread.pull-request-link-synced" || + event.type === "thread.visit-recorded") + ) { + // Older clients need the legacy full-thread event. The live stream reads + // committed metadata only; historical compact events use a fresh snapshot below. + const { thread } = yield* threadManagement.getThreadRecords(input.threadId, []); + return { + kind: "event" as const, + sequence: stored.sequence, + event: projectDomainEventForWire({ + ...event, + type: + event.type === "thread.visit-recorded" + ? "thread.visited" + : "thread.pull-request-synced", + payload: thread, + }), + }; + } + return { + kind: "event" as const, + sequence: stored.sequence, + event: projectDomainEventForWire(event), + }; + }), + ), coalesceThreadLiveStream, Stream.mapError( (cause) => @@ -924,6 +951,16 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs if (shell !== null) return yield* snapshotThenLive(); } const replay = yield* loadReplayThrough(input.afterSequence, highWater); + if ( + input.acceptThreadFieldEvents !== true && + replay.some( + (item) => + item.event.type === "thread.pull-request-link-synced" || + item.event.type === "thread.visit-recorded", + ) + ) { + return yield* snapshotThenLive(); + } const plan = decideThreadResume({ afterSequence: input.afterSequence, highWater, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index 1a53afb8d7e8b..1367753774009 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -105,6 +105,61 @@ const emptyProjection = { } as OrchestrationV2ThreadProjection; describe("applyOrchestrationV2ProjectionEvent", () => { + it("applies compact visits without changing activity or transcript state", () => { + const visitedAt = DateTime.add(now, { hours: 1 }); + const event = { + id: "event-visit-recorded", + type: "thread.visit-recorded", + threadId, + occurredAt: visitedAt, + payload: { lastVisitedAt: visitedAt }, + } as OrchestrationV2DomainEvent; + const next = applyOrchestrationV2ProjectionEvent(emptyProjection, event); + expect(next?.thread.lastVisitedAt).toEqual(visitedAt); + expect(next?.thread.updatedAt).toEqual(now); + expect(next?.updatedAt).toEqual(now); + expect(next?.turnItems).toBe(emptyProjection.turnItems); + }); + + it("refreshes one linked pull request and keeps other links", () => { + const link = { + host: "github.com", + repository: "owner/repo", + number: 1, + url: "https://github.com/owner/repo/pull/1", + source: "manual" as const, + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, + }; + const other = { ...link, number: 2, url: "https://github.com/owner/repo/pull/2" }; + const projection = { + ...emptyProjection, + thread: { ...emptyProjection.thread, pullRequests: [link, other] }, + }; + const snapshot = { + state: "open" as const, + title: "Host title", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(now), + syncedAt: DateTime.formatIso(now), + }; + const event = { + id: "event-pr-link-synced", + type: "thread.pull-request-link-synced", + threadId, + occurredAt: DateTime.add(now, { hours: 1 }), + payload: { host: link.host, repository: link.repository, number: 1, snapshot, stack: null }, + } as OrchestrationV2DomainEvent; + const next = applyOrchestrationV2ProjectionEvent(projection, event); + expect(next?.thread.pullRequests?.[0]?.snapshot).toEqual(snapshot); + expect(next?.thread.pullRequests?.[1]).toBe(other); + expect(next?.thread.updatedAt).toEqual(now); + expect(next?.turnItems).toBe(projection.turnItems); + }); + it("keeps live token usage when the terminal provider turn omits it", () => { const providerTurnId = ProviderTurnId.make("provider-turn-reducer"); const running = { diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 7e96a3039353f..ce1fea88aac0b 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -7,6 +7,7 @@ import { createOrchestrationV2TurnItemVisibility, isOrchestrationV2TurnItemVisible, } from "@t3tools/shared/orchestrationV2Timeline"; +import { applyThreadPullRequestLinkSync } from "@t3tools/shared/threadPullRequests"; export type ApplyOrchestrationV2ProjectionEventOptions = { readonly partialTimeline?: boolean; @@ -181,10 +182,17 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.model-selection-updated": case "thread.provider-switched": return { ...base, thread: event.payload }; + case "thread.pull-request-link-synced": + return { ...base, thread: applyThreadPullRequestLinkSync(projection.thread, event.payload) }; // Visited tracking is read state, not activity: skip the updatedAt bump. case "thread.visited": case "thread.marked-unread": return { ...projection, thread: event.payload }; + case "thread.visit-recorded": + return { + ...projection, + thread: { ...projection.thread, lastVisitedAt: event.payload.lastVisitedAt }, + }; case "run.created": case "run.updated": { const next = { ...base, runs: upsertEntity(base.runs, event.payload) }; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6e94f2451ad69..a3248fc8fff9d 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1633,6 +1633,25 @@ export const OrchestrationV2RawProviderEvent = Schema.Struct({ }); export type OrchestrationV2RawProviderEvent = typeof OrchestrationV2RawProviderEvent.Type; +/** + * One linked pull request's fresh host snapshot. Every thread linked to a pull request takes + * one of these when the host reports a change, so it names the link instead of carrying the + * thread: a full-thread event repeated the thread's every other link per change. + */ +export const OrchestrationV2ThreadPullRequestLinkSynced = Schema.Struct({ + ...ThreadPullRequestKey.fields, + snapshot: ThreadPullRequestSnapshot, + stack: Schema.NullOr(ThreadPullRequestStack), +}); +export type OrchestrationV2ThreadPullRequestLinkSynced = + typeof OrchestrationV2ThreadPullRequestLinkSynced.Type; + +/** The thread's read watermark after a visit, the only field a visit changes. */ +export const OrchestrationV2ThreadVisitRecorded = Schema.Struct({ + lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc), +}); +export type OrchestrationV2ThreadVisitRecorded = typeof OrchestrationV2ThreadVisitRecorded.Type; + const OrchestrationV2EventBase = Schema.Struct({ id: EventId, threadId: ThreadId, @@ -1676,6 +1695,16 @@ export const OrchestrationV2DomainEvent = Schema.Union([ ]), payload: OrchestrationV2AppThread, }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("thread.pull-request-link-synced"), + payload: OrchestrationV2ThreadPullRequestLinkSynced, + }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("thread.visit-recorded"), + payload: OrchestrationV2ThreadVisitRecorded, + }), Schema.Struct({ ...OrchestrationV2EventBase.fields, type: Schema.Literal("run.created"), @@ -2496,6 +2525,19 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ ]), payload: OrchestrationV2AppThreadJson, }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("thread.pull-request-link-synced"), + payload: OrchestrationV2ThreadPullRequestLinkSynced, + }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("thread.visit-recorded"), + payload: OrchestrationV2ThreadVisitRecorded.mapFields((fields) => ({ + ...fields, + lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + })), + }), Schema.Struct({ ...OrchestrationV2JsonEventBaseFields, type: Schema.Literal("run.created"), @@ -3262,6 +3304,8 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ * file list their checkpoint already carries. See `checkpointFilesOmittedItemIds`. */ acceptCompactCheckpointItems: Schema.optionalKey(Schema.Boolean), + /** Accepts compact read-watermark and pull-request-link events. */ + acceptThreadFieldEvents: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index d749272375348..884d8463e8743 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -345,3 +345,39 @@ export function threadPullRequestsOf(thread: { }, ]; } + +/** + * A thread after one link takes a fresh host snapshot, as `thread.pull-request-link-synced` + * records it. A link the thread no longer has leaves it unchanged. The legacy single link is + * kept only while a visible link still matches it. + */ +export function applyThreadPullRequestLinkSync< + Thread extends { + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + }, +>( + thread: Thread, + sync: ThreadPullRequestKey & Pick, +): Thread { + const links = threadPullRequestsOf(thread); + const existing = links.find((link) => threadPullRequestKeysEqual(link, sync)); + if (existing === undefined) return thread; + const pullRequests = links.map((link) => + link === existing ? { ...link, snapshot: sync.snapshot, stack: sync.stack } : link, + ); + const linked = thread.linkedPullRequest; + return { + ...thread, + pullRequests, + linkedPullRequest: + linked && + pullRequests.some( + (link) => + link.source !== "stack-dismissed" && + threadPullRequestKeysEqual(link, legacyThreadPullRequestKey(linked)), + ) + ? linked + : null, + }; +} From 14dbe70e35435438da688ac19e2ff0d242487e7e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:34:51 +0000 Subject: [PATCH 013/143] fix(startup): preserve snapshot opt-ins and serialize main window creation --- apps/desktop/src/window/DesktopWindow.test.ts | 10 ++++++++-- apps/desktop/src/window/DesktopWindow.ts | 8 ++++---- apps/server/src/serverRuntimeStartup.ts | 5 +++-- packages/client-runtime/src/state/threads.ts | 1 + 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 9e4ed5ca434ad..2dfe86dc53b1c 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -632,7 +632,7 @@ describe("DesktopWindow", () => { ); }); - it.effect("does not open a development window until the backend is ready", () => + it.effect("creates one window when bootstrap and backend readiness overlap", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); @@ -650,7 +650,13 @@ describe("DesktopWindow", () => { yield* desktopWindow.activate; assert.equal(yield* Ref.get(createCount), 0); - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* Effect.all( + [ + desktopWindow.ensureMain, + desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), + ], + { concurrency: "unbounded" }, + ); assert.equal(yield* Ref.get(createCount), 1); assert.equal(createdWindowOptions[0]?.width, 1100); assert.equal(createdWindowOptions[0]?.height, 780); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 07942b67356b9..7c82f6df4adb9 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Electron from "electron"; @@ -339,6 +340,7 @@ export const make = Effect.gen(function* () { // createMainIfBackendReady, which gates the post-readiness window // open in development and the macOS "activate without windows" path. const backendReadyRef = yield* Ref.make(false); + const mainWindowCreation = yield* Semaphore.make(1); // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); @@ -879,7 +881,7 @@ export const make = Effect.gen(function* () { return existingWindow.value; } return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + }).pipe(mainWindowCreation.withPermit, Effect.withSpan("desktop.window.ensureMain")); const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain; @@ -896,9 +898,7 @@ export const make = Effect.gen(function* () { const createMainIfBackendReady = Effect.gen(function* () { if (yield* waitingForBackend) return; - const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) return; - yield* createMain; + yield* ensureMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 429fbf8686619..3ac1376fed7bb 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -585,8 +585,9 @@ const make = (options?: StartupOptions) => yield* forkBackground( recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), + Effect.withSpan("server.startup.heartbeat.record", { + attributes: { "startup.phase": "heartbeat.record" }, + }), Effect.ignoreCause({ log: true }), ), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index c724e06ff575f..74e88fe4f4b6b 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -885,6 +885,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return { threadId, + acceptThreadFieldEvents: true as const, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), ...(acceptBoundedSnapshot From 5662ff5eb238d57d7c8325eca1fdc491f16a4c75 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:34:00 +0000 Subject: [PATCH 014/143] perf(web): trim and parallelize application startup --- apps/server/src/http.test.ts | 69 +++++ apps/server/src/http.ts | 41 ++- apps/web/src/browser/ElectronBrowserHost.tsx | 14 +- apps/web/src/cloud/dpop.ts | 37 ++- apps/web/src/components/AppSidebarLayout.tsx | 20 +- apps/web/src/components/ChatMarkdown.test.tsx | 5 +- apps/web/src/components/ChatMarkdown.tsx | 75 ++++-- apps/web/src/components/ChatView.tsx | 80 +++++- apps/web/src/components/CommandPalette.tsx | 241 ++--------------- .../web/src/components/CommandPaletteHost.tsx | 245 ++++++++++++++++++ .../web/src/components/CustomSnoozeDialog.tsx | 37 +-- apps/web/src/components/GitActionsControl.tsx | 9 +- apps/web/src/components/ProjectMonogram.tsx | 11 +- apps/web/src/components/Sidebar.tsx | 16 +- .../src/components/chat/MessagesTimeline.tsx | 83 +++--- .../src/components/chat/ReviewCommentDiff.tsx | 53 ++++ .../chat/ThreadDetailsPanel.test.tsx | 9 +- .../components/chat/ThreadDetailsPanel.tsx | 169 ++++++------ .../components/chat/threadFindHighlights.ts | 2 +- .../chat/useComposerMultilinePrompt.ts | 7 +- apps/web/src/connection/platform.ts | 54 +++- apps/web/src/lib/sharedHighlighter.ts | 2 + apps/web/src/lib/syntaxHighlighting.ts | 38 +-- apps/web/src/main.tsx | 3 +- apps/web/src/markdown-incremental.test.tsx | 4 +- apps/web/src/observability/clientTracing.ts | 4 +- apps/web/src/router.ts | 20 ++ apps/web/src/routes/__root.tsx | 45 +++- apps/web/src/routes/pair.tsx | 11 +- apps/web/src/state/shell.ts | 27 ++ apps/web/vite.config.ts | 10 +- apps/web/vite/precompress.ts | 60 +++++ apps/web/vite/startupChunks.ts | 198 ++++++++++++++ packages/client-runtime/src/state/session.ts | 9 +- packages/shared/package.json | 4 + packages/shared/src/markdownPipeline.test.ts | 90 ++++++- packages/shared/src/markdownPipeline.ts | 45 +++- packages/shared/src/threadFindBlockTags.ts | 39 +++ packages/shared/src/threadFindText.ts | 44 +--- 39 files changed, 1388 insertions(+), 542 deletions(-) create mode 100644 apps/web/src/components/CommandPaletteHost.tsx create mode 100644 apps/web/src/components/chat/ReviewCommentDiff.tsx create mode 100644 apps/web/src/lib/sharedHighlighter.ts create mode 100644 apps/web/vite/precompress.ts create mode 100644 apps/web/vite/startupChunks.ts create mode 100644 packages/shared/src/threadFindBlockTags.ts diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index dc4ff0283d2e7..21d8c04d2c949 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; +import { brotliCompressSync, gzipSync } from "node:zlib"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -144,6 +145,74 @@ it.layer( Layer.provideMerge(NodeServices.layer), ), )("static HTTP responses", (it) => { + it.effect("serves precompressed manifest assets and revalidates other files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compressed-" }); + yield* fs.makeDirectory(path.join(staticDir, "assets")); + const body = 'export const build = "precompressed";\n'.repeat(100); + const asset = "assets/app-1234abcd.js"; + const br = brotliCompressSync(body); + const gz = gzipSync(body); + yield* fs.writeFileString(path.join(staticDir, asset), body); + yield* fs.writeFile(path.join(staticDir, `${asset}.br`), br); + yield* fs.writeFile(path.join(staticDir, `${asset}.gz`), gz); + yield* fs.writeFileString( + path.join(staticDir, "vite-manifest.json"), + JSON.stringify({ "src/app.ts": { file: asset, isEntry: true } }), + ); + // A hash-like filename and a compressed copy alone cannot make a custom file immutable. + const custom = "assets/custom-1234abcd.js"; + yield* fs.writeFileString(path.join(staticDir, custom), body); + yield* fs.writeFile(path.join(staticDir, `${custom}.br`), br); + const request = yield* makeStaticRequest(staticDir); + + for (const [acceptEncoding, encoding, length] of [ + ["br, gzip", "br", br.byteLength], + ["br;q=0, gzip", "gzip", gz.byteLength], + ["identity", undefined, Buffer.byteLength(body)], + ] as const) { + const response = yield* request(`/${asset}`, { + headers: { "accept-encoding": acceptEncoding }, + }); + expect(response.status).toBe(200); + expect(response.headers["content-encoding"]).toBe(encoding); + expect(response.headers["content-length"]).toBe(String(length)); + expect(response.headers["content-type"]).toContain("javascript"); + expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); + expect(response.headers["vary"]).toBe("Accept-Encoding"); + expect(yield* response.text).toBe(body); + const head = yield* request(`/${asset}`, { + method: "HEAD", + headers: { "accept-encoding": acceptEncoding }, + }); + expect(head.headers["content-encoding"]).toBe(encoding); + expect(head.headers["content-length"]).toBe(String(length)); + expect(yield* head.text).toBe(""); + const unchanged = yield* request(`/${asset}`, { + headers: { + "accept-encoding": acceptEncoding, + "if-none-match": response.headers["etag"]!, + }, + }); + expect(unchanged.status).toBe(304); + expect(unchanged.headers["vary"]).toBe("Accept-Encoding"); + expect(yield* unchanged.text).toBe(""); + } + const unlisted = yield* request(`/${custom}`, { headers: { "accept-encoding": "br" } }); + expect(unlisted.headers["cache-control"]).toBe("no-cache"); + expect(unlisted.headers["content-length"]).toBeUndefined(); + expect(yield* unlisted.text).toBe(body); + + yield* fs.remove(path.join(staticDir, `${asset}.br`)); + const fallback = yield* request(`/${asset}`, { headers: { "accept-encoding": "br, gzip" } }); + expect(fallback.headers["content-encoding"]).toBe("gzip"); + expect(fallback.headers["content-length"]).toBe(String(gz.byteLength)); + expect(yield* fallback.text).toBe(body); + }), + ); + it.effect("revalidates non-HTML files and returns changed contents", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index de4ce26cc915d..c17893321e434 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -505,8 +505,14 @@ const loadImmutableBuildAssets = Effect.gen(function* () { if (!staticDir) return new Set(); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe( + return yield* fileSystem.readFileString(path.join(staticDir, "vite-manifest.json")).pipe( Effect.flatMap(decodeBuildManifest), + // Older builds and custom static directories can still use Vite's default location. + Effect.catch(() => + fileSystem + .readFileString(path.join(staticDir, ".vite", "manifest.json")) + .pipe(Effect.flatMap(decodeBuildManifest)), + ), Effect.map( (manifest) => new Set( @@ -543,6 +549,26 @@ const streamStaticFile = (file: FileSystem.File, size: bigint) => }), ); +/** Match the compression middleware's preference and reject malformed quality values. */ +function acceptedStaticEncodings(header: string | undefined): ReadonlyArray<"br" | "gzip"> { + if (!header?.trim()) return []; + const accepted = new Map(); + for (const part of header.split(",")) { + const member = part + .trim() + .toLowerCase() + .replace(/[ \t]*;[ \t]*/g, ";"); + const match = /^([a-z0-9!#$%&'*+.^_`|~-]+)(?:;q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/.exec( + member, + ); + if (!match) return []; + accepted.set(match[1]!, match[2] === undefined ? 1 : Number(match[2])); + } + return (["br", "gzip"] as const).filter( + (encoding) => (accepted.get(encoding) ?? accepted.get("*") ?? 0) > 0, + ); +} + const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; @@ -659,6 +685,19 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; + // Only manifest-backed hashed assets can trust an adjacent compressed copy. + // Custom static files may be overwritten without updating their sidecars. + if (immutable) { + for (const encoding of acceptedStaticEncodings(request.headers["accept-encoding"])) { + const compressed = yield* openStaticFile(`${filePath}.${encoding === "br" ? "br" : "gz"}`); + if (!compressed) continue; + return HttpServerResponse.stream(streamStaticFile(compressed.file, compressed.info.size), { + headers: { ...headers, "Content-Encoding": encoding, Vary: "Accept-Encoding" }, + contentType, + contentLength: Number(compressed.info.size), + }); + } + } // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. // HEAD still passes through compression, which selects headers without reading the stream. return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index b076a30a5221f..ed0ec5daaadc7 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -3,7 +3,7 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { type ComponentProps, useEffect, useMemo, useRef, useState } from "react"; +import { type ComponentProps, lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; @@ -18,7 +18,6 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserDefaults } from "./browserDefaults"; import { useBrowserPointerStore } from "./browserPointerStore"; -import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { openUrlInPreview } from "./openFileInPreview"; import { rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; @@ -155,13 +154,22 @@ export function ElectronBrowserHost() { ); } +// Only Electron with an open preview mounts a webview; the web build never loads it. +const HostedBrowserWebview = lazy(() => + import("./HostedBrowserWebview").then((module) => ({ default: module.HostedBrowserWebview })), +); + function AuthorizedBrowserWebview(props: ComponentProps) { const canOperatePreview = useEnvironmentScope( props.threadRef.environmentId, AuthPreviewOperateScope, ); const profileId = useTabProfileId(props.profileId); - return canOperatePreview ? : null; + return canOperatePreview ? ( + + + + ) : null; } /** diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index cf5d4a68fb23b..0bedbfdc5d7f9 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -8,7 +8,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { importJWK, SignJWT, type JWK } from "jose"; +import type { JWK } from "jose"; export interface BrowserDpopKey { readonly privateKey: CryptoKey; @@ -26,6 +26,9 @@ const DPOP_DATABASE_VERSION = 1; const DPOP_KEY_STORE_NAME = "keys"; const DPOP_KEY_ID = "relay-dpop-proof-key"; const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); +// jose only signs proofs for T3 Connect, so local-only sessions never load it. +const loadJoseKeyImport = () => import("jose/key/import"); +const loadJoseJwtSign = () => import("jose/jwt/sign"); export const layer = Layer.succeed( Crypto.Crypto, @@ -132,7 +135,11 @@ export const generateBrowserDpopKey = Effect.gen(function* () { ), ); const privateKey = yield* Effect.tryPromise({ - try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, + try: () => + loadJoseKeyImport().then( + ({ importJWK }) => + importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, + ), catch: (cause) => dpopError("Could not import DPoP private key.", cause), }); return { @@ -165,19 +172,21 @@ export function createBrowserDpopProof(input: { ); const proof = yield* Effect.tryPromise({ try: () => - new SignJWT({ - htm: input.method.toUpperCase(), - htu: normalizedUrl.toString(), - jti, - ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), - }) - .setProtectedHeader({ - typ: "dpop+jwt", - alg: "ES256", - jwk: input.proofKey.publicJwk, + loadJoseJwtSign().then(({ SignJWT }) => + new SignJWT({ + htm: input.method.toUpperCase(), + htu: normalizedUrl.toString(), + jti, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), }) - .setIssuedAt() - .sign(input.proofKey.privateKey), + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: input.proofKey.publicJwk, + }) + .setIssuedAt() + .sign(input.proofKey.privateKey), + ), catch: (cause) => dpopError("Could not sign DPoP proof.", cause), }); return { proof, thumbprint: input.proofKey.thumbprint }; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 00d5c05ef0890..18299537eb706 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { + lazy, + Suspense, useEffect, useState, useSyncExternalStore, @@ -31,10 +33,8 @@ import { usePanelAnimationSettings, usePanelNavigationSuppression, } from "../panelAnimations"; -import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; -import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; @@ -57,6 +57,14 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +// Only the sidebar the legacy setting picks should be in the startup graph. +const LegacyThreadSidebar = lazy(() => import("./LegacySidebar")); +const SettingsSidebarNav = lazy(() => + import("./settings/SettingsSidebarNav").then((module) => ({ + default: module.SettingsSidebarNav, + })), +); + const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; function subscribeToViewportWidth(onChange: () => void): () => void { @@ -333,10 +341,14 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {isOnSettings ? ( <> - + + + ) : legacySidebarEnabled ? ( - + + + ) : ( )} diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index b62431a083832..2a9d8ff1d6254 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -3,6 +3,7 @@ import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; import { createRoot } from "react-dom/client"; import { useThreadFindHighlights } from "./chat/threadFindHighlights"; +import { loadRehypeRaw } from "@t3tools/shared/markdownPipeline"; import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; @@ -10,7 +11,7 @@ import { MarkdownFindContext } from "./chat/markdownFindContext"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; @@ -18,6 +19,8 @@ import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +// Messages with tags render synchronously once the app has loaded rehype-raw. +beforeAll(() => loadRehypeRaw()); vi.mock("./chat/MermaidDiagram", () => ({ // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. MermaidDiagram: () => , diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4c712365475d1..340465d8c65a6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -17,7 +17,10 @@ import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools import { CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, - CHAT_MARKDOWN_REHYPE_PLUGINS, + chatMarkdownRehypePlugins, + loadedRehypeRaw, + loadRehypeRaw, + markdownMayContainRawHtml, } from "@t3tools/shared/markdownPipeline"; import { useAtomValue } from "@effect/atom-react"; import { @@ -76,6 +79,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import React, { Children, + lazy, Suspense, type CSSProperties, type ComponentProps, @@ -155,7 +159,6 @@ import { GitHubIcon } from "./Icons"; import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; -import { MermaidDiagram } from "./chat/MermaidDiagram"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { @@ -1870,10 +1873,48 @@ function rehypeHeadingIds() { // Heading ids are added after sanitizing, which would prefix them a second time. const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ - ...CHAT_MARKDOWN_REHYPE_PLUGINS, + ...chatMarkdownRehypePlugins(null), rehypeHeadingIds, ] satisfies NonNullable; +type RehypePlugins = NonNullable; +let rawHtmlRehypePlugins: RehypePlugins | null = null; +let rawHtmlRehypePluginsPromise: Promise | null = null; + +function withRawHtml(rehypeRaw: NonNullable>) { + rawHtmlRehypePlugins ??= [...chatMarkdownRehypePlugins(rehypeRaw), rehypeHeadingIds]; + return rawHtmlRehypePlugins; +} + +// rehype-raw brings parse5, so it loads with the first message that has a tag. +function loadRawHtmlRehypePlugins(): Promise { + rawHtmlRehypePluginsPromise ??= loadRehypeRaw().then(withRawHtml, () => { + // Retry on the next render; meanwhile the message renders without its HTML. + rawHtmlRehypePluginsPromise = null; + return CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS; + }); + return rawHtmlRehypePluginsPromise; +} + +function ChatMarkdownContent({ + text, + parseRawHtml, + render, +}: { + text: string; + parseRawHtml: boolean; + render: (rehypePlugins: RehypePlugins) => ReactNode; +}) { + if (!parseRawHtml) return render(CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS); + if (!markdownMayContainRawHtml(text)) return render(CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS); + const rehypeRaw = loadedRehypeRaw(); + return render(rehypeRaw ? withRawHtml(rehypeRaw) : use(loadRawHtmlRehypePlugins())); +} + +const MermaidDiagram = lazy(() => + import("./chat/MermaidDiagram").then((module) => ({ default: module.MermaidDiagram })), +); + const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< ReactMarkdownOptions["rehypePlugins"] >; @@ -3410,6 +3451,17 @@ function ChatMarkdown({ // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. + const renderMarkdown = (rehypePlugins: RehypePlugins) => ( + + {text} + + ); return (
- - {text} - + {/* While rehype-raw loads, the message renders with its raw HTML left out. */} + + + {localMediaPreview ? ( import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +// Panels that only some threads open load on first use, keeping them out of the +// chunks that must arrive before the composer can paint. +const LazyPullRequestDetailPanel = lazy(() => + import("./pullRequest/PullRequestDetailPanel").then((module) => ({ + default: module.PullRequestDetailPanel, + })), +); +function PullRequestDetailPanel(props: ComponentProps) { + return ( + }> + + + ); +} +const ThreadPullRequestsPanel = lazy(() => + import("./pullRequest/ThreadPullRequestsPanel").then((module) => ({ + default: module.ThreadPullRequestsPanel, + })), +); +const LazyThreadTerminalDrawer = lazy(() => import("./ThreadTerminalDrawer")); +function ThreadTerminalDrawer(props: ComponentProps) { + // Keep existing terminal sessions mounted when hidden, but an empty closed drawer + // has no terminal to render or synchronize and need not load its renderer. + if (props.visible === false && props.terminalIds.length === 0) return null; + return ( + + + + ); +} +const ThreadPreviewMiniPlayer = lazy(() => + import("./preview/ThreadPreviewMiniPlayer").then((module) => ({ + default: module.ThreadPreviewMiniPlayer, + })), +); +const ThreadDetailsPanelContent = lazy(() => + import("./chat/ThreadDetailsPanel").then((module) => ({ + default: module.ThreadDetailsPanelContent, + })), +); +function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { + return ( + + {(density) => ( + + + + )} + + ); +} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -11087,7 +11141,9 @@ export default function ChatView(props: ChatViewProps) { } /> ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - + + + ) : renderedRightPanelSurface?.kind === "device" ? ( + + + ) : null} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0e9ea387cb170..78b98707c3322 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -44,7 +44,7 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, @@ -60,22 +60,21 @@ import { LinkIcon, MessageSquareIcon, MonitorIcon, - MoonIcon, PaletteIcon, RotateCcwIcon, SettingsIcon, SquarePenIcon, - SunIcon, TextSearchIcon, } from "lucide-react"; import { requestThreadFindOpen } from "./chat/threadFindActionBus"; import { + lazy, + Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, - useReducer, useRef, useState, type KeyboardEvent, @@ -129,14 +128,8 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; -import { COMMAND_PALETTE_ELEMENT_ID, onOpenCommandPalette } from "../commandPaletteBus"; -import { isPreviewFocused } from "../lib/previewFocus"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { - PULL_REQUESTS_PANEL_REF, - selectActiveRightPanel, - useRightPanelStore, -} from "../rightPanelStore"; +import { COMMAND_PALETTE_ELEMENT_ID } from "../commandPaletteBus"; +import { PULL_REQUESTS_PANEL_REF, useRightPanelStore } from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, @@ -145,8 +138,7 @@ import { isWindowsPlatform, newProjectId, } from "../lib/utils"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { buildThreadRouteParams } from "../threadRoutes"; import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, @@ -177,9 +169,9 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, - reduceCommandPaletteUiState, type SearchOverlayMode, } from "./CommandPalette.logic"; +import { APPEARANCE_OPTIONS, notifyThemeSaveFailure } from "./CommandPaletteHost"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; @@ -193,7 +185,6 @@ import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; -import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { @@ -209,12 +200,12 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; -import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; +import { CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; +import { useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -437,214 +428,18 @@ function errorMessage(error: unknown): string { return "An error occurred."; } -const OVERLAY_MODE_BY_COMMAND = { - "commandPalette.toggle": "command", - "filePicker.toggle": "files", - "projectSearch.toggle": "content", -} as const satisfies Partial>; - -function overlayModeForCommand(command: string | null): SearchOverlayMode | null { - if (command === null) return null; - return command in OVERLAY_MODE_BY_COMMAND - ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] - : null; -} - -const APPEARANCE_OPTIONS = [ - { mode: "system", label: "System", icon: MonitorIcon }, - { mode: "light", label: "Light", icon: SunIcon }, - { mode: "dark", label: "Dark", icon: MoonIcon }, -] as const; - -function notifyThemeSaveFailure(): void { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't save theme selection", - description: "Try again.", - }), - ); -} +// Content search highlights matches with shiki; it loads when that mode opens. +const ProjectContentSearchDialog = lazy(() => + import("./search/ProjectContentSearchDialog").then((module) => ({ + default: module.ProjectContentSearchDialog, + })), +); function projectFavicon(project: Project) { return ; } -export function CommandPalette({ children }: { children: ReactNode }) { - const navigate = useNavigate(); - const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { - open: false, - mode: "command", - openIntent: null, - }); - const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); - const toggleMode = useCallback( - (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), - [], - ); - const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); - const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); - const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme(); - const composerHandleRef = useRef(null); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const terminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const previewOpen = useRightPanelStore((state) => - routeThreadRef - ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" - : false, - ); - - useEffect(() => { - if (!state.open || state.mode === "command") return; - const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.isComposing || event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - toggleMode("command"); - }; - window.addEventListener("keydown", onEscapeKeyDown, true); - return () => window.removeEventListener("keydown", onEscapeKeyDown, true); - }, [state.mode, state.open, toggleMode]); - - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented) return; - // Resolve with the complete shortcut context so customized bindings - // using any documented `when` condition (e.g. previewFocus) work. - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - terminalOpen, - previewFocus: isPreviewFocused(), - previewOpen, - modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false, - }, - }); - if (command === "appearance.cycle") { - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - const nextMode = - appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system"; - if (!setAppearanceMode(nextMode)) { - notifyThemeSaveFailure(); - } else { - toastManager.add({ - id: "appearance-cycle", - title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`, - timeout: 1500, - }); - } - return; - } - if (command === "theme.select") { - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - dispatch({ _tag: "OpenChangeTheme" }); - return; - } - if (command === "themeEditor.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleThemeEditorForTheme({ - theme, - themeHalves, - initialAppearance: resolvedTheme, - }); - return; - } - if (command === "usage.open") { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - void navigate({ to: "/usage" }); - return; - } - const mode = overlayModeForCommand(command); - if (mode === null) { - return; - } - event.preventDefault(); - event.stopPropagation(); - toggleMode(mode); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [ - appearanceMode, - keybindings, - navigate, - previewOpen, - resolvedTheme, - setAppearanceMode, - setOpen, - terminalOpen, - theme, - themeHalves, - toggleMode, - ]); - - useEffect( - () => - onOpenCommandPalette((detail) => { - if (detail.open === "new-thread-in") { - openNewThreadIn(); - } else if (detail.open === "add-project") { - openAddProject(); - } else if (detail.query !== undefined) { - dispatch({ - _tag: "OpenSearch", - query: detail.query, - ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), - }); - } else { - setOpen(true); - } - }), - [openAddProject, openNewThreadIn, setOpen], - ); - - return ( - - { - if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { - eventDetails.cancel(); - toggleMode("command"); - return; - } - setOpen(open); - }} - > - {/* Block background focus calls for the entire time the palette is open. */} -
- {children} -
- -
-
- ); -} - -function CommandPaletteDialog(props: { +export function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; @@ -678,7 +473,9 @@ function CommandPaletteDialog(props: { {props.mode === "files" ? ( ) : props.mode === "content" ? ( - + + + ) : ( + import("./CommandPalette").then((module) => ({ default: module.CommandPaletteDialog })), +); + +const OVERLAY_MODE_BY_COMMAND = { + "commandPalette.toggle": "command", + "filePicker.toggle": "files", + "projectSearch.toggle": "content", +} as const satisfies Partial>; + +function overlayModeForCommand(command: string | null): SearchOverlayMode | null { + if (command === null) return null; + return command in OVERLAY_MODE_BY_COMMAND + ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] + : null; +} + +export const APPEARANCE_OPTIONS = [ + { mode: "system", label: "System", icon: MonitorIcon }, + { mode: "light", label: "Light", icon: SunIcon }, + { mode: "dark", label: "Dark", icon: MoonIcon }, +] as const; + +export function notifyThemeSaveFailure(): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't save theme selection", + description: "Try again.", + }), + ); +} + +export function CommandPaletteHost({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { + open: false, + mode: "command", + openIntent: null, + }); + // Stays mounted after the first open so closing still animates out. + const [dialogMounted, setDialogMounted] = useState(false); + if (state.open && !dialogMounted) setDialogMounted(true); + const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); + const toggleMode = useCallback( + (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), + [], + ); + const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); + const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme(); + const composerHandleRef = useRef(null); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const terminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); + + useEffect(() => { + if (!state.open || state.mode === "command") return; + const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.isComposing || event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + toggleMode("command"); + }; + window.addEventListener("keydown", onEscapeKeyDown, true); + return () => window.removeEventListener("keydown", onEscapeKeyDown, true); + }, [state.mode, state.open, toggleMode]); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented) return; + // Resolve with the complete shortcut context so customized bindings + // using any documented `when` condition (e.g. previewFocus) work. + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, + modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false, + }, + }); + if (command === "appearance.cycle") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + const nextMode = + appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system"; + if (!setAppearanceMode(nextMode)) { + notifyThemeSaveFailure(); + } else { + toastManager.add({ + id: "appearance-cycle", + title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`, + timeout: 1500, + }); + } + return; + } + if (command === "theme.select") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + dispatch({ _tag: "OpenChangeTheme" }); + return; + } + if (command === "themeEditor.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + return; + } + if (command === "usage.open") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + void navigate({ to: "/usage" }); + return; + } + const mode = overlayModeForCommand(command); + if (mode === null) { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleMode(mode); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [ + appearanceMode, + keybindings, + navigate, + previewOpen, + resolvedTheme, + setAppearanceMode, + setOpen, + terminalOpen, + theme, + themeHalves, + toggleMode, + ]); + + useEffect( + () => + onOpenCommandPalette((detail) => { + if (detail.open === "new-thread-in") { + openNewThreadIn(); + } else if (detail.open === "add-project") { + openAddProject(); + } else if (detail.query !== undefined) { + dispatch({ + _tag: "OpenSearch", + query: detail.query, + ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), + }); + } else { + setOpen(true); + } + }), + [openAddProject, openNewThreadIn, setOpen], + ); + + return ( + + { + if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { + eventDetails.cancel(); + toggleMode("command"); + return; + } + setOpen(open); + }} + > + {/* Block background focus calls for the entire time the palette is open. */} +
+ {children} +
+ {dialogMounted ? ( + + + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 670df0e3e12e8..c34f1622089d0 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useState } from "react"; +import { lazy, Suspense, useEffect, useId, useState } from "react"; import { create } from "zustand"; import { localSnoozeDate, @@ -8,7 +8,6 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; -import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; @@ -32,6 +31,10 @@ import { DialogFooter, } from "./ui/dialog"; +// react-day-picker and date-fns load with the dialog, not with the app. +const loadCalendar = () => import("./ui/calendar"); +const Calendar = lazy(() => loadCalendar().then((module) => ({ default: module.Calendar }))); + type SnoozeChoice = { readonly snoozedUntil: string }; type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; const useRequest = create<{ request: Request | null }>(() => ({ request: null })); @@ -55,6 +58,8 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); + // Fetch the calendar while the user reads the dialog so the date picker opens ready. + useEffect(() => void loadCalendar().catch(() => undefined), []); const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const [mode, setMode] = useState("date"); const [date, setDate] = useState(initial); @@ -131,19 +136,21 @@ function CustomSnoozeDialog() { - { - setDate(selected); - setCalendarOpen(false); - setError(null); - }} - /> + + { + setDate(selected); + setCalendarOpen(false); + setError(null); + }} + /> +
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index a174c4b8a92e6..60f3d71c671d3 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -522,6 +522,12 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu interface PublishRepositoryDialogProps { readonly open: boolean; + /** + * Whether publishing is on offer, so the provider scan is ready when the + * dialog opens. The scan probes git and every provider CLI on the server, + * so a checkout that already has a remote never runs it. + */ + readonly publishAvailable: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; /** Thread the dialog was opened from, so the new repository can open beside it. */ @@ -533,7 +539,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const openLink = useOpenLink(props.threadRef); const navigate = useNavigate(); const sourceControlDiscovery = useEnvironmentQuery( - props.environmentId === null + props.environmentId === null || !(props.open || props.publishAvailable) ? null : sourceControlEnvironment.discovery({ environmentId: props.environmentId, @@ -2160,6 +2166,7 @@ export default function GitActionsControl({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8a5db463884e0..5648161084f3b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -153,6 +153,7 @@ import { import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, + useAllEnvironmentShellsBootstrapped, useProjects, useThreadShells, } from "../state/entities"; @@ -2384,6 +2385,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { export default function Sidebar() { const projects = useProjects(); + // Before every environment's cached or live shell lands, an empty list + // means "not loaded yet", not "no projects". + const shellsBootstrapped = useAllEnvironmentShellsBootstrapped(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); @@ -3849,16 +3853,23 @@ export default function Sidebar() { [sidebarListItems], ); const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0; + // The rows that arrive with the first cached or live shell are the initial + // list, so they paint in place instead of fading in. + const listMotionArmedRef = useRef(false); useLayoutEffect(() => { // Drag release clears the baseline, so its commit cannot replay the // sortable preview; rows glide from their released positions instead. // Later thread actions can animate while writes settle. // Draft navigation can reveal a frozen row without changing the draft count. void sidebarListOrderKey; - listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows); + listMotionRef.current?.update( + listMotionArmedRef.current && !listMotionPaused && sidebarListHasRows, + ); + listMotionArmedRef.current = shellsBootstrapped; }, [ listMotionPaused, routeDraftIdForRows, + shellsBootstrapped, sidebarListHasRows, sidebarListOrderKey, visibleDraftSessionCount, @@ -5546,7 +5557,8 @@ export default function Sidebar() { ) : null} - {!isSearchingThreads && + {shellsBootstrapped && + !isSearchingThreads && visibleDraftSessionCount === 0 && pinnedThreads.length + activeThreads.length + diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 70d849b38b084..54b5de15fd70b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -70,7 +70,9 @@ import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { createContext, + lazy, memo, + Suspense, use, useCallback, useContext, @@ -88,8 +90,6 @@ import { type LegendListRef, type MaintainScrollAtEndOptions, } from "@legendapp/list/react"; -import { FileDiff } from "@pierre/diffs/react"; -import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { type TimelineEntry, providerErrorPresentation, @@ -109,12 +109,6 @@ import { isVideoAttachment, type TurnDiffSummary, } from "../../types"; -import { - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -174,7 +168,6 @@ import { } from "./SnapShotAttachmentDetails"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; -import { McpAppFrame } from "./McpAppFrame"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; @@ -296,11 +289,7 @@ import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { SkillChipIcon, SkillInlineText } from "./SkillInlineText"; import * as DateTime from "effect/DateTime"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; -import { - buildReviewCommentRenderablePatch, - formatReviewCommentFence, - type ReviewCommentContext, -} from "../../reviewCommentContext"; +import { formatReviewCommentFence, type ReviewCommentContext } from "../../reviewCommentContext"; // --------------------------------------------------------------------------- // Context — shared state consumed by every row component via Context. @@ -2979,25 +2968,32 @@ function HtmlRenderTimelineRow({ row }: { row: Extract + import("./McpAppFrame").then((module) => ({ default: module.McpAppFrame })), +); + function McpAppTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { awaitingUser } = use(TimelineRowActivityCtx); return (
- ctx.onAppFullscreenChange(row.id, fullscreen)} - /> + + ctx.onAppFullscreenChange(row.id, fullscreen)} + /> +
); } @@ -4820,13 +4816,14 @@ const UserMessageBody = memo(function UserMessageBody(props: { ); }); +// The diff renderer (with shiki) loads only for messages that quote a review comment. +const ReviewCommentDiff = lazy(() => + import("./ReviewCommentDiff").then((module) => ({ default: module.ReviewCommentDiff })), +); + function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { const ctx = use(TimelineRowCtx); const fenceLanguage = comment.fenceLanguage ?? "diff"; - const renderablePatch = getRenderablePatch( - buildReviewCommentRenderablePatch(comment), - `review-comment:${comment.id}`, - ); return (
@@ -4852,27 +4849,9 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte className="text-foreground" /> )} - {renderablePatch?.kind === "files" && ( - - {renderablePatch.files.map((fileDiff) => ( - - ))} - - )} - {renderablePatch?.kind === "raw" && ( -
-          {renderablePatch.text}
-        
- )} + + +
); } diff --git a/apps/web/src/components/chat/ReviewCommentDiff.tsx b/apps/web/src/components/chat/ReviewCommentDiff.tsx new file mode 100644 index 0000000000000..00a3f8c08324d --- /dev/null +++ b/apps/web/src/components/chat/ReviewCommentDiff.tsx @@ -0,0 +1,53 @@ +import { FileDiff } from "@pierre/diffs/react"; + +import { + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; +import { + buildReviewCommentRenderablePatch, + type ReviewCommentContext, +} from "../../reviewCommentContext"; +import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; + +/** The diff of a review comment quoted in a user message. Loaded lazily with the diff renderer. */ +export function ReviewCommentDiff({ + comment, + resolvedTheme, +}: { + comment: ReviewCommentContext; + resolvedTheme: "light" | "dark"; +}) { + const renderablePatch = getRenderablePatch( + buildReviewCommentRenderablePatch(comment), + `review-comment:${comment.id}`, + ); + if (renderablePatch?.kind === "files") { + return ( + + {renderablePatch.files.map((fileDiff) => ( + + ))} + + ); + } + if (renderablePatch?.kind === "raw") { + return ( +
+        {renderablePatch.text}
+      
+ ); + } + return null; +} diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx index a9f1867f39d2e..f3a62e95e7902 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx @@ -27,12 +27,7 @@ vi.mock("./ThreadAutomationsPanel", () => ({ vi.mock("./ThreadRelationshipsControl", () => ({ ThreadRelationshipsPanel: () => null, })); -vi.mock("./ThreadDetailsCard", () => ({ - ThreadDetailsCard: ({ children }: { children: (density: "full") => React.ReactNode }) => - children("full"), -})); - -import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; +import { ThreadDetailsPanelContent, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; describe("ThreadDetailsPanel", () => { beforeEach(() => { @@ -80,7 +75,7 @@ describe("ThreadDetailsPanel", () => { onDeleteProjectScript: vi.fn() as ThreadDetailsPanelProps["onDeleteProjectScript"], }; - renderToStaticMarkup(); + renderToStaticMarkup(); expect(testState.useT3ProjectFileScripts).toHaveBeenCalledWith(environmentId, gitCwd); expect(testState.projectScriptsControl).toHaveBeenCalledWith( diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 4bfcaa7881187..cb5a4d2366014 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -16,7 +16,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import type { ComponentProps } from "react"; -import { ThreadDetailsCard } from "./ThreadDetailsCard"; +import type { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; @@ -62,7 +62,10 @@ export interface ThreadDetailsPanelProps extends Pick< onDeleteProjectScript: (scriptId: string) => Promise; } -export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { +export function ThreadDetailsPanelContent( + props: ThreadDetailsPanelProps & { readonly density: "full" | "compact" | "essential" }, +) { + const { density } = props; const fileScripts = useT3ProjectFileScripts( props.environmentId, props.activeProjectScripts ? props.gitCwd : null, @@ -91,99 +94,87 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { }; return ( - - {(density) => ( - <> - -
- {density === "full" ? ( - - ) : null} - - {density !== "essential" && props.showOpenInPicker ? ( - - ) : null} - - {props.activeProjectScripts ? ( - - ) : null} -
-
- - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} -
-
+ <> + +
+ {density === "full" ? ( + ) : null} - {density === "full" && !props.draftId ? ( - + {density !== "essential" && props.showOpenInPicker ? ( + ) : null} - {density === "full" && !props.draftId ? ( - ) : null} - - )} - +
+
+ + {props.gitCwd ? ( + +
+ {props.isGitRepo ? ( + + ) : null} + {props.activeProjectName ? ( + + ) : null} +
+
+ ) : null} + + {density === "full" && !props.draftId ? ( + + ) : null} + + {density === "full" && !props.draftId ? ( + + ) : null} + ); } diff --git a/apps/web/src/components/chat/threadFindHighlights.ts b/apps/web/src/components/chat/threadFindHighlights.ts index 6a505de65fee4..ecf29cd47b4ac 100644 --- a/apps/web/src/components/chat/threadFindHighlights.ts +++ b/apps/web/src/components/chat/threadFindHighlights.ts @@ -1,6 +1,6 @@ import { findThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import { useEffect, useLayoutEffect, useRef } from "react"; -import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindText"; +import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindBlockTags"; const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find"; const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active"; diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.ts b/apps/web/src/components/chat/useComposerMultilinePrompt.ts index eaabd605de86d..fb24ae389e5ba 100644 --- a/apps/web/src/components/chat/useComposerMultilinePrompt.ts +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.ts @@ -29,10 +29,13 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { const next = measureComposerMultilinePrompt(body); if (next !== null) setIsMultiline(next); }; - measure(); + const editor = body.querySelector('[data-testid="composer-editor"]'); + // An empty prompt is one line. Skipping the read keeps a fresh composer from forcing + // a layout mid-commit; the observers below still measure once layout settles. + if (editor && editor.childElementCount <= 1 && editor.textContent === "") setIsMultiline(false); + else measure(); const resizeObserver = new ResizeObserver(measure); resizeObserver.observe(body); - const editor = body.querySelector('[data-testid="composer-editor"]'); if (editor) resizeObserver.observe(editor); const mutationObserver = new MutationObserver(measure); mutationObserver.observe(body, { childList: true, characterData: true, subtree: true }); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 63c6563aea700..94462f8edb683 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -28,6 +28,7 @@ import { type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, type EnvironmentId, + type ExecutionEnvironmentDescriptor, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -45,6 +46,7 @@ import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAu import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; import { readPrimaryEnvironmentTarget, + readPrimaryEnvironmentTargetResult, type PrimaryEnvironmentTarget, } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; @@ -341,15 +343,55 @@ const layerCapabilities = Layer.effectContext( }), ); +let primaryDescriptorPrefetchStarted = false; +let primaryDescriptorPrefetch: { + readonly httpBaseUrl: string; + readonly startedAt: number; + readonly settled: Promise; +} | null = null; + +/** + * Reads the primary environment's descriptor alongside the boot auth check, + * before route chunks queue on the browser's connections. The first primary + * registration reuses the answer instead of fetching it again. + */ +export function prefetchPrimaryEnvironmentDescriptor(): void { + if (primaryDescriptorPrefetchStarted) return; + primaryDescriptorPrefetchStarted = true; + const read = readPrimaryEnvironmentTargetResult(); + if (read._tag === "Failure" || read.target === null) return; + const httpBaseUrl = read.target.target.httpBaseUrl; + primaryDescriptorPrefetch = { + httpBaseUrl, + startedAt: Date.now(), + settled: Effect.runPromise( + fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + ), + ).catch(() => null), + }; +} + +const PREFETCHED_DESCRIPTOR_MAX_AGE_MS = 10_000; + const loadPrimaryConnectionRegistration = Effect.fn( "web.connectionPlatform.loadPrimaryConnectionRegistration", )(function* (resolved: PrimaryEnvironmentTarget) { - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolved.target.httpBaseUrl, - }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - Effect.mapError(mapRemoteEnvironmentError), - ); + const prefetch = primaryDescriptorPrefetch; + primaryDescriptorPrefetch = null; + const prefetched = + prefetch?.httpBaseUrl === resolved.target.httpBaseUrl && + Date.now() - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS + ? yield* Effect.promise(() => prefetch.settled) + : null; + const descriptor = + prefetched ?? + (yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + Effect.mapError(mapRemoteEnvironmentError), + )); return new PrimaryConnectionRegistration({ target: new PrimaryConnectionTarget({ environmentId: descriptor.environmentId, diff --git a/apps/web/src/lib/sharedHighlighter.ts b/apps/web/src/lib/sharedHighlighter.ts new file mode 100644 index 0000000000000..5fcb03d599e34 --- /dev/null +++ b/apps/web/src/lib/sharedHighlighter.ts @@ -0,0 +1,2 @@ +// Split point for shiki: syntaxHighlighting.ts imports this on the first highlight. +export { getSharedHighlighter } from "@pierre/diffs"; diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index 3be008ac2b794..b1bdb48bb6cf6 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -1,9 +1,4 @@ -import { - getSharedHighlighter, - type DiffsHighlighter, - type HighlighterTypes, - type SupportedLanguages, -} from "@pierre/diffs"; +import type { DiffsHighlighter, HighlighterTypes, SupportedLanguages } from "@pierre/diffs"; import { resolveDiffThemeName } from "./diffRendering"; @@ -21,19 +16,24 @@ export function getSyntaxHighlighterPromise(language: string): Promise { - if (language === "text") { - highlighterPromiseCache.delete(language); - // "text" itself failed — Shiki cannot initialize at all, surface the error - throw error; - } - // Language not supported by Shiki — fall back to "text" - return getSyntaxHighlighterPromise("text"); - }); + // Shiki and its engines load with the first highlight, not with the app. + const promise = import("./sharedHighlighter") + .then(({ getSharedHighlighter }) => + getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: PREFERRED_HIGHLIGHTER, + }), + ) + .catch((error) => { + if (language === "text") { + highlighterPromiseCache.delete(language); + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw error; + } + // Language not supported by Shiki — fall back to "text" + return getSyntaxHighlighterPromise("text"); + }); highlighterPromiseCache.set(language, promise); return promise; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 81d1c2140af6b..de3071a12714d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,7 +7,7 @@ import "./index.css"; import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; -import { getRouter } from "./router"; +import { getRouter, preloadInitialRouteChunks } from "./router"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, @@ -21,6 +21,7 @@ prepareProviderAuthDelivery(); const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); +preloadInitialRouteChunks(router); if (isElectron) { syncDocumentElectronPlatformClasses(navigator.platform); diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx index 3682deb281fa9..e8b3158fa23b2 100644 --- a/apps/web/src/markdown-incremental.test.tsx +++ b/apps/web/src/markdown-incremental.test.tsx @@ -1,4 +1,4 @@ -import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; +import { chatMarkdownRehypePlugins, loadRehypeRaw } from "@t3tools/shared/markdownPipeline"; import type { Root } from "mdast"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown from "react-markdown"; @@ -11,6 +11,8 @@ import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; +const CHAT_MARKDOWN_REHYPE_PLUGINS = chatMarkdownRehypePlugins(await loadRehypeRaw()); + function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { let tree: Root | undefined; const observeParsing: Plugin<[], Root> = function () { diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 8dbdbe55d891f..2d6f761b6a63b 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -13,7 +13,9 @@ import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer" import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; -const DEFAULT_EXPORT_INTERVAL_MS = 1_000; +// Every export is a POST the page serializes and the server writes, and boot +// and background polling produce spans every second. +const DEFAULT_EXPORT_INTERVAL_MS = 5_000; const CLIENT_TRACING_RESOURCE = { serviceName: "t3code-web", attributes: { diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 4362dd3d70bac..67bf61c40a593 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -16,6 +16,26 @@ export function getRouter(history: RouterHistory) { export type AppRouter = ReturnType; +/** + * The router fetches split route chunks only after every beforeLoad resolves, + * and the root beforeLoad waits on the auth check. Starting the initial + * location's chunks during that check runs both at once. The index route + * opens a draft right away, so its chunk starts too. The chunks start a task + * later, so the boot's own requests reach the server ahead of them. + */ +export function preloadInitialRouteChunks(router: AppRouter) { + const { pathname } = router.latestLocation; + const routes = [...router.getMatchedRoutes(pathname).matchedRoutes]; + const draftRoute = router.looseRoutesById["/_chat/draft/$draftId"]; + if (pathname === "/" && draftRoute) routes.push(draftRoute); + setTimeout(() => { + for (const route of routes) { + // Failures surface again when the router loads the same chunk. + void Promise.resolve(router.loadRouteChunk(route)).catch(() => undefined); + } + }, 0); +} + declare module "@tanstack/react-router" { interface Register { router: AppRouter; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index d1c4ef730c86a..dd37be5b34c93 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -12,24 +12,22 @@ import { useRouter, } from "@tanstack/react-router"; import { Check, Copy } from "lucide"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; -import { CommandPalette } from "../components/CommandPalette"; +import { CommandPaletteHost } from "../components/CommandPaletteHost"; import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning"; import { FirstRunGate } from "../components/onboarding/FirstRunGate"; -import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; -import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; @@ -51,6 +49,7 @@ import { toastManager, } from "../components/ui/toast"; import { isElectron } from "../env"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; @@ -74,6 +73,8 @@ import { primaryServerWelcomeAtom, } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { startEnvironmentShells } from "../state/shell"; +import { prefetchPrimaryEnvironmentDescriptor } from "../connection/platform"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -83,6 +84,17 @@ import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; +const ConnectOnboardingDialog = lazy(() => + import("../components/cloud/ConnectOnboardingDialog").then((module) => ({ + default: module.ConnectOnboardingDialog, + })), +); +const NightlyMobileBetaNotice = lazy(() => + import("../components/NightlyMobileBeta").then((module) => ({ + default: module.NightlyMobileBetaNotice, + })), +); + export const Route = createRootRoute({ beforeLoad: async ({ location }) => { if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { @@ -94,6 +106,7 @@ export const Route = createRootRoute({ } if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) { + startEnvironmentShells(); return { authGateState: { status: "hosted-static", @@ -101,7 +114,11 @@ export const Route = createRootRoute({ }; } - const authGateState = await resolveInitialServerAuthGateState(); + const authGate = resolveInitialServerAuthGateState(); + prefetchPrimaryEnvironmentDescriptor(); + const authGateState = await authGate; + // Connect and read cached shells while the route chunks still load. + if (authGateState.status === "authenticated") startEnvironmentShells(); if ( authGateState.status === "authenticated" && getDesktopSnapShotBridge() && @@ -181,11 +198,11 @@ function RootRouteView() { - + - + ); @@ -201,11 +218,11 @@ function RootRouteView() { } const appShell = ( - + - + ); // FirstRunGate holds back everything below it — including EventRouter, @@ -230,7 +247,11 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} - + {hasCloudPublicConfig() ? ( + + + + ) : null} @@ -247,7 +268,9 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} - + + + {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 769ad48abda9f..b6829a35b6a4c 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -5,6 +5,8 @@ import { PairingPendingSurface, PairingRouteSurface, } from "../components/auth/PairingRouteSurface"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const Route = createFileRoute("/pair")({ beforeLoad: async ({ context }) => { @@ -42,8 +44,13 @@ function PairRouteView() { { - // Recreate the primary connection so its WebSocket and cached scopes - // use the newly issued cookie after re-pairing. + // A first pairing has no connection yet, so the app continues in + // place. Re-pairing reloads to recreate the primary connection, so its + // WebSocket and cached scopes use the newly issued cookie. + if (!appAtomRegistry.getNodes().has(connectionAtomRuntime)) { + void router.navigate({ to: "/", replace: true }); + return; + } router.history.replace("/"); router.history.flush(); window.location.reload(); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index d600f542782f9..e14096974cb1d 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -19,6 +19,8 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); @@ -29,6 +31,17 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { if (Option.isNone(catalog)) { return false; } + // The persisted catalog emits before platform discovery registers the + // primary environment. Until it does, an empty workspace is unknown. + if ( + !isHostedStaticApp() && + !isLocalEnvironmentDisabled() && + !Array.from(catalog.value.entries.values()).some( + (entry) => entry.target._tag === "PrimaryConnectionTarget", + ) + ) { + return false; + } for (const environmentId of enabledEnvironmentIds(catalog.value)) { if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { continue; @@ -49,6 +62,20 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { return true; }).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped")); +let environmentShellsStarted = false; + +/** + * Builds the connection runtime, reads every enabled environment's cached + * shell, and starts its connection without waiting for the first render to + * read them. Called once the app knows it will render the workspace. The + * mount lasts for the session, like the sidebar that reads the same atoms. + */ +export function startEnvironmentShells(): void { + if (environmentShellsStarted) return; + environmentShellsStarted = true; + appAtomRegistry.mount(allEnvironmentShellsBootstrappedAtom); +} + /** Cached or missing snapshots cannot establish that a saved project no longer exists. */ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { readonly catalogValueAtom: Atom.Atom; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cf343e26a4fcf..e9e3bedd8c62e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,6 +16,8 @@ import { import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; +import { precompressPlugin } from "./vite/precompress"; +import { lucideDynamicIconsPlugin, startupChunksPlugin } from "./vite/startupChunks"; import { tailwindPlugins } from "./vite/tailwind"; const repoEnv = loadRepoEnv(); @@ -184,6 +186,10 @@ export default defineConfig(() => { presets: [reactCompilerPreset()], }), tailwindPlugins(bundledDev), + lucideDynamicIconsPlugin(), + startupChunksPlugin(), + // Vercel compresses the hosted app itself; the T3 server serves these copies. + ...(process.env.VERCEL ? [] : [precompressPlugin()]), ], optimizeDeps: { include: [ @@ -283,7 +289,9 @@ export default defineConfig(() => { build: { outDir: "dist", emptyOutDir: true, - manifest: true, + // Not under .vite/: release artifact uploads skip hidden directories, and + // the server reads this file to mark hashed assets immutable. + manifest: "vite-manifest.json", sourcemap: buildSourcemap, }, test: { diff --git a/apps/web/vite/precompress.ts b/apps/web/vite/precompress.ts new file mode 100644 index 0000000000000..5eaa51e03339f --- /dev/null +++ b/apps/web/vite/precompress.ts @@ -0,0 +1,60 @@ +import * as NodeFs from "node:fs/promises"; +import * as NodePath from "node:path"; +import { promisify } from "node:util"; +import * as NodeZlib from "node:zlib"; + +import type { Plugin } from "vite-plus"; + +const brotli = promisify(NodeZlib.brotliCompress); +const gzip = promisify(NodeZlib.gzip); + +// Same types and floor as the server's on-the-fly compression. +const COMPRESSIBLE_FILE = /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/; +const MIN_BYTES = 1024; + +/** + * Writes `.br` (quality 11) and `.gz` (level 9) next to each hashed asset so + * the server sends finished bytes instead of compressing on every cold load. + * Only `assets/` gets copies: those names are content hashed, so a copy can + * never describe a different version of its file. + */ +export function precompressPlugin(): Plugin { + return { + name: "t3code:precompress", + apply: "build", + async writeBundle(options, bundle) { + const outDir = options.dir; + if (!outDir) return; + const files = Object.keys(bundle).filter( + (fileName) => fileName.startsWith("assets/") && COMPRESSIBLE_FILE.test(fileName), + ); + await Promise.all( + files.map(async (fileName) => { + const filePath = NodePath.join(outDir, fileName); + const source = await NodeFs.readFile(filePath); + if (source.byteLength < MIN_BYTES) return; + const [br, gz] = await Promise.all([ + brotli(source, { + params: { + [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 11, + [NodeZlib.constants.BROTLI_PARAM_MODE]: fileName.endsWith(".wasm") + ? NodeZlib.constants.BROTLI_MODE_GENERIC + : NodeZlib.constants.BROTLI_MODE_TEXT, + [NodeZlib.constants.BROTLI_PARAM_SIZE_HINT]: source.byteLength, + }, + }), + gzip(source, { level: 9 }), + ]); + await Promise.all([ + br.byteLength < source.byteLength + ? NodeFs.writeFile(`${filePath}.br`, br) + : Promise.resolve(), + gz.byteLength < source.byteLength + ? NodeFs.writeFile(`${filePath}.gz`, gz) + : Promise.resolve(), + ]); + }), + ); + }, + }; +} diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts new file mode 100644 index 0000000000000..5f86dd3642b9c --- /dev/null +++ b/apps/web/vite/startupChunks.ts @@ -0,0 +1,198 @@ +import * as NodePath from "node:path"; + +import type { HtmlTagDescriptor, Plugin, Rolldown } from "vite-plus"; + +// What a cold open runs before its first commit: the app, then the chat +// layout, home, a draft, or a thread. Keep in sync with src/routes. +const STARTUP_MODULES = { + main: "src/main.tsx", + chat: "src/routes/_chat.tsx?tsr-split=component", + "chat-index": "src/routes/_chat.index.tsx?tsr-split=component", + "chat-draft": "src/routes/_chat.draft.$draftId.tsx?tsr-split=component", + "chat-thread": "src/routes/_chat.$environmentId.$threadId.tsx?tsr-split=component", +}; +// Rolldown measures modules before minification. This keeps startup to a +// handful of requests while the browser still compiles pieces in parallel. +const STARTUP_CHUNK_MAX_BYTES = 3 * 1024 * 1024; + +/** + * Groups the startup graphs into a few chunks while preserving route boundaries. + * + * index.html loads `src/bootstrap.ts`, which imports `src/main.tsx` lazily so + * it can show boot errors. Left alone, the browser learns the main graph only + * after that entry runs and the route chunks only after the auth check, and + * both arrive as hundreds of small shared chunks. + * + * Listing main and the startup routes as build inputs lets Rolldown tag their + * static graphs `$initial` after tree shaking. Entry-aware groups merge only + * modules with the same reachability, so settings and pairing do not download + * chat code. index.html preloads main's static graph; the router preloads the + * initial location's route chunks alongside authentication. + * The bootstrap entry keeps its own modules, so it never statically imports + * a startup chunk and can still report a failed load. + */ +export function startupChunksPlugin(): Plugin { + let root = process.cwd(); + let base = "/"; + let bootstrapModules: ReadonlySet = new Set(); + let startupFiles: ReadonlyArray = []; + let startupFileSet: ReadonlySet = new Set(); + let startupCss: ReadonlyArray = []; + + return { + name: "t3code:startup-chunks", + apply: "build", + config: (config) => { + root = NodePath.resolve(config.root ?? process.cwd()); + return { + build: { + modulePreload: { + resolveDependencies: (_file, deps, { hostType }) => + hostType === "js" ? deps.filter((dep) => !startupFileSet.has(dep)) : deps, + }, + rolldownOptions: { + input: { + index: NodePath.join(root, "index.html"), + ...Object.fromEntries( + Object.entries(STARTUP_MODULES).map(([name, id]) => [ + name, + NodePath.join(root, id), + ]), + ), + }, + output: { + codeSplitting: { + groups: [ + { + name: "startup", + tags: ["$initial"], + test: (id: string) => !id.startsWith("\0") && !bootstrapModules.has(id), + entriesAware: true, + // Otherwise excluded bootstrap helpers are recaptured through main's imports. + includeDependenciesRecursively: false, + maxSize: STARTUP_CHUNK_MAX_BYTES, + }, + ], + }, + }, + }, + }, + }; + }, + configResolved(config) { + base = config.base; + }, + buildEnd(error) { + if (error) return; + const html = [...this.getModuleIds()].find((id) => id === NodePath.join(root, "index.html")); + const closure = new Set(); + const pending = html ? [html] : []; + for (let id = pending.pop(); id !== undefined; id = pending.pop()) { + if (closure.has(id)) continue; + closure.add(id); + pending.push(...(this.getModuleInfo(id)?.importedIds ?? [])); + } + bootstrapModules = closure; + }, + generateBundle: { + order: "pre", + handler(_options, bundle) { + const chunks = Object.values(bundle).filter( + (output): output is Rolldown.OutputChunk => output.type === "chunk", + ); + const importClosure = (roots: ReadonlyArray) => { + const files = new Set(); + const pending = roots.map((chunk) => chunk.fileName); + for (let file = pending.pop(); file !== undefined; file = pending.pop()) { + const chunk = bundle[file]; + if (chunk?.type !== "chunk" || files.has(file)) continue; + files.add(file); + pending.push(...chunk.imports); + } + return files; + }; + // index.html already loads the bootstrap entry and its imports. + const bootstrapFiles = importClosure( + chunks.filter((chunk) => chunk.facadeModuleId === NodePath.join(root, "index.html")), + ); + const mainId = NodePath.join(root, STARTUP_MODULES.main); + if ( + [...bootstrapFiles].some((file) => { + const chunk = bundle[file]; + return ( + chunk?.type === "chunk" && + chunk.moduleIds.some((id) => !id.startsWith("\0") && !bootstrapModules.has(id)) + ); + }) + ) { + this.error( + "The boot entry must catch main's chunk load failures before importing app code.", + ); + } + // The main entry can become a facade after grouping. Preload its + // static dependencies only; selected routes retain their own preload lists. + const files = [ + ...importClosure( + chunks.filter( + (chunk) => chunk.facadeModuleId === mainId || chunk.moduleIds.includes(mainId), + ), + ), + ].filter((file) => !bootstrapFiles.has(file)); + const chunkAt = (file: string) => { + const chunk = bundle[file]; + return chunk?.type === "chunk" ? chunk : undefined; + }; + // Largest first: those downloads take longest. + startupFiles = files.toSorted( + (left, right) => (chunkAt(right)?.code.length ?? 0) - (chunkAt(left)?.code.length ?? 0), + ); + startupFileSet = new Set(startupFiles); + startupCss = [ + ...new Set( + files.flatMap((file) => [...(chunkAt(file)?.viteMetadata?.importedCss ?? [])]), + ), + ]; + }, + }, + transformIndexHtml: { + order: "post", + handler: (): HtmlTagDescriptor[] => [ + // A stylesheet link would hold the splash until the CSS arrives; the + // runtime still inserts the stylesheet before main runs. + ...startupCss.map((file): HtmlTagDescriptor => ({ + tag: "link", + attrs: { rel: "preload", as: "style", crossorigin: true, href: `${base}${file}` }, + injectTo: "head", + })), + ...startupFiles.map((file): HtmlTagDescriptor => ({ + tag: "link", + attrs: { rel: "modulepreload", crossorigin: true, href: `${base}${file}` }, + injectTo: "head", + })), + ], + }, + }; +} + +/** + * lucide-react/dynamic imports each of ~1,900 icons lazily. When the app also + * imports one of those icons statically, Rolldown keeps it as a dynamic entry + * in a chunk of its own, which put ~140 one-icon files on the startup path. + * A query suffix gives the lazy copies their own module ids, so static imports + * bundle normally and only icons picked at runtime load as separate files. + */ +export function lucideDynamicIconsPlugin(): Plugin { + const suffix = "?lucide-dynamic"; + return { + name: "t3code:lucide-dynamic-icons", + apply: "build", + enforce: "pre", + async resolveId(source, importer) { + if (!source.startsWith("./icons/") || !importer?.endsWith("/dynamicIconImports.js")) { + return null; + } + const resolved = await this.resolve(source, importer, { skipSelf: true }); + return resolved ? `${resolved.id}${suffix}` : null; + }, + }; +} diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 63c23d735fa9f..017618a297571 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,6 +1,7 @@ import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Equivalence from "effect/Equivalence"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -120,12 +121,18 @@ function makeEnvironmentSessionAtoms( ), ); + // A connect attempt reports the same prepared connection at each stage, each + // in a fresh Option. Comparing the connection itself keeps the session fetch + // below to one per attempt. const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => Option.none(), ), - ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), + ).pipe( + Atom.withEquality(Option.makeEquivalence(Equivalence.strictEqual())), + Atom.withLabel(`environment-prepared-connection:${environmentId}`), + ), ); // Keyed on the prepared connection's identity: a reconnect (new credential, diff --git a/packages/shared/package.json b/packages/shared/package.json index fdc368b6c3995..6ad7a4c424b2b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -443,6 +443,10 @@ "types": "./src/threadFindText.ts", "import": "./src/threadFindText.ts" }, + "./threadFindBlockTags": { + "types": "./src/threadFindBlockTags.ts", + "import": "./src/threadFindBlockTags.ts" + }, "./threadSearch": { "types": "./src/threadSearch.ts", "import": "./src/threadSearch.ts" diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 1227e77b942fd..8ca2b543d53e5 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -1,19 +1,55 @@ -import { expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; -import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS } from "./markdownPipeline.ts"; +import rehypeRaw from "rehype-raw"; +import { + CHAT_MARKDOWN_REMARK_PLUGINS, + chatMarkdownRehypePlugins, + markdownMayContainRawHtml, +} from "./markdownPipeline.ts"; -it("preserves in-app thread links through the shared Markdown sanitizer", () => { +function chatMarkdownTree(markdown: string, rawHtml: boolean) { const processor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS) .use(remarkRehype, { allowDangerousHtml: true }) - .use(CHAT_MARKDOWN_REHYPE_PLUGINS); - const tree = processor.runSync( - processor.parse( - '[Open thread](t3-thread://v1/environment/thread)\n\nUnsafe', - ), + .use(chatMarkdownRehypePlugins(rawHtml ? rehypeRaw : null)); + return processor.runSync(processor.parse(markdown)); +} + +type TreeNode = { + type: string; + tagName?: string; + value?: string; + position?: unknown; + children?: TreeNode[]; +}; + +/** + * What the renderer shows: text positions and table whitespace are dropped, adjacent + * text joins, and whitespace between blocks collapses. + */ +const text = (value: string): TreeNode => ({ type: "text", value: value.trim() ? value : "\n" }); +function rendered(node: TreeNode): TreeNode { + if (node.type === "text") return text(node.value ?? ""); + if (!node.children) return node; + const table = ["table", "thead", "tbody", "tfoot", "tr"].includes(node.tagName ?? ""); + const children: TreeNode[] = []; + for (const child of node.children.map(rendered)) { + if (table && child.type === "text" && child.value?.trim() === "") continue; + const last = children.at(-1); + if (child.type === "text" && last?.type === "text") { + children[children.length - 1] = text(`${last.value}${child.value}`); + } else children.push(child); + } + return { ...node, children }; +} + +it("preserves in-app thread links through the shared Markdown sanitizer", () => { + const tree = chatMarkdownTree( + '[Open thread](t3-thread://v1/environment/thread)\n\nUnsafe', + true, ); expect(tree.children[0]).toMatchObject({ tagName: "p", @@ -27,3 +63,41 @@ it("preserves in-app thread links through the shared Markdown sanitizer", () => }); expect(JSON.stringify(tree)).not.toContain("javascript:"); }); + +describe("raw HTML detection", () => { + it("flags every way markdown can start raw HTML", () => { + for (const markdown of ["
", "a", "", "", ""]) { + expect(markdownMayContainRawHtml(markdown)).toBe(true); + } + }); + + it("renders tag-free markdown the same without rehype-raw", () => { + const markdown = [ + "# Title & *emphasis*", + "", + "- [ ] task", + "- [x] done with `code` and a [link](https://example.com 'Title')", + "", + "| a | b |", + "| - | -: |", + "| 1 < 2 | x |", + "", + "> [!NOTE]", + "> An alert with ~~strike~~ and footnote[^1].", + "", + "```ts title=example.ts", + "const a = 1 < 2;", + "```", + "", + "![shot](C:\\Users\\me\\.t3\\shot.png)", + "", + "Entities & © and https://autolink.example", + "", + "[^1]: The footnote.", + ].join("\n"); + expect(markdownMayContainRawHtml(markdown)).toBe(false); + expect(rendered(chatMarkdownTree(markdown, false)).children).toEqual( + rendered(chatMarkdownTree(markdown, true)).children, + ); + }); +}); diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 7317dcc5b9fff..8ae1ffdb6a101 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -1,7 +1,6 @@ import type { Processor } from "unified"; import { isWindowsAbsolutePath } from "./path.ts"; -import type { PluggableList } from "unified"; -import rehypeRaw from "rehype-raw"; +import type { Pluggable, PluggableList } from "unified"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; @@ -190,12 +189,42 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [ remarkNormalizeLinksAndTagInlineCode, ]; -export const CHAT_MARKDOWN_REHYPE_PLUGINS: PluggableList = [ - rehypePreserveBareAnchorPlaceholders, - rehypeRaw, - rehypePreserveImageSourceMeta, - [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], -]; +/** + * The chat rehype pipeline, parsing raw HTML only when given rehype-raw. The web + * client loads it once a message has a tag, so parse5 stays out of its startup; + * a static import anywhere in this module would pull it back into that chunk. + */ +export function chatMarkdownRehypePlugins(rawHtml: Pluggable | null): PluggableList { + return [ + rehypePreserveBareAnchorPlaceholders, + ...(rawHtml ? [rawHtml] : []), + rehypePreserveImageSourceMeta, + [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], + ]; +} + +let rehypeRaw: Pluggable | undefined; +let rehypeRawPromise: Promise | undefined; + +/** Loads rehype-raw for clients that keep parse5 out of their startup bundle. */ +export function loadRehypeRaw(): Promise { + rehypeRawPromise ??= import("rehype-raw").then( + (module) => (rehypeRaw = module.default), + (error: unknown) => { + rehypeRawPromise = undefined; + throw error; + }, + ); + return rehypeRawPromise; +} + +/** rehype-raw once {@link loadRehypeRaw} has resolved, for synchronous renders. */ +export const loadedRehypeRaw = (): Pluggable | undefined => rehypeRaw; + +/** Raw HTML always starts with `<` and a letter, `/`, `!` or `?`; anything else parses the same without rehype-raw. */ +export function markdownMayContainRawHtml(markdown: string): boolean { + return /<[A-Za-z/!?]/.test(markdown); +} type MarkdownAstNode = { type?: string; diff --git a/packages/shared/src/threadFindBlockTags.ts b/packages/shared/src/threadFindBlockTags.ts new file mode 100644 index 0000000000000..685c56619f8b8 --- /dev/null +++ b/packages/shared/src/threadFindBlockTags.ts @@ -0,0 +1,39 @@ +// Inline wrappers (including Shiki token spans) must not split a search phrase. +export const THREAD_FIND_BLOCK_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "br", + "dd", + "details", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "summary", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", +]); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index ab293fa68ea44..e9c6634c44a93 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -28,52 +28,16 @@ import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanTex import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; +import rehypeRaw from "rehype-raw"; import { shouldPreserveAssistantLineBreaks, - CHAT_MARKDOWN_REHYPE_PLUGINS, + chatMarkdownRehypePlugins, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, } from "./markdownPipeline.ts"; +import { THREAD_FIND_BLOCK_TAGS } from "./threadFindBlockTags.ts"; -// Inline wrappers (including Shiki token spans) must not split a search phrase. -export const THREAD_FIND_BLOCK_TAGS = new Set([ - "address", - "article", - "aside", - "blockquote", - "br", - "dd", - "details", - "div", - "dl", - "dt", - "figcaption", - "figure", - "footer", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "header", - "hr", - "li", - "main", - "nav", - "ol", - "p", - "pre", - "section", - "summary", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - "ul", -]); +const CHAT_MARKDOWN_REHYPE_PLUGINS = chatMarkdownRehypePlugins(rehypeRaw); const assistantProcessor = unified() .use(remarkParse) From ef932eabb8a57fc7076f3e5a2e8c271f1845181d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:35:30 +0000 Subject: [PATCH 015/143] fix(web): preserve boot module boundaries across platforms --- apps/web/src/connection/platform.ts | 1 - apps/web/vite/startupChunks.ts | 17 ++++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 94462f8edb683..50703801ff6ae 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -46,7 +46,6 @@ import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAu import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; import { readPrimaryEnvironmentTarget, - readPrimaryEnvironmentTargetResult, type PrimaryEnvironmentTarget, } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts index 5f86dd3642b9c..aa275546ed153 100644 --- a/apps/web/vite/startupChunks.ts +++ b/apps/web/vite/startupChunks.ts @@ -1,6 +1,6 @@ import * as NodePath from "node:path"; -import type { HtmlTagDescriptor, Plugin, Rolldown } from "vite-plus"; +import { normalizePath, type HtmlTagDescriptor, type Plugin, type Rolldown } from "vite-plus"; // What a cold open runs before its first commit: the app, then the chat // layout, home, a draft, or a thread. Keep in sync with src/routes. @@ -43,7 +43,7 @@ export function startupChunksPlugin(): Plugin { name: "t3code:startup-chunks", apply: "build", config: (config) => { - root = NodePath.resolve(config.root ?? process.cwd()); + root = normalizePath(NodePath.resolve(config.root ?? process.cwd())); return { build: { modulePreload: { @@ -52,11 +52,11 @@ export function startupChunksPlugin(): Plugin { }, rolldownOptions: { input: { - index: NodePath.join(root, "index.html"), + index: normalizePath(NodePath.join(root, "index.html")), ...Object.fromEntries( Object.entries(STARTUP_MODULES).map(([name, id]) => [ name, - NodePath.join(root, id), + normalizePath(NodePath.join(root, id)), ]), ), }, @@ -84,7 +84,8 @@ export function startupChunksPlugin(): Plugin { }, buildEnd(error) { if (error) return; - const html = [...this.getModuleIds()].find((id) => id === NodePath.join(root, "index.html")); + const htmlId = normalizePath(NodePath.join(root, "index.html")); + const html = [...this.getModuleIds()].find((id) => id === htmlId); const closure = new Set(); const pending = html ? [html] : []; for (let id = pending.pop(); id !== undefined; id = pending.pop()) { @@ -113,9 +114,11 @@ export function startupChunksPlugin(): Plugin { }; // index.html already loads the bootstrap entry and its imports. const bootstrapFiles = importClosure( - chunks.filter((chunk) => chunk.facadeModuleId === NodePath.join(root, "index.html")), + chunks.filter( + (chunk) => chunk.facadeModuleId === normalizePath(NodePath.join(root, "index.html")), + ), ); - const mainId = NodePath.join(root, STARTUP_MODULES.main); + const mainId = normalizePath(NodePath.join(root, STARTUP_MODULES.main)); if ( [...bootstrapFiles].some((file) => { const chunk = bundle[file]; From 0dda2838e11d61461a9360558360841042896244 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:34:58 +0000 Subject: [PATCH 016/143] fix(server): preserve activity stamps when replaying legacy pull request syncs --- apps/server/src/orchestration-v2/ProjectionStore.test.ts | 3 ++- apps/server/src/orchestration-v2/ProjectionStore.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index fc0a809c7191b..5028797804be4 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -4787,7 +4787,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { id: EventId.make(`event:watched-pull-request:${id}`), type: "thread.pull-request-synced", threadId, - occurredAt: at, + occurredAt: DateTime.add(at, { seconds: 1 }), payload: { ...thread, pullRequests }, }); const project = { title: "Project" }; @@ -4825,6 +4825,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }, ]); assert.equal(yield* phase, "running"); + assert.deepEqual((yield* store.getThread(threadId)).updatedAt, at); const snapshot = { state: "open" as const, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 1c007807135a7..4262a6f69357e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2704,6 +2704,7 @@ export const layer: Layer.Layer = event.type !== "thread.visited" && event.type !== "thread.visit-recorded" && event.type !== "thread.pull-request-link-synced" && + event.type !== "thread.pull-request-synced" && event.type !== "thread.marked-unread" && event.type !== "thread.metadata-updated" && event.type !== "thread.runtime-mode-updated" && From d52027d067a9a088e4dbef6bff3f6025416f9d53 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:37:06 +0000 Subject: [PATCH 017/143] test(provider): cover deferred startup probes using current settings --- .../src/server/managedProvider.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 239508cec41c6..273dcf48024fa 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -141,6 +141,40 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("parks the initial probe and reads settings again when startup releases it", () => + Effect.scoped( + Effect.gen(function* () { + const host = yield* ProviderHost.ProviderHost; + const startup = yield* Deferred.make(); + const settings = yield* Ref.make({ enabled: true }); + const checkCalls = yield* Ref.make(0); + const enrichedWith = yield* Deferred.make(); + yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Ref.get(settings), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.as(refreshedSnapshot), + ), + enrichSnapshot: ({ settings }) => Deferred.succeed(enrichedWith, settings), + refreshInterval: "1 hour", + }).pipe( + Effect.provideService(ProviderHost.ProviderHost, { + ...host, + awaitStartupProbe: Deferred.await(startup), + }), + ); + assert.strictEqual(yield* Ref.get(checkCalls), 0); + yield* Ref.set(settings, { enabled: false }); + yield* Deferred.succeed(startup, undefined); + assert.deepStrictEqual(yield* Deferred.await(enrichedWith), { enabled: false }); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => From a6c2e9d12bd721872c0d23a6b3827bcf1726cd6a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:37:52 +0000 Subject: [PATCH 018/143] test(provider): return void from deferred probe enrichment --- packages/provider-core/src/server/managedProvider.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 273dcf48024fa..f6acdb79152f5 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -158,7 +158,8 @@ describe("makeManagedServerProvider", () => { checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( Effect.as(refreshedSnapshot), ), - enrichSnapshot: ({ settings }) => Deferred.succeed(enrichedWith, settings), + enrichSnapshot: ({ settings }) => + Deferred.succeed(enrichedWith, settings).pipe(Effect.asVoid), refreshInterval: "1 hour", }).pipe( Effect.provideService(ProviderHost.ProviderHost, { From 7f09da65910ee0d29025e67cd00c6e12b7f878e1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:38:24 +0000 Subject: [PATCH 019/143] fix(web): use the runtime clock for descriptor freshness --- apps/web/src/connection/platform.ts | 3 ++- apps/web/vite/precompress.ts | 1 + apps/web/vite/startupChunks.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 50703801ff6ae..ce4482921fc5f 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -378,9 +378,10 @@ const loadPrimaryConnectionRegistration = Effect.fn( )(function* (resolved: PrimaryEnvironmentTarget) { const prefetch = primaryDescriptorPrefetch; primaryDescriptorPrefetch = null; + const now = yield* Clock.currentTimeMillis; const prefetched = prefetch?.httpBaseUrl === resolved.target.httpBaseUrl && - Date.now() - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS + now - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS ? yield* Effect.promise(() => prefetch.settled) : null; const descriptor = diff --git a/apps/web/vite/precompress.ts b/apps/web/vite/precompress.ts index 5eaa51e03339f..a6eb7f17750a0 100644 --- a/apps/web/vite/precompress.ts +++ b/apps/web/vite/precompress.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite build hooks run outside an Effect runtime. import * as NodeFs from "node:fs/promises"; import * as NodePath from "node:path"; import { promisify } from "node:util"; diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts index aa275546ed153..253b1b6db2404 100644 --- a/apps/web/vite/startupChunks.ts +++ b/apps/web/vite/startupChunks.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite resolves module paths before an Effect runtime exists. import * as NodePath from "node:path"; import { normalizePath, type HtmlTagDescriptor, type Plugin, type Rolldown } from "vite-plus"; From 5035247ef2f234e692f6e51b72312d73a6285223 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:40:51 +0000 Subject: [PATCH 020/143] fix(server): close abandoned claude turn preparation --- .../Adapters/ClaudeAdapterV2.test.ts | 91 +++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 56 +++++++++--- .../RunExecutionService.test.ts | 48 ++++++++-- .../orchestration-v2/RunExecutionService.ts | 2 +- .../src/server/ProviderAdapter.ts | 7 +- 5 files changed, 170 insertions(+), 34 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d08b67b8f93..8bd5fae6a9324 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1046,7 +1046,10 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { }); describe("ClaudeAdapterV2 prepareTurn", () => { - const runPrepareScenario = (input: { readonly startModel: string }) => + const runPrepareScenario = (input: { + readonly startModel: string; + readonly abandon?: "scope-end" | "interruption" | "stop" | "superseded"; + }) => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1113,13 +1116,59 @@ describe("ClaudeAdapterV2 prepareTurn", () => { modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model }, }); assert.isFunction(runtime.prepareTurn); - yield* runtime.prepareTurn!(turnInput(CLAUDE_TEST_MODEL_SELECTION.model)); - // The CLI is started, but nothing reaches it before the turn starts. - assert.deepEqual( - queries.map((query) => query.offers), - [0], - ); - yield* runtime.startTurn(turnInput(input.startModel)); + const preparation = turnInput(CLAUDE_TEST_MODEL_SELECTION.model); + const start = turnInput(input.startModel); + if (input.abandon === "superseded") { + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + const replacementAttemptId = RunAttemptId.make("replacement-claude-prepare-turn"); + yield* runtime.prepareTurn!(preparation).pipe(Scope.provide(firstScope)); + yield* runtime.prepareTurn!({ + ...preparation, + attemptId: replacementAttemptId, + }).pipe(Scope.provide(secondScope)); + yield* Scope.close(firstScope, Exit.void); + assert.isTrue(queries[0]?.closed); + assert.isFalse(queries[1]?.closed); + yield* runtime.startTurn({ ...start, attemptId: replacementAttemptId }); + yield* Scope.close(secondScope, Exit.void); + assert.isFalse(queries[1]?.closed); + return queries; + } + const preparationExit = yield* Effect.scoped( + Effect.gen(function* () { + yield* runtime.prepareTurn!(preparation); + // The CLI is started, but nothing reaches it before the turn starts. + assert.deepEqual( + queries.map((query) => query.offers), + [0], + ); + if (input.abandon === "interruption") return yield* Effect.interrupt; + if (input.abandon === "stop") { + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: ProviderTurnId.make("not-started-claude-prepare-turn"), + requestRuntimeRestart: true, + }); + assert.isTrue(queries[0]?.closed); + return; + } + if (input.abandon === "scope-end") return; + yield* runtime.startTurn(start); + }), + ).pipe(Effect.exit); + if (input.abandon === "interruption") { + assert.isTrue(Exit.isFailure(preparationExit)); + } else { + assert.isTrue(Exit.isSuccess(preparationExit)); + } + if (input.abandon !== undefined) { + assert.isTrue(queries[0]?.closed); + assert.equal(queries[0]?.offers, 0); + // A cancelled preparation cannot be adopted by the next prompt. + yield* runtime.startTurn(start); + } + assert.isFalse(queries.at(-1)?.closed); return queries; }).pipe( Effect.provide( @@ -1145,6 +1194,32 @@ describe("ClaudeAdapterV2 prepareTurn", () => { assert.equal(queries[1]?.offers, 1); }), ); + + it.effect.each(["scope-end", "interruption", "stop"] as const)( + "closes an unprompted process after %s", + (abandon) => + Effect.gen(function* () { + const queries = yield* runPrepareScenario({ + startModel: CLAUDE_TEST_MODEL_SELECTION.model, + abandon, + }); + assert.equal(queries.length, 2); + assert.equal(queries[0]?.offers, 0); + assert.equal(queries[1]?.offers, 1); + }), + ); + + it.effect("closing a superseded start leaves replacement preparation and adoption alive", () => + Effect.gen(function* () { + const queries = yield* runPrepareScenario({ + startModel: CLAUDE_TEST_MODEL_SELECTION.model, + abandon: "superseded", + }); + assert.equal(queries.length, 2); + assert.equal(queries[0]?.offers, 0); + assert.equal(queries[1]?.offers, 1); + }), + ); }); describe("ClaudeAdapterV2 approval cancellation", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5953c54405529..bbf14e89c97e8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3096,6 +3096,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // next `openQuery` with the same open key adopts it; any other closes it. const prewarmedQuery = yield* Ref.make<{ readonly key: string; + readonly nativeThreadId: string; readonly query: ClaudeAgentSdkQuerySession; } | null>(null); const openedNativeThreads = yield* Ref.make(new Set()); @@ -7346,11 +7347,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }; }); - const closePrewarmedQuery = Ref.getAndSet(prewarmedQuery, null).pipe( - Effect.flatMap((prewarmed) => - prewarmed === null ? Effect.void : prewarmed.query.close.pipe(Effect.ignore), - ), - ); + const closePrewarmedQuery = (nativeThreadId?: string) => + Ref.modify(prewarmedQuery, (current) => + nativeThreadId === undefined || current?.nativeThreadId === nativeThreadId + ? [current, null] + : [null, current], + ).pipe( + Effect.flatMap((prewarmed) => + prewarmed === null ? Effect.void : prewarmed.query.close.pipe(Effect.ignore), + ), + ); const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, @@ -7362,7 +7368,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (existing !== null) { // `prepareTurn` only prewarms while no process is live, so a // prewarmed process here is stale whatever this turn does next. - yield* closePrewarmedQuery; + yield* closePrewarmedQuery(); } // A continuation prompts nothing: it drains output the live process // already produced, so it keeps that process whatever its selection. @@ -7544,14 +7550,33 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); const plan = yield* planQueryOpen(turnInput, nativeThreadId); - if ((yield* Ref.get(prewarmedQuery))?.key === plan.openKey) return; - const query = yield* queryRunner.open({ - threadId: turnInput.threadId, - providerSessionId: input.providerSessionId, - options: plan.queryOptions, - }); - const replaced = yield* Ref.getAndSet(prewarmedQuery, { key: plan.openKey, query }); - if (replaced !== null) yield* replaced.query.close.pipe(Effect.ignore); + yield* Effect.acquireRelease( + Effect.gen(function* () { + const query = yield* queryRunner.open({ + threadId: turnInput.threadId, + providerSessionId: input.providerSessionId, + options: plan.queryOptions, + }); + const replaced = yield* Ref.getAndSet(prewarmedQuery, { + key: plan.openKey, + nativeThreadId, + query, + }); + if (replaced !== null) yield* replaced.query.close.pipe(Effect.ignore); + return query; + }), + (query) => + // Adoption atomically removes this entry. A cancelled start + // only closes its own pending process, never a live query or + // another attempt's replacement preparation. + Ref.modify(prewarmedQuery, (current) => + current?.query === query ? [current, null] : [null, current], + ).pipe( + Effect.flatMap((pending) => + pending === null ? Effect.void : pending.query.close.pipe(Effect.ignore), + ), + ), + ); }, (effect, turnInput) => effect.pipe( @@ -7769,6 +7794,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const existing = yield* Ref.get(queryContext); const currentTurn = yield* Ref.get(activeTurn); const nativeThreadId = turnInput.providerThread.nativeThreadRef?.nativeId ?? null; + if (nativeThreadId !== null) yield* closePrewarmedQuery(nativeThreadId); if (currentTurn === null && turnInput.requestRuntimeRestart === true) { // Stop after the turn settled. With no CLI process of this // native thread left, nothing it started is still running: its @@ -7905,7 +7931,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (existing !== null) { yield* existing.query.close.pipe(Effect.ignore); } - yield* closePrewarmedQuery; + yield* closePrewarmedQuery(); yield* Effect.yieldNow; yield* queryRunner.assertComplete.pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 7082056637871..20f489d3fdf08 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1053,7 +1053,14 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); -it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)( +it.effect.each([ + "failure", + "interruption", + "stale-attempt", + "start-guard", + "late-start-guard", + "baseline-interruption", +] as const)( "handles %s before the provider turn starts", (scenario) => Effect.gen(function* () { @@ -1070,6 +1077,14 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co id: CheckpointScopeId.make("checkpoint-scope:run-execution-settings-failure"), } as OrchestrationV2CheckpointScope; const providerStarts = yield* Ref.make(0); + const preparations = yield* Ref.make(0); + const preparationCloses = yield* Ref.make(0); + const preparationStarted = yield* Deferred.make(); + const startGuards = yield* Ref.make(0); + const prepares = + scenario === "start-guard" || + scenario === "late-start-guard" || + scenario === "baseline-interruption"; const refreshes = yield* Ref.make(0); const guardedWrites = yield* Ref.make(0); const writes = yield* Ref.make>>([]); @@ -1079,7 +1094,11 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co McpAppModelContext.layerEmpty, Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => - scenario === "start-guard" ? Effect.void : Effect.die("not reached"), + scenario === "baseline-interruption" + ? Deferred.await(preparationStarted).pipe(Effect.andThen(Effect.interrupt)) + : prepares + ? Effect.void + : Effect.die("not reached"), }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent: (input) => @@ -1100,7 +1119,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: () => Effect.succeed([]), }), - scenario === "start-guard" + prepares ? ServerSettings.layerTest() : Layer.mock(ServerSettings.ServerSettingsService)({ getSettings: @@ -1130,6 +1149,13 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co providerSessionId, session: { events: Stream.never, + prepareTurn: () => + Effect.acquireRelease( + Ref.update(preparations, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(preparationStarted, undefined)), + ), + () => Ref.update(preparationCloses, (count) => count + 1), + ).pipe(Effect.asVoid), startTurn: () => Ref.update(providerStarts, (count) => count + 1), } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { @@ -1153,8 +1179,13 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co attemptId, providerTurnOrdinal: 1, // A declined start is a normal exit, not a preparation failure. - ...(scenario === "start-guard" - ? { shouldStartProviderTurn: () => Effect.succeed(false) } + ...(scenario === "start-guard" || scenario === "late-start-guard" + ? { + shouldStartProviderTurn: () => + Ref.updateAndGet(startGuards, (count) => count + 1).pipe( + Effect.map((count) => scenario === "late-start-guard" && count === 1), + ), + } : {}), message: { messageId: MessageId.make("message:run-execution-settings-failure"), @@ -1179,8 +1210,10 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co }).pipe(Effect.provide(layerTest), Effect.exit); assert.equal(yield* Ref.get(providerStarts), 0); + assert.equal(yield* Ref.get(preparations), prepares ? 1 : 0); + assert.equal(yield* Ref.get(preparationCloses), prepares ? 1 : 0); const events = (yield* Ref.get(writes)).flat(); - if (scenario === "interruption") { + if (scenario === "interruption" || scenario === "baseline-interruption") { assert.isTrue(Exit.isFailure(result)); if (Exit.isFailure(result)) assert.isTrue(Cause.hasInterruptsOnly(result.cause)); assert.equal(yield* Ref.get(guardedWrites), 0); @@ -1189,7 +1222,8 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co return; } assert.isTrue(Exit.isSuccess(result)); - if (scenario === "start-guard") { + if (scenario === "start-guard" || scenario === "late-start-guard") { + assert.equal(yield* Ref.get(startGuards), scenario === "late-start-guard" ? 2 : 1); assert.equal(yield* Ref.get(guardedWrites), 0); assert.equal(yield* Ref.get(refreshes), 0); assert.isEmpty(events); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 1b30ba23a9e51..b00dd6819311c 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -1485,7 +1485,7 @@ export const layer: Layer.Layer< ), ), ); - }), + }).pipe(Effect.scoped), } satisfies RunExecutionServiceV2Shape); }), ); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 24952b03fbb08..9f744b715d390 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -589,12 +589,13 @@ export interface ProviderAdapterV2SessionRuntime { * a CLI process) without sending the prompt, so it overlaps the baseline * checkpoint. It must not deliver the prompt, so the agent cannot act before * the baseline exists. `startTurn` with the same input reuses what it - * started. Best effort: a - * failure leaves `startTurn` to do the work itself. + * started. Pending work belongs to the caller's start scope and must close + * if that scope ends before `startTurn` adopts it. Best effort: a failure + * leaves `startTurn` to do the work itself. */ readonly prepareTurn?: ( input: ProviderAdapterV2TurnInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; From 44cb05460eb520b0d51d3c3160a98968b661dae8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:44:04 +0000 Subject: [PATCH 021/143] fix(startup): follow node namespace imports in build hooks --- apps/server/src/http.test.ts | 6 +++--- apps/web/vite/precompress.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 21d8c04d2c949..01c0bee0846ed 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; -import { brotliCompressSync, gzipSync } from "node:zlib"; +import * as NodeZlib from "node:zlib"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -153,8 +153,8 @@ it.layer( yield* fs.makeDirectory(path.join(staticDir, "assets")); const body = 'export const build = "precompressed";\n'.repeat(100); const asset = "assets/app-1234abcd.js"; - const br = brotliCompressSync(body); - const gz = gzipSync(body); + const br = NodeZlib.brotliCompressSync(body); + const gz = NodeZlib.gzipSync(body); yield* fs.writeFileString(path.join(staticDir, asset), body); yield* fs.writeFile(path.join(staticDir, `${asset}.br`), br); yield* fs.writeFile(path.join(staticDir, `${asset}.gz`), gz); diff --git a/apps/web/vite/precompress.ts b/apps/web/vite/precompress.ts index a6eb7f17750a0..f12e90b2c67d7 100644 --- a/apps/web/vite/precompress.ts +++ b/apps/web/vite/precompress.ts @@ -1,13 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off - Vite build hooks run outside an Effect runtime. -import * as NodeFs from "node:fs/promises"; +import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import { promisify } from "node:util"; +import * as NodeUtil from "node:util"; import * as NodeZlib from "node:zlib"; import type { Plugin } from "vite-plus"; -const brotli = promisify(NodeZlib.brotliCompress); -const gzip = promisify(NodeZlib.gzip); +const brotli = NodeUtil.promisify(NodeZlib.brotliCompress); +const gzip = NodeUtil.promisify(NodeZlib.gzip); // Same types and floor as the server's on-the-fly compression. const COMPRESSIBLE_FILE = /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/; @@ -32,7 +32,7 @@ export function precompressPlugin(): Plugin { await Promise.all( files.map(async (fileName) => { const filePath = NodePath.join(outDir, fileName); - const source = await NodeFs.readFile(filePath); + const source = await NodeFSP.readFile(filePath); if (source.byteLength < MIN_BYTES) return; const [br, gz] = await Promise.all([ brotli(source, { @@ -48,10 +48,10 @@ export function precompressPlugin(): Plugin { ]); await Promise.all([ br.byteLength < source.byteLength - ? NodeFs.writeFile(`${filePath}.br`, br) + ? NodeFSP.writeFile(`${filePath}.br`, br) : Promise.resolve(), gz.byteLength < source.byteLength - ? NodeFs.writeFile(`${filePath}.gz`, gz) + ? NodeFSP.writeFile(`${filePath}.gz`, gz) : Promise.resolve(), ]); }), From f471559b0004a79e93c69336455d450bcd4f8143 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:43:51 +0000 Subject: [PATCH 022/143] test(server): isolate sweep migration upgrade state --- .../src/persistence/Migrations/055_OrchestrationV2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index a032d941891cb..16f681835f663 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -168,6 +168,6 @@ layer("055_OrchestrationV2", (it) => { AND pull_request_count = 2 `; assert.deepStrictEqual(rows, [{ thread_id: "sweep-thread" }]); - }), + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); }); From c18aead59c5221a45daf2f93ea44c8ddad9e9251 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:45:52 +0000 Subject: [PATCH 023/143] test(markdown): compare rendered whitespace and boolean attributes --- packages/shared/src/markdownPipeline.test.ts | 25 ++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 8ca2b543d53e5..5fa442caaf55b 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -23,6 +23,7 @@ type TreeNode = { tagName?: string; value?: string; position?: unknown; + properties?: Record; children?: TreeNode[]; }; @@ -31,19 +32,35 @@ type TreeNode = { * text joins, and whitespace between blocks collapses. */ const text = (value: string): TreeNode => ({ type: "text", value: value.trim() ? value : "\n" }); -function rendered(node: TreeNode): TreeNode { - if (node.type === "text") return text(node.value ?? ""); +function rendered(node: TreeNode, preserveWhitespace = false): TreeNode { + if (node.type === "text") { + return text(preserveWhitespace ? (node.value ?? "") : (node.value ?? "").replace(/\s+/g, " ")); + } if (!node.children) return node; + const literal = preserveWhitespace || node.tagName === "pre"; const table = ["table", "thead", "tbody", "tfoot", "tr"].includes(node.tagName ?? ""); const children: TreeNode[] = []; - for (const child of node.children.map(rendered)) { + for (const child of node.children.map((child) => rendered(child, literal))) { if (table && child.type === "text" && child.value?.trim() === "") continue; const last = children.at(-1); if (child.type === "text" && last?.type === "text") { children[children.length - 1] = text(`${last.value}${child.value}`); } else children.push(child); } - return { ...node, children }; + return { + type: node.type, + ...(node.tagName ? { tagName: node.tagName } : {}), + ...(node.properties + ? { + properties: Object.fromEntries( + Object.entries(node.properties) + .filter(([, value]) => value !== false) + .map(([key, value]) => [key, key.startsWith("data") && value === true ? "" : value]), + ), + } + : {}), + children, + }; } it("preserves in-app thread links through the shared Markdown sanitizer", () => { From f7990e87fc09c86ba0ebec104bb6a648170adf90 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:45:40 +0000 Subject: [PATCH 024/143] perf(web): group static startup graphs explicitly --- apps/web/vite/startupChunks.ts | 60 +++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts index 253b1b6db2404..8fe5a7677f694 100644 --- a/apps/web/vite/startupChunks.ts +++ b/apps/web/vite/startupChunks.ts @@ -25,8 +25,8 @@ const STARTUP_CHUNK_MAX_BYTES = 3 * 1024 * 1024; * both arrive as hundreds of small shared chunks. * * Listing main and the startup routes as build inputs lets Rolldown tag their - * static graphs `$initial` after tree shaking. Entry-aware groups merge only - * modules with the same reachability, so settings and pairing do not download + * static graphs `$initial` after tree shaking. Shared modules belong to the + * first startup graph that imports them, so settings and pairing do not download * chat code. index.html preloads main's static graph; the router preloads the * initial location's route chunks alongside authentication. * The bootstrap entry keeps its own modules, so it never statically imports @@ -36,6 +36,7 @@ export function startupChunksPlugin(): Plugin { let root = process.cwd(); let base = "/"; let bootstrapModules: ReadonlySet = new Set(); + let startupModuleOwners: ReadonlyMap = new Map(); let startupFiles: ReadonlyArray = []; let startupFileSet: ReadonlySet = new Set(); let startupCss: ReadonlyArray = []; @@ -63,17 +64,17 @@ export function startupChunksPlugin(): Plugin { }, output: { codeSplitting: { - groups: [ - { - name: "startup", - tags: ["$initial"], - test: (id: string) => !id.startsWith("\0") && !bootstrapModules.has(id), - entriesAware: true, - // Otherwise excluded bootstrap helpers are recaptured through main's imports. - includeDependenciesRecursively: false, - maxSize: STARTUP_CHUNK_MAX_BYTES, - }, - ], + groups: Object.keys(STARTUP_MODULES).map((name) => ({ + name: `startup-${name}`, + tags: ["$initial"], + test: (id: string) => + !id.startsWith("\0") && startupModuleOwners.get(normalizePath(id)) === name, + // Dynamic settings/theme entries must not fragment the startup graphs. + entriesAware: false, + // Otherwise excluded bootstrap helpers are recaptured through main's imports. + includeDependenciesRecursively: false, + maxSize: STARTUP_CHUNK_MAX_BYTES, + })), }, }, }, @@ -85,16 +86,27 @@ export function startupChunksPlugin(): Plugin { }, buildEnd(error) { if (error) return; - const htmlId = normalizePath(NodePath.join(root, "index.html")); - const html = [...this.getModuleIds()].find((id) => id === htmlId); - const closure = new Set(); - const pending = html ? [html] : []; - for (let id = pending.pop(); id !== undefined; id = pending.pop()) { - if (closure.has(id)) continue; - closure.add(id); - pending.push(...(this.getModuleInfo(id)?.importedIds ?? [])); + const moduleIds = new Map([...this.getModuleIds()].map((id) => [normalizePath(id), id])); + const staticClosure = (entryId: string) => { + const closure = new Set(); + const entry = moduleIds.get(entryId); + const pending = entry === undefined ? [] : [entry]; + for (let id = pending.pop(); id !== undefined; id = pending.pop()) { + const normalizedId = normalizePath(id); + if (closure.has(normalizedId)) continue; + closure.add(normalizedId); + pending.push(...(this.getModuleInfo(id)?.importedIds ?? [])); + } + return closure; + }; + bootstrapModules = staticClosure(normalizePath(NodePath.join(root, "index.html"))); + const owners = new Map(); + for (const [name, entry] of Object.entries(STARTUP_MODULES)) { + for (const id of staticClosure(normalizePath(NodePath.join(root, entry)))) { + if (!bootstrapModules.has(id) && !owners.has(id)) owners.set(id, name); + } } - bootstrapModules = closure; + startupModuleOwners = owners; }, generateBundle: { order: "pre", @@ -125,7 +137,9 @@ export function startupChunksPlugin(): Plugin { const chunk = bundle[file]; return ( chunk?.type === "chunk" && - chunk.moduleIds.some((id) => !id.startsWith("\0") && !bootstrapModules.has(id)) + chunk.moduleIds.some( + (id) => !id.startsWith("\0") && !bootstrapModules.has(normalizePath(id)), + ) ); }) ) { From 417dfe43530defde556c61fdcf2782e5626ba062 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:47:41 +0000 Subject: [PATCH 025/143] test(markdown): normalize adjacent rendered text --- packages/shared/src/markdownPipeline.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 5fa442caaf55b..b54e6ea0c99d9 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -44,7 +44,8 @@ function rendered(node: TreeNode, preserveWhitespace = false): TreeNode { if (table && child.type === "text" && child.value?.trim() === "") continue; const last = children.at(-1); if (child.type === "text" && last?.type === "text") { - children[children.length - 1] = text(`${last.value}${child.value}`); + const joined = `${last.value}${child.value}`; + children[children.length - 1] = text(literal ? joined : joined.replace(/\s+/g, " ")); } else children.push(child); } return { From 807cea80d9b7603382a427ac6447b262841133d6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:49:25 +0000 Subject: [PATCH 026/143] test(web): await raw markdown support in timeline rendering --- apps/web/src/components/chat/MessagesTimeline.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f7aeb679a87bd..de6367f02f7fe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -262,6 +262,7 @@ function stubDomGlobals() { beforeEach(stubDomGlobals); beforeAll(async () => { Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true }); + await (await import("@t3tools/shared/markdownPipeline")).loadRehypeRaw(); ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline")); }, 30_000); From 95a752a531504b4fa05ef45eb8cca12426e7b631 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:58:50 +0000 Subject: [PATCH 027/143] fix(web): preserve startup initialization and scoped palette identity --- .../components/CommandPalette.logic.test.ts | 49 +++++++++++++++---- .../src/components/CommandPalette.logic.ts | 2 +- apps/web/vite/startupChunks.ts | 7 +-- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2bd7c118c69fb..7c3868a994e11 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -367,6 +367,35 @@ describe("buildProjectActionItems", () => { }); describe("buildThreadActionItems", () => { + it("keeps equal thread ids in different environments distinct and navigable", async () => { + const local = makeThread({ title: "Local work" }); + const remote = makeThread({ + id: local.id, + environmentId: EnvironmentId.make("environment-remote"), + title: "Remote work", + }); + const runThread = vi.fn(async () => {}); + const items = buildThreadActionItems({ + threads: [local, remote], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + getContentMatch: (thread) => + thread.environmentId === remote.environmentId + ? { source: "assistant", snippet: "remote message", query: "message" } + : undefined, + runThread, + }); + const remoteItem = items.find((item) => item.threadContentMatch); + const matches = new Map(remoteItem ? [[remoteItem.value, remoteItem]] : []); + const merged = items.map((item) => matches.get(item.value) ?? item); + expect(merged.map((item) => item.title)).toEqual(["Local work", "Remote work"]); + expect(new Set(merged.map((item) => item.value)).size).toBe(2); + await merged[0]?.run(); + await merged[1]?.run(); + expect(runThread.mock.calls).toEqual([[local], [remote]]); + }); + it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); @@ -393,8 +422,8 @@ describe("buildThreadActionItems", () => { }); expect(items.map((item) => item.value)).toEqual([ - "thread:thread-older", - "thread:thread-newer", + "thread:environment-local:thread-older", + "thread:environment-local:thread-newer", ]); expect(items[0]?.timestamp).toBe("1d ago"); expect(items[1]?.timestamp).toBe("5d ago"); @@ -435,8 +464,8 @@ describe("buildThreadActionItems", () => { expect(groups).toHaveLength(1); expect(groups[0]?.value).toBe("threads-search"); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:thread-title-match", - "thread:thread-context-match", + "thread:environment-local:thread-title-match", + "thread:environment-local:thread-context-match", ]); }); @@ -482,9 +511,9 @@ describe("buildThreadActionItems", () => { }); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:recent-title", - "thread:old-prefix", - "thread:recent-content", + "thread:environment-local:recent-title", + "thread:environment-local:old-prefix", + "thread:environment-local:recent-content", ]); }); @@ -661,8 +690,8 @@ describe("buildThreadActionItems", () => { }); expect(groups.flatMap((group) => group.items)).toEqual([ - expect.objectContaining({ value: `thread:${titleThread.id}` }), - expect.objectContaining({ value: `thread:${idThread.id}` }), + expect.objectContaining({ value: `thread:${titleThread.environmentId}:${titleThread.id}` }), + expect.objectContaining({ value: `thread:${idThread.environmentId}:${idThread.id}` }), ]); }); @@ -702,7 +731,7 @@ describe("buildThreadActionItems", () => { runThread: async (_thread) => undefined, }); - expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); + expect(items.map((item) => item.value)).toEqual(["thread:environment-local:thread-active"]); }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f052221645261..c835f9105f285 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -360,7 +360,7 @@ export function buildThreadActionItems ({ name: `startup-${name}`, tags: ["$initial"], @@ -73,7 +71,6 @@ export function startupChunksPlugin(): Plugin { entriesAware: false, // Otherwise excluded bootstrap helpers are recaptured through main's imports. includeDependenciesRecursively: false, - maxSize: STARTUP_CHUNK_MAX_BYTES, })), }, }, From 281c4872331a944ee991cc31a02b226cec4330c6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 21:18:55 +0000 Subject: [PATCH 028/143] perf(web): avoid sidebar work on the first keystroke --- apps/web/src/assets/projectFaviconCache.ts | 10 +++- apps/web/src/components/Sidebar.tsx | 57 ++++++++++++------- .../src/orchestrationV2BoundedSnapshot.ts | 2 +- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts index b44e919af4b3d..2eb1cbf4500a1 100644 --- a/apps/web/src/assets/projectFaviconCache.ts +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -4,6 +4,7 @@ import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, } from "@t3tools/client-runtime/project-favicon-cache"; +import { readFileAsDataUrl } from "../lib/imageCompression"; const DATABASE_NAME = "t3code:project-favicons"; const DATABASE_VERSION = 2; @@ -62,7 +63,14 @@ async function downscaleProjectFavicon( if (!context) throw new Error("Canvas is unavailable."); context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); - const dataUrl = canvas.toDataURL("image/webp", 0.85); + // Thumbnail encoding runs asynchronously so it cannot hold up the first keystroke. + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/webp", 0.85), + ); + signal.throwIfAborted(); + if (!blob) throw new Error("Project icon thumbnail could not be encoded."); + const dataUrl = await readFileAsDataUrl(blob); + signal.throwIfAborted(); if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; } throw new Error("Project icon thumbnail exceeds the cache limit."); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5648161084f3b..7a40c979bbae5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -98,6 +98,7 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; +import { useShallow } from "zustand/react/shallow"; import { useParams, useRouter } from "@tanstack/react-router"; import { useRightPanelStore } from "../rightPanelStore"; @@ -2688,30 +2689,40 @@ export default function Sidebar() { }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content - // subscription. Selecting a number keeps typing in a draft composer from - // re-rendering the whole sidebar. Approximates the block's row filter + // subscription. Selecting counts and session presence keeps typing in a draft + // composer from re-rendering the whole sidebar. The total approximates the row filter // (every non-promoted session with content); it can overcount by one for // an open never-left draft, which only softens the empty state. const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; - const visibleDraftSessionCount = useComposerDraftStore((store) => { - let count = 0; - for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { - if (session.promotedTo != null) { - continue; - } - if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { - continue; - } - if ( - scopedProjectKeys !== null && - !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) - ) { - continue; - } - count += 1; - } - return count; - }); + const [visibleDraftSessionCount, inactiveDraftSessionCount, activeDraftSessionPresent] = + useComposerDraftStore( + useShallow((store) => { + let count = 0; + let inactiveCount = 0; + let activePresent = false; + for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if ( + scopedProjectKeys !== null && + !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + ) { + continue; + } + const isActive = draftKey === routeDraftIdForRows; + // The active row is frozen or absent while typing. Its live session still + // controls send/discard removal, independently of whether it has content. + if (isActive) activePresent = true; + if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { + continue; + } + count += 1; + if (!isActive) inactiveCount += 1; + } + return [count, inactiveCount, activePresent] as const; + }), + ); // Scope flips drop the selection: rows selected under the old scope may be // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { @@ -3861,6 +3872,7 @@ export default function Sidebar() { // sortable preview; rows glide from their released positions instead. // Later thread actions can animate while writes settle. // Draft navigation can reveal a frozen row without changing the draft count. + // Typing into a never-left draft changes the empty-state count, not the rows. void sidebarListOrderKey; listMotionRef.current?.update( listMotionArmedRef.current && !listMotionPaused && sidebarListHasRows, @@ -3872,7 +3884,8 @@ export default function Sidebar() { shellsBootstrapped, sidebarListHasRows, sidebarListOrderKey, - visibleDraftSessionCount, + inactiveDraftSessionCount, + activeDraftSessionPresent, ]); const handleThreadDragOver = useCallback( (event: DragOverEvent) => { diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.ts index 036c1df902935..0351decf9b48d 100644 --- a/packages/shared/src/orchestrationV2BoundedSnapshot.ts +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.ts @@ -107,7 +107,7 @@ export function omitCheckpointItemFiles(projection: OrchestrationV2ThreadProject } /** Client half of `omitCheckpointItemFiles`, before reducers or caches see the projection. */ -export function restoreCheckpointItemFiles( +function restoreCheckpointItemFiles( projection: OrchestrationV2ThreadProjection, itemIds: ReadonlyArray, ): OrchestrationV2ThreadProjection { From d848a58af2f02ce90b05986db6946e6d31747dd7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 21:28:44 +0000 Subject: [PATCH 029/143] test(server): include sweep migration in preview upgrades --- apps/server/src/persistence/reconcileV2PreviewMigration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 675d5f5db0215..affb0e380a14d 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,6 +41,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "ProjectionThreadSweepColumns"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -124,6 +125,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "ProjectionThreadSweepColumns"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 856af1550a779fc053abc5c7afa8447d7ee50f9d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 21:42:56 +0000 Subject: [PATCH 030/143] fix(server): preserve newline paths in checkpoint probes --- apps/server/src/vcs/GitVcsDriver.test.ts | 25 +++++++++++++++++++++++- apps/server/src/vcs/GitVcsDriver.ts | 6 +++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index a6bbef6ed1de0..723c0a8907206 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -117,6 +117,29 @@ const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( return { git, checkpointRef }; }); +it.effect("checkpoint capture preserves a linked worktree's common directory containing a newline", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-newline-" }); + const cwd = path.join(root, "main\nrepository"); + const worktree = path.join(root, "linked"); + yield* fileSystem.makeDirectory(cwd); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["worktree", "add", "--detach", worktree, "HEAD"]); + yield* fileSystem.writeFileString(path.join(worktree, "file.txt"), "linked worktree\n"); + + yield* driver.checkpoints.captureCheckpoint({ cwd: worktree, checkpointRef }); + + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:file.txt`])).stdout, + "linked worktree\n", + ); + assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, "file.txt")), "unstaged\n"); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("checkpoint capture skips untracked nested repositories without a commit", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1175,7 +1198,7 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi : input.args.includes("commit-tree") ? "commit0000\n" : input.args.includes("--git-common-dir") - ? ".git\n" + ? ".git\nhead0000\n" : ""; return { exitCode: ChildProcessSpawner.ExitCode(0), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index d90d7faf0f971..bb24ab63c2a17 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -823,7 +823,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: ["rev-parse", "--git-common-dir", "--verify", "--quiet", "HEAD"], allowNonZeroExit: true, }); - const gitCommonDir = result.stdout.split("\n")[0]?.trim() ?? ""; + const output = result.stdout.trimEnd(); + // HEAD is the final line; the common directory can itself contain newlines. + const gitCommonDir = ( + result.exitCode === 0 ? output.slice(0, output.lastIndexOf("\n")) : output + ).trim(); if (gitCommonDir.length === 0 || (result.exitCode !== 0 && result.exitCode !== 1)) { return yield* new VcsProcessExitError({ operation, From 2c5fe99d0c176591205b6b5b310f175418a47a2a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 21:57:43 +0000 Subject: [PATCH 031/143] style(server): format existing performance regression checks --- .../RunExecutionService.test.ts | 384 +++++++++--------- apps/server/src/vcs/GitVcsDriver.test.ts | 43 +- 2 files changed, 215 insertions(+), 212 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 20f489d3fdf08..3aba0451ae9ec 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1060,203 +1060,201 @@ it.effect.each([ "start-guard", "late-start-guard", "baseline-interruption", -] as const)( - "handles %s before the provider turn starts", - (scenario) => - Effect.gen(function* () { - const threadId = ThreadId.make("thread:run-execution-settings-failure"); - const runId = RunId.make("run:run-execution-settings-failure"); - const attemptId = RunAttemptId.make("attempt:run-execution-settings-failure"); - const providerInstanceId = ProviderInstanceId.make("codex"); - const providerSessionId = ProviderSessionId.make("session:run-execution-settings-failure"); - const providerThreadId = ProviderThreadId.make( - "provider-thread:run-execution-settings-failure", - ); - const rootNodeId = NodeId.make("node:run-execution-settings-failure"); - const checkpointScope = { - id: CheckpointScopeId.make("checkpoint-scope:run-execution-settings-failure"), - } as OrchestrationV2CheckpointScope; - const providerStarts = yield* Ref.make(0); - const preparations = yield* Ref.make(0); - const preparationCloses = yield* Ref.make(0); - const preparationStarted = yield* Deferred.make(); - const startGuards = yield* Ref.make(0); - const prepares = - scenario === "start-guard" || - scenario === "late-start-guard" || - scenario === "baseline-interruption"; - const refreshes = yield* Ref.make(0); - const guardedWrites = yield* Ref.make(0); - const writes = yield* Ref.make>>([]); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - captureBaseline: () => - scenario === "baseline-interruption" - ? Deferred.await(preparationStarted).pipe(Effect.andThen(Effect.interrupt)) - : prepares - ? Effect.void - : Effect.die("not reached"), - }), - Layer.mock(EventSink.EventSinkV2)({ - writeIfRunCurrent: (input) => - Effect.gen(function* () { - assert.equal(input.threadId, threadId); - assert.equal(input.runId, runId); - assert.equal(input.activeAttemptId, attemptId); - assert.equal(input.expectedStatus, "running"); - yield* Ref.update(guardedWrites, (count) => count + 1); - if (scenario === "stale-attempt") { - return { committed: false, storedEvents: [] }; - } - yield* Ref.update(writes, (current) => [...current, input.events]); - return { committed: true, storedEvents: [] }; - }), - }), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: () => Effect.succeed([]), - }), - prepares - ? ServerSettings.layerTest() - : Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: - scenario === "interruption" - ? Effect.interrupt - : Effect.fail( - new ServerSettingsError({ - settingsPath: "", - operation: "read-file", - cause: new Error("settings read failed"), - }), - ), - }), - Layer.succeed(RunFinalizationService.RunFinalizationObserver, { - refresh: () => Effect.void, - refreshAfterTurn: () => Ref.update(refreshes, (count) => count + 1), - }), - ), +] as const)("handles %s before the provider turn starts", (scenario) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:run-execution-settings-failure"); + const runId = RunId.make("run:run-execution-settings-failure"); + const attemptId = RunAttemptId.make("attempt:run-execution-settings-failure"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const providerSessionId = ProviderSessionId.make("session:run-execution-settings-failure"); + const providerThreadId = ProviderThreadId.make( + "provider-thread:run-execution-settings-failure", + ); + const rootNodeId = NodeId.make("node:run-execution-settings-failure"); + const checkpointScope = { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-settings-failure"), + } as OrchestrationV2CheckpointScope; + const providerStarts = yield* Ref.make(0); + const preparations = yield* Ref.make(0); + const preparationCloses = yield* Ref.make(0); + const preparationStarted = yield* Deferred.make(); + const startGuards = yield* Ref.make(0); + const prepares = + scenario === "start-guard" || + scenario === "late-start-guard" || + scenario === "baseline-interruption"; + const refreshes = yield* Ref.make(0); + const guardedWrites = yield* Ref.make(0); + const writes = yield* Ref.make>>([]); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => + scenario === "baseline-interruption" + ? Deferred.await(preparationStarted).pipe(Effect.andThen(Effect.interrupt)) + : prepares + ? Effect.void + : Effect.die("not reached"), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent: (input) => + Effect.gen(function* () { + assert.equal(input.threadId, threadId); + assert.equal(input.runId, runId); + assert.equal(input.activeAttemptId, attemptId); + assert.equal(input.expectedStatus, "running"); + yield* Ref.update(guardedWrites, (count) => count + 1); + if (scenario === "stale-attempt") { + return { committed: false, storedEvents: [] }; + } + yield* Ref.update(writes, (current) => [...current, input.events]); + return { committed: true, storedEvents: [] }; + }), + }), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + prepares + ? ServerSettings.layerTest() + : Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: + scenario === "interruption" + ? Effect.interrupt + : Effect.fail( + new ServerSettingsError({ + settingsPath: "", + operation: "read-file", + cause: new Error("settings read failed"), + }), + ), + }), + Layer.succeed(RunFinalizationService.RunFinalizationObserver, { + refresh: () => Effect.void, + refreshAfterTurn: () => Ref.update(refreshes, (count) => count + 1), + }), ), - ); + ), + ); - const result = yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make("command:run-execution-settings-failure"), - appThread: { id: threadId } as OrchestrationV2AppThread, - providerSessionId, - session: { - events: Stream.never, - prepareTurn: () => - Effect.acquireRelease( - Ref.update(preparations, (count) => count + 1).pipe( - Effect.andThen(Deferred.succeed(preparationStarted, undefined)), + const result = yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make("command:run-execution-settings-failure"), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId, + session: { + events: Stream.never, + prepareTurn: () => + Effect.acquireRelease( + Ref.update(preparations, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(preparationStarted, undefined)), + ), + () => Ref.update(preparationCloses, (count) => count + 1), + ).pipe(Effect.asVoid), + startTurn: () => Ref.update(providerStarts, (count) => count + 1), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + status: "running", + } as OrchestrationV2Run, + rootNode: { id: rootNodeId, status: "running" } as OrchestrationV2ExecutionNode, + checkpointScope, + providerThread: { + id: providerThreadId, + driver, + } as OrchestrationV2ProviderThread, + attempt: { + id: attemptId, + providerTurnId: null, + status: "running", + } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + // A declined start is a normal exit, not a preparation failure. + ...(scenario === "start-guard" || scenario === "late-start-guard" + ? { + shouldStartProviderTurn: () => + Ref.updateAndGet(startGuards, (count) => count + 1).pipe( + Effect.map((count) => scenario === "late-start-guard" && count === 1), ), - () => Ref.update(preparationCloses, (count) => count + 1), - ).pipe(Effect.asVoid), - startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId, - status: "running", - } as OrchestrationV2Run, - rootNode: { id: rootNodeId, status: "running" } as OrchestrationV2ExecutionNode, - checkpointScope, - providerThread: { - id: providerThreadId, - driver, - } as OrchestrationV2ProviderThread, - attempt: { - id: attemptId, - providerTurnId: null, - status: "running", - } as OrchestrationV2RunAttempt, - attemptId, - providerTurnOrdinal: 1, - // A declined start is a normal exit, not a preparation failure. - ...(scenario === "start-guard" || scenario === "late-start-guard" - ? { - shouldStartProviderTurn: () => - Ref.updateAndGet(startGuards, (count) => count + 1).pipe( - Effect.map((count) => scenario === "late-start-guard" && count === 1), - ), - } - : {}), - message: { - messageId: MessageId.make("message:run-execution-settings-failure"), - text: "Start after settings fail.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, + } + : {}), + message: { + messageId: MessageId.make("message:run-execution-settings-failure"), + text: "Start after settings fail.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, }, - }); - }).pipe(Effect.provide(layerTest), Effect.exit); - - assert.equal(yield* Ref.get(providerStarts), 0); - assert.equal(yield* Ref.get(preparations), prepares ? 1 : 0); - assert.equal(yield* Ref.get(preparationCloses), prepares ? 1 : 0); - const events = (yield* Ref.get(writes)).flat(); - if (scenario === "interruption" || scenario === "baseline-interruption") { - assert.isTrue(Exit.isFailure(result)); - if (Exit.isFailure(result)) assert.isTrue(Cause.hasInterruptsOnly(result.cause)); - assert.equal(yield* Ref.get(guardedWrites), 0); - assert.equal(yield* Ref.get(refreshes), 0); - assert.isEmpty(events); - return; - } - assert.isTrue(Exit.isSuccess(result)); - if (scenario === "start-guard" || scenario === "late-start-guard") { - assert.equal(yield* Ref.get(startGuards), scenario === "late-start-guard" ? 2 : 1); - assert.equal(yield* Ref.get(guardedWrites), 0); - assert.equal(yield* Ref.get(refreshes), 0); - assert.isEmpty(events); - return; - } - assert.equal(yield* Ref.get(guardedWrites), 1); - if (scenario === "stale-attempt") { - assert.equal(yield* Ref.get(refreshes), 0); - assert.isEmpty(events); - return; - } - assert.equal(yield* Ref.get(refreshes), 1); - assert.deepEqual( - events - .filter( - (event) => - event.type === "run.updated" || - event.type === "run-attempt.updated" || - event.type === "node.updated", - ) - .map((event) => event.payload.status), - ["failed", "failed", "failed"], - ); - const errorItem = events.find( - (event) => event.type === "turn-item.updated" && event.payload.type === "error", - ); - assert.isDefined(errorItem); - if (errorItem?.type === "turn-item.updated" && errorItem.payload.type === "error") { - // The persisted item carries a bounded curated message; the exact - // underlying text stays in the logged cause. - assert.equal(errorItem.payload.failure.message, "Run preparation failed."); - } - }), + }, + }); + }).pipe(Effect.provide(layerTest), Effect.exit); + + assert.equal(yield* Ref.get(providerStarts), 0); + assert.equal(yield* Ref.get(preparations), prepares ? 1 : 0); + assert.equal(yield* Ref.get(preparationCloses), prepares ? 1 : 0); + const events = (yield* Ref.get(writes)).flat(); + if (scenario === "interruption" || scenario === "baseline-interruption") { + assert.isTrue(Exit.isFailure(result)); + if (Exit.isFailure(result)) assert.isTrue(Cause.hasInterruptsOnly(result.cause)); + assert.equal(yield* Ref.get(guardedWrites), 0); + assert.equal(yield* Ref.get(refreshes), 0); + assert.isEmpty(events); + return; + } + assert.isTrue(Exit.isSuccess(result)); + if (scenario === "start-guard" || scenario === "late-start-guard") { + assert.equal(yield* Ref.get(startGuards), scenario === "late-start-guard" ? 2 : 1); + assert.equal(yield* Ref.get(guardedWrites), 0); + assert.equal(yield* Ref.get(refreshes), 0); + assert.isEmpty(events); + return; + } + assert.equal(yield* Ref.get(guardedWrites), 1); + if (scenario === "stale-attempt") { + assert.equal(yield* Ref.get(refreshes), 0); + assert.isEmpty(events); + return; + } + assert.equal(yield* Ref.get(refreshes), 1); + assert.deepEqual( + events + .filter( + (event) => + event.type === "run.updated" || + event.type === "run-attempt.updated" || + event.type === "node.updated", + ) + .map((event) => event.payload.status), + ["failed", "failed", "failed"], + ); + const errorItem = events.find( + (event) => event.type === "turn-item.updated" && event.payload.type === "error", + ); + assert.isDefined(errorItem); + if (errorItem?.type === "turn-item.updated" && errorItem.payload.type === "error") { + // The persisted item carries a bounded curated message; the exact + // underlying text stays in the logged cause. + assert.equal(errorItem.payload.failure.message, "Run preparation failed."); + } + }), ); it.effect("keeps ingesting owned child events after the root turn terminalizes", () => diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 723c0a8907206..c25b786053de0 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -117,27 +117,32 @@ const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( return { git, checkpointRef }; }); -it.effect("checkpoint capture preserves a linked worktree's common directory containing a newline", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-newline-" }); - const cwd = path.join(root, "main\nrepository"); - const worktree = path.join(root, "linked"); - yield* fileSystem.makeDirectory(cwd); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* git(["worktree", "add", "--detach", worktree, "HEAD"]); - yield* fileSystem.writeFileString(path.join(worktree, "file.txt"), "linked worktree\n"); +it.effect( + "checkpoint capture preserves a linked worktree's common directory containing a newline", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-newline-" }); + const cwd = path.join(root, "main\nrepository"); + const worktree = path.join(root, "linked"); + yield* fileSystem.makeDirectory(cwd); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["worktree", "add", "--detach", worktree, "HEAD"]); + yield* fileSystem.writeFileString(path.join(worktree, "file.txt"), "linked worktree\n"); - yield* driver.checkpoints.captureCheckpoint({ cwd: worktree, checkpointRef }); + yield* driver.checkpoints.captureCheckpoint({ cwd: worktree, checkpointRef }); - assert.strictEqual( - (yield* git(["show", `${checkpointRef}:file.txt`])).stdout, - "linked worktree\n", - ); - assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, "file.txt")), "unstaged\n"); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:file.txt`])).stdout, + "linked worktree\n", + ); + assert.strictEqual( + yield* fileSystem.readFileString(path.join(cwd, "file.txt")), + "unstaged\n", + ); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); it.effect("checkpoint capture skips untracked nested repositories without a commit", () => From 7b2e9846f5af0e3daa984b4b511bc8dd59983f52 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 23:16:26 +0000 Subject: [PATCH 032/143] fix(server): preserve structured causes in performance warnings --- .../src/orchestration-v2/RunExecutionService.ts | 4 ++-- .../src/orchestration-v2/RunFinalizationService.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index b00dd6819311c..c96ee1f99049a 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -887,8 +887,8 @@ export const layer: Layer.Layer< ? Effect.failCause(cause) : Effect.logWarning( "orchestration V2 provider turn preparation failed; starting it with the turn", - { runId: input.run.id, cause: Cause.pretty(cause) }, - ), + cause, + ).pipe(Effect.annotateLogs({ runId: input.run.id })), ), ), ], diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 0cceb064aa113..986e168752af1 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -81,12 +81,13 @@ const make = Effect.gen(function* () { Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void - : Effect.logWarning("failed to refresh workspace after run completion", { - threadId: input.threadId, - runId: input.runId, - cwd, - cause: Cause.pretty(cause), - }), + : Effect.logWarning("failed to refresh workspace after run completion", cause).pipe( + Effect.annotateLogs({ + threadId: input.threadId, + runId: input.runId, + cwd, + }), + ), ), Effect.forkIn(refreshScope), ); From 98d4945d9129a9eb4defef0aaebfa0b42687546b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 02:17:44 +0000 Subject: [PATCH 033/143] fix(web): size the custom snooze date picker on its first open Base UI measures a popover once, as it opens. The lazy calendar suspended on its first render, so the popover measured the empty Suspense fallback and stayed about 34 px wide. The dialog now keeps the preloaded calendar module in state and opens the date picker once it has resolved. --- .../web/src/components/CustomSnoozeDialog.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index c34f1622089d0..35e861ce06c66 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { create } from "zustand"; import { localSnoozeDate, @@ -33,7 +33,6 @@ import { // react-day-picker and date-fns load with the dialog, not with the app. const loadCalendar = () => import("./ui/calendar"); -const Calendar = lazy(() => loadCalendar().then((module) => ({ default: module.Calendar }))); type SnoozeChoice = { readonly snoozedUntil: string }; type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; @@ -58,8 +57,11 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); - // Fetch the calendar while the user reads the dialog so the date picker opens ready. - useEffect(() => void loadCalendar().catch(() => undefined), []); + // Fetch the calendar while the user reads the dialog. Base UI sizes a popover once, as it + // opens, so the date picker waits for the calendar instead of opening around a placeholder. + const [calendar, setCalendar] = useState> | null>(null); + useEffect(() => void loadCalendar().then(setCalendar, () => undefined), []); + const Calendar = calendar?.Calendar; const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const [mode, setMode] = useState("date"); const [date, setDate] = useState(initial); @@ -118,7 +120,10 @@ function CustomSnoozeDialog() {
- + - + {Calendar && ( - + )}
From a84b9e978c6bf5af2888ba293cf4ee51f4dfc0e4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:10:54 +0000 Subject: [PATCH 034/143] fix(server): parked startup requests reach the handler in arrival order --- apps/server/src/httpResponseErrorGuard.test.ts | 17 +++++++++++++++++ apps/server/src/httpResponseErrorGuard.ts | 6 ++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index fe61e62ae614f..3008b995211c4 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -152,4 +152,21 @@ describe("holdRequestsUntilServing", () => { await expect(response).resolves.toBe(204); await expect(upgradeResponse).resolves.toBe("HTTP/1.1 101 Switching Protocols"); }); + + it("hands parked requests to the handler before later ones", async () => { + const server = holdRequestsUntilServing(NodeHttp.createServer()); + const received: Array = []; + server.emit("request", "parked"); + // A request whose I/O callback is already queued when the handler attaches. + const laterEmitted = new Promise((resolve) => + setImmediate(() => { + server.emit("request", "later"); + resolve(); + }), + ); + server.on("request", (request) => received.push(request)); + await laterEmitted; + + expect(received).toEqual(["parked", "later"]); + }); }); diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts index 8924f3d3373bf..76b093eb3c332 100644 --- a/apps/server/src/httpResponseErrorGuard.ts +++ b/apps/server/src/httpResponseErrorGuard.ts @@ -57,8 +57,10 @@ export function holdRequestsUntilServing(server: T): if (name !== event) return; server.off("newListener", onNewListener); server.off(event, park); - // "newListener" fires before the listener is added. - setImmediate(() => { + // "newListener" fires before the listener is added. A microtask runs + // once it is, and before any later connection's I/O callback, so parked + // requests still reach the handler in arrival order. + queueMicrotask(() => { for (const args of parked.splice(0)) server.emit(event, ...args); }); }; From 02602d61d5df7f9c507a787fb782a1ede4720308 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:10:54 +0000 Subject: [PATCH 035/143] fix(server): a new item's first text skips another item's pending flush interval --- .../Adapters/CodexAdapterV2.test.ts | 30 +++++++++++++++++++ .../Adapters/ProviderTextDeltaCoalescer.ts | 20 +++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index aeecf52a15de6..67d3354917944 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -254,6 +254,36 @@ describe("CodexAdapterV2 assistant message streaming", () => { }), ); + it.effect("shows a new item's first text while another item waits for its interval", () => + Effect.gen(function* () { + const updates = yield* Ref.make>([]); + const coalescer = yield* makeProviderTextDeltaCoalescer({ + flushIntervalMs: 50, + emit: (update) => Ref.update(updates, (current) => [...current, update]), + }); + + yield* coalescer.append({ turnId: "turn-1", itemId: "reasoning-1", delta: "think" }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + // This delta schedules the interval flush; the next item's first text skips it. + yield* coalescer.append({ turnId: "turn-1", itemId: "reasoning-1", delta: "ing" }); + yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "answer" }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.deepEqual( + (yield* Ref.get(updates)).map((update) => update.text), + ["think", "answer"], + ); + + yield* TestClock.adjust("50 millis"); + yield* Effect.yieldNow; + assert.deepEqual( + (yield* Ref.get(updates)).map((update) => update.text), + ["think", "answer", "thinking"], + ); + }), + ); + it.effect("coalesces multiple token deltas into one assistant update per interval", () => Effect.gen(function* () { const updates = yield* Ref.make>([]); diff --git a/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts b/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts index 63eca9377d767..9149b76fa82ec 100644 --- a/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts +++ b/apps/server/src/orchestration-v2/Adapters/ProviderTextDeltaCoalescer.ts @@ -123,16 +123,26 @@ export const makeProviderTextDeltaCoalescer = Effect.fn("makeProviderTextDeltaCo return [shouldSchedule, firstDelta] as const; }), ); - if (shouldSchedule) { + if (firstDelta) { // An item's first text flushes on the next tick, so the first // token is not held for the interval; later deltas coalesce. + // Another item's pending interval flush keeps its schedule. yield* ( - firstDelta + shouldSchedule ? flushDirty - : Effect.sleep(Duration.millis(Math.max(1, input.flushIntervalMs))).pipe( - Effect.andThen(flushDirty), - ) + : drain({ + predicate: (message) => + message.turnId === turnId && message.itemId === itemId, + completed: false, + onlyDirty: true, + }) ).pipe(Effect.interruptible, Effect.forkIn(coalescerScope)); + } else if (shouldSchedule) { + yield* Effect.sleep(Duration.millis(Math.max(1, input.flushIntervalMs))).pipe( + Effect.andThen(flushDirty), + Effect.interruptible, + Effect.forkIn(coalescerScope), + ); } }), ), From d96766099fb8d913ba8d40b6dde6d4daa08602de Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:10:54 +0000 Subject: [PATCH 036/143] fix(orchestration): pull request link syncs no longer count as thread activity in memory --- apps/server/src/orchestration-v2/ProjectionStore.ts | 3 ++- .../src/state/orchestrationV2Projection.test.ts | 1 + .../client-runtime/src/state/orchestrationV2Projection.ts | 6 +++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4262a6f69357e..d7ce30b52050b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -719,9 +719,10 @@ export function applyToProjection( ...base, thread: event.payload, }; + // A host refresh is not activity, matching the stored projection. case "thread.pull-request-link-synced": return { - ...base, + ...projection, thread: applyThreadPullRequestLinkSync(projection.thread, event.payload), }; // Visited tracking is read state, not activity: skip the updatedAt bump so diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index 1367753774009..920a506ac7590 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -157,6 +157,7 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.thread.pullRequests?.[0]?.snapshot).toEqual(snapshot); expect(next?.thread.pullRequests?.[1]).toBe(other); expect(next?.thread.updatedAt).toEqual(now); + expect(next?.updatedAt).toEqual(now); expect(next?.turnItems).toBe(projection.turnItems); }); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index ce1fea88aac0b..a2c61bc2ce4b2 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -182,8 +182,12 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.model-selection-updated": case "thread.provider-switched": return { ...base, thread: event.payload }; + // A host refresh is not activity, matching the server projection. case "thread.pull-request-link-synced": - return { ...base, thread: applyThreadPullRequestLinkSync(projection.thread, event.payload) }; + return { + ...projection, + thread: applyThreadPullRequestLinkSync(projection.thread, event.payload), + }; // Visited tracking is read state, not activity: skip the updatedAt bump. case "thread.visited": case "thread.marked-unread": From ce256b91dbdc858669c2899731f3a6e5fe1050e2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:10:54 +0000 Subject: [PATCH 037/143] fix(server): keep git stderr out of the checkpoint probe error detail --- apps/server/src/vcs/GitVcsDriver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 011f204fb8588..629d92df78933 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -834,7 +834,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( command: "git rev-parse", cwd, exitCode: result.exitCode, - detail: result.stderr.trim() || "git rev-parse --git-common-dir failed", + detail: "git rev-parse --git-common-dir failed", }); } return { From 7f37690773ad184c2b25f85279fa2727b5284d7a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:10:54 +0000 Subject: [PATCH 038/143] fix(provider-core): settings changes wait for the startup probe gate --- packages/provider-core/src/server/managedProvider.test.ts | 8 +++++++- packages/provider-core/src/server/managedProvider.ts | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index f6acdb79152f5..a4cdc141122d2 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -147,12 +147,13 @@ describe("makeManagedServerProvider", () => { const host = yield* ProviderHost.ProviderHost; const startup = yield* Deferred.make(); const settings = yield* Ref.make({ enabled: true }); + const settingsChanges = yield* PubSub.unbounded(); const checkCalls = yield* Ref.make(0); const enrichedWith = yield* Deferred.make(); yield* makeManagedServerProvider({ resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), getSettings: Ref.get(settings), - streamSettings: Stream.empty, + streamSettings: Stream.fromPubSub(settingsChanges), haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( @@ -167,8 +168,13 @@ describe("makeManagedServerProvider", () => { awaitStartupProbe: Deferred.await(startup), }), ); + yield* Effect.yieldNow; assert.strictEqual(yield* Ref.get(checkCalls), 0); yield* Ref.set(settings, { enabled: false }); + // A settings change while parked does not probe early. + yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 0); yield* Deferred.succeed(startup, undefined); assert.deepStrictEqual(yield* Deferred.await(enrichedWith), { enabled: false }); assert.strictEqual(yield* Ref.get(checkCalls), 1); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index c5bca887adbeb..8ab118bb340a6 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -243,8 +243,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); } + const awaitStartupProbe = host.awaitStartupProbe ?? Effect.void; + + // A settings change while startup holds the first probe waits for it too. yield* Stream.runForEach(input.streamSettings, (nextSettings) => - Effect.asVoid(applySnapshot(nextSettings)), + awaitStartupProbe.pipe(Effect.andThen(applySnapshot(nextSettings)), Effect.asVoid), ).pipe(Effect.forkScoped); yield* Effect.forever( @@ -277,7 +280,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ).pipe(Effect.forkScoped); // Until it lands, clients see the cached or pending snapshot. - yield* (host.awaitStartupProbe ?? Effect.void).pipe( + yield* awaitStartupProbe.pipe( Effect.andThen(refreshSnapshot()), Effect.ignoreCause({ log: true }), Effect.forkScoped, From eafa8bd068cab4f23a7c5eddc7a9d9e40300b82f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:10:54 +0000 Subject: [PATCH 039/143] fix(web): a held timeline no longer changes the active thread's follow state --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 33f76f320fff9..b6270345923ec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1197,7 +1197,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); } } - if (isAtEnd !== undefined && !citationPositioning) { + if (isAtEnd !== undefined && !citationPositioning && !paintOnly) { onIsAtEndChange(isAtEnd); } reportContentOverflow(); From 16f9e9f93c86f9f9884bc8bd867b3bc75f6ccc42 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 05:10:54 +0000 Subject: [PATCH 040/143] fix(web): recover from failed lazy chunk loads --- apps/web/src/components/CustomSnoozeDialog.tsx | 8 +++++++- apps/web/src/lib/syntaxHighlighting.test.ts | 11 +++++++++++ apps/web/src/lib/syntaxHighlighting.ts | 5 ++++- apps/web/src/routes/__root.tsx | 16 ++++++++++------ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 35e861ce06c66..6de8fdcbff338 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -122,7 +122,13 @@ function CustomSnoozeDialog() { { + setCalendarOpen(open); + // A failed calendar load retries when the picker is asked for again. + if (open && Calendar === undefined) { + void loadCalendar().then(setCalendar, () => undefined); + } + }} > { + vi.resetModules(); + const { getSyntaxHighlighterPromise: load } = await import("./syntaxHighlighting"); + const highlighter = {} as DiffsHighlighter; + getSharedHighlighter.mockRejectedValue(new Error("chunk failed")); + await expect(load("typescript")).rejects.toThrow("chunk failed"); + + getSharedHighlighter.mockResolvedValue(highlighter); + await expect(load("typescript")).resolves.toBe(highlighter); +}); diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index b1bdb48bb6cf6..ef89470cb8644 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -27,7 +27,6 @@ export function getSyntaxHighlighterPromise(language: string): Promise { if (language === "text") { - highlighterPromiseCache.delete(language); // "text" itself failed — Shiki cannot initialize at all, surface the error throw error; } @@ -35,5 +34,9 @@ export function getSyntaxHighlighterPromise(language: string): Promise { + if (highlighterPromiseCache.get(language) === promise) highlighterPromiseCache.delete(language); + }); return promise; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3098d4e2ae628..67abe61c26dbd 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -85,15 +85,19 @@ import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; +// These optional notices mount with the shell. A stale chunk still gets the +// `vite:preloadError` reload first; a chunk that keeps failing renders nothing +// instead of replacing the shell with the route error view. +const renderNothing = () => null; const ConnectOnboardingDialog = lazy(() => - import("../components/cloud/ConnectOnboardingDialog").then((module) => ({ - default: module.ConnectOnboardingDialog, - })), + import("../components/cloud/ConnectOnboardingDialog") + .then((module) => ({ default: module.ConnectOnboardingDialog })) + .catch(() => ({ default: renderNothing })), ); const NightlyMobileBetaNotice = lazy(() => - import("../components/NightlyMobileBeta").then((module) => ({ - default: module.NightlyMobileBetaNotice, - })), + import("../components/NightlyMobileBeta") + .then((module) => ({ default: module.NightlyMobileBetaNotice })) + .catch(() => ({ default: renderNothing })), ); export const Route = createRootRoute({ From ba867d55c97b6590bfd99aa83ea741283fbeb778 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 06:13:40 +0000 Subject: [PATCH 041/143] fix(web): a failed highlighter load stays on the plain-text fallback Evicting the rejected promise handed React use() a new pending promise on every render, so the error never reached the boundary and the main thread spun on failed imports. Chromium caches a failed dynamic import, so a retry could not succeed anyway. --- apps/web/src/lib/syntaxHighlighting.test.ts | 17 +++++++++++------ apps/web/src/lib/syntaxHighlighting.ts | 7 +++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/syntaxHighlighting.test.ts b/apps/web/src/lib/syntaxHighlighting.test.ts index 73ab8968bc6d6..4dc60d36246ec 100644 --- a/apps/web/src/lib/syntaxHighlighting.test.ts +++ b/apps/web/src/lib/syntaxHighlighting.test.ts @@ -27,13 +27,18 @@ it("caches the recovered text highlighter for unsupported languages", async () = expect(getSharedHighlighter).toHaveBeenCalledTimes(2); }); -it("retries a language whose load and text fallback both failed", async () => { +it("keeps a failed load cached so render-time callers get one stable rejection", async () => { vi.resetModules(); const { getSyntaxHighlighterPromise: load } = await import("./syntaxHighlighting"); - const highlighter = {} as DiffsHighlighter; getSharedHighlighter.mockRejectedValue(new Error("chunk failed")); - await expect(load("typescript")).rejects.toThrow("chunk failed"); - - getSharedHighlighter.mockResolvedValue(highlighter); - await expect(load("typescript")).resolves.toBe(highlighter); + const failed = load("typescript"); + await expect(failed).rejects.toThrow("chunk failed"); + const failedText = load("text"); + await expect(failedText).rejects.toThrow("chunk failed"); + const calls = getSharedHighlighter.mock.calls.length; + + getSharedHighlighter.mockResolvedValue({} as DiffsHighlighter); + expect(load("typescript")).toBe(failed); + expect(load("text")).toBe(failedText); + expect(getSharedHighlighter).toHaveBeenCalledTimes(calls); }); diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index ef89470cb8644..87394b4e517ff 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -33,10 +33,9 @@ export function getSyntaxHighlighterPromise(language: string): Promise { - if (highlighterPromiseCache.get(language) === promise) highlighterPromiseCache.delete(language); - }); return promise; } From 904a4b7d0beae55b1bb62a7c2b77d3771680a067 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 06:13:40 +0000 Subject: [PATCH 042/143] fix(web): custom snooze falls back to a typed date when the calendar fails Retrying the calendar import re-ran a cached failed import, so the picker never opened. A failed load now shows a message and a native date field. --- .../web/src/components/CustomSnoozeDialog.tsx | 105 ++++++++++-------- 1 file changed, 61 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 6de8fdcbff338..5b840e2eb7c5e 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -59,8 +59,10 @@ function CustomSnoozeDialog() { const id = useId(); // Fetch the calendar while the user reads the dialog. Base UI sizes a popover once, as it // opens, so the date picker waits for the calendar instead of opening around a placeholder. + // Chromium caches a failed dynamic import, so a failed load swaps in a typed date field. const [calendar, setCalendar] = useState> | null>(null); - useEffect(() => void loadCalendar().then(setCalendar, () => undefined), []); + const [calendarFailed, setCalendarFailed] = useState(false); + useEffect(() => void loadCalendar().then(setCalendar, () => setCalendarFailed(true)), []); const Calendar = calendar?.Calendar; const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const [mode, setMode] = useState("date"); @@ -120,50 +122,60 @@ function CustomSnoozeDialog() {
- { - setCalendarOpen(open); - // A failed calendar load retries when the picker is asked for again. - if (open && Calendar === undefined) { - void loadCalendar().then(setCalendar, () => undefined); - } - }} - > - - } + {calendarFailed ? ( + { + setDate(new Date(`${event.target.value}T00:00:00`)); + setError(null); + }} + /> + ) : ( + - {date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - })} - - - - {Calendar && ( - { - setDate(selected); - setCalendarOpen(false); - setError(null); - }} - /> - )} - - + + } + > + {date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + {Calendar && ( + { + setDate(selected); + setCalendarOpen(false); + setError(null); + }} + /> + )} + + + )}
+ {mode === "date" && calendarFailed && ( +

+ The calendar could not load. Type the date instead. +

+ )} {error && (

{error} From 2f88b24e2122c9ee15002c49fc6cc7637c9ca9cb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 06:13:40 +0000 Subject: [PATCH 043/143] fix(web): a chunk that fails every boot no longer reload-loops The guard was cleared before first render, so a lazy chunk requested during render re-armed it on every boot. The guard now keeps the time of the last automatic reload and allows at most one per minute. The root notices also render nothing when Vite resolves a failed import to undefined during a pending reload. --- apps/web/src/lib/chunkReloadGuard.test.ts | 18 +++++++------- apps/web/src/lib/chunkReloadGuard.ts | 29 +++++++++-------------- apps/web/src/main.tsx | 8 ++----- apps/web/src/routes/__root.tsx | 7 +++--- 4 files changed, 26 insertions(+), 36 deletions(-) diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 632e8ec8cf0dc..0418623b28580 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; +import { reloadOnceForChunkLoadError } from "./chunkReloadGuard"; function createStorageStub(): Storage { const store = new Map(); @@ -21,25 +21,26 @@ function createStorageStub(): Storage { } describe("reloadOnceForChunkLoadError", () => { - it("reloads on the first failure and lets the second one surface", () => { + it("reloads on the first failure and lets failures within the next minute surface", () => { const storage = createStorageStub(); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(() => storage, reload, 1_000_000)).toBe(true); expect(reload).toHaveBeenCalledTimes(1); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); + // The reloaded page requests the same failing chunk during its first render. + expect(reloadOnceForChunkLoadError(() => storage, reload, 1_002_000)).toBe(false); + expect(reloadOnceForChunkLoadError(() => storage, reload, 1_059_999)).toBe(false); expect(reload).toHaveBeenCalledTimes(1); }); - it("reloads again after a successful boot cleared the guard", () => { + it("reloads again for a stale deploy a minute after the last reload", () => { const storage = createStorageStub(); const reload = vi.fn(); - reloadOnceForChunkLoadError(() => storage, reload); - clearChunkReloadGuard(() => storage); + reloadOnceForChunkLoadError(() => storage, reload, 1_000_000); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(() => storage, reload, 1_060_000)).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); @@ -51,6 +52,5 @@ describe("reloadOnceForChunkLoadError", () => { expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); - expect(() => clearChunkReloadGuard(blocked)).not.toThrow(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index c31127816b09a..eef803c50e01f 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,39 +1,32 @@ // Split chunks are fetched lazily, so a deploy (or desktop server swap) // between page load and a later fetch can 404 the old hashed assets. One -// reload picks up the fresh index.html. A sessionStorage flag keeps a -// persistent failure from becoming a reload loop, and a successful boot clears -// it so the next stale deploy gets its own single reload. +// reload picks up the fresh index.html. sessionStorage keeps the time of that +// reload, and no automatic reload follows within a minute of it, so a chunk +// that keeps failing, even one every boot requests, cannot become a reload +// loop. A stale deploy after that minute gets its own single reload. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; +const CHUNK_RELOAD_WINDOW_MS = 60_000; /** * Called from the `vite:preloadError` listener. Reloads at most once per - * failure streak and returns whether it did, so the caller knows whether to - * swallow the event or let the error surface through the normal paths. + * window and returns whether it did, so the caller knows whether to swallow + * the event or let the error surface through the normal paths. */ export function reloadOnceForChunkLoadError( getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), + now = Date.now(), ): boolean { - let alreadyReloaded: boolean; try { const storage = getStorage(); - alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; - if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); + const sinceLastReload = now - Number(storage.getItem(CHUNK_RELOAD_GUARD_KEY)); + if (sinceLastReload >= 0 && sinceLastReload < CHUNK_RELOAD_WINDOW_MS) return false; + storage.setItem(CHUNK_RELOAD_GUARD_KEY, String(now)); } catch { // Without storage the guard cannot survive a reload, so a persistent // failure would loop forever. Let the error surface instead. return false; } - if (alreadyReloaded) return false; reload(); return true; } - -/** Clears the guard after a successful boot so a later stale deploy can reload again. */ -export function clearChunkReloadGuard(getStorage: () => Storage = () => window.sessionStorage) { - try { - getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); - } catch { - // Blocked storage never held the flag. - } -} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index de3071a12714d..6c84ccb9183b5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,7 +13,7 @@ import { syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; prepareProviderAuthDelivery(); @@ -32,10 +32,8 @@ const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string // A failed split-chunk fetch usually means the hashed assets went stale under // a deploy; one guarded reload picks up the fresh index.html. -let chunkLoadFailed = false; let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { - chunkLoadFailed = true; if (reloadOnceForChunkLoadError()) { reloadScheduled = true; event.preventDefault(); @@ -67,10 +65,8 @@ export const startup = Promise.all([ .then(([ManagedAuthShell]) => { // A route chunk failure still resolves router.load(): the error is parked in // the lazy component and surfaces through the route error boundary. Skip the - // paint when a reload is on its way, and only re-arm the guard after a boot - // that fetched every chunk it asked for. + // paint when a reload is on its way. if (reloadScheduled) return; - if (!chunkLoadFailed) clearChunkReloadGuard(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {ManagedAuthShell && clerkPublishableKey ? ( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 67abe61c26dbd..8314f43d9165e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -86,17 +86,18 @@ import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; // These optional notices mount with the shell. A stale chunk still gets the -// `vite:preloadError` reload first; a chunk that keeps failing renders nothing +// `vite:preloadError` reload first, and while that reload is pending Vite +// resolves the import to undefined. A chunk that keeps failing renders nothing // instead of replacing the shell with the route error view. const renderNothing = () => null; const ConnectOnboardingDialog = lazy(() => import("../components/cloud/ConnectOnboardingDialog") - .then((module) => ({ default: module.ConnectOnboardingDialog })) + .then((module) => ({ default: module?.ConnectOnboardingDialog ?? renderNothing })) .catch(() => ({ default: renderNothing })), ); const NightlyMobileBetaNotice = lazy(() => import("../components/NightlyMobileBeta") - .then((module) => ({ default: module.NightlyMobileBetaNotice })) + .then((module) => ({ default: module?.NightlyMobileBetaNotice ?? renderNothing })) .catch(() => ({ default: renderNothing })), ); From a6624c242cde609ccdf3bc14e05ef39ee7cf7c9b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 06:31:10 +0000 Subject: [PATCH 044/143] fix(server): capture the baseline checkpoint before the Claude CLI starts Opening a Claude query spawns the CLI, which runs the user's SessionStart hooks and starts MCP servers before any prompt arrives. On a fresh thread a hook that edits a tracked file could finish before a slow baseline capture, so its edit landed in the baseline, vanished from the turn diff and survived undo. No SDK or CLI option defers that startup work, so the CLI opens only after the baseline exists again, and the prepareTurn overlap is removed. --- .../Adapters/ClaudeAdapterV2.test.ts | 177 ------------- .../Adapters/ClaudeAdapterV2.ts | 248 +++++------------- .../ProviderSessionManager.ts | 9 - .../RunExecutionService.test.ts | 121 +++++++-- .../orchestration-v2/RunExecutionService.ts | 98 +++---- .../src/server/ProviderAdapter.ts | 12 - 6 files changed, 207 insertions(+), 458 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index eff6c96748b0f..6777f0a2902a9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1045,183 +1045,6 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); }); -describe("ClaudeAdapterV2 prepareTurn", () => { - const runPrepareScenario = (input: { - readonly startModel: string; - readonly abandon?: "scope-end" | "interruption" | "stop" | "superseded"; - }) => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-prepare-turn-", - }); - const queries: Array<{ offers: number; closed: boolean; model: string | undefined }> = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-prepare-turn"), - open: (openInput) => - Effect.sync(() => { - const query = { offers: 0, closed: false, model: openInput.options.model }; - queries.push(query); - return { - messages: Stream.never, - offer: () => - Effect.sync(() => { - query.offers += 1; - }), - setModel: () => Effect.void, - setPermissionMode: () => Effect.void, - interrupt: Effect.void, - close: Effect.sync(() => { - query.closed = true; - }), - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-prepare-turn"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-prepare-turn"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const now = yield* DateTime.now; - const turnInput = (model: string) => - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-prepare-turn"), - text: "Reply with ok.", - attachments: [], - modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model }, - }); - assert.isFunction(runtime.prepareTurn); - const preparation = turnInput(CLAUDE_TEST_MODEL_SELECTION.model); - const start = turnInput(input.startModel); - if (input.abandon === "superseded") { - const firstScope = yield* Scope.make(); - const secondScope = yield* Scope.make(); - const replacementAttemptId = RunAttemptId.make("replacement-claude-prepare-turn"); - yield* runtime.prepareTurn!(preparation).pipe(Scope.provide(firstScope)); - yield* runtime.prepareTurn!({ - ...preparation, - attemptId: replacementAttemptId, - }).pipe(Scope.provide(secondScope)); - yield* Scope.close(firstScope, Exit.void); - assert.isTrue(queries[0]?.closed); - assert.isFalse(queries[1]?.closed); - yield* runtime.startTurn({ ...start, attemptId: replacementAttemptId }); - yield* Scope.close(secondScope, Exit.void); - assert.isFalse(queries[1]?.closed); - return queries; - } - const preparationExit = yield* Effect.scoped( - Effect.gen(function* () { - yield* runtime.prepareTurn!(preparation); - // The CLI is started, but nothing reaches it before the turn starts. - assert.deepEqual( - queries.map((query) => query.offers), - [0], - ); - if (input.abandon === "interruption") return yield* Effect.interrupt; - if (input.abandon === "stop") { - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: ProviderTurnId.make("not-started-claude-prepare-turn"), - requestRuntimeRestart: true, - }); - assert.isTrue(queries[0]?.closed); - return; - } - if (input.abandon === "scope-end") return; - yield* runtime.startTurn(start); - }), - ).pipe(Effect.exit); - if (input.abandon === "interruption") { - assert.isTrue(Exit.isFailure(preparationExit)); - } else { - assert.isTrue(Exit.isSuccess(preparationExit)); - } - if (input.abandon !== undefined) { - assert.isTrue(queries[0]?.closed); - assert.equal(queries[0]?.offers, 0); - // A cancelled preparation cannot be adopted by the next prompt. - yield* runtime.startTurn(start); - } - assert.isFalse(queries.at(-1)?.closed); - return queries; - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("starts the turn on the process it started before the prompt", () => - Effect.gen(function* () { - const queries = yield* runPrepareScenario({ startModel: CLAUDE_TEST_MODEL_SELECTION.model }); - assert.equal(queries.length, 1); - assert.equal(queries[0]?.offers, 1); - }), - ); - - it.effect("replaces a prepared process the turn would not have opened", () => - Effect.gen(function* () { - const queries = yield* runPrepareScenario({ startModel: "claude-haiku-4-5" }); - assert.equal(queries.length, 2); - assert.isTrue(queries[0]?.closed); - assert.equal(queries[0]?.offers, 0); - assert.equal(queries[1]?.offers, 1); - }), - ); - - it.effect.each(["scope-end", "interruption", "stop"] as const)( - "closes an unprompted process after %s", - (abandon) => - Effect.gen(function* () { - const queries = yield* runPrepareScenario({ - startModel: CLAUDE_TEST_MODEL_SELECTION.model, - abandon, - }); - assert.equal(queries.length, 2); - assert.equal(queries[0]?.offers, 0); - assert.equal(queries[1]?.offers, 1); - }), - ); - - it.effect("closing a superseded start leaves replacement preparation and adoption alive", () => - Effect.gen(function* () { - const queries = yield* runPrepareScenario({ - startModel: CLAUDE_TEST_MODEL_SELECTION.model, - abandon: "superseded", - }); - assert.equal(queries.length, 2); - assert.equal(queries[0]?.offers, 0); - assert.equal(queries[1]?.offers, 1); - }), - ); -}); - describe("ClaudeAdapterV2 approval cancellation", () => { it.effect("observes an approval signal that was already aborted", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f5cbf152ca39e..1451ec9ff62e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1675,19 +1675,6 @@ export function claudeEffectiveQueryPolicyKey( }); } -// Identifies how a CLI process was opened, so a process started before its -// prompt is reused only by a turn that would open it the same way. -function claudeQueryOpenKey(input: { - readonly nativeThreadId: string; - readonly queryPolicyKey: string; - readonly selectionKey: string; - readonly shouldResume: boolean; - readonly resumeSessionAt: string | null; - readonly cwd: string | null; -}): string { - return JSON.stringify(input); -} - type ClaudeToolItemType = Extract< OrchestrationV2TurnItem["type"], "command_execution" | "file_change" | "dynamic_tool" | "web_search" @@ -3120,13 +3107,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const interruptedTurns = yield* Ref.make(new Set()); const steeredTurns = yield* Ref.make(new Set()); const queryContext = yield* Ref.make(null); - // A CLI started by `prepareTurn` before its first prompt exists. The - // next `openQuery` with the same open key adopts it; any other closes it. - const prewarmedQuery = yield* Ref.make<{ - readonly key: string; - readonly nativeThreadId: string; - readonly query: ClaudeAgentSdkQuerySession; - } | null>(null); const openedNativeThreads = yield* Ref.make(new Set()); const latestPlanByKind = yield* Ref.make(new Map()); const planIdsByNativeItem = yield* Ref.make( @@ -7305,10 +7285,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; }); - // Everything a new CLI process for this turn is opened with. `openKey` - // covers every input that varies by turn, so a prewarmed process is - // adopted only when it was opened exactly as this turn would open one. - const planQueryOpen = Effect.fnUntraced(function* ( + const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, ) { @@ -7324,80 +7301,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); - const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); - // openedNativeThreads is per session instance and is lost when the - // provider session is idle-released. A prior turn on this native id - // requires resume; sessionId would fail with "already in use". - // A fresh-session fallback keeps provider-thread history but binds - // a new native id, which must be created before it can be resumed. - const hasPersistedProviderTurn = - turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; - const shouldResume = - resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; - const queryOptions = makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - attachmentsDir, - settings: adapterOptions.settings, - environment: { ...adapterOptions.environment, ...mcpOverrides.mcpEnvironment }, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...(mcpOverrides.allowedTools === undefined - ? {} - : { allowedTools: mcpOverrides.allowedTools }), - ...(mcpOverrides.mcpServers === undefined - ? {} - : { mcpServers: mcpOverrides.mcpServers }), - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { - allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, - }), - canUseTool, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }); - return { - queryPolicyKey, - selectionKey: compiledSelection.queryIdentity, - queryOptions, - openKey: claudeQueryOpenKey({ - nativeThreadId, - queryPolicyKey, - selectionKey: compiledSelection.queryIdentity, - shouldResume, - resumeSessionAt: resumeSessionAt ?? null, - cwd: turnInput.runtimePolicy.cwd ?? null, - }), - }; - }); - - const closePrewarmedQuery = (nativeThreadId?: string) => - Ref.modify(prewarmedQuery, (current) => - nativeThreadId === undefined || current?.nativeThreadId === nativeThreadId - ? [current, null] - : [null, current], - ).pipe( - Effect.flatMap((prewarmed) => - prewarmed === null ? Effect.void : prewarmed.query.close.pipe(Effect.ignore), - ), - ); - - const openQuery = Effect.fnUntraced(function* ( - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - nativeThreadId: string, - ) { - const plan = yield* planQueryOpen(turnInput, nativeThreadId); - const { queryPolicyKey } = plan; const existing = yield* Ref.get(queryContext); - if (existing !== null) { - // `prepareTurn` only prewarms while no process is live, so a - // prewarmed process here is stale whatever this turn does next. - yield* closePrewarmedQuery(); - } // A continuation prompts nothing: it drains output the live process // already produced, so it keeps that process whatever its selection. if ( @@ -7411,7 +7315,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === plan.selectionKey + existing.selectionKey === compiledSelection.queryIdentity ) { // Claude can switch its own mode mid-session (EnterPlanMode), and // a denied ExitPlanMode leaves it there. Put the live process back @@ -7453,36 +7357,65 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } - const { queryOptions } = plan; - const prewarmed = yield* Ref.getAndSet(prewarmedQuery, null); - if (prewarmed !== null && prewarmed.key !== plan.openKey) { - yield* prewarmed.query.close.pipe(Effect.ignore); - } - const querySession = yield* ( - prewarmed !== null && prewarmed.key === plan.openKey - ? Effect.succeed(prewarmed.query) - : queryRunner.open({ - threadId: turnInput.threadId, - providerSessionId: input.providerSessionId, - options: queryOptions, - }) - ).pipe( - // An interrupted open leaves the old process just as dead. - Effect.onError(() => - // Same-native-thread replacement: the old process is already - // dead, so its process-scoped roster is not authoritative. - // First-ever failed open (no prior live query) must not invent - // native-session reset events. - closedExistingNativeThreadId === nativeThreadId - ? Effect.gen(function* () { - yield* clearWakeStateForNativeThread(nativeThreadId); - yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { - status: "idle", - }); - }) - : Effect.void, - ), - ); + const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); + // openedNativeThreads is per session instance and is lost when the + // provider session is idle-released. A prior turn on this native id + // requires resume; sessionId would fail with "already in use". + // A fresh-session fallback keeps provider-thread history but binds + // a new native id, which must be created before it can be resumed. + const hasPersistedProviderTurn = + turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; + const shouldResume = + resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const queryOptions = makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + attachmentsDir, + settings: adapterOptions.settings, + environment: { ...adapterOptions.environment, ...mcpOverrides.mcpEnvironment }, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...(mcpOverrides.allowedTools === undefined + ? {} + : { allowedTools: mcpOverrides.allowedTools }), + ...(mcpOverrides.mcpServers === undefined + ? {} + : { mcpServers: mcpOverrides.mcpServers }), + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], + }); + const querySession = yield* queryRunner + .open({ + threadId: turnInput.threadId, + providerSessionId: input.providerSessionId, + options: queryOptions, + }) + .pipe( + // An interrupted open leaves the old process just as dead. + Effect.onError(() => + // Same-native-thread replacement: the old process is already + // dead, so its process-scoped roster is not authoritative. + // First-ever failed open (no prior live query) must not invent + // native-session reset events. + closedExistingNativeThreadId === nativeThreadId + ? Effect.gen(function* () { + yield* clearWakeStateForNativeThread(nativeThreadId); + yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { + status: "idle", + }); + }) + : Effect.void, + ), + ); // Marked only after a successful open: a failed create must not // leave the runtime believing the native session exists, or the // retry would resume a session that was never created. @@ -7509,7 +7442,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId, query: querySession, queryPolicyKey, - selectionKey: plan.selectionKey, + selectionKey: compiledSelection.queryIdentity, closed, promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, @@ -7567,60 +7500,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return context; }); - // Starts the CLI process for a turn that will need a new one, without a - // prompt. The SDK spawns the CLI when the query opens and the CLI emits - // nothing until it reads a prompt, so its ~1.3 s boot overlaps the - // baseline checkpoint. `startTurn` adopts it through `openQuery`. - const prepareTurn = Effect.fn("ClaudeAdapterV2.prepareTurn")( - function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { - if ((yield* Ref.get(queryContext)) !== null || (yield* Ref.get(activeTurn)) !== null) { - return; - } - const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); - const plan = yield* planQueryOpen(turnInput, nativeThreadId); - yield* Effect.acquireRelease( - Effect.gen(function* () { - const query = yield* queryRunner.open({ - threadId: turnInput.threadId, - providerSessionId: input.providerSessionId, - options: plan.queryOptions, - }); - const replaced = yield* Ref.getAndSet(prewarmedQuery, { - key: plan.openKey, - nativeThreadId, - query, - }); - if (replaced !== null) yield* replaced.query.close.pipe(Effect.ignore); - return query; - }), - (query) => - // Adoption atomically removes this entry. A cancelled start - // only closes its own pending process, never a live query or - // another attempt's replacement preparation. - Ref.modify(prewarmedQuery, (current) => - current?.query === query ? [current, null] : [null, current], - ).pipe( - Effect.flatMap((pending) => - pending === null ? Effect.void : pending.query.close.pipe(Effect.ignore), - ), - ), - ); - }, - (effect, turnInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: CLAUDE_PROVIDER, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), - ), - ), - ); - const startTurn = Effect.fn("ClaudeAdapterV2.startTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { const startedAt = yield* DateTime.now; @@ -7822,7 +7701,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const existing = yield* Ref.get(queryContext); const currentTurn = yield* Ref.get(activeTurn); const nativeThreadId = turnInput.providerThread.nativeThreadRef?.nativeId ?? null; - if (nativeThreadId !== null) yield* closePrewarmedQuery(nativeThreadId); if (currentTurn === null && turnInput.requestRuntimeRestart === true) { // Stop after the turn settled. With no CLI process of this // native thread left, nothing it started is still running: its @@ -7959,7 +7837,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (existing !== null) { yield* existing.query.close.pipe(Effect.ignore); } - yield* closePrewarmedQuery(); yield* Effect.yieldNow; yield* queryRunner.assertComplete.pipe( Effect.catchCause((cause) => @@ -8100,7 +7977,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ), ), - prepareTurn, startTurn, compactThread: (turnInput) => startTurn({ diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 85f53e7414e84..0dec68dc1ec9c 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1671,7 +1671,6 @@ export const layerWithOptions = ( >, ): ProviderAdapter.ProviderAdapterV2SessionRuntime => { const providerSessionId = runtime.providerSessionId; - const prepareTurn = runtime.prepareTurn; const subscribeEvents = makeEventSubscription(eventSubscribers); // Every provider's turn operations pass through here, so this is where they are // counted. Only turn starts are timed: until the provider accepts the turn. @@ -1831,14 +1830,6 @@ export const layerWithOptions = ( ), ), ), - ...(prepareTurn === undefined - ? {} - : { - prepareTurn: (input: ProviderAdapter.ProviderAdapterV2TurnInput) => - observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( - Effect.andThen(prepareTurn(input)), - ), - }), steerTurn: (input) => observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( Effect.andThen(runtime.steerTurn(input).pipe(turnMetrics("steer"))), diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 3aba0451ae9ec..f2e572e16948e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1053,6 +1053,107 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); +// Opening a provider process already runs workspace-capable startup (Claude's +// SessionStart hooks and MCP servers), so its edits must land after the baseline. +it.effect("touches the provider session only after the baseline checkpoint exists", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:run-execution-baseline-order"); + const providerInstanceId = ProviderInstanceId.make("codex"); + // Property reads happen synchronously, so the log is a plain array. + const order: Array = []; + const runtime = { + events: Stream.never, + startTurn: () => Effect.void, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; + const session = new Proxy(runtime, { + get: (target, key, receiver) => { + order.push(`session.${String(key)}`); + return Reflect.get(target, key, receiver); + }, + }); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => + Effect.sync(() => { + order.push("baseline:start"); + }).pipe( + Effect.andThen(Effect.yieldNow), + Effect.andThen( + Effect.sync(() => { + order.push("baseline:end"); + }), + ), + ), + }), + Layer.mock(EventSink.EventSinkV2)({}), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make("command:run-execution-baseline-order"), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make("session:run-execution-baseline-order"), + session, + run: { + id: RunId.make("run:run-execution-baseline-order"), + threadId, + ordinal: 1, + providerInstanceId, + } as OrchestrationV2Run, + rootNode: { + id: NodeId.make("node:run-execution-baseline-order"), + } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-baseline-order"), + } as OrchestrationV2CheckpointScope, + providerThread: { + id: ProviderThreadId.make("provider-thread:run-execution-baseline-order"), + driver, + } as OrchestrationV2ProviderThread, + attempt: { + id: RunAttemptId.make("attempt:run-execution-baseline-order"), + providerTurnId: null, + } as OrchestrationV2RunAttempt, + attemptId: RunAttemptId.make("attempt:run-execution-baseline-order"), + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make("message:run-execution-baseline-order"), + text: "Start after the baseline.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(layerTest)); + + assert.deepEqual(order.slice(0, 2), ["baseline:start", "baseline:end"]); + assert.include(order, "session.startTurn"); + }), +); + it.effect.each([ "failure", "interruption", @@ -1075,11 +1176,8 @@ it.effect.each([ id: CheckpointScopeId.make("checkpoint-scope:run-execution-settings-failure"), } as OrchestrationV2CheckpointScope; const providerStarts = yield* Ref.make(0); - const preparations = yield* Ref.make(0); - const preparationCloses = yield* Ref.make(0); - const preparationStarted = yield* Deferred.make(); const startGuards = yield* Ref.make(0); - const prepares = + const reachesBaseline = scenario === "start-guard" || scenario === "late-start-guard" || scenario === "baseline-interruption"; @@ -1093,8 +1191,8 @@ it.effect.each([ Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => scenario === "baseline-interruption" - ? Deferred.await(preparationStarted).pipe(Effect.andThen(Effect.interrupt)) - : prepares + ? Effect.interrupt + : reachesBaseline ? Effect.void : Effect.die("not reached"), }), @@ -1117,7 +1215,7 @@ it.effect.each([ Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: () => Effect.succeed([]), }), - prepares + reachesBaseline ? ServerSettings.layerTest() : Layer.mock(ServerSettings.ServerSettingsService)({ getSettings: @@ -1147,13 +1245,6 @@ it.effect.each([ providerSessionId, session: { events: Stream.never, - prepareTurn: () => - Effect.acquireRelease( - Ref.update(preparations, (count) => count + 1).pipe( - Effect.andThen(Deferred.succeed(preparationStarted, undefined)), - ), - () => Ref.update(preparationCloses, (count) => count + 1), - ).pipe(Effect.asVoid), startTurn: () => Ref.update(providerStarts, (count) => count + 1), } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { @@ -1208,8 +1299,6 @@ it.effect.each([ }).pipe(Effect.provide(layerTest), Effect.exit); assert.equal(yield* Ref.get(providerStarts), 0); - assert.equal(yield* Ref.get(preparations), prepares ? 1 : 0); - assert.equal(yield* Ref.get(preparationCloses), prepares ? 1 : 0); const events = (yield* Ref.get(writes)).flat(); if (scenario === "interruption" || scenario === "baseline-interruption") { assert.isTrue(Exit.isFailure(result)); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index c96ee1f99049a..a6d31eff74dee 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -830,27 +830,6 @@ export const layer: Layer.Layer< failure, threadDisposition: "reusable", }); - const baseTurnInput: ProviderAdapter.ProviderAdapterV2TurnInput = { - appThread: input.appThread, - threadId: input.run.threadId, - runId: input.run.id, - runOrdinal: input.run.ordinal, - providerTurnOrdinal: input.providerTurnOrdinal, - ...(input.nativeThreadHasTurns === undefined - ? {} - : { nativeThreadHasTurns: input.nativeThreadHasTurns }), - ...(input.run.restartContinuationOfRunId === undefined - ? {} - : { - restartContinuationOfRunId: input.run.restartContinuationOfRunId, - }), - attemptId: input.attemptId, - rootNodeId: input.rootNode.id, - providerThread: input.providerThread, - message: input.message, - modelSelection: input.modelSelection, - runtimePolicy: input.runtimePolicy, - }; const responseStreamingMode = yield* Effect.gen(function* () { const responseStreamingMode = yield* serverSettings.getSettings.pipe( Effect.map( @@ -859,41 +838,24 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); - // The prompt is delivered only after the baseline exists. Native - // startup that cannot act on the workspace (a CLI boot) runs beside it. - const prepareTurn = input.session.prepareTurn; - yield* Effect.all( - [ - 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 }, - ), - ), - ), - prepareTurn === undefined - ? Effect.void - : prepareTurn(baseTurnInput).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning( - "orchestration V2 provider turn preparation failed; starting it with the turn", - cause, - ).pipe(Effect.annotateLogs({ runId: input.run.id })), + // 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({ + 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 }, ), - ), - ], - { concurrency: "unbounded", discard: true }, - ); + ), + ); if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) @@ -1415,8 +1377,28 @@ export const layer: Layer.Layer< key: `mcp_app_${entry.itemId.replace(/[^\w.-]/g, "_")}`, text: entry.text, })); - const turnInput = - appContext.length === 0 ? baseTurnInput : { ...baseTurnInput, appContext }; + const turnInput = { + appThread: input.appThread, + threadId: input.run.threadId, + runId: input.run.id, + runOrdinal: input.run.ordinal, + providerTurnOrdinal: input.providerTurnOrdinal, + ...(input.nativeThreadHasTurns === undefined + ? {} + : { nativeThreadHasTurns: input.nativeThreadHasTurns }), + ...(input.run.restartContinuationOfRunId === undefined + ? {} + : { + restartContinuationOfRunId: input.run.restartContinuationOfRunId, + }), + attemptId: input.attemptId, + rootNodeId: input.rootNode.id, + providerThread: input.providerThread, + message: input.message, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + ...(appContext.length === 0 ? {} : { appContext }), + }; const compact = input.message.attachments.length === 0 && input.message.text.trim().toLowerCase() === "/compact"; @@ -1485,7 +1467,7 @@ export const layer: Layer.Layer< ), ), ); - }).pipe(Effect.scoped), + }), } satisfies RunExecutionServiceV2Shape); }), ); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9f744b715d390..086300e21878e 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -584,18 +584,6 @@ export interface ProviderAdapterV2SessionRuntime { readonly providerThread: OrchestrationV2ProviderThread; }, ) => Effect.Effect; - /** - * Starts native work the coming `startTurn` would wait for (such as spawning - * a CLI process) without sending the prompt, so it overlaps the baseline - * checkpoint. It must not deliver the prompt, so the agent cannot act before - * the baseline exists. `startTurn` with the same input reuses what it - * started. Pending work belongs to the caller's start scope and must close - * if that scope ends before `startTurn` adopts it. Best effort: a failure - * leaves `startTurn` to do the work itself. - */ - readonly prepareTurn?: ( - input: ProviderAdapterV2TurnInput, - ) => Effect.Effect; readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; From 105597c3b463091da508abfa2c5c7b40d6035e2e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 06:41:16 +0000 Subject: [PATCH 045/143] fix(web): a sign-in during the onboarding chunk load still opens the wizard The Connect onboarding wizard loads lazily, and it watched for sign-ins itself. A sign-in or account switch that completed while its chunk loaded was first seen as a restored session, so the wizard never opened. The managed-auth shell now provides the Clerk account through a Clerk-free context, and a small eager hook at the wizard's mount point records the sign-in for the wizard to take when it arrives. --- apps/web/src/cloud/connectOnboarding.ts | 50 +++++++++++++++ apps/web/src/cloud/managedAuth.test.ts | 63 +++++++++++++++++++ apps/web/src/cloud/managedAuth.tsx | 6 +- .../cloud/ConnectOnboardingDialog.tsx | 42 +++++-------- apps/web/src/routes/__root.tsx | 18 ++++-- 5 files changed, 144 insertions(+), 35 deletions(-) diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts index 7d61e11c6c6a1..a40c406668455 100644 --- a/apps/web/src/cloud/connectOnboarding.ts +++ b/apps/web/src/cloud/connectOnboarding.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; /** * Accounts that opted out of the post-sign-in T3 Connect onboarding wizard @@ -16,3 +17,52 @@ export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema. export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = { optOutAccounts: [], }; + +/** + * The Clerk account as the managed-auth shell sees it. The shell provides it + * so startup code can follow sign-ins without importing Clerk. Null when no + * shell is mounted. + */ +export interface ManagedAccountState { + readonly isLoaded: boolean; + readonly isSignedIn: boolean | undefined; + readonly userId: string | null | undefined; +} + +export const ManagedAccountContext = createContext(null); + +/** + * The account whose sign-in should open the onboarding wizard. Every sign-in + * or account switch that completes while this is mounted requests it, since + * account transitions clear the connected relay environments. A cold load + * observes undefined → account and must not re-prompt. The wizard loads + * lazily, so this runs eagerly where it mounts and the wizard takes the + * request whenever its chunk arrives. + */ +export function useConnectOnboardingRequest() { + const account = useContext(ManagedAccountContext); + const isLoaded = account?.isLoaded ?? false; + const isSignedIn = account?.isSignedIn; + const userId = account?.userId; + const [requestedAccount, setRequestedAccount] = useState(null); + const observedAccountRef = useRef(undefined); + + useEffect(() => { + if (!isLoaded) return; + // A loaded-but-incomplete snapshot (signed in, user id not yet populated) + // must not be recorded as signed-out — the next render would then look + // like a fresh sign-in on a cold load. + if (isSignedIn && !userId) return; + const previousAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) { + setRequestedAccount(nextAccount); + } + }, [isLoaded, isSignedIn, userId]); + + const clearRequestedAccount = useCallback(() => setRequestedAccount(null), []); + return { requestedAccount, clearRequestedAccount }; +} + +export type ConnectOnboardingRequest = ReturnType; diff --git a/apps/web/src/cloud/managedAuth.test.ts b/apps/web/src/cloud/managedAuth.test.ts index 63001bdb184da..8bd32c6ae91c3 100644 --- a/apps/web/src/cloud/managedAuth.test.ts +++ b/apps/web/src/cloud/managedAuth.test.ts @@ -1,8 +1,16 @@ import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import * as Effect from "effect/Effect"; +import { act, createElement, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { + ManagedAccountContext, + type ConnectOnboardingRequest, + type ManagedAccountState, + useConnectOnboardingRequest, +} from "./connectOnboarding"; import { activateManagedRelayAuthentication, deactivateManagedRelayAuthentication, @@ -56,3 +64,58 @@ describe("managed relay authentication", () => { expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2"); }); }); + +describe("connect onboarding sign-in requests", () => { + let renderer: ReactTestRenderer | null = null; + let request: ConnectOnboardingRequest | null = null; + + function RequestProbe() { + const value = useConnectOnboardingRequest(); + useLayoutEffect(() => { + request = value; + }); + return null; + } + + const render = (account: ManagedAccountState) => + act(() => { + const tree = createElement( + ManagedAccountContext, + { value: account }, + createElement(RequestProbe), + ); + if (renderer === null) renderer = create(tree); + else renderer.update(tree); + }); + + afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + request = null; + vi.unstubAllGlobals(); + }); + + it("does not request the wizard for a restored session", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await render({ isLoaded: false, isSignedIn: undefined, userId: undefined }); + await render({ isLoaded: true, isSignedIn: true, userId: null }); + await render({ isLoaded: true, isSignedIn: true, userId: "account-1" }); + + expect(request?.requestedAccount).toBeNull(); + }); + + // The wizard chunk loads lazily, so the request waits until it is taken. + it("holds each in-session sign-in until the wizard takes it", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await render({ isLoaded: true, isSignedIn: false, userId: null }); + await render({ isLoaded: true, isSignedIn: true, userId: "account-1" }); + await render({ isLoaded: true, isSignedIn: true, userId: "account-1" }); + expect(request?.requestedAccount).toBe("account-1"); + + await act(() => request?.clearRequestedAccount()); + expect(request?.requestedAccount).toBeNull(); + + await render({ isLoaded: true, isSignedIn: true, userId: "account-2" }); + expect(request?.requestedAccount).toBe("account-2"); + }); +}); diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index f9bf1c966f40b..edad78989e004 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -6,12 +6,13 @@ import { settlePromise, } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, type ReactNode } from "react"; import { environmentCatalog } from "../connection/catalog"; import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useAtomCommand } from "../state/use-atom-command"; +import { ManagedAccountContext } from "./connectOnboarding"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; export function deactivateManagedRelayAuthentication(): void { @@ -104,5 +105,6 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac useEffect(() => () => deactivateManagedRelayAuthentication(), []); - return children; + const account = useMemo(() => ({ isLoaded, isSignedIn, userId }), [isLoaded, isSignedIn, userId]); + return {children}; } diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index afe7146979918..8595a411db7b2 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -13,6 +13,7 @@ import { CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, + type ConnectOnboardingRequest, } from "~/cloud/connectOnboarding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; @@ -39,21 +40,25 @@ import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } fro * current session is authorized to manage the relay link, then lists the * account's T3 Connect environments so every device can be connected right * away. A cold load with a restored session does not count as a sign-in. + * `useConnectOnboardingRequest` observes the sign-ins outside this lazy chunk. */ -export function ConnectOnboardingDialog() { +export function ConnectOnboardingDialog(props: ConnectOnboardingRequest) { if (!hasCloudPublicConfig()) return null; - return ; + return ; } type OnboardingStep = "publish" | "devices"; const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial()); -function ConfiguredConnectOnboardingDialog() { +function ConfiguredConnectOnboardingDialog({ + requestedAccount, + clearRequestedAccount, +}: ConnectOnboardingRequest) { // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as // signed-out, or its later activation would look like a fresh sign-in. - const { isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const [optOutState, setOptOutState] = useLocalStorage( CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, @@ -83,7 +88,6 @@ function ConfiguredConnectOnboardingDialog() { ? ["publish", "devices"] : ["devices"]; - const [requestedAccount, setRequestedAccount] = useState(null); const [openForAccount, setOpenForAccount] = useState(null); const [step, setStep] = useState("devices"); const [exposeEnvironment, setExposeEnvironment] = useState(true); @@ -91,7 +95,6 @@ function ConfiguredConnectOnboardingDialog() { const [dontShowAgain, setDontShowAgain] = useState(false); const [isApplying, setIsApplying] = useState(false); const prefilledFromLinkStateRef = useRef(false); - const observedAccountRef = useRef(undefined); useEffect(() => { if (step === "publish" && sessionScopesKnown && !showPublishStep && !isApplying) { @@ -101,24 +104,6 @@ function ConfiguredConnectOnboardingDialog() { const optOutAccounts = optOutState.optOutAccounts; - // Every sign-in or account switch that completes during this session - // requests the wizard — account transitions clear the connected relay - // environments, so each new session starts with no devices to reach. A cold - // load observes undefined → account and must not re-prompt. - useEffect(() => { - if (!isLoaded) return; - // A loaded-but-incomplete snapshot (signed in, user id not yet populated) - // must not be recorded as signed-out — the next render would then look - // like a fresh sign-in on a cold load. - if (isSignedIn && !userId) return; - const previousAccount = observedAccountRef.current; - const nextAccount = isSignedIn && userId ? userId : null; - observedAccountRef.current = nextAccount; - if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) { - setRequestedAccount(nextAccount); - } - }, [isLoaded, isSignedIn, userId]); - // A manageable session implies a primary environment, so when the scopes // allow publishing, wait for the connection target too — otherwise the // wizard could open on the devices step moments before the publish step @@ -130,11 +115,11 @@ function ConfiguredConnectOnboardingDialog() { useEffect(() => { if (requestedAccount === null || openForAccount !== null) return; if (optOutAccounts.includes(requestedAccount)) { - setRequestedAccount(null); + clearRequestedAccount(); return; } if (!sessionScopesKnown || !publishStepDecided) return; - setRequestedAccount(null); + clearRequestedAccount(); prefilledFromLinkStateRef.current = false; setExposeEnvironment(true); setPublishAgentActivity(true); @@ -143,6 +128,7 @@ function ConfiguredConnectOnboardingDialog() { setOpenForAccount(requestedAccount); }, [ canManageRelay, + clearRequestedAccount, controller.linkState.target, openForAccount, optOutAccounts, @@ -158,9 +144,9 @@ function ConfiguredConnectOnboardingDialog() { setOpenForAccount(null); } if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) { - setRequestedAccount(null); + clearRequestedAccount(); } - }, [isSignedIn, openForAccount, requestedAccount, userId]); + }, [clearRequestedAccount, isSignedIn, openForAccount, requestedAccount, userId]); // Toggles default on, but an environment that is already linked should show // its actual configuration instead of silently proposing to rewrite it. diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8314f43d9165e..a3ff8aa2bbe49 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -51,6 +51,7 @@ import { } from "../components/ui/toast"; import { isElectron } from "../env"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { useConnectOnboardingRequest } from "../cloud/connectOnboarding"; import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; @@ -101,6 +102,17 @@ const NightlyMobileBetaNotice = lazy(() => .catch(() => ({ default: renderNothing })), ); +// Sign-ins are observed here, outside the lazy wizard chunk, so one that +// completes while the chunk loads still opens the wizard. +function ConnectOnboarding() { + const request = useConnectOnboardingRequest(); + return ( + + + + ); +} + export const Route = createRootRoute({ beforeLoad: async ({ location }) => { if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { @@ -254,11 +266,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} - {hasCloudPublicConfig() ? ( - - - - ) : null} + {hasCloudPublicConfig() ? : null} From 900652d817683ffc24b80465ca6d66c84ab7c8d3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:31:37 +0000 Subject: [PATCH 046/143] fix(server): sweep index follows payloads written by any build Migration 061 copied the settlement and pull request fields into plain columns that only this build's projection writer kept current. An older build sharing the database rewrote payloads without them, so sweeps could miss a thread until this build wrote it again. The migration now indexes the same values as expressions over payload_json. SQLite maintains the index on every write, and the sweep queries read the values from it, so no app code keeps columns in sync. --- .../orchestration-v2/ProjectionStore.test.ts | 17 ++++- .../src/orchestration-v2/ProjectionStore.ts | 43 +++++------- apps/server/src/persistence/Migrations.ts | 4 +- .../Migrations/055_OrchestrationV2.test.ts | 38 ++++++---- .../061_ProjectionThreadSweepColumns.ts | 69 ------------------- .../061_ProjectionThreadSweepIndexes.ts | 42 +++++++++++ .../reconcileV2PreviewMigration.test.ts | 4 +- 7 files changed, 103 insertions(+), 114 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts create mode 100644 apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 5028797804be4..b6af6d1404cf5 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2645,8 +2645,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ]) { yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = json_set(payload_json, ${`$.${field}`}, ${value}), - archived_at = CASE WHEN ${field} = 'archivedAt' THEN ${value} ELSE archived_at END, - settled_override = CASE WHEN ${field} = 'settledOverride' THEN ${value} ELSE settled_override END + archived_at = CASE WHEN ${field} = 'archivedAt' THEN ${value} ELSE archived_at END WHERE thread_id = ${threadId}`; assert.isUndefined( (yield* store.getLimitRecoveryCandidates({ now, autoResume: true, snooze: false })).find( @@ -2654,7 +2653,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), ); yield* sql`UPDATE orchestration_v2_projection_threads - SET payload_json = ${originalRow!.payload_json}, archived_at = NULL, settled_override = NULL + SET payload_json = ${originalRow!.payload_json}, archived_at = NULL WHERE thread_id = ${threadId}`; } yield* sql`UPDATE orchestration_v2_projection_threads SET deleted_at = ${DateTime.formatIso(now)} WHERE thread_id = ${threadId}`; @@ -4871,6 +4870,18 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }); assert.deepEqual((yield* store.getThread(threadId)).pullRequests, synced.pullRequests); + // An older build sharing the database writes only the payload; the sweep still follows it. + const sql = yield* SqlClient.SqlClient; + const [stored] = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.pullRequests', json('[]')) + WHERE thread_id = ${threadId}`; + assert.deepEqual(yield* store.getThreadsWithPullRequests(), []); + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = ${stored!.payload_json} WHERE thread_id = ${threadId}`; + assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); + yield* syncPullRequests("unwatched", [link]); assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, []); assert.equal(yield* phase, "completed"); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index d7ce30b52050b..557ca2f93936e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1849,12 +1849,6 @@ export const layer: Layer.Layer = updated_at, archived_at, deleted_at, - settled_at, - settled_override, - pinned_at, - auto_settle_disabled_at, - forked_from_run_thread_id, - pull_request_count, payload_json ) VALUES ( @@ -1870,12 +1864,6 @@ export const layer: Layer.Layer = ${stringField(payload, "updatedAt")}, ${nullableStringField(payload, "archivedAt")}, ${nullableStringField(payload, "deletedAt")}, - ${nullableStringField(payload, "settledAt")}, - ${nullableStringField(payload, "settledOverride")}, - ${nullableStringField(payload, "pinnedAt")}, - ${nullableStringField(payload, "autoSettleDisabledAt")}, - ${event.payload.forkedFrom?.type === "run" ? event.payload.forkedFrom.threadId : null}, - ${event.payload.pullRequests?.length ?? 0}, ${payloadJson} ) ON CONFLICT(thread_id) @@ -1891,12 +1879,6 @@ export const layer: Layer.Layer = updated_at = excluded.updated_at, archived_at = excluded.archived_at, deleted_at = excluded.deleted_at, - settled_at = excluded.settled_at, - settled_override = excluded.settled_override, - pinned_at = excluded.pinned_at, - auto_settle_disabled_at = excluded.auto_settle_disabled_at, - forked_from_run_thread_id = excluded.forked_from_run_thread_id, - pull_request_count = excluded.pull_request_count, payload_json = excluded.payload_json `; break; @@ -1923,7 +1905,6 @@ export const layer: Layer.Layer = UPDATE orchestration_v2_projection_threads SET updated_at = ${stringField(parseEncodedPayload(payloadJson), "updatedAt")}, - pull_request_count = ${updatedThread.pullRequests?.length ?? 0}, payload_json = ${payloadJson} WHERE thread_id = ${event.threadId} `; @@ -3525,7 +3506,9 @@ export const layer: Layer.Layer = ORDER BY session.updated_at DESC, session.provider_session_id DESC LIMIT 1 ) AS last_error + -- The sweep index holds settledOverride, so failed threads' payloads are not parsed. FROM orchestration_v2_projection_threads t + INDEXED BY orchestration_v2_projection_threads_active_idx INNER JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest WHERE latest.thread_id = t.thread_id @@ -3549,7 +3532,7 @@ export const layer: Layer.Layer = ) WHERE t.deleted_at IS NULL AND t.archived_at IS NULL - AND t.settled_override IS NOT 'settled' + AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled' AND t.thread_id IN ( SELECT thread_id FROM orchestration_v2_projection_runs WHERE status = 'failed' ) @@ -5177,6 +5160,8 @@ export const layer: Layer.Layer = ), ); + // The settlement filters and fork source spell the sweep index's expressions (migration 061) + // exactly, so a scan of active threads reads them from the index instead of each payload. const selectShellThreadRows = ( threadId?: ThreadId, location?: "active" | "archive", @@ -5186,7 +5171,11 @@ export const layer: Layer.Layer = SELECT t.thread_id, t.payload_json, - t.forked_from_run_thread_id AS forked_from_run_source_thread_id, + CASE + WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' + THEN json_extract(t.payload_json, '$.forkedFrom.threadId') + ELSE NULL + END AS forked_from_run_source_thread_id, presented.run_id AS latest_run_id, presented.status AS latest_run_status, presented.requested_at AS latest_run_requested_at, @@ -5359,7 +5348,7 @@ export const layer: Layer.Layer = : sql`` }${ unsettledOnly - ? sql` AND t.settled_at IS NULL AND t.settled_override IS NOT 'settled'` + ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` : sql`` } ORDER BY t.updated_at ASC, t.thread_id ASC @@ -5524,10 +5513,11 @@ export const layer: Layer.Layer = LIMIT 1 ) WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} + -- These match the sweep index (migration 061), which holds the values. AND t.archived_at IS NULL - AND t.settled_override IS NULL - AND t.pinned_at IS NULL - AND t.auto_settle_disabled_at IS NULL + AND json_extract(t.payload_json, '$.settledOverride') IS NULL + AND json_extract(t.payload_json, '$.pinnedAt') IS NULL + AND json_extract(t.payload_json, '$.autoSettleDisabledAt') IS NULL AND NOT EXISTS ( SELECT 1 FROM orchestration_v2_projection_runs active WHERE active.thread_id = t.thread_id @@ -5611,7 +5601,8 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} AND archived_at IS NULL - AND pull_request_count > 0 + -- Read from the sweep index (migration 061) rather than each payload. + AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC `; return yield* Effect.forEach(rows, (row) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 15af266b217fc..e22a1f7a7de3d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,7 +74,7 @@ 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_ProjectionThreadSweepColumns.ts"; +import Migration0061 from "./Migrations/061_ProjectionThreadSweepIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -149,7 +149,7 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], - [61, "ProjectionThreadSweepColumns", Migration0061], + [61, "ProjectionThreadSweepIndexes", Migration0061], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 16f681835f663..3b6085b9e6bf5 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -32,7 +32,7 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [61, "ProjectionThreadSweepColumns"], + [61, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -59,7 +59,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, - { migration_id: 61, name: "ProjectionThreadSweepColumns" }, + { migration_id: 61, name: "ProjectionThreadSweepIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -134,7 +134,7 @@ layer("055_OrchestrationV2", (it) => { }), ); - it.effect("preserves existing thread filters when adding sweep columns", () => + it.effect("indexes sweep filters for existing threads and later payload-only writes", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 60 }); @@ -157,17 +157,31 @@ layer("055_OrchestrationV2", (it) => { ) `; yield* runMigrations(); - const rows = yield* sql<{ readonly thread_id: string }>` - SELECT thread_id FROM orchestration_v2_projection_threads + const sweep = sql<{ readonly thread_id: string; readonly pull_requests: number }>` + SELECT thread_id, json_array_length(payload_json, '$.pullRequests') AS pull_requests + FROM orchestration_v2_projection_threads + INDEXED BY orchestration_v2_projection_threads_active_idx WHERE deleted_at IS NULL AND archived_at IS NULL - AND settled_at = '2026-07-24T00:00:00.000Z' - AND settled_override = 'settled' - AND pinned_at = '2026-07-23T00:00:00.000Z' - AND auto_settle_disabled_at = '2026-07-22T00:00:00.000Z' - AND forked_from_run_thread_id = 'source-thread' - AND pull_request_count = 2 + AND json_extract(payload_json, '$.settledOverride') IS NULL + AND json_extract(payload_json, '$.pinnedAt') = '2026-07-23T00:00:00.000Z' + AND CASE + WHEN json_extract(payload_json, '$.forkedFrom.type') = 'run' + THEN json_extract(payload_json, '$.forkedFrom.threadId') + ELSE NULL + END = 'source-thread' `; - assert.deepStrictEqual(rows, [{ thread_id: "sweep-thread" }]); + assert.deepStrictEqual(yield* sweep, []); + // An older build rewrites the payload alone; the index follows it. + yield* sql` + UPDATE orchestration_v2_projection_threads + SET payload_json = json_set( + json_remove(payload_json, '$.settledOverride'), + '$.pullRequests', + json('[{"number":1},{"number":2},{"number":3}]') + ) + WHERE thread_id = 'sweep-thread' + `; + assert.deepStrictEqual(yield* sweep, [{ thread_id: "sweep-thread", pull_requests: 3 }]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); }); diff --git a/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts b/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts deleted file mode 100644 index d1b77d15b9b01..0000000000000 --- a/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepColumns.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/sql/SqlClient"; - -/** - * Background sweeps (settlement, pull request sync, the shell reads behind pull request - * discovery) filtered every thread with `json_extract` on its payload, which holds every linked - * pull request's snapshot: tens of KB parsed per thread per sweep. These columns copy the - * fields they filter on out of the payload. ProjectionStore's thread upsert keeps them current. - */ -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const columns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(orchestration_v2_projection_threads) - `; - const existing = new Set(columns.map((column) => column.name)); - for (const [name, definition] of [ - ["settled_at", "TEXT"], - ["settled_override", "TEXT"], - ["pinned_at", "TEXT"], - ["auto_settle_disabled_at", "TEXT"], - ["forked_from_run_thread_id", "TEXT"], - ["pull_request_count", "INTEGER NOT NULL DEFAULT 0"], - ] as const) { - if (!existing.has(name)) { - yield* sql.unsafe( - `ALTER TABLE orchestration_v2_projection_threads ADD COLUMN ${name} ${definition}`, - ).unprepared; - } - } - yield* sql` - UPDATE orchestration_v2_projection_threads - SET - settled_at = json_extract(payload_json, '$.settledAt'), - settled_override = json_extract(payload_json, '$.settledOverride'), - pinned_at = json_extract(payload_json, '$.pinnedAt'), - auto_settle_disabled_at = json_extract(payload_json, '$.autoSettleDisabledAt'), - forked_from_run_thread_id = CASE - WHEN json_extract(payload_json, '$.forkedFrom.type') = 'run' - THEN json_extract(payload_json, '$.forkedFrom.threadId') - ELSE NULL - END, - pull_request_count = COALESCE(json_array_length(payload_json, '$.pullRequests'), 0) - `; - // Covers the sweeps' filters and order without touching the payload. Columns added by - // ALTER TABLE sit after the payload in each row, so reading them from the table walks the - // payload's overflow pages. - yield* sql` - CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_active_idx - ON orchestration_v2_projection_threads( - updated_at, - thread_id, - settled_at, - settled_override, - pinned_at, - auto_settle_disabled_at, - pull_request_count, - forked_from_run_thread_id, - provider_instance_id - ) - WHERE deleted_at IS NULL AND archived_at IS NULL - `; - // Usage-limit recovery only concerns threads whose latest run failed; this finds the few - // threads with any failed run without reading the rest. - yield* sql` - CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_failed_idx - ON orchestration_v2_projection_runs(thread_id) - WHERE status = 'failed' - `; -}); diff --git a/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts b/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts new file mode 100644 index 0000000000000..9abb86143dc1b --- /dev/null +++ b/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +/** + * Background sweeps (settlement, pull request sync, the shell reads behind pull request + * discovery) filtered every thread with `json_extract` on its payload, which holds every linked + * pull request's snapshot: tens of KB parsed per thread per sweep. This index stores the values + * they filter on, so SQLite reads them from the index instead of parsing payloads. + * + * SQLite computes the entries from `payload_json` on every write, so they stay correct for any + * writer, including an older build sharing this database. ProjectionStore's sweep queries must + * spell each expression exactly as it is here, or SQLite parses the payload again. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_active_idx + ON orchestration_v2_projection_threads( + updated_at, + thread_id, + json_extract(payload_json, '$.settledAt'), + json_extract(payload_json, '$.settledOverride'), + json_extract(payload_json, '$.pinnedAt'), + json_extract(payload_json, '$.autoSettleDisabledAt'), + json_array_length(payload_json, '$.pullRequests'), + CASE + WHEN json_extract(payload_json, '$.forkedFrom.type') = 'run' + THEN json_extract(payload_json, '$.forkedFrom.threadId') + ELSE NULL + END, + provider_instance_id + ) + WHERE deleted_at IS NULL AND archived_at IS NULL + `; + // Usage-limit recovery only concerns threads whose latest run failed; this finds the few + // threads with any failed run without reading the rest. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_failed_idx + ON orchestration_v2_projection_runs(thread_id) + WHERE status = 'failed' + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index affb0e380a14d..f03bdbd65fdf3 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,7 +41,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [61, "ProjectionThreadSweepColumns"], + [61, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -125,7 +125,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [61, "ProjectionThreadSweepColumns"], + [61, "ProjectionThreadSweepIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From d4e7adfc574daf3b2b70956bb747d39942b7c3f2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:47:08 +0000 Subject: [PATCH 047/143] fix(web): a second deploy within a minute still gets its chunk reload The chunk reload guard allowed one automatic reload per minute. A deploy that broke a lazy chunk soon after another deploy's reload got no reload, and Chromium kept the failed import until a manual reload. The guard now remembers which failures already reloaded, keyed by the page's entry URL and the failed asset. A chunk that fails on every boot still reloads once, a new deploy's failure gets its own reload, and a tab reloads automatically at most 20 times. --- apps/web/src/lib/chunkReloadGuard.test.ts | 57 +++++++++++++++++++---- apps/web/src/lib/chunkReloadGuard.ts | 29 ++++++------ apps/web/src/main.tsx | 9 +++- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 0418623b28580..966b67073349c 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -20,37 +20,76 @@ function createStorageStub(): Storage { }; } +const BUILD_B = "https://app.t3.codes/assets/index-B.js"; +const failedImport = (asset: string) => + `${BUILD_B} TypeError: Failed to fetch dynamically imported module: https://app.t3.codes/assets/${asset}`; + describe("reloadOnceForChunkLoadError", () => { - it("reloads on the first failure and lets failures within the next minute surface", () => { + it("reloads once for a chunk that fails on every boot, then lets it surface", () => { const storage = createStorageStub(); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(() => storage, reload, 1_000_000)).toBe(true); + expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), () => storage, reload)).toBe( + true, + ); expect(reload).toHaveBeenCalledTimes(1); - // The reloaded page requests the same failing chunk during its first render. - expect(reloadOnceForChunkLoadError(() => storage, reload, 1_002_000)).toBe(false); - expect(reloadOnceForChunkLoadError(() => storage, reload, 1_059_999)).toBe(false); + // Every later boot requests the same failing chunk during its first render. + expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), () => storage, reload)).toBe( + false, + ); + expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), () => storage, reload)).toBe( + false, + ); expect(reload).toHaveBeenCalledTimes(1); }); - it("reloads again for a stale deploy a minute after the last reload", () => { + it("reloads for each deploy that fails a chunk, however soon after the last reload", () => { const storage = createStorageStub(); const reload = vi.fn(); - reloadOnceForChunkLoadError(() => storage, reload, 1_000_000); + // Deploy B fails a chunk of the open build, then deploy C, seconds later, + // fails a chunk of the build B reload loaded. + expect( + reloadOnceForChunkLoadError( + "https://app.t3.codes/assets/index-A.js TypeError: Failed to fetch dynamically imported module: https://app.t3.codes/assets/settings-a.js", + () => storage, + reload, + ), + ).toBe(true); + expect(reloadOnceForChunkLoadError(failedImport("diff-b.js"), () => storage, reload)).toBe( + true, + ); + expect(reload).toHaveBeenCalledTimes(2); - expect(reloadOnceForChunkLoadError(() => storage, reload, 1_060_000)).toBe(true); + // A chunk of build B that keeps failing still stops after its one reload. + expect(reloadOnceForChunkLoadError(failedImport("diff-b.js"), () => storage, reload)).toBe( + false, + ); expect(reload).toHaveBeenCalledTimes(2); }); + it("stops reloading once a tab has used its reloads", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + + for (let index = 0; index < 20; index += 1) { + reloadOnceForChunkLoadError(failedImport(`chunk-${index}.js`), () => storage, reload); + } + + expect(reloadOnceForChunkLoadError(failedImport("chunk-20.js"), () => storage, reload)).toBe( + false, + ); + expect(reload).toHaveBeenCalledTimes(20); + }); + it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { const reload = vi.fn(); const blocked = () => { throw new DOMException("blocked", "SecurityError"); }; - expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); + expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), blocked, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index eef803c50e01f..b1056c2092cf0 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,27 +1,30 @@ // Split chunks are fetched lazily, so a deploy (or desktop server swap) // between page load and a later fetch can 404 the old hashed assets. One -// reload picks up the fresh index.html. sessionStorage keeps the time of that -// reload, and no automatic reload follows within a minute of it, so a chunk -// that keeps failing, even one every boot requests, cannot become a reload -// loop. A stale deploy after that minute gets its own single reload. -const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; -const CHUNK_RELOAD_WINDOW_MS = 60_000; +// reload picks up the fresh index.html. sessionStorage remembers which +// failures already had their reload, so a chunk that keeps failing, even one +// every boot requests, reloads once and then surfaces. A later deploy fails a +// different asset and gets its own reload. The list is capped, so a tab +// reloads automatically at most that many times. +const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloads"; +const MAX_CHUNK_RELOADS = 20; /** - * Called from the `vite:preloadError` listener. Reloads at most once per - * window and returns whether it did, so the caller knows whether to swallow - * the event or let the error surface through the normal paths. + * Called from the `vite:preloadError` listener with a key naming the page's + * build and the asset that failed. Reloads at most once per key and returns + * whether it did, so the caller knows whether to swallow the event or let + * the error surface through the normal paths. */ export function reloadOnceForChunkLoadError( + failure: string, getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), - now = Date.now(), ): boolean { try { const storage = getStorage(); - const sinceLastReload = now - Number(storage.getItem(CHUNK_RELOAD_GUARD_KEY)); - if (sinceLastReload >= 0 && sinceLastReload < CHUNK_RELOAD_WINDOW_MS) return false; - storage.setItem(CHUNK_RELOAD_GUARD_KEY, String(now)); + const stored: unknown = JSON.parse(storage.getItem(CHUNK_RELOAD_GUARD_KEY) ?? "[]"); + const reloaded = Array.isArray(stored) ? stored : []; + if (reloaded.includes(failure) || reloaded.length >= MAX_CHUNK_RELOADS) return false; + storage.setItem(CHUNK_RELOAD_GUARD_KEY, JSON.stringify([...reloaded, failure])); } catch { // Without storage the guard cannot survive a reload, so a persistent // failure would loop forever. Let the error surface instead. diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6c84ccb9183b5..ceae3cd6abb99 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -31,10 +31,15 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; // A failed split-chunk fetch usually means the hashed assets went stale under -// a deploy; one guarded reload picks up the fresh index.html. +// a deploy; one guarded reload picks up the fresh index.html. The failure key +// pairs this build's entry URL with the error, which names the asset (except +// in Safari), so each failed asset of each build gets one reload. Later +// failures while that reload is pending keep their rejection and their reload. let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { - if (reloadOnceForChunkLoadError()) { + if (reloadScheduled) return; + const failure = `${import.meta.url} ${String(event.payload)}`; + if (reloadOnceForChunkLoadError(failure)) { reloadScheduled = true; event.preventDefault(); } From a2cef498e9af98d55da02fbf0309e4309ea89697 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 07:47:08 +0000 Subject: [PATCH 048/143] fix(desktop): a cold local backend no longer fails the window's sign-in The window opens while the local backend starts, but the bearer-token exchange gave up after 15 s. A backend slower than that left the renderer on its bootstrap error, and nothing retried once the backend became ready. The exchange now waits for the backend to report ready, up to 2 minutes, before its 15 s retry window, so a backend that never becomes ready still shows the error. --- .../DesktopLocalEnvironmentAuth.test.ts | 71 +++++++++++++++++++ .../backend/DesktopLocalEnvironmentAuth.ts | 8 +++ 2 files changed, 79 insertions(+) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 3f0bd82d5888d..3b17490f4633b 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,8 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -67,6 +69,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome(config), }, ]), @@ -120,6 +123,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome({ ...config, bootstrap: { ...config.bootstrap, desktopBootstrapSecret: "desktop-secret" }, @@ -167,6 +171,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome(config), }, ]), @@ -234,4 +239,70 @@ describe("DesktopLocalEnvironmentAuth", () => { assert.strictEqual(yield* Ref.get(requestCount), 1); }).pipe(Effect.provide(TestClock.layer())), ); + + // A backend that refuses the exchange until it is ready at `readyAfter`, like + // a cold start. The window opens while it starts. + const makeStartingBackend = (readyAfter: Duration.Duration) => + Effect.gen(function* () { + const requestCount = yield* Ref.make(0); + const layerStartingPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Windows"), + waitForReady: (timeout: Duration.Duration) => + Effect.sleep(readyAfter).pipe( + Effect.as(true), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ), + currentConfig: Effect.succeedSome(config), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + const layerHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(requestCount, (count) => count + 1); + const now = yield* Clock.currentTimeMillis; + return now < Duration.toMillis(readyAfter) + ? HttpClientResponse.fromWeb(request, new Response("", { status: 503 })) + : tokenResponse(request); + }), + ), + ); + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(layerStartingPool, layerHttpClient)), + ), + ), + ); + return { auth, requestCount }; + }); + + it.effect("waits for a backend that becomes ready after the retry window", () => + Effect.gen(function* () { + const { auth, requestCount } = yield* makeStartingBackend(Duration.seconds(40)); + + const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(41)); + + assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token"); + assert.strictEqual(yield* Ref.get(requestCount), 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("still fails for a backend that does not become ready", () => + Effect.gen(function* () { + const { auth } = yield* makeStartingBackend(Duration.minutes(10)); + + const fiber = yield* auth.getBearerToken.pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust(Duration.minutes(3)); + const error = yield* Fiber.join(fiber); + + assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index d137d502e6c5e..ea5e77b293a96 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -23,6 +23,10 @@ import * as DesktopBackendPool from "./DesktopBackendPool.ts"; const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); +// The window opens while the backend starts, and a cold start can outlast the +// retry window above. The exchange waits for readiness first, up to this +// bound, so a backend that never becomes ready still fails it. +const BACKEND_START_TIMEOUT = Duration.minutes(2); const isTransientBearerBootstrapError = (error: RemoteEnvironmentRequestError): boolean => { switch (error._tag) { @@ -84,6 +88,10 @@ export const make = Effect.gen(function* () { const instances = yield* pool.list; const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); + // Returns at once for a ready backend or one that is not running. + if (primary !== undefined) { + yield* primary.waitForReady(BACKEND_START_TIMEOUT); + } const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; if (Option.isNone(configOption)) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); From d06ae6d9f27a8ae646743dea7dccd000e5630bf1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 08:26:39 +0000 Subject: [PATCH 049/143] fix(server): sweep index migration takes a slot no other build has used Published previews ran PeerLinks as migration 61 and open pull requests claim 62 and 63. A database that recorded any of them would skip this change's 61, leaving the limit-recovery sweep's INDEXED BY pointing at a missing index and failing every tick. The sweep index migration now runs as 64. It drops any same-named index first, so databases that ran an earlier build of this change get the payload expression index too. The limit-recovery query no longer names the index: the planner drives it from threads with a failed run through the primary key, and settledOverride is checked after decoding so those payloads are not parsed in SQL. --- .../orchestration-v2/ProjectionStore.test.ts | 11 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 13 ++++---- apps/server/src/persistence/Migrations.ts | 7 ++-- .../Migrations/055_OrchestrationV2.test.ts | 32 ++++++++++++++++--- ...ts => 064_ProjectionThreadSweepIndexes.ts} | 5 ++- .../reconcileV2PreviewMigration.test.ts | 4 +-- 6 files changed, 57 insertions(+), 15 deletions(-) rename apps/server/src/persistence/Migrations/{061_ProjectionThreadSweepIndexes.ts => 064_ProjectionThreadSweepIndexes.ts} (85%) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index b6af6d1404cf5..def51c27e16f3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -32,6 +32,7 @@ import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; +import ProjectionThreadSweepIndexes from "../persistence/Migrations/064_ProjectionThreadSweepIndexes.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -2690,6 +2691,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { now: reset, })).find((row) => row.id === threadId)!; assert.deepEqual(due.limitRecovery, recovery); + // A database can lack the sweep index (its migration id taken by another build); the + // recovery sweep still finds the thread. + yield* sql`DROP INDEX orchestration_v2_projection_threads_active_idx`; + assert.deepEqual( + (yield* store.getLimitRecoveryCandidates({ ...recoveryOptions, now: reset })).find( + (row) => row.id === threadId, + )?.limitRecovery, + recovery, + ); + yield* ProjectionThreadSweepIndexes; yield* sql`INSERT INTO orchestration_v2_projection_runtime_requests (runtime_request_id, thread_id, node_id, kind, status, created_at, payload_json) VALUES ('limit-shell:pending-request', ${threadId}, ${original.rootNodeId}, 'approval', 'pending', ${DateTime.formatIso(now)}, '{}')`; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 557ca2f93936e..f981bfc49b21b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -3506,9 +3506,7 @@ export const layer: Layer.Layer = ORDER BY session.updated_at DESC, session.provider_session_id DESC LIMIT 1 ) AS last_error - -- The sweep index holds settledOverride, so failed threads' payloads are not parsed. FROM orchestration_v2_projection_threads t - INDEXED BY orchestration_v2_projection_threads_active_idx INNER JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest WHERE latest.thread_id = t.thread_id @@ -3530,9 +3528,11 @@ export const layer: Layer.Layer = ORDER BY error.updated_at DESC, error.ordinal DESC, error.turn_item_id DESC LIMIT 1 ) + -- Threads with a failed run drive the scan through their primary key. settledOverride is + -- checked after decoding: here it would parse each of those payloads, though the + -- latest-run join drops most of them. WHERE t.deleted_at IS NULL AND t.archived_at IS NULL - AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled' AND t.thread_id IN ( SELECT thread_id FROM orchestration_v2_projection_runs WHERE status = 'failed' ) @@ -3570,6 +3570,7 @@ export const layer: Layer.Layer = const candidates: Array = []; for (const row of rows) { const thread = yield* decodeThreadPayload(row.payload_json); + if (thread.settledOverride === "settled") continue; const item = yield* decodeTurnItemPayload(row.failure_payload_json); const summary = threadErrorSummary( item.type === "error" ? item.failure : null, @@ -5160,7 +5161,7 @@ export const layer: Layer.Layer = ), ); - // The settlement filters and fork source spell the sweep index's expressions (migration 061) + // The settlement filters and fork source spell the sweep index's expressions (migration 064) // exactly, so a scan of active threads reads them from the index instead of each payload. const selectShellThreadRows = ( threadId?: ThreadId, @@ -5513,7 +5514,7 @@ export const layer: Layer.Layer = LIMIT 1 ) WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} - -- These match the sweep index (migration 061), which holds the values. + -- These match the sweep index (migration 064), which holds the values. AND t.archived_at IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NULL AND json_extract(t.payload_json, '$.pinnedAt') IS NULL @@ -5601,7 +5602,7 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} AND archived_at IS NULL - -- Read from the sweep index (migration 061) rather than each payload. + -- Read from the sweep index (migration 064) rather than each payload. AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC `; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index e22a1f7a7de3d..1499abc1d7e7a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,7 +74,7 @@ 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 Migration0064 from "./Migrations/064_ProjectionThreadSweepIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -149,7 +149,10 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], - [61, "ProjectionThreadSweepIndexes", Migration0061], + // Ids 61-63 are skipped: published previews ran PeerLinks as 61 and open pull requests claim + // 62 and 63. The migrator skips every id at or below a database's highest, so sharing one + // would skip this migration there. + [64, "ProjectionThreadSweepIndexes", Migration0064], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 3b6085b9e6bf5..b8828d0bc2b64 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -9,11 +9,11 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); layer("055_OrchestrationV2", (it) => { - it.effect("keeps released migrations contiguous", () => + it.effect("keeps released migrations contiguous apart from ids claimed elsewhere", () => Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 61 }, (_, index) => index + 1), + [...Array.from({ length: 60 }, (_, index) => index + 1), 64], ); }), ); @@ -32,7 +32,7 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [61, "ProjectionThreadSweepIndexes"], + [64, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -59,7 +59,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, - { migration_id: 61, name: "ProjectionThreadSweepIndexes" }, + { migration_id: 64, name: "ProjectionThreadSweepIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -184,4 +184,28 @@ layer("055_OrchestrationV2", (it) => { assert.deepStrictEqual(yield* sweep, [{ thread_id: "sweep-thread", pull_requests: 3 }]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); + + // Published previews ran PeerLinks as 61. Earlier builds of this change ran 61 and indexed + // copied columns under the same index name. + it.effect.each(["PeerLinks", "ProjectionThreadSweepColumns"])( + "builds the sweep index over a database that ran %s as 61", + (recorded) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 60 }); + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (61, ${recorded})`; + if (recorded === "ProjectionThreadSweepColumns") { + yield* sql` + CREATE INDEX orchestration_v2_projection_threads_active_idx + ON orchestration_v2_projection_threads(updated_at, thread_id) + WHERE deleted_at IS NULL AND archived_at IS NULL + `; + } + assert.deepStrictEqual(yield* runMigrations(), [[64, "ProjectionThreadSweepIndexes"]]); + const [index] = yield* sql<{ readonly sql: string }>` + SELECT sql FROM sqlite_master WHERE name = 'orchestration_v2_projection_threads_active_idx' + `; + assert.include(index!.sql, "json_extract(payload_json, '$.settledOverride')"); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); }); diff --git a/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts b/apps/server/src/persistence/Migrations/064_ProjectionThreadSweepIndexes.ts similarity index 85% rename from apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts rename to apps/server/src/persistence/Migrations/064_ProjectionThreadSweepIndexes.ts index 9abb86143dc1b..940b5ab91c18a 100644 --- a/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts +++ b/apps/server/src/persistence/Migrations/064_ProjectionThreadSweepIndexes.ts @@ -13,8 +13,11 @@ import * as SqlClient from "effect/sql/SqlClient"; */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Development builds of this change created an index of the same name over copied columns. + // Replace it so every database gets this definition. + yield* sql`DROP INDEX IF EXISTS orchestration_v2_projection_threads_active_idx`; yield* sql` - CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_active_idx + CREATE INDEX orchestration_v2_projection_threads_active_idx ON orchestration_v2_projection_threads( updated_at, thread_id, diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index f03bdbd65fdf3..86b4065153375 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,7 +41,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [61, "ProjectionThreadSweepIndexes"], + [64, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -125,7 +125,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [61, "ProjectionThreadSweepIndexes"], + [64, "ProjectionThreadSweepIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From c0a58598e81ca7c3258701387b6b795d13e45351 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 09:34:48 +0000 Subject: [PATCH 050/143] fix(server): sweep index migration runs as 61 so main keeps no gap Taking slot 64 left 61-63 empty on main. Once it ships, upgraded databases record 64, and the migrator skips every id at or below that, so pull requests that claim 61-63 would never run there. The sweep index migration is 61 again. Preview databases that ran PeerLinks as 61 skip it and lose only the index: no query names it with INDEXED BY, so the pull request and usage-limit sweeps still return the same rows. runMigrations now warns with each migration of this build that sits below the database's latest recorded id but never ran, so that skip, and any future collision, shows in the log. --- .../orchestration-v2/ProjectionStore.test.ts | 2 +- .../src/orchestration-v2/ProjectionStore.ts | 6 +- apps/server/src/persistence/Migrations.ts | 21 +- .../Migrations/055_OrchestrationV2.test.ts | 225 ++++++++++++++++-- ...ts => 061_ProjectionThreadSweepIndexes.ts} | 0 .../reconcileV2PreviewMigration.test.ts | 4 +- 6 files changed, 221 insertions(+), 37 deletions(-) rename apps/server/src/persistence/Migrations/{064_ProjectionThreadSweepIndexes.ts => 061_ProjectionThreadSweepIndexes.ts} (100%) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index def51c27e16f3..15bef6433d798 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -32,7 +32,7 @@ import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; -import ProjectionThreadSweepIndexes from "../persistence/Migrations/064_ProjectionThreadSweepIndexes.ts"; +import ProjectionThreadSweepIndexes from "../persistence/Migrations/061_ProjectionThreadSweepIndexes.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index f981bfc49b21b..948258e81b9aa 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5161,7 +5161,7 @@ export const layer: Layer.Layer = ), ); - // The settlement filters and fork source spell the sweep index's expressions (migration 064) + // The settlement filters and fork source spell the sweep index's expressions (migration 061) // exactly, so a scan of active threads reads them from the index instead of each payload. const selectShellThreadRows = ( threadId?: ThreadId, @@ -5514,7 +5514,7 @@ export const layer: Layer.Layer = LIMIT 1 ) WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} - -- These match the sweep index (migration 064), which holds the values. + -- These match the sweep index (migration 061), which holds the values. AND t.archived_at IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NULL AND json_extract(t.payload_json, '$.pinnedAt') IS NULL @@ -5602,7 +5602,7 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} AND archived_at IS NULL - -- Read from the sweep index (migration 064) rather than each payload. + -- Read from the sweep index (migration 061) rather than each payload. AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC `; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1499abc1d7e7a..e0169dab65cb6 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,7 +74,7 @@ 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 Migration0064 from "./Migrations/064_ProjectionThreadSweepIndexes.ts"; +import Migration0061 from "./Migrations/061_ProjectionThreadSweepIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -149,10 +149,9 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], - // Ids 61-63 are skipped: published previews ran PeerLinks as 61 and open pull requests claim - // 62 and 63. The migrator skips every id at or below a database's highest, so sharing one - // would skip this migration there. - [64, "ProjectionThreadSweepIndexes", Migration0064], + // 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], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -226,5 +225,17 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ "Database migration history diverges from this build; recorded migration ids are skipped, not reconciled by name.", ).pipe(Effect.annotateLogs({ divergent })); } + // The migrator never runs an id at or below the latest recorded one, so a migration this + // build adds below another build's highest id never runs here. + const latestRecorded = Math.max(0, ...recorded.map((row) => row.migration_id)); + const recordedKeys = new Set(recorded.map((row) => `${row.migration_id}_${row.name}`)); + const skipped = migrationEntries.flatMap(([id, name]) => + id <= latestRecorded && !recordedKeys.has(`${id}_${name}`) ? [`${id}_${name}`] : [], + ); + if (skipped.length > 0) { + yield* Effect.logWarning( + "Migrations of this build never ran on this database: the migrator skips ids at or below its latest recorded migration.", + ).pipe(Effect.annotateLogs({ skipped })); + } return executedMigrations; }); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index b8828d0bc2b64..bc08cdb9e6d5e 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -1,19 +1,34 @@ import { assert, it } from "@effect/vitest"; +import { + EventId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; import * as SqlClient from "effect/sql/SqlClient"; +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import { migrationEntries, runMigrations } from "../Migrations.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); layer("055_OrchestrationV2", (it) => { - it.effect("keeps released migrations contiguous apart from ids claimed elsewhere", () => + it.effect("keeps released migrations contiguous", () => Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - [...Array.from({ length: 60 }, (_, index) => index + 1), 64], + Array.from({ length: 61 }, (_, index) => index + 1), ); }), ); @@ -32,7 +47,7 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [64, "ProjectionThreadSweepIndexes"], + [61, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -59,7 +74,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, - { migration_id: 64, name: "ProjectionThreadSweepIndexes" }, + { migration_id: 61, name: "ProjectionThreadSweepIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -185,27 +200,185 @@ layer("055_OrchestrationV2", (it) => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); - // Published previews ran PeerLinks as 61. Earlier builds of this change ran 61 and indexed - // copied columns under the same index name. - it.effect.each(["PeerLinks", "ProjectionThreadSweepColumns"])( - "builds the sweep index over a database that ran %s as 61", - (recorded) => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 60 }); - yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (61, ${recorded})`; - if (recorded === "ProjectionThreadSweepColumns") { - yield* sql` - CREATE INDEX orchestration_v2_projection_threads_active_idx - ON orchestration_v2_projection_threads(updated_at, thread_id) - WHERE deleted_at IS NULL AND archived_at IS NULL - `; - } - assert.deepStrictEqual(yield* runMigrations(), [[64, "ProjectionThreadSweepIndexes"]]); - const [index] = yield* sql<{ readonly sql: string }>` - SELECT sql FROM sqlite_master WHERE name = 'orchestration_v2_projection_threads_active_idx' - `; - assert.include(index!.sql, "json_extract(payload_json, '$.settledOverride')"); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + // Development builds of this change indexed copied columns under the same name. + it.effect("replaces a same-named sweep index", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 60 }); + yield* sql` + CREATE INDEX orchestration_v2_projection_threads_active_idx + ON orchestration_v2_projection_threads(updated_at, thread_id) + WHERE deleted_at IS NULL AND archived_at IS NULL + `; + assert.deepStrictEqual(yield* runMigrations(), [[61, "ProjectionThreadSweepIndexes"]]); + const [index] = yield* sql<{ readonly sql: string }>` + SELECT sql FROM sqlite_master WHERE name = 'orchestration_v2_projection_threads_active_idx' + `; + assert.include(index!.sql, "json_extract(payload_json, '$.settledOverride')"); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); + + // Published previews ran PeerLinks as 61, so they never run this build's 61. + it.effect("keeps sweeps correct on a preview database that ran PeerLinks as 61", () => { + const skippedWarnings: Array = []; + const logger = Logger.make(({ fiber, logLevel }) => { + const skipped = fiber.getRef(References.CurrentLogAnnotations).skipped; + if (logLevel === "Warn" && skipped !== undefined) skippedWarnings.push(skipped); + }); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 60 }); + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (61, 'PeerLinks')`; + assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual(skippedWarnings, [["61_ProjectionThreadSweepIndexes"]]); + assert.deepStrictEqual( + yield* sql` + SELECT name FROM sqlite_master + WHERE name IN ( + 'orchestration_v2_projection_threads_active_idx', + 'orchestration_v2_projection_runs_failed_idx' + ) + `, + [], + ); + + // Without the index, the pull request and usage-limit sweeps still find the thread. + const store = yield* ProjectionStore.ProjectionStoreV2; + const at = DateTime.makeUnsafe("2026-10-01T00:00:00.000Z"); + const threadId = ThreadId.make("thread:peer-links"); + const runId = RunId.make("run:peer-links"); + const rootNodeId = NodeId.make("node:peer-links"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; + const driver = ProviderDriverKind.make("codex"); + const thread = { + createdBy: "user" as const, + creationSource: "web" as const, + id: threadId, + projectId: ProjectId.make("project:peer-links"), + title: "Preview thread", + providerInstanceId, + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: at, + updatedAt: at, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + yield* store.apply({ + id: EventId.make("event:peer-links:thread"), + type: "thread.created", + threadId, + occurredAt: at, + payload: thread, + }); + yield* store.apply({ + id: EventId.make("event:peer-links:pull-request"), + type: "thread.pull-request-synced", + threadId, + occurredAt: at, + payload: { + ...thread, + pullRequests: [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent", + linkedAt: DateTime.formatIso(at), + snapshot: null, + stack: null, + }, + ], + }, + }); + yield* store.apply({ + id: EventId.make("event:peer-links:run"), + type: "run.created", + threadId, + runId, + nodeId: rootNodeId, + driver, + providerInstanceId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message:peer-links"), + rootNodeId, + activeAttemptId: null, + status: "failed", + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }); + yield* store.apply({ + id: EventId.make("event:peer-links:limit"), + type: "turn-item.updated", + threadId, + runId, + nodeId: rootNodeId, + driver, + occurredAt: at, + payload: { + id: TurnItemId.make("item:peer-links:limit"), + threadId, + runId, + nodeId: rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: "Usage limit reached", + startedAt: at, + completedAt: at, + updatedAt: at, + type: "error", + failure: { + class: "usage_limit", + message: "Plan limit reached.", + resetAt: "2026-10-02T00:00:00.000Z", + code: "usageLimitExceeded", + retryable: null, + }, + }, + }); + assert.deepStrictEqual( + (yield* store.getThreadsWithPullRequests()).map((row) => row.id), + [threadId], + ); + assert.deepStrictEqual( + (yield* store.getLimitRecoveryCandidates({ now: at, autoResume: true, snooze: false })).map( + (row) => row.id, + ), + [threadId], + ); + }).pipe( + Effect.provide( + ProjectionStore.layer.pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), + Layer.merge(Logger.layer([logger], { mergeWithExisting: false })), + ), + ), + ); + }); }); diff --git a/apps/server/src/persistence/Migrations/064_ProjectionThreadSweepIndexes.ts b/apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts similarity index 100% rename from apps/server/src/persistence/Migrations/064_ProjectionThreadSweepIndexes.ts rename to apps/server/src/persistence/Migrations/061_ProjectionThreadSweepIndexes.ts diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 86b4065153375..f03bdbd65fdf3 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,7 +41,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [64, "ProjectionThreadSweepIndexes"], + [61, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -125,7 +125,7 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], - [64, "ProjectionThreadSweepIndexes"], + [61, "ProjectionThreadSweepIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 6f750e92499c23db4805f0edc360f68aa2969863 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 09:34:48 +0000 Subject: [PATCH 051/143] fix(server): retry the post-turn workspace refresh Moving the VCS and file-list refresh off the thread's effect lane also dropped the effect worker's retry, so one failure left status stale until the next poll. The forked refresh now retries on the same bounded schedule: five attempts, backing off from 100ms. --- .../RunFinalizationService.test.ts | 44 +++++++++++++++++++ .../RunFinalizationService.ts | 3 ++ 2 files changed, 47 insertions(+) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index ab5923c8362eb..b1a9f9f8cfccf 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -8,6 +8,7 @@ import { import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { TestClock } from "effect/testing"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; @@ -66,6 +67,49 @@ it.effect("refreshes workspace after checkpoint capture without reading history" }).pipe(Effect.provide(layer)); }); +it.effect("retries a failed workspace refresh up to five attempts", () => { + const threadId = ThreadId.make("thread_finalize_retry"); + const runId = RunId.make("run_finalize_retry"); + const scopeId = CheckpointScopeId.make("scope_finalize_retry"); + let attempts = 0; + const refresh = () => + Effect.suspend(() => { + attempts += 1; + return Effect.fail( + new RunFinalization.RunFinalizationRefreshError({ cwd: "/repo", cause: "index.lock" }), + ); + }); + const layer = RunFinalization.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.void }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getCheckpointContext: () => + Effect.succeed({ + runs: [], + checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], + checkpoints: [], + }), + }), + Layer.succeed(RunFinalization.RunFinalizationObserver, { + refresh, + refreshAfterTurn: () => Effect.void, + }), + ), + ), + ); + return Effect.gen(function* () { + const service = yield* RunFinalization.RunFinalizationService; + yield* service.finalize({ threadId, runId, scopeId }); + // Steps through the 1.5s of backoff, then shows the retries stop. + for (let step = 0; step < 20; step += 1) { + yield* TestClock.adjust("100 millis"); + } + yield* TestClock.adjust("1 minute"); + assert.equal(attempts, 5); + }).pipe(Effect.provide(layer)); +}); + it.effect.each( ( [ diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 986e168752af1..d582c1829c5b4 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -3,6 +3,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; @@ -78,6 +79,8 @@ const make = Effect.gen(function* () { // file-list refresh only updates client caches, so it runs beside the // thread's effect lane instead of holding the next turn back for seconds. yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }).pipe( + // Retries as the effect worker did when this ran on the lane: five attempts, from 100ms. + Effect.retry({ schedule: Schedule.exponential("100 millis"), times: 4 }), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void From 7c663d7185374df0c91e6a60f9475c4fec936d6c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 09:34:48 +0000 Subject: [PATCH 052/143] fix(server): a link sync retries sibling links that never synced A link sync used to emit the whole thread, and the sync reactor requested every sibling link still missing a snapshot. The compact link-synced event names one link, so a sibling whose first sync failed waited for the minute sweep. The reactor now reads the thread on that event and requests those siblings. --- .../PullRequestSyncReactor.test.ts | 58 +++++++++++++++++++ .../PullRequestSyncReactor.ts | 24 ++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 19e67f949b611..85c90616212df 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1164,4 +1164,62 @@ describe("PullRequestSyncReactor", () => { }), ), ); + + it.effect("retries a link whose first sync failed when a sibling link syncs", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const hostDown = yield* Ref.make(true); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { pullRequests: [makeLink(7, { state: "open" }), makeLink(8)] }), + ]), + summary: (input) => + Ref.get(hostDown).pipe( + Effect.flatMap((down) => + down && input.number === 8 + ? Effect.fail( + new PullRequestOperationError({ operation: "summary", detail: "host down" }), + ) + : Effect.succeed(makeSummary(input)), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set(hostDown, false); + yield* Ref.set(fixture.summaryCalls, []); + yield* Ref.set(fixture.syncCommands, []); + + yield* Queue.offer(fixture.domainEvents, { + type: "thread.pull-request-link-synced", + id: EventId.make("event:one:link-synced"), + threadId: ThreadId.make("one"), + occurredAt: DateTime.makeUnsafe(NOW), + payload: { + host: "github.com", + repository: "owner/repository", + number: 7, + snapshot: makeLink(7, { state: "open" }).snapshot!, + stack: null, + }, + }); + // The thread's lookup, then the requested sweep. + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), + [8], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), + [8], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index d1537e30899aa..43b5e1f449370 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -423,6 +423,12 @@ export const make = Effect.gen(function* () { ), Effect.catchCause(logSkipped("pull request refresh after run skipped", { threadId })), ); + const requestUnsyncedLinks = (links: ReadonlyArray) => + Effect.forEach( + visibleThreadPullRequests(links).filter((link) => link.snapshot === null), + requestSync, + { discard: true }, + ); const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( "PullRequestSyncReactor.start", @@ -443,12 +449,20 @@ export const make = Effect.gen(function* () { Stream.runForEach(events, (event) => { switch (event.type) { case "thread.pull-request-synced": - return Effect.forEach( - visibleThreadPullRequests(event.payload.pullRequests ?? []).filter( - (link) => link.snapshot === null, + return requestUnsyncedLinks(event.payload.pullRequests ?? []); + // Names only the synced link, so read the thread for siblings whose first sync failed. + case "thread.pull-request-link-synced": + return projections.getThreadsWithPullRequests(event.threadId).pipe( + Effect.flatMap((threads) => + Effect.forEach( + threads, + (thread) => requestUnsyncedLinks(thread.pullRequests ?? []), + { discard: true }, + ), + ), + Effect.catchCause( + logSkipped("pull request sibling sync skipped", { threadId: event.threadId }), ), - requestSync, - { discard: true }, ); // An agent can merge or close its pull request from a shell (`gh pr merge`), which // sends no merge notification. When a run that ran such a command ends, read the From 209eaf9c6e2ab9d8cfe298b55fdba2bc123e937b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 09:22:22 +0000 Subject: [PATCH 053/143] fix(desktop): a window closed during a cold start stays closed at backend readiness The main window now opens while the local backend starts. On macOS the app keeps running after its last window closes, so closing the window during a cold start used to bring it back once the backend became ready. Readiness now leaves it closed; activating the app (dock click) reopens it as before. --- apps/desktop/src/window/DesktopWindow.test.ts | 33 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 15 ++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 2dfe86dc53b1c..76066f4bb26da 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -671,6 +671,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("keeps a window closed during a cold start closed until activation", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + const readyUrl = new URL("http://127.0.0.1:3773"); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const closeMain = Effect.suspend(() => { + const closed = fakeWindow.windowListeners.get("closed"); + return closed ? Effect.sync(() => closed()) : Effect.die("closed listener missing"); + }); + + yield* desktopWindow.ensureMain; + yield* closeMain; + yield* desktopWindow.handleBackendReady(readyUrl); + assert.equal(yield* Ref.get(createCount), 1); + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + + // A dock click while the backend restarts reopens the window at readiness. + yield* desktopWindow.handleBackendNotReady; + yield* closeMain; + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* desktopWindow.handleBackendReady(readyUrl); + assert.equal(yield* Ref.get(createCount), 3); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 7c82f6df4adb9..5a94d9d5bea81 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -341,6 +341,9 @@ export const make = Effect.gen(function* () { // open in development and the macOS "activate without windows" path. const backendReadyRef = yield* Ref.make(false); const mainWindowCreation = yield* Semaphore.make(1); + // Set when the main window closes while the backend starts, so readiness + // leaves it closed. macOS keeps the app running; activation reopens it. + const mainClosedBeforeReadyRef = yield* Ref.make(false); // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); @@ -862,7 +865,14 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); clearBoundsPersist(); - void runPromise(electronWindow.clearMain(Option.some(window))); + void runPromise( + electronWindow.clearMain(Option.some(window)).pipe( + Effect.andThen(waitingForBackend), + Effect.flatMap((waiting) => + waiting ? Ref.set(mainClosedBeforeReadyRef, true) : Effect.void, + ), + ), + ); }); return window; @@ -993,6 +1003,8 @@ export const make = Effect.gen(function* () { // back instead of doing nothing. Once the backend is ready we fall // through to (re)create the real main -- including retrying a previously // failed open the pool swallowed -- rather than latching onto the splash. + // Asking for the window also undoes an earlier close, so readiness opens it. + yield* Ref.set(mainClosedBeforeReadyRef, false); const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) { const splash = yield* Ref.get(splashWindowRef); @@ -1008,6 +1020,7 @@ export const make = Effect.gen(function* () { handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); + if (yield* Ref.getAndSet(mainClosedBeforeReadyRef, false)) return; yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( From bb6d69a9a6ce02f67f0780180382b0be59a6675c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 09:22:22 +0000 Subject: [PATCH 054/143] fix(desktop): a failed early window open no longer quits the app Opening the main window while the backend starts made a window-creation error fatal at startup. It is logged again, and backend readiness retries the open, as it did when the window waited for readiness. --- apps/desktop/src/app/DesktopApp.ts | 11 +++++++++-- apps/desktop/src/window/DesktopWindow.test.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 623e2d753387f..e35c39330a4d9 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -256,9 +256,16 @@ const bootstrap = Effect.gen(function* () { // The renderer loads bundled assets and can connect while the backend // boots. Starting first makes its endpoint and bootstrap credential // available before the preload reads them. WSL-only retains its splash - // until the primary has a usable endpoint. + // until the primary has a usable endpoint. A failed open is not fatal: + // backend readiness tries again. if (!(settings.wslOnly === true && settings.wslBackendEnabled === true)) { - yield* desktopWindow.ensureMain; + yield* desktopWindow.ensureMain.pipe( + Effect.catch((error) => + logBootstrapWarning("failed to open main window while the backend starts", { + error: error.message, + }), + ), + ); } yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 76066f4bb26da..8fe94edc269f2 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -704,6 +704,23 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens the main window at readiness when the early open failed", () => + Effect.gen(function* () { + const main = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([null, main.window]); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const earlyExit = yield* Effect.exit(desktopWindow.ensureMain); + assert.equal(earlyExit._tag, "Failure"); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(scenario.createCalls), 2); + assert.equal(Option.getOrThrow(yield* Ref.get(scenario.mainWindow)), main.window); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => From aaf4ac73205b16efa60be4cfc8ec79c042222b95 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:13:49 +0000 Subject: [PATCH 055/143] fix(desktop): quitting during a cold backend start no longer shows a main-process error The backend's input pipes (bootstrap fd 3, telemetry fd 4, browser events fd 6) only had an error listener while Effect's spawner was writing to them. A backend killed before it read its telemetry reset fd 4, and the read ECONNRESET became an uncaught exception: Electron's modal error dialog, which blocks the quit on Linux. Patch @effect/platform-node-shared so an input fd keeps a listener for the pipe's lifetime; write errors still reach the running sink. --- .../src/backend/DesktopBackendManager.test.ts | 32 +++++++++++++++++++ .../@effect__platform-node-shared@4.0.2.patch | 15 +++++++++ pnpm-lock.yaml | 7 ++-- pnpm-workspace.yaml | 3 ++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 patches/@effect__platform-node-shared@4.0.2.patch diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 24303aa66997c..faee379c65e59 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -3,6 +3,8 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, DesktopTelemetryControlMessage, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -570,6 +572,36 @@ describe("DesktopBackendManager", () => { ), ); + // POSIX socketpairs reset when the reader exits with unread data; Windows uses named pipes. + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "survives a backend that exits with unread bootstrap input", + () => + Effect.scoped( + Effect.gen(function* () { + const uncaught: Array = []; + const onUncaught = (error: unknown) => { + uncaught.push(error); + }; + yield* Effect.acquireRelease( + Effect.sync(() => process.on("uncaughtException", onUncaught)), + () => Effect.sync(() => process.off("uncaughtException", onUncaught)), + ); + + // Reads one byte of the bootstrap line and exits, so its pipe resets. + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + executablePath: process.execPath, + args: ["-e", 'require("node:fs").readSync(3, Buffer.alloc(1)); process.exit(0)'], + cwd: process.cwd(), + desktopTelemetryStream: Stream.empty, + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, layerHealthyHttpClient))); + + assert.equal(exit.code.pipe(Option.getOrUndefined), 0); + assert.deepEqual(uncaught, []); + }), + ), + ); + it.effect("continues routing desktop telemetry control messages after an invalid line", () => Effect.scoped( Effect.gen(function* () { diff --git a/patches/@effect__platform-node-shared@4.0.2.patch b/patches/@effect__platform-node-shared@4.0.2.patch new file mode 100644 index 0000000000000..98dff40c04bba --- /dev/null +++ b/patches/@effect__platform-node-shared@4.0.2.patch @@ -0,0 +1,15 @@ +diff --git a/dist/NodeChildProcessSpawner.js b/dist/NodeChildProcessSpawner.js +index b3e4dea..d6379fa 100644 +--- a/dist/NodeChildProcessSpawner.js ++++ b/dist/NodeChildProcessSpawner.js +@@ -170,6 +170,10 @@ const make = /*#__PURE__*/Effect.gen(function* () { + // Create a sink to write to for input file descriptors + let sink = Sink.drain; + if (nodeStream && "write" in nodeStream) { ++ // The sink only listens for errors while it writes, but the pipe ++ // outlives it: a child that exits with unread input resets it ++ // (ECONNRESET), which must not become an uncaught exception. ++ nodeStream.on("error", () => {}); + sink = NodeSink.fromWritable({ + evaluate: () => nodeStream, + onError: error => toPlatformError(`fromWritable(fd${fd})`, toError(error), command) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e44380dde31e..91d47694eec8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,7 @@ packageExtensionsChecksum: sha256-FYDaKefjFcda1+vgiq8yMpZC4ehE5CyLi/bsCGLgTtY= patchedDependencies: '@clerk/expo@4.6.8': b8b22ab733de5bea846ad9e88046c10582352fe9ad86f314f368b2a823f0f281 + '@effect/platform-node-shared@4.0.2': deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535 '@effect/vitest@4.0.2': 595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9 '@expo/metro-config@58.0.6': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 @@ -611,7 +612,7 @@ importers: version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.2 - version: 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) + version: 4.0.2(patch_hash=deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8) @@ -13778,7 +13779,7 @@ snapshots: transitivePeerDependencies: - encoding - '@effect/platform-node-shared@4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.2(patch_hash=deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.2 effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) @@ -13789,7 +13790,7 @@ snapshots: '@effect/platform-node@4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(redis@6.2.1)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.2(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) + '@effect/platform-node-shared': 4.0.2(patch_hash=deaf42ac8518671bcaba11355411f3dced41d7aeca6778e96ce5982773639535)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(utf-8-validate@6.0.6) effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) redis: 6.2.1 undici: 8.11.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 59358907b7c4b..0c0c52c20814f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -288,6 +288,9 @@ patchedDependencies: expo-blur@58.0.3: patches/expo-blur@58.0.3.patch expo-audio@58.0.4: patches/expo-audio@58.0.4.patch "@clerk/expo@4.6.8": patches/@clerk__expo@4.6.8.patch + # An input fd pipe keeps an error listener after its writer stops, so a child that exits with + # unread input (the desktop backend quit during a cold start) does not crash the parent. + "@effect/platform-node-shared@4.0.2": patches/@effect__platform-node-shared@4.0.2.patch "@effect/vitest@4.0.2": patches/@effect__vitest@4.0.2.patch "@expo/metro-config@58.0.6": patches/@expo__metro-config@58.0.6.patch expo-sharing@58.0.13: patches/expo-sharing@58.0.13.patch From 6c028a4c0d0333b47a6e15ba4a04023322eeade3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:41 +0000 Subject: [PATCH 056/143] fix(web): keys typed while the palette loads go to its query The first Ctrl+K made the app inert at once, but the palette only counted as open once its lazy dialog mounted. Until then the chat view's type-to-focus put typed keys into the persisted draft. The host now marks the palette open as soon as it is requested, keeps keys typed during the load as the palette's query, and preloads the chunk once the app is idle after boot. A chunk that fails to load closes the palette instead of replacing the app with the route error view; a stale deploy still gets its reload first, and the next open requests the chunk again. --- apps/web/src/commandPaletteBus.ts | 11 +- .../web/src/components/CommandPaletteHost.tsx | 113 +++++++++++++++--- 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index d72602172e35c..380b9ee28a594 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -34,9 +34,18 @@ export function onOpenCommandPalette( /** Element id of the mounted palette popup. An id lookup is O(1); keydown handlers call this per key. */ export const COMMAND_PALETTE_ELEMENT_ID = "t3-command-palette"; +let commandPaletteRequested = false; + +/** The palette host marks an open as soon as it is requested, before the dialog's chunk loads. */ +export function setCommandPaletteRequested(requested: boolean): void { + commandPaletteRequested = requested; +} + /** Read at event time so consumers do not subscribe to transient dialog state. */ export function isCommandPaletteOpen(): boolean { return ( - typeof document !== "undefined" && document.getElementById(COMMAND_PALETTE_ELEMENT_ID) !== null + commandPaletteRequested || + (typeof document !== "undefined" && + document.getElementById(COMMAND_PALETTE_ELEMENT_ID) !== null) ); } diff --git a/apps/web/src/components/CommandPaletteHost.tsx b/apps/web/src/components/CommandPaletteHost.tsx index ba93f64d1e781..964944f2348bf 100644 --- a/apps/web/src/components/CommandPaletteHost.tsx +++ b/apps/web/src/components/CommandPaletteHost.tsx @@ -6,13 +6,18 @@ import { Suspense, useCallback, useEffect, + useLayoutEffect, useReducer, useRef, useState, type ReactNode, } from "react"; -import { onOpenCommandPalette } from "../commandPaletteBus"; +import { + COMMAND_PALETTE_ELEMENT_ID, + onOpenCommandPalette, + setCommandPaletteRequested, +} from "../commandPaletteBus"; import { ComposerHandleContext } from "../composerHandleContext"; import { useTheme } from "../hooks/useTheme"; import { resolveShortcutCommand } from "../keybindings"; @@ -25,14 +30,25 @@ import { resolveThreadRouteTarget } from "../threadRoutes"; import type { ChatComposerHandle } from "./chat/ChatComposer"; import { reduceCommandPaletteUiState, type SearchOverlayMode } from "./CommandPalette.logic"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { CommandDialog } from "./ui/command"; import { stackedThreadToast, toastManager } from "./ui/toast"; // The palette's views pull in project browsing, search, and syntax highlighting. -// Only the shortcut handling stays in the startup graph; the dialog loads on first open. -const CommandPaletteDialog = lazy(() => - import("./CommandPalette").then((module) => ({ default: module.CommandPaletteDialog })), -); +// Only the shortcut handling stays in the startup graph; the dialog loads on first +// open, or once the app is idle after boot. +const loadCommandPalette = () => import("./CommandPalette"); +const lazyCommandPaletteDialog = () => + lazy(() => loadCommandPalette().then((module) => ({ default: module.CommandPaletteDialog }))); +const COMMAND_PALETTE_PRELOAD_DELAY_MS = 3_000; + +// A failed load closes the palette instead of replacing the app with the route +// error view. A stale deploy gets its `vite:preloadError` reload first; otherwise +// the next open requests the chunk again. +function CommandPaletteUnavailable({ onUnavailable }: { onUnavailable: () => void }) { + useEffect(() => onUnavailable(), [onUnavailable]); + return null; +} const OVERLAY_MODE_BY_COMMAND = { "commandPalette.toggle": "command", @@ -71,9 +87,17 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { openIntent: null, }); // Stays mounted after the first open so closing still animates out. - const [dialogMounted, setDialogMounted] = useState(false); - if (state.open && !dialogMounted) setDialogMounted(true); + const [CommandPaletteDialog, setCommandPaletteDialog] = useState | null>(null); + if (state.open && CommandPaletteDialog === null) { + setCommandPaletteDialog(() => lazyCommandPaletteDialog()); + } const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); + const closeUnavailablePalette = useCallback(() => { + setCommandPaletteDialog(null); + setOpen(false); + }, [setOpen]); const toggleMode = useCallback( (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), [], @@ -100,6 +124,57 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { : false, ); + // Keyboard handlers outside the palette ignore keys from the moment it is + // requested, including while its chunk loads. + useLayoutEffect(() => { + setCommandPaletteRequested(state.open); + return () => setCommandPaletteRequested(false); + }, [state.open]); + + useEffect(() => { + let idleCallback: number | null = null; + const timeout = window.setTimeout(() => { + const preload = () => void loadCommandPalette().catch(() => undefined); + if (typeof window.requestIdleCallback === "function") { + idleCallback = window.requestIdleCallback(preload); + } else { + preload(); + } + }, COMMAND_PALETTE_PRELOAD_DELAY_MS); + return () => { + window.clearTimeout(timeout); + if (idleCallback !== null) window.cancelIdleCallback(idleCallback); + }; + }, []); + + // While the chunk loads, the app behind the palette is inert and nothing has + // focus. Text typed then becomes the palette's query, and Escape closes it. + const loadingQuery = + state.openIntent === null + ? "" + : state.openIntent.kind === "search" + ? state.openIntent.query + : null; + useLayoutEffect(() => { + if (!state.open || state.mode !== "command" || loadingQuery === null) return; + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (document.getElementById(COMMAND_PALETTE_ELEMENT_ID) !== null) return; + if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key !== "Escape" && event.key !== "Backspace" && event.key.length !== 1) return; + event.preventDefault(); + event.stopPropagation(); + if (event.key === "Escape") { + setOpen(false); + return; + } + const query = + event.key === "Backspace" ? loadingQuery.slice(0, -1) : loadingQuery + event.key; + dispatch({ _tag: "OpenSearch", query }); + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [loadingQuery, setOpen, state.mode, state.open]); + useEffect(() => { if (!state.open || state.mode === "command") return; const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { @@ -228,16 +303,20 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) {

{children}
- {dialogMounted ? ( - - - + {CommandPaletteDialog ? ( + } + > + + + + ) : null} From 121afb01fe7f26e858dc82fa7ab612b192bb07af Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:41 +0000 Subject: [PATCH 057/143] fix(web): lazy sidebars keep their header while loading or after a failed load The legacy sidebar rendered an empty column until its chunk arrived, and a failed sidebar or settings nav chunk showed the route error view over the whole app. The legacy sidebar now shows the sidebar header while it loads or if its chunk fails, a failed settings nav renders nothing, and the settings route loads the nav with its own chunks instead of after its first paint. --- apps/web/src/components/AppSidebarLayout.tsx | 25 +++++++++++++++----- apps/web/src/routes/settings.tsx | 7 ++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 18299537eb706..43930af3a4227 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -6,6 +6,7 @@ import { useEffect, useState, useSyncExternalStore, + type ComponentType, type CSSProperties, type ReactNode, } from "react"; @@ -58,11 +59,23 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; // Only the sidebar the legacy setting picks should be in the startup graph. -const LegacyThreadSidebar = lazy(() => import("./LegacySidebar")); -const SettingsSidebarNav = lazy(() => - import("./settings/SettingsSidebarNav").then((module) => ({ - default: module.SettingsSidebarNav, - })), +// While a sidebar chunk loads, or if it fails, the column keeps its chrome +// header; a stale deploy gets its `vite:preloadError` reload first. +function SidebarChromeShell() { + return ; +} +const renderNoSettingsNav = (_props: { pathname: string }) => null; +const LegacyThreadSidebar = lazy(() => + import("./LegacySidebar") + .then((module) => ({ default: module?.default ?? SidebarChromeShell })) + .catch(() => ({ default: SidebarChromeShell })), +); +/** The settings route loads the nav alongside its own chunks instead of after its first paint. */ +export const loadSettingsSidebarNav = () => import("./settings/SettingsSidebarNav"); +const SettingsSidebarNav = lazy>(() => + loadSettingsSidebarNav() + .then((module) => ({ default: module?.SettingsSidebarNav ?? renderNoSettingsNav })) + .catch(() => ({ default: renderNoSettingsNav })), ); const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; @@ -346,7 +359,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ) : legacySidebarEnabled ? ( - + }> ) : ( diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 576307fbe7add..ae7baed8f00b4 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -5,6 +5,7 @@ import { Button } from "../components/ui/button"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; +import { loadSettingsSidebarNav } from "../components/AppSidebarLayout"; import { SidebarInset } from "../components/ui/sidebar"; import { useNavigateToMainApp } from "../components/sidebar/mainAppLocation"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; @@ -189,5 +190,11 @@ export const Route = createFileRoute("/settings")({ throw redirect({ to: "/settings/general", replace: true }); } }, + // The app sidebar renders the settings nav lazily. A failed load only drops the nav. + loader: () => + loadSettingsSidebarNav().then( + () => undefined, + () => undefined, + ), component: SettingsRouteLayout, }); From 1a4faf06aa5a047300f669fa6791fe1baa4a9849 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:41 +0000 Subject: [PATCH 058/143] perf(web): settings, usage and welcome cold loads skip chat-only code A module shared by the chat graph and another page went to the chat chunk, so /settings/*, /usage and /welcome, and the root notices on every page, downloaded all of chat-*.js. Chat modules that other pages or the shell's lazy parts also import now go to one chunk per set of those users, and small sets join main. Cold load (brotli wire bytes before first paint, 3 runs, identical each run): /settings/general 1,511,697 -> 1,308,834, /welcome 1,378,703 -> 867,958, /usage 1,314,594 -> 806,396; / 1,356,465 -> 1,371,447 with 3 more requests. --- apps/web/vite/startupChunks.ts | 121 ++++++++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 17 deletions(-) diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts index d96bebeedeb18..5131fff72a154 100644 --- a/apps/web/vite/startupChunks.ts +++ b/apps/web/vite/startupChunks.ts @@ -21,13 +21,22 @@ const STARTUP_MODULES = { * both arrive as hundreds of small shared chunks. * * Listing main and the startup routes as build inputs lets Rolldown tag their - * static graphs `$initial` after tree shaking. Shared modules belong to the - * first startup graph that imports them, so settings and pairing do not download - * chat code. index.html preloads main's static graph; the router preloads the - * initial location's route chunks alongside authentication. + * static graphs `$initial` after tree shaking. A module belongs to the first + * startup graph that imports it. Chat modules that other pages (settings, usage, + * welcome) or the shell's lazy parts (the root notices, the command palette) + * also import move into one chunk per set of those users. A settings or welcome + * cold open then downloads only the chat code it uses, and a chat cold open + * fetches those few extra chunks in the same wave. index.html preloads main's + * static graph; the router preloads the initial location's route chunks + * alongside authentication. * The bootstrap entry keeps its own modules, so it never statically imports * a startup chunk and can still report a failed load. */ +// Transformed code size below which a set of shared chat modules joins main. +const MAIN_SHARED_SET_SIZE = 100_000; +const sharedChunkName = (owner: string | undefined) => + owner === undefined || owner in STARTUP_MODULES ? null : owner; + export function startupChunksPlugin(): Plugin { let root = process.cwd(); let base = "/"; @@ -62,16 +71,30 @@ export function startupChunksPlugin(): Plugin { codeSplitting: { // Keep each graph whole: size splitting creates chunk cycles inside Effect's // eagerly initialized runtime and can run consumers before their dependencies. - groups: Object.keys(STARTUP_MODULES).map((name) => ({ - name: `startup-${name}`, - tags: ["$initial"], - test: (id: string) => - !id.startsWith("\0") && startupModuleOwners.get(normalizePath(id)) === name, - // Dynamic settings/theme entries must not fragment the startup graphs. - entriesAware: false, - // Otherwise excluded bootstrap helpers are recaptured through main's imports. - includeDependenciesRecursively: false, - })), + groups: [ + ...Object.keys(STARTUP_MODULES).map((name) => ({ + name: `startup-${name}`, + tags: ["$initial" as const], + test: (id: string) => + !id.startsWith("\0") && startupModuleOwners.get(normalizePath(id)) === name, + // Dynamic settings/theme entries must not fragment the startup graphs. + entriesAware: false, + // Otherwise excluded bootstrap helpers are recaptured through main's imports. + includeDependenciesRecursively: false, + })), + { + // The chat modules other pages also render; see buildEnd. + name: (id: string) => + sharedChunkName(startupModuleOwners.get(normalizePath(id))), + debugName: "startup-shared", + tags: ["$initial" as const], + test: (id: string) => + !id.startsWith("\0") && + sharedChunkName(startupModuleOwners.get(normalizePath(id))) !== null, + entriesAware: false, + includeDependenciesRecursively: false, + }, + ], }, }, }, @@ -84,10 +107,9 @@ export function startupChunksPlugin(): Plugin { buildEnd(error) { if (error) return; const moduleIds = new Map([...this.getModuleIds()].map((id) => [normalizePath(id), id])); - const staticClosure = (entryId: string) => { + const staticClosure = (...entryIds: ReadonlyArray) => { const closure = new Set(); - const entry = moduleIds.get(entryId); - const pending = entry === undefined ? [] : [entry]; + const pending = entryIds.flatMap((entryId) => moduleIds.get(entryId) ?? []); for (let id = pending.pop(); id !== undefined; id = pending.pop()) { const normalizedId = normalizePath(id); if (closure.has(normalizedId)) continue; @@ -103,6 +125,71 @@ export function startupChunksPlugin(): Plugin { if (!bootstrapModules.has(id) && !owners.has(id)) owners.set(id, name); } } + const codeLength = (id: string) => + this.getModuleInfo(moduleIds.get(id) ?? id)?.code?.length ?? 0; + // Other pages and the app shell also load chat modules: route pages outside + // the chat layout, grouped by family since settings.general renders inside + // settings, and the shell's lazy parts, such as the root notices. + const startupEntries = new Set( + Object.values(STARTUP_MODULES).map((entry) => normalizePath(NodePath.join(root, entry))), + ); + const routesDir = normalizePath(NodePath.join(root, "src/routes/")); + const families = new Map>(); + for (const id of staticClosure(normalizePath(NodePath.join(root, STARTUP_MODULES.main)))) { + for (const lazyId of this.getModuleInfo(moduleIds.get(id) ?? id)?.dynamicallyImportedIds ?? + []) { + const entry = normalizePath(lazyId); + const route = entry.startsWith(routesDir) + ? entry.slice(routesDir.length).split(/[.?]/)[0] + : undefined; + // `_` routes render inside the chat layout. + if (startupEntries.has(entry) || route?.startsWith("_")) continue; + const family = route ?? entry; + families.set(family, [...(families.get(family) ?? []), entry]); + } + } + const familyGraphs = [...families].map( + ([family, entries]) => + [NodePath.basename(family).split(".")[0], staticClosure(...entries)] as const, + ); + // Each set of families that uses a chat module gets one chunk, so a page or + // lazy part downloads only the chat code it uses. A set's chunk imports only + // chunks of its supersets, which every family in it also needs. + const setSizes = new Map(); + for (const [id, owner] of owners) { + if (owner === "main") continue; + const users = familyGraphs.filter(([, graph]) => graph.has(id)).map(([family]) => family); + if (users.length === 0) continue; + const set = `${[...new Set(users)].sort().join("-")}-shared`; + owners.set(id, set); + setSizes.set(set, (setSizes.get(set) ?? 0) + codeLength(id)); + } + // Small sets join main instead of adding requests to every chat cold open. + // A module moves only when everything it imports is in main or moves too, + // so main never imports a shared chunk. + const toMain = new Set( + [...owners].flatMap(([id, owner]) => + (setSizes.get(owner) ?? Infinity) < MAIN_SHARED_SET_SIZE ? [id] : [], + ), + ); + for (let changed = true; changed;) { + changed = false; + for (const id of toMain) { + const imports = this.getModuleInfo(moduleIds.get(id) ?? id)?.importedIds ?? []; + if ( + imports.some((dependency) => { + const owner = owners.get(normalizePath(dependency)); + return ( + owner !== undefined && owner !== "main" && !toMain.has(normalizePath(dependency)) + ); + }) + ) { + toMain.delete(id); + changed = true; + } + } + } + for (const id of toMain) owners.set(id, "main"); startupModuleOwners = owners; }, generateBundle: { From d1373eb609170d9719513b914feca9d48f45949c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:42 +0000 Subject: [PATCH 059/143] fix(web): leaving an anchored reply remembers the reading position Live follow stays on while a sent message's reply is anchored below it, so a scroll during that time recorded the thread as at the end and a revisit landed at the bottom. Only a reader who follows the live edge is now remembered at the end; layout changes during live follow still cannot strand a revisit at a transient offset. --- apps/web/src/components/ChatView.tsx | 5 +++++ apps/web/src/components/chat/MessagesTimeline.tsx | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4d416dad2bf69..e92d3349125a2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6412,6 +6412,10 @@ export default function ChatView(props: ChatViewProps) { // re-pins on its own (independent of the refs), so the timeline needs a // render-visible flag to switch it off once the user scrolls away. const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); + const isTimelineFollowingEnd = useCallback( + () => timelineScrollModeRef.current === "following-end", + [], + ); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -11597,6 +11601,7 @@ export default function ChatView(props: ChatViewProps) { onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + isFollowingEnd={isTimelineFollowingEnd} onIsAtEndChange={onIsAtEndChange} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b6270345923ec..ef0bb61ee09cf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -514,6 +514,11 @@ interface MessagesTimelineProps { * scroll-mode refs whenever the user drifts near the bottom. */ liveFollowEnabled: boolean; + /** + * Whether the reader follows the live edge, as opposed to reading a new + * turn anchored at the top or history. Read when the scroll position is remembered. + */ + isFollowingEnd?: () => boolean; /** * Whether the real rows extend past the viewport above the composer. * Reported after scrolls, row size changes, and viewport resizes. @@ -614,6 +619,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onIsAtEndChange, onContentOverflowChange, liveFollowEnabled, + isFollowingEnd, paintOnly = false, onToolOutputCollapsedAtEnd, onManualNavigation, @@ -1186,8 +1192,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ // Live follow only ends on a user scroll gesture. Layout changes // (the composer inset, a thread switch) can still move the list off // the end without the reader leaving it, and a revisit must land - // at the end then rather than at that transient offset. - atEnd: isAtEnd || liveFollowEnabled, + // at the end then rather than at that transient offset. A reply + // anchored below its message keeps the reader where they are. + atEnd: isAtEnd || (liveFollowEnabled && (isFollowingEnd?.() ?? true)), disclosures: { runs: paintedExpandedRunIds, workGroups: paintedExpandedWorkGroupIds, @@ -1247,6 +1254,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ rows, listIdentityKey, liveFollowEnabled, + isFollowingEnd, paintOnly, restoringThreadPosition, listRef, From c7fee2a526cb358ef19bc8311ea50bca1aa11efc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:42 +0000 Subject: [PATCH 060/143] fix(web): a failed rehype-raw load stays on the sanitized fallback Each failure cleared the cached promise, so every later render of a raw-HTML message suspended on a new promise and imported again, though Chromium keeps the failed import. The failed load now stays cached and messages render without their HTML. --- apps/web/src/components/ChatMarkdown.tsx | 11 ++++++----- packages/shared/src/markdownPipeline.test.ts | 19 ++++++++++++++++++- packages/shared/src/markdownPipeline.ts | 13 +++++-------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 340465d8c65a6..99ebaea7ed850 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1887,12 +1887,13 @@ function withRawHtml(rehypeRaw: NonNullable>) } // rehype-raw brings parse5, so it loads with the first message that has a tag. +// After a failed load, messages keep rendering without their HTML: `use()` needs +// this same settled promise on every render, and the failed import stays failed. function loadRawHtmlRehypePlugins(): Promise { - rawHtmlRehypePluginsPromise ??= loadRehypeRaw().then(withRawHtml, () => { - // Retry on the next render; meanwhile the message renders without its HTML. - rawHtmlRehypePluginsPromise = null; - return CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS; - }); + rawHtmlRehypePluginsPromise ??= loadRehypeRaw().then( + withRawHtml, + () => CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS, + ); return rawHtmlRehypePluginsPromise; } diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index b54e6ea0c99d9..073c67758beb4 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; @@ -119,3 +119,20 @@ describe("raw HTML detection", () => { ); }); }); + +describe("loadRehypeRaw", () => { + it("keeps a failed load so every render waits on the same settled promise", async () => { + vi.resetModules(); + vi.doMock("rehype-raw", () => { + throw new Error("chunk failed"); + }); + try { + const { loadRehypeRaw } = await import("./markdownPipeline.ts"); + const failed = loadRehypeRaw(); + await expect(failed).rejects.toThrow(); + expect(loadRehypeRaw()).toBe(failed); + } finally { + vi.doUnmock("rehype-raw"); + } + }); +}); diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 8ae1ffdb6a101..61c503b1ee2cb 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -206,15 +206,12 @@ export function chatMarkdownRehypePlugins(rawHtml: Pluggable | null): PluggableL let rehypeRaw: Pluggable | undefined; let rehypeRawPromise: Promise | undefined; -/** Loads rehype-raw for clients that keep parse5 out of their startup bundle. */ +/** + * Loads rehype-raw for clients that keep parse5 out of their startup bundle. A failed + * load stays cached: browsers cache a failed dynamic import, so a retry cannot succeed. + */ export function loadRehypeRaw(): Promise { - rehypeRawPromise ??= import("rehype-raw").then( - (module) => (rehypeRaw = module.default), - (error: unknown) => { - rehypeRawPromise = undefined; - throw error; - }, - ); + rehypeRawPromise ??= import("rehype-raw").then((module) => (rehypeRaw = module.default)); return rehypeRawPromise; } From 76c7d1dc0c152c3dda9623f828a52ba71314a3a2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:42 +0000 Subject: [PATCH 061/143] fix(web): each new build gets its own chunk reloads The 20-reload cap lasted for the tab's lifetime, so a long-lived tab stopped reloading after 20 deploy failures. The guard now keeps only the current build's failures and resets the cap when the build changes. --- apps/web/src/lib/chunkReloadGuard.test.ts | 80 +++++++++++++++-------- apps/web/src/lib/chunkReloadGuard.ts | 27 ++++++-- apps/web/src/main.tsx | 9 ++- 3 files changed, 77 insertions(+), 39 deletions(-) diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 966b67073349c..7816d77740ccf 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -20,27 +20,28 @@ function createStorageStub(): Storage { }; } +const BUILD_A = "https://app.t3.codes/assets/index-A.js"; const BUILD_B = "https://app.t3.codes/assets/index-B.js"; const failedImport = (asset: string) => - `${BUILD_B} TypeError: Failed to fetch dynamically imported module: https://app.t3.codes/assets/${asset}`; + `TypeError: Failed to fetch dynamically imported module: https://app.t3.codes/assets/${asset}`; describe("reloadOnceForChunkLoadError", () => { it("reloads once for a chunk that fails on every boot, then lets it surface", () => { const storage = createStorageStub(); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), () => storage, reload)).toBe( - true, - ); + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), () => storage, reload), + ).toBe(true); expect(reload).toHaveBeenCalledTimes(1); // Every later boot requests the same failing chunk during its first render. - expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), () => storage, reload)).toBe( - false, - ); - expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), () => storage, reload)).toBe( - false, - ); + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), () => storage, reload), + ).toBe(false); + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), () => storage, reload), + ).toBe(false); expect(reload).toHaveBeenCalledTimes(1); }); @@ -51,45 +52,70 @@ describe("reloadOnceForChunkLoadError", () => { // Deploy B fails a chunk of the open build, then deploy C, seconds later, // fails a chunk of the build B reload loaded. expect( - reloadOnceForChunkLoadError( - "https://app.t3.codes/assets/index-A.js TypeError: Failed to fetch dynamically imported module: https://app.t3.codes/assets/settings-a.js", - () => storage, - reload, - ), + reloadOnceForChunkLoadError(BUILD_A, failedImport("settings-a.js"), () => storage, reload), + ).toBe(true); + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("diff-b.js"), () => storage, reload), ).toBe(true); - expect(reloadOnceForChunkLoadError(failedImport("diff-b.js"), () => storage, reload)).toBe( - true, - ); expect(reload).toHaveBeenCalledTimes(2); // A chunk of build B that keeps failing still stops after its one reload. - expect(reloadOnceForChunkLoadError(failedImport("diff-b.js"), () => storage, reload)).toBe( - false, - ); + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("diff-b.js"), () => storage, reload), + ).toBe(false); expect(reload).toHaveBeenCalledTimes(2); }); - it("stops reloading once a tab has used its reloads", () => { + it("stops reloading once a build has used its reloads", () => { const storage = createStorageStub(); const reload = vi.fn(); for (let index = 0; index < 20; index += 1) { - reloadOnceForChunkLoadError(failedImport(`chunk-${index}.js`), () => storage, reload); + reloadOnceForChunkLoadError( + BUILD_B, + failedImport(`chunk-${index}.js`), + () => storage, + reload, + ); } - expect(reloadOnceForChunkLoadError(failedImport("chunk-20.js"), () => storage, reload)).toBe( - false, - ); + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("chunk-20.js"), () => storage, reload), + ).toBe(false); expect(reload).toHaveBeenCalledTimes(20); }); + it("gives each new build its own reloads, however many earlier builds used", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + + for (let index = 0; index < 20; index += 1) { + reloadOnceForChunkLoadError( + BUILD_A, + failedImport(`chunk-${index}.js`), + () => storage, + reload, + ); + } + + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("chunk-0.js"), () => storage, reload), + ).toBe(true); + expect( + reloadOnceForChunkLoadError(BUILD_B, failedImport("chunk-0.js"), () => storage, reload), + ).toBe(false); + expect(reload).toHaveBeenCalledTimes(21); + }); + it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { const reload = vi.fn(); const blocked = () => { throw new DOMException("blocked", "SecurityError"); }; - expect(reloadOnceForChunkLoadError(failedImport("chat-1.js"), blocked, reload)).toBe(false); + expect(reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), blocked, reload)).toBe( + false, + ); expect(reload).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index b1056c2092cf0..10269acfaa15e 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -3,28 +3,41 @@ // reload picks up the fresh index.html. sessionStorage remembers which // failures already had their reload, so a chunk that keeps failing, even one // every boot requests, reloads once and then surfaces. A later deploy fails a -// different asset and gets its own reload. The list is capped, so a tab -// reloads automatically at most that many times. +// different asset and gets its own reload. The list is capped per build and +// restarts when the page's build changes, so a long-lived tab keeps reloading +// for each new deploy. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloads"; const MAX_CHUNK_RELOADS = 20; /** - * Called from the `vite:preloadError` listener with a key naming the page's - * build and the asset that failed. Reloads at most once per key and returns + * Called from the `vite:preloadError` listener with the page's build and a + * key naming the asset that failed. Reloads at most once per key and returns * whether it did, so the caller knows whether to swallow the event or let * the error surface through the normal paths. */ export function reloadOnceForChunkLoadError( + build: string, failure: string, getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), ): boolean { try { const storage = getStorage(); - const stored: unknown = JSON.parse(storage.getItem(CHUNK_RELOAD_GUARD_KEY) ?? "[]"); - const reloaded = Array.isArray(stored) ? stored : []; + const stored: unknown = JSON.parse(storage.getItem(CHUNK_RELOAD_GUARD_KEY) ?? "null"); + const reloaded = + stored !== null && + typeof stored === "object" && + "build" in stored && + stored.build === build && + "failures" in stored && + Array.isArray(stored.failures) + ? stored.failures + : []; if (reloaded.includes(failure) || reloaded.length >= MAX_CHUNK_RELOADS) return false; - storage.setItem(CHUNK_RELOAD_GUARD_KEY, JSON.stringify([...reloaded, failure])); + storage.setItem( + CHUNK_RELOAD_GUARD_KEY, + JSON.stringify({ build, failures: [...reloaded, failure] }), + ); } catch { // Without storage the guard cannot survive a reload, so a persistent // failure would loop forever. Let the error surface instead. diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ceae3cd6abb99..af76a3311dd52 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -31,15 +31,14 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; // A failed split-chunk fetch usually means the hashed assets went stale under -// a deploy; one guarded reload picks up the fresh index.html. The failure key -// pairs this build's entry URL with the error, which names the asset (except -// in Safari), so each failed asset of each build gets one reload. Later +// a deploy; one guarded reload picks up the fresh index.html. This build's +// entry URL names the build, and the error names the asset (except in +// Safari), so each failed asset of each build gets one reload. Later // failures while that reload is pending keep their rejection and their reload. let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { if (reloadScheduled) return; - const failure = `${import.meta.url} ${String(event.payload)}`; - if (reloadOnceForChunkLoadError(failure)) { + if (reloadOnceForChunkLoadError(import.meta.url, String(event.payload))) { reloadScheduled = true; event.preventDefault(); } From 2f8e108446a3b8eeb192d8998a6c12d42bfa6fe4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:42 +0000 Subject: [PATCH 062/143] fix(web): a failed primary discovery shows the empty workspace again The landing waited for the primary environment to register, so when its discovery failed the index page rendered nothing. A platform poll that ends without the primary now settles the landing without it, as before. --- apps/web/src/connection/platform.ts | 11 +++++++++++ apps/web/src/state/shell.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index ce4482921fc5f..a68d30046b7f7 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -40,6 +40,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { FetchHttpClient } from "effect/http"; +import { Atom } from "effect/reactivity"; import { APP_VERSION } from "../branding"; import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; @@ -468,6 +469,12 @@ const loadSecondaryConnectionRegistration = Effect.fn( const PLATFORM_POLL_INTERVAL = "3 seconds"; const SECONDARY_BEARER_REFRESH_SKEW_MS = 5_000; +/** Whether the latest platform poll ended without the primary environment, e.g. its discovery failed. */ +export const primaryEnvironmentMissingAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("web-primary-environment-missing"), +); + export function secondaryBearerExpiresAtEpochMs( issuedAtEpochMs: number, expiresInSeconds: number, @@ -773,6 +780,10 @@ const layerPlatformConnectionSource = Layer.effect( } yield* Ref.set(cacheRef, next); + const primaryMissing = !next.has(PRIMARY_LOCAL_ENVIRONMENT_ID); + if (appAtomRegistry.get(primaryEnvironmentMissingAtom) !== primaryMissing) { + appAtomRegistry.set(primaryEnvironmentMissingAtom, primaryMissing); + } return registrations as ReadonlyArray; }).pipe(Effect.provide(FetchHttpClient.layer)); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index e14096974cb1d..fa78f9bff1d98 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; +import { primaryEnvironmentMissingAtom } from "../connection/platform"; import { connectionAtomRuntime } from "../connection/runtime"; import { isHostedStaticApp } from "../hostedPairing"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; @@ -32,10 +33,12 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { return false; } // The persisted catalog emits before platform discovery registers the - // primary environment. Until it does, an empty workspace is unknown. + // primary environment. Until it does, an empty workspace is unknown. A + // discovery that ends without the primary settles the landing without it. if ( !isHostedStaticApp() && !isLocalEnvironmentDisabled() && + !get(primaryEnvironmentMissingAtom) && !Array.from(catalog.value.entries.values()).some( (entry) => entry.target._tag === "PrimaryConnectionTarget", ) From 3451c686a4223a0b488bd6375f39efe22f504b5e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 10 Oct 2026 10:55:42 +0000 Subject: [PATCH 063/143] fix(web): a second theme change within two frames also paints without transitions The pending settle from the first change removed no-transitions before the second change painted, so it faded in. Each change now restarts the two-frame wait. --- apps/web/src/hooks/useTheme.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 02a6a2ee596e1..1d93255b3aba8 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -362,20 +362,20 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview } } -let themeSettlePending = false; +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 * 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. */ function settleThemeAfterPaint() { - if (themeSettlePending) return; - themeSettlePending = true; + const generation = ++themeSettleGeneration; requestAnimationFrame(() => { requestAnimationFrame(() => { - themeSettlePending = false; + if (generation !== themeSettleGeneration) return; syncBrowserChromeTheme(); document.documentElement.classList.remove("no-transitions"); }); From 42e2b35b2939886d87acb51ced4b1784ba0d312e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 11 Oct 2026 06:13:27 +0000 Subject: [PATCH 064/143] fix(web): snooze date fallback uses the Input's own size --- apps/web/src/components/CustomSnoozeDialog.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 5b840e2eb7c5e..2cca9144b9ae5 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -126,7 +126,6 @@ function CustomSnoozeDialog() { Date: Wed, 14 Oct 2026 21:19:33 +0000 Subject: [PATCH 065/143] fix(server): move the sweep index migration after main's migrations --- .../orchestration-v2/ProjectionStore.test.ts | 2 +- apps/server/src/persistence/Migrations.ts | 4 +++ .../Migrations/055_OrchestrationV2.test.ts | 35 +++++++++++++------ ...ts => 063_ProjectionThreadSweepIndexes.ts} | 10 +++++- .../reconcileV2PreviewMigration.test.ts | 2 ++ 5 files changed, 40 insertions(+), 13 deletions(-) rename apps/server/src/persistence/Migrations/{062_ProjectionThreadSweepIndexes.ts => 063_ProjectionThreadSweepIndexes.ts} (81%) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index e88d53b648eb6..d0e35baa71b5d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -32,7 +32,7 @@ import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; -import ProjectionThreadSweepIndexes from "../persistence/Migrations/062_ProjectionThreadSweepIndexes.ts"; +import ProjectionThreadSweepIndexes from "../persistence/Migrations/063_ProjectionThreadSweepIndexes.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a477100038675..16aeb10aed303 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -76,6 +76,7 @@ import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts"; import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts"; +import Migration0063 from "./Migrations/063_ProjectionThreadSweepIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -152,6 +153,7 @@ export const migrationEntries = [ [60, "ThreadSnapshotWindowIndexes", Migration0060], [61, "PullRequestReviewGuides", Migration0061], [62, "ThreadFailureLookupIndex", Migration0062], + [63, "ProjectionThreadSweepIndexes", Migration0063], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -198,6 +200,8 @@ const assertV2MigrationsCanRun = Effect.fn("assertV2MigrationsCanRun")(function* return []; const recordedName = recordedNames.get(id); if (recordedName === name) return []; + // Migration 63 repairs the index skipped by older sweep builds at id 62. + if (id === 62 && recordedName === "ProjectionThreadSweepIndexes") return []; return [`${id}_${name} (recorded: ${recordedName ?? "nothing"})`]; }); if (skipped.length === 0) return; diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 3fd33227550cf..6de5c28e1a8f5 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -19,6 +19,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import { migrationEntries, runMigrations } from "../Migrations.ts"; +import ProjectionThreadSweepIndexes from "./063_ProjectionThreadSweepIndexes.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); @@ -28,7 +29,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 62 }, (_, index) => index + 1), + Array.from({ length: 63 }, (_, index) => index + 1), ); }), ); @@ -49,6 +50,7 @@ layer("055_OrchestrationV2", (it) => { [60, "ThreadSnapshotWindowIndexes"], [61, "PullRequestReviewGuides"], [62, "ThreadFailureLookupIndex"], + [63, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -77,6 +79,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, { migration_id: 61, name: "PullRequestReviewGuides" }, { migration_id: 62, name: "ThreadFailureLookupIndex" }, + { migration_id: 63, name: "ProjectionThreadSweepIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -206,13 +209,13 @@ layer("055_OrchestrationV2", (it) => { it.effect("replaces a same-named sweep index", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 61 }); + yield* runMigrations({ toMigrationInclusive: 62 }); yield* sql` CREATE INDEX orchestration_v2_projection_threads_active_idx ON orchestration_v2_projection_threads(updated_at, thread_id) WHERE deleted_at IS NULL AND archived_at IS NULL `; - assert.deepStrictEqual(yield* runMigrations(), [[62, "ProjectionThreadSweepIndexes"]]); + assert.deepStrictEqual(yield* runMigrations(), [[63, "ProjectionThreadSweepIndexes"]]); const [index] = yield* sql<{ readonly sql: string }>` SELECT sql FROM sqlite_master WHERE name = 'orchestration_v2_projection_threads_active_idx' `; @@ -220,8 +223,7 @@ layer("055_OrchestrationV2", (it) => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); - // Stacked builds ran ProjectionThreadReadPayload as 62, so they never run this build's 62. - it.effect("keeps sweeps correct on a database that recorded a different 62", () => { + it.effect("repairs indexes after an older sweep build recorded id 62", () => { const skippedWarnings: Array = []; const logger = Logger.make(({ fiber, logLevel }) => { const skipped = fiber.getRef(References.CurrentLogAnnotations).skipped; @@ -230,21 +232,32 @@ layer("055_OrchestrationV2", (it) => { return Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 61 }); - yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (62, 'ProjectionThreadReadPayload')`; - assert.deepStrictEqual(yield* runMigrations(), []); - assert.deepStrictEqual(skippedWarnings, [["62_ProjectionThreadSweepIndexes"]]); + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (62, 'ProjectionThreadSweepIndexes')`; + yield* sql`CREATE INDEX orchestration_v2_projection_threads_active_idx + ON orchestration_v2_projection_threads(updated_at, thread_id)`; + yield* sql`CREATE INDEX orchestration_v2_projection_runs_failed_idx + ON orchestration_v2_projection_runs(thread_id) WHERE status = 'failed'`; + assert.deepStrictEqual(yield* runMigrations(), [[63, "ProjectionThreadSweepIndexes"]]); + assert.deepStrictEqual(skippedWarnings, [["62_ThreadFailureLookupIndex"]]); + yield* ProjectionThreadSweepIndexes; assert.deepStrictEqual( yield* sql` SELECT name FROM sqlite_master WHERE name IN ( 'orchestration_v2_projection_threads_active_idx', - 'orchestration_v2_projection_runs_failed_idx' + 'orchestration_v2_projection_runs_failed_idx', + 'orchestration_v2_projection_turn_items_failed_error_idx' ) + ORDER BY name `, - [], + [ + { name: "orchestration_v2_projection_runs_failed_idx" }, + { name: "orchestration_v2_projection_threads_active_idx" }, + { name: "orchestration_v2_projection_turn_items_failed_error_idx" }, + ], ); - // Without the index, the pull request and usage-limit sweeps still find the thread. + // The repaired indexes keep pull request and usage-limit sweeps working. const store = yield* ProjectionStore.ProjectionStoreV2; const at = DateTime.makeUnsafe("2026-10-01T00:00:00.000Z"); const threadId = ThreadId.make("thread:peer-links"); diff --git a/apps/server/src/persistence/Migrations/062_ProjectionThreadSweepIndexes.ts b/apps/server/src/persistence/Migrations/063_ProjectionThreadSweepIndexes.ts similarity index 81% rename from apps/server/src/persistence/Migrations/062_ProjectionThreadSweepIndexes.ts rename to apps/server/src/persistence/Migrations/063_ProjectionThreadSweepIndexes.ts index 940b5ab91c18a..3397291062d59 100644 --- a/apps/server/src/persistence/Migrations/062_ProjectionThreadSweepIndexes.ts +++ b/apps/server/src/persistence/Migrations/063_ProjectionThreadSweepIndexes.ts @@ -17,7 +17,7 @@ export default Effect.gen(function* () { // Replace it so every database gets this definition. yield* sql`DROP INDEX IF EXISTS orchestration_v2_projection_threads_active_idx`; yield* sql` - CREATE INDEX orchestration_v2_projection_threads_active_idx + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_active_idx ON orchestration_v2_projection_threads( updated_at, thread_id, @@ -42,4 +42,12 @@ export default Effect.gen(function* () { ON orchestration_v2_projection_runs(thread_id) WHERE status = 'failed' `; + // Older sweep builds recorded id 62, so they skip main's failed-error index migration. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_failed_error_idx + ON orchestration_v2_projection_turn_items( + thread_id, run_id, node_id, updated_at DESC, ordinal DESC, turn_item_id DESC + ) + WHERE type = 'error' AND status = 'failed' + `; }); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 684520bce168a..2143e3f76db37 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -43,6 +43,7 @@ describe("V2 preview upgrade", () => { [60, "ThreadSnapshotWindowIndexes"], [61, "PullRequestReviewGuides"], [62, "ThreadFailureLookupIndex"], + [63, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -128,6 +129,7 @@ describe("V2 preview upgrade", () => { [60, "ThreadSnapshotWindowIndexes"], [61, "PullRequestReviewGuides"], [62, "ThreadFailureLookupIndex"], + [63, "ProjectionThreadSweepIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 7fba2c3fe781741ea47ba9276a3ec49c4809aa26 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 21:24:32 +0000 Subject: [PATCH 066/143] test(server): align compact PR replay coverage after main merge --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 310c28940f64c..1ba38daa94dfd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4966,7 +4966,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", WHERE command_id = ${"compact-pr-sync"} `; assert.equal(rows.length, 1); - assert.equal(rows[0]?.event_type, "thread.pull-request-link-updated"); + assert.equal(rows[0]?.event_type, "thread.pull-request-link-synced"); const payload = JSON.parse(rows[0]!.payload_json); assert.equal(payload.number, 2); assert.isUndefined(payload.pullRequests); From 91bffa0aa230504b0f49c2ab31775560933faf58 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 21:14:33 +0000 Subject: [PATCH 067/143] refactor(server): infer enrichment method types --- .../src/project/ProjectEnrichmentService.ts | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 36f4b4c461a4e..e77107c3f7d59 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -241,48 +241,42 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( // peek, request and getAvailable are cache reads called per project and per // thread on every sweep. Spans live on the lanes that do the actual work. - const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fnUntraced( - function* (workspaceRoot) { - const [repositoryIdentity, faviconPath] = yield* Effect.all( - [ - Cache.getSuccess(repositoryIdentityCache, workspaceRoot), - Cache.getSuccess(faviconCache, workspaceRoot), - ] as const, - { concurrency: "unbounded" }, - ); - return { - repositoryIdentity: availableValue(repositoryIdentity), - faviconPath: availableValue(faviconPath), - repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), - }; - }, - ); + const peek = Effect.fnUntraced(function* (workspaceRoot: string) { + const [repositoryIdentity, faviconPath] = yield* Effect.all( + [ + Cache.getSuccess(repositoryIdentityCache, workspaceRoot), + Cache.getSuccess(faviconCache, workspaceRoot), + ] as const, + { concurrency: "unbounded" }, + ); + return { + repositoryIdentity: availableValue(repositoryIdentity), + faviconPath: availableValue(faviconPath), + repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), + }; + }); - const request: ProjectEnrichmentService["Service"]["request"] = Effect.fnUntraced( - function* (workspaceRoot) { - const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( - [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], - { concurrency: "unbounded" }, - ); - yield* Effect.all( - [ - hasRepositoryIdentity - ? Effect.void - : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), - hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), - ], - { concurrency: "unbounded", discard: true }, - ); - }, - ); + const request = Effect.fnUntraced(function* (workspaceRoot: string) { + const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( + [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], + { concurrency: "unbounded" }, + ); + yield* Effect.all( + [ + hasRepositoryIdentity + ? Effect.void + : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), + hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), + ], + { concurrency: "unbounded", discard: true }, + ); + }); - const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fnUntraced( - function* (workspaceRoot) { - const available = yield* peek(workspaceRoot); - yield* request(workspaceRoot); - return available; - }, - ); + const getAvailable = Effect.fnUntraced(function* (workspaceRoot: string) { + const available = yield* peek(workspaceRoot); + yield* request(workspaceRoot); + return available; + }); const invalidate: ProjectEnrichmentService["Service"]["invalidate"] = Effect.fn( "ProjectEnrichmentService.invalidate", From c2f05203131dc3cca8c7ecc38627676a16d4f051 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 21:14:40 +0000 Subject: [PATCH 068/143] refactor(server): name the execution failure scenarios --- .../RunExecutionService.test.ts | 365 +++++++++--------- 1 file changed, 184 insertions(+), 181 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index c54834d9524e8..3a2fd6a28a366 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1250,196 +1250,199 @@ it.effect("touches the provider session only after the baseline checkpoint exist }), ); -it.effect.each([ +const preparationFailureScenarios = [ "failure", "interruption", "stale-attempt", "start-guard", "late-start-guard", "baseline-interruption", -] as const)("handles %s before the provider turn starts", (scenario) => - Effect.gen(function* () { - const threadId = ThreadId.make("thread:run-execution-settings-failure"); - const runId = RunId.make("run:run-execution-settings-failure"); - const attemptId = RunAttemptId.make("attempt:run-execution-settings-failure"); - const providerInstanceId = ProviderInstanceId.make("codex"); - const providerSessionId = ProviderSessionId.make("session:run-execution-settings-failure"); - const providerThreadId = ProviderThreadId.make( - "provider-thread:run-execution-settings-failure", - ); - const rootNodeId = NodeId.make("node:run-execution-settings-failure"); - const checkpointScope = { - id: CheckpointScopeId.make("checkpoint-scope:run-execution-settings-failure"), - } as OrchestrationV2CheckpointScope; - const providerStarts = yield* Ref.make(0); - const startGuards = yield* Ref.make(0); - const reachesBaseline = - scenario === "start-guard" || - scenario === "late-start-guard" || - scenario === "baseline-interruption"; - const refreshes = yield* Ref.make(0); - const guardedWrites = yield* Ref.make(0); - const writes = yield* Ref.make>>([]); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - captureBaseline: () => - scenario === "baseline-interruption" - ? Effect.interrupt - : reachesBaseline - ? Effect.void - : Effect.die("not reached"), - }), - Layer.mock(EventSink.EventSinkV2)({ - writeIfRunCurrent: (input) => - Effect.gen(function* () { - assert.equal(input.threadId, threadId); - assert.equal(input.runId, runId); - assert.equal(input.activeAttemptId, attemptId); - assert.equal(input.expectedStatus, "running"); - yield* Ref.update(guardedWrites, (count) => count + 1); - if (scenario === "stale-attempt") { - return { committed: false, storedEvents: [] }; - } - yield* Ref.update(writes, (current) => [...current, input.events]); - return { committed: true, storedEvents: [] }; - }), - }), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: () => Effect.succeed([]), - }), - reachesBaseline - ? ServerSettings.layerTest() - : Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: - scenario === "interruption" - ? Effect.interrupt - : Effect.fail( - new ServerSettingsError({ - settingsPath: "", - operation: "read-file", - cause: new Error("settings read failed"), - }), - ), - }), - Layer.succeed(RunFinalizationService.RunFinalizationObserver, { - refresh: () => Effect.void, - refreshAfterTurn: () => Ref.update(refreshes, (count) => count + 1), - }), +] as const; +it.effect.each(preparationFailureScenarios)( + "handles %s before the provider turn starts", + (scenario) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:run-execution-settings-failure"); + const runId = RunId.make("run:run-execution-settings-failure"); + const attemptId = RunAttemptId.make("attempt:run-execution-settings-failure"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const providerSessionId = ProviderSessionId.make("session:run-execution-settings-failure"); + const providerThreadId = ProviderThreadId.make( + "provider-thread:run-execution-settings-failure", + ); + const rootNodeId = NodeId.make("node:run-execution-settings-failure"); + const checkpointScope = { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-settings-failure"), + } as OrchestrationV2CheckpointScope; + const providerStarts = yield* Ref.make(0); + const startGuards = yield* Ref.make(0); + const reachesBaseline = + scenario === "start-guard" || + scenario === "late-start-guard" || + scenario === "baseline-interruption"; + const refreshes = yield* Ref.make(0); + const guardedWrites = yield* Ref.make(0); + const writes = yield* Ref.make>>([]); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => + scenario === "baseline-interruption" + ? Effect.interrupt + : reachesBaseline + ? Effect.void + : Effect.die("not reached"), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent: (input) => + Effect.gen(function* () { + assert.equal(input.threadId, threadId); + assert.equal(input.runId, runId); + assert.equal(input.activeAttemptId, attemptId); + assert.equal(input.expectedStatus, "running"); + yield* Ref.update(guardedWrites, (count) => count + 1); + if (scenario === "stale-attempt") { + return { committed: false, storedEvents: [] }; + } + yield* Ref.update(writes, (current) => [...current, input.events]); + return { committed: true, storedEvents: [] }; + }), + }), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + reachesBaseline + ? ServerSettings.layerTest() + : Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: + scenario === "interruption" + ? Effect.interrupt + : Effect.fail( + new ServerSettingsError({ + settingsPath: "", + operation: "read-file", + cause: new Error("settings read failed"), + }), + ), + }), + Layer.succeed(RunFinalizationService.RunFinalizationObserver, { + refresh: () => Effect.void, + refreshAfterTurn: () => Ref.update(refreshes, (count) => count + 1), + }), + ), ), - ), - ); + ); - const result = yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make("command:run-execution-settings-failure"), - appThread: { id: threadId } as OrchestrationV2AppThread, - providerSessionId, - session: { - events: Stream.never, - startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId, - status: "running", - } as OrchestrationV2Run, - rootNode: { id: rootNodeId, status: "running" } as OrchestrationV2ExecutionNode, - checkpointScope, - providerThread: { - id: providerThreadId, - driver, - } as OrchestrationV2ProviderThread, - attempt: { - id: attemptId, - providerTurnId: null, - status: "running", - } as OrchestrationV2RunAttempt, - attemptId, - providerTurnOrdinal: 1, - // A declined start is a normal exit, not a preparation failure. - ...(scenario === "start-guard" || scenario === "late-start-guard" - ? { - shouldStartProviderTurn: () => - Ref.updateAndGet(startGuards, (count) => count + 1).pipe( - Effect.map((count) => scenario === "late-start-guard" && count === 1), - ), - } - : {}), - message: { - messageId: MessageId.make("message:run-execution-settings-failure"), - text: "Start after settings fail.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, + const result = yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make("command:run-execution-settings-failure"), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId, + session: { + events: Stream.never, + startTurn: () => Ref.update(providerStarts, (count) => count + 1), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + status: "running", + } as OrchestrationV2Run, + rootNode: { id: rootNodeId, status: "running" } as OrchestrationV2ExecutionNode, + checkpointScope, + providerThread: { + id: providerThreadId, + driver, + } as OrchestrationV2ProviderThread, + attempt: { + id: attemptId, + providerTurnId: null, + status: "running", + } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + // A declined start is a normal exit, not a preparation failure. + ...(scenario === "start-guard" || scenario === "late-start-guard" + ? { + shouldStartProviderTurn: () => + Ref.updateAndGet(startGuards, (count) => count + 1).pipe( + Effect.map((count) => scenario === "late-start-guard" && count === 1), + ), + } + : {}), + message: { + messageId: MessageId.make("message:run-execution-settings-failure"), + text: "Start after settings fail.", + attachments: [], + createdBy: "user", + creationSource: "web", }, - }, - }); - }).pipe(Effect.provide(layerTest), Effect.exit); - - assert.equal(yield* Ref.get(providerStarts), 0); - const events = (yield* Ref.get(writes)).flat(); - if (scenario === "interruption" || scenario === "baseline-interruption") { - assert.isTrue(Exit.isFailure(result)); - if (Exit.isFailure(result)) assert.isTrue(Cause.hasInterruptsOnly(result.cause)); - assert.equal(yield* Ref.get(guardedWrites), 0); - assert.equal(yield* Ref.get(refreshes), 0); - assert.isEmpty(events); - return; - } - assert.isTrue(Exit.isSuccess(result)); - if (scenario === "start-guard" || scenario === "late-start-guard") { - assert.equal(yield* Ref.get(startGuards), scenario === "late-start-guard" ? 2 : 1); - assert.equal(yield* Ref.get(guardedWrites), 0); - assert.equal(yield* Ref.get(refreshes), 0); - assert.isEmpty(events); - return; - } - assert.equal(yield* Ref.get(guardedWrites), 1); - if (scenario === "stale-attempt") { - assert.equal(yield* Ref.get(refreshes), 0); - assert.isEmpty(events); - return; - } - assert.equal(yield* Ref.get(refreshes), 1); - assert.deepEqual( - events - .filter( - (event) => - event.type === "run.updated" || - event.type === "run-attempt.updated" || - event.type === "node.updated", - ) - .map((event) => event.payload.status), - ["failed", "failed", "failed"], - ); - const errorItem = events.find( - (event) => event.type === "turn-item.updated" && event.payload.type === "error", - ); - assert.isDefined(errorItem); - if (errorItem?.type === "turn-item.updated" && errorItem.payload.type === "error") { - // The persisted item carries a bounded curated message; the exact - // underlying text stays in the logged cause. - assert.equal(errorItem.payload.failure.message, "Run preparation failed."); - } - }), + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(layerTest), Effect.exit); + + assert.equal(yield* Ref.get(providerStarts), 0); + const events = (yield* Ref.get(writes)).flat(); + if (scenario === "interruption" || scenario === "baseline-interruption") { + assert.isTrue(Exit.isFailure(result)); + if (Exit.isFailure(result)) assert.isTrue(Cause.hasInterruptsOnly(result.cause)); + assert.equal(yield* Ref.get(guardedWrites), 0); + assert.equal(yield* Ref.get(refreshes), 0); + assert.isEmpty(events); + return; + } + assert.isTrue(Exit.isSuccess(result)); + if (scenario === "start-guard" || scenario === "late-start-guard") { + assert.equal(yield* Ref.get(startGuards), scenario === "late-start-guard" ? 2 : 1); + assert.equal(yield* Ref.get(guardedWrites), 0); + assert.equal(yield* Ref.get(refreshes), 0); + assert.isEmpty(events); + return; + } + assert.equal(yield* Ref.get(guardedWrites), 1); + if (scenario === "stale-attempt") { + assert.equal(yield* Ref.get(refreshes), 0); + assert.isEmpty(events); + return; + } + assert.equal(yield* Ref.get(refreshes), 1); + assert.deepEqual( + events + .filter( + (event) => + event.type === "run.updated" || + event.type === "run-attempt.updated" || + event.type === "node.updated", + ) + .map((event) => event.payload.status), + ["failed", "failed", "failed"], + ); + const errorItem = events.find( + (event) => event.type === "turn-item.updated" && event.payload.type === "error", + ); + assert.isDefined(errorItem); + if (errorItem?.type === "turn-item.updated" && errorItem.payload.type === "error") { + // The persisted item carries a bounded curated message; the exact + // underlying text stays in the logged cause. + assert.equal(errorItem.payload.failure.message, "Run preparation failed."); + } + }), ); it.effect("ingests new member threads of an owned child after the root turn terminalizes", () => From 37fb3a592c0bf6d9011fdb7b98bde3c43ff920ad Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 21:22:50 +0000 Subject: [PATCH 069/143] refactor(web): trim startup performance commentary --- apps/web/src/cloud/connectOnboarding.ts | 20 ++----- .../web/src/components/CommandPaletteHost.tsx | 14 ++--- apps/web/src/components/GitActionsControl.tsx | 6 +-- .../components/chat/draftHeroTransition.ts | 4 -- .../settings/SettingsFontPreviews.tsx | 6 +-- apps/web/src/connection/platform.ts | 6 +-- apps/web/src/hooks/useTheme.ts | 8 +-- apps/web/src/keybindings.ts | 6 +-- apps/web/src/lib/chunkReloadGuard.ts | 15 ++---- apps/web/src/main.tsx | 5 +- apps/web/src/router.ts | 8 +-- apps/web/src/routes/__root.tsx | 8 +-- apps/web/src/state/shell.ts | 11 +--- apps/web/vite/precompress.ts | 7 +-- apps/web/vite/startupChunks.ts | 52 +++---------------- 15 files changed, 31 insertions(+), 145 deletions(-) diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts index a40c406668455..1b54a4af3c6e2 100644 --- a/apps/web/src/cloud/connectOnboarding.ts +++ b/apps/web/src/cloud/connectOnboarding.ts @@ -18,11 +18,7 @@ export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutStat optOutAccounts: [], }; -/** - * The Clerk account as the managed-auth shell sees it. The shell provides it - * so startup code can follow sign-ins without importing Clerk. Null when no - * shell is mounted. - */ +/** Lets startup observe sign-ins without importing Clerk. */ export interface ManagedAccountState { readonly isLoaded: boolean; readonly isSignedIn: boolean | undefined; @@ -31,14 +27,8 @@ export interface ManagedAccountState { export const ManagedAccountContext = createContext(null); -/** - * The account whose sign-in should open the onboarding wizard. Every sign-in - * or account switch that completes while this is mounted requests it, since - * account transitions clear the connected relay environments. A cold load - * observes undefined → account and must not re-prompt. The wizard loads - * lazily, so this runs eagerly where it mounts and the wizard takes the - * request whenever its chunk arrives. - */ +// Observe outside the lazy wizard so sign-ins during its load aren't lost. +// A restored session must not re-prompt; account switches clear relay connections. export function useConnectOnboardingRequest() { const account = useContext(ManagedAccountContext); const isLoaded = account?.isLoaded ?? false; @@ -49,9 +39,7 @@ export function useConnectOnboardingRequest() { useEffect(() => { if (!isLoaded) return; - // A loaded-but-incomplete snapshot (signed in, user id not yet populated) - // must not be recorded as signed-out — the next render would then look - // like a fresh sign-in on a cold load. + // An incomplete signed-in snapshot must not look like a fresh sign-in later. if (isSignedIn && !userId) return; const previousAccount = observedAccountRef.current; const nextAccount = isSignedIn && userId ? userId : null; diff --git a/apps/web/src/components/CommandPaletteHost.tsx b/apps/web/src/components/CommandPaletteHost.tsx index 964944f2348bf..57c78294a7fb7 100644 --- a/apps/web/src/components/CommandPaletteHost.tsx +++ b/apps/web/src/components/CommandPaletteHost.tsx @@ -34,17 +34,13 @@ import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { CommandDialog } from "./ui/command"; import { stackedThreadToast, toastManager } from "./ui/toast"; -// The palette's views pull in project browsing, search, and syntax highlighting. -// Only the shortcut handling stays in the startup graph; the dialog loads on first -// open, or once the app is idle after boot. +// Keep browsing, search, and highlighting out of the shortcut handler's startup graph. const loadCommandPalette = () => import("./CommandPalette"); const lazyCommandPaletteDialog = () => lazy(() => loadCommandPalette().then((module) => ({ default: module.CommandPaletteDialog }))); const COMMAND_PALETTE_PRELOAD_DELAY_MS = 3_000; -// A failed load closes the palette instead of replacing the app with the route -// error view. A stale deploy gets its `vite:preloadError` reload first; otherwise -// the next open requests the chunk again. +// Close on import failure so it cannot replace the app with a route error. function CommandPaletteUnavailable({ onUnavailable }: { onUnavailable: () => void }) { useEffect(() => onUnavailable(), [onUnavailable]); return null; @@ -124,8 +120,7 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { : false, ); - // Keyboard handlers outside the palette ignore keys from the moment it is - // requested, including while its chunk loads. + // Block background shortcuts while the palette's chunk loads too. useLayoutEffect(() => { setCommandPaletteRequested(state.open); return () => setCommandPaletteRequested(false); @@ -147,8 +142,7 @@ export function CommandPaletteHost({ children }: { children: ReactNode }) { }; }, []); - // While the chunk loads, the app behind the palette is inert and nothing has - // focus. Text typed then becomes the palette's query, and Escape closes it. + // Buffer query input while the chunk loads and the app behind it is inert. const loadingQuery = state.openIntent === null ? "" diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index abe67564578ee..5f9f1bcc3901a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -460,11 +460,7 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu interface PublishRepositoryDialogProps { readonly open: boolean; - /** - * Whether publishing is on offer, so the provider scan is ready when the - * dialog opens. The scan probes git and every provider CLI on the server, - * so a checkout that already has a remote never runs it. - */ + /** Preload discovery only for checkouts that offer publishing. */ readonly publishAvailable: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; diff --git a/apps/web/src/components/chat/draftHeroTransition.ts b/apps/web/src/components/chat/draftHeroTransition.ts index afc73298a483d..ddd7e4d5f623f 100644 --- a/apps/web/src/components/chat/draftHeroTransition.ts +++ b/apps/web/src/components/chat/draftHeroTransition.ts @@ -37,10 +37,6 @@ export async function waitForDraftHeroTransition(): Promise { ]); } -/** - * Whether `runMobileComposerTransition` runs its update inside a view - * transition. Otherwise it runs the update synchronously. - */ export function isMobileComposerTransitionEnabled(active: boolean): boolean { if (!active || typeof document === "undefined" || typeof window === "undefined") return false; const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 5a0f827c4cc66..c77c81bf9a0ae 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -17,11 +17,7 @@ import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; 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. - */ +// Below-fold previews defer their grammars, fonts, and WASM until visible. function useRevealed(ref: RefObject): boolean { const [revealed, setRevealed] = useState(false); useEffect(() => { diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index a68d30046b7f7..8196d75ff1fb9 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -350,11 +350,7 @@ let primaryDescriptorPrefetch: { readonly settled: Promise; } | null = null; -/** - * Reads the primary environment's descriptor alongside the boot auth check, - * before route chunks queue on the browser's connections. The first primary - * registration reuses the answer instead of fetching it again. - */ +// Start discovery before route chunks occupy the browser's connections. export function prefetchPrimaryEnvironmentDescriptor(): void { if (primaryDescriptorPrefetchStarted) return; primaryDescriptorPrefetchStarted = true; diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 1d93255b3aba8..1793a098588b2 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -364,13 +364,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview 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 - * 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. - */ +// Wait for paint before reading colors or restoring transitions to avoid an extra style recalc. function settleThemeAfterPaint() { const generation = ++themeSettleGeneration; requestAnimationFrame(() => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 5babb6a9669bb..6831ac9649075 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -108,11 +108,7 @@ export function shortcutConflictKey( type EffectiveShortcutsByCommand = ReadonlyMap; -/** - * Resolved configs are replaced, never mutated, so the effective shortcuts for - * one config, platform and context never change. Labels resolve on every - * render and keydown, so they read this index instead of walking every binding. - */ +// Configs are immutable, so labels and keydowns can reuse their shortcut index. const effectiveShortcutIndex = new WeakMap< ResolvedKeybindingsConfig, Map diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index 10269acfaa15e..787440358a560 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,20 +1,11 @@ // Split chunks are fetched lazily, so a deploy (or desktop server swap) // between page load and a later fetch can 404 the old hashed assets. One -// reload picks up the fresh index.html. sessionStorage remembers which -// failures already had their reload, so a chunk that keeps failing, even one -// every boot requests, reloads once and then surfaces. A later deploy fails a -// different asset and gets its own reload. The list is capped per build and -// restarts when the page's build changes, so a long-lived tab keeps reloading -// for each new deploy. +// reload picks up the fresh index.html. Remember failures per build so a +// persistent failure cannot loop and later deploys still get a reload. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloads"; const MAX_CHUNK_RELOADS = 20; -/** - * Called from the `vite:preloadError` listener with the page's build and a - * key naming the asset that failed. Reloads at most once per key and returns - * whether it did, so the caller knows whether to swallow the event or let - * the error surface through the normal paths. - */ +/** Returns whether the preload-error listener should swallow the failure for a reload. */ export function reloadOnceForChunkLoadError( build: string, failure: string, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index af76a3311dd52..105ac72f48a7c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -31,10 +31,7 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; // A failed split-chunk fetch usually means the hashed assets went stale under -// a deploy; one guarded reload picks up the fresh index.html. This build's -// entry URL names the build, and the error names the asset (except in -// Safari), so each failed asset of each build gets one reload. Later -// failures while that reload is pending keep their rejection and their reload. +// a deploy; one guarded reload picks up the fresh index.html. let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { if (reloadScheduled) return; diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 67bf61c40a593..edfbb76877c4c 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -16,13 +16,7 @@ export function getRouter(history: RouterHistory) { export type AppRouter = ReturnType; -/** - * The router fetches split route chunks only after every beforeLoad resolves, - * and the root beforeLoad waits on the auth check. Starting the initial - * location's chunks during that check runs both at once. The index route - * opens a draft right away, so its chunk starts too. The chunks start a task - * later, so the boot's own requests reach the server ahead of them. - */ +// Overlap route chunks with auth; defer one task so boot requests start first. export function preloadInitialRouteChunks(router: AppRouter) { const { pathname } = router.latestLocation; const routes = [...router.getMatchedRoutes(pathname).matchedRoutes]; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ebfd2b83d61fd..47a58684e766c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -89,10 +89,8 @@ import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; -// These optional notices mount with the shell. A stale chunk still gets the -// `vite:preloadError` reload first, and while that reload is pending Vite -// resolves the import to undefined. A chunk that keeps failing renders nothing -// instead of replacing the shell with the route error view. +// The preload-error guard runs first and may resolve an import to undefined. +// Persistent optional-chunk failures must leave the shell usable. const renderNothing = () => null; const ConnectOnboardingDialog = lazy(() => import("../components/cloud/ConnectOnboardingDialog") @@ -105,8 +103,6 @@ const NightlyMobileBetaNotice = lazy(() => .catch(() => ({ default: renderNothing })), ); -// Sign-ins are observed here, outside the lazy wizard chunk, so one that -// completes while the chunk loads still opens the wizard. function ConnectOnboarding() { const request = useConnectOnboardingRequest(); return ( diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index 97de635ef98f1..1ed3ef9c50eb9 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -32,9 +32,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { if (Option.isNone(catalog)) { return false; } - // The persisted catalog emits before platform discovery registers the - // primary environment. Until it does, an empty workspace is unknown. A - // discovery that ends without the primary settles the landing without it. + // Wait for primary discovery before treating a persisted catalog as complete. if ( !isHostedStaticApp() && !isLocalEnvironmentDisabled() && @@ -67,12 +65,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { let environmentShellsStarted = false; -/** - * Builds the connection runtime, reads every enabled environment's cached - * shell, and starts its connection without waiting for the first render to - * read them. Called once the app knows it will render the workspace. The - * mount lasts for the session, like the sidebar that reads the same atoms. - */ +// Keep the shell mounted for the session, starting cache reads and connections before render. export function startEnvironmentShells(): void { if (environmentShellsStarted) return; environmentShellsStarted = true; diff --git a/apps/web/vite/precompress.ts b/apps/web/vite/precompress.ts index f12e90b2c67d7..aea1d4e4d3a44 100644 --- a/apps/web/vite/precompress.ts +++ b/apps/web/vite/precompress.ts @@ -13,12 +13,7 @@ const gzip = NodeUtil.promisify(NodeZlib.gzip); const COMPRESSIBLE_FILE = /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/; const MIN_BYTES = 1024; -/** - * Writes `.br` (quality 11) and `.gz` (level 9) next to each hashed asset so - * the server sends finished bytes instead of compressing on every cold load. - * Only `assets/` gets copies: those names are content hashed, so a copy can - * never describe a different version of its file. - */ +// Only hashed assets get sidecars, so compressed copies cannot describe stale content. export function precompressPlugin(): Plugin { return { name: "t3code:precompress", diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts index 5131fff72a154..ba17c6d898d1e 100644 --- a/apps/web/vite/startupChunks.ts +++ b/apps/web/vite/startupChunks.ts @@ -3,8 +3,7 @@ import * as NodePath from "node:path"; import { normalizePath, type HtmlTagDescriptor, type Plugin, type Rolldown } from "vite-plus"; -// What a cold open runs before its first commit: the app, then the chat -// layout, home, a draft, or a thread. Keep in sync with src/routes. +// Keep the initial route graphs in sync with src/routes. const STARTUP_MODULES = { main: "src/main.tsx", chat: "src/routes/_chat.tsx?tsr-split=component", @@ -12,27 +11,8 @@ const STARTUP_MODULES = { "chat-draft": "src/routes/_chat.draft.$draftId.tsx?tsr-split=component", "chat-thread": "src/routes/_chat.$environmentId.$threadId.tsx?tsr-split=component", }; -/** - * Groups the startup graphs into a few chunks while preserving route boundaries. - * - * index.html loads `src/bootstrap.ts`, which imports `src/main.tsx` lazily so - * it can show boot errors. Left alone, the browser learns the main graph only - * after that entry runs and the route chunks only after the auth check, and - * both arrive as hundreds of small shared chunks. - * - * Listing main and the startup routes as build inputs lets Rolldown tag their - * static graphs `$initial` after tree shaking. A module belongs to the first - * startup graph that imports it. Chat modules that other pages (settings, usage, - * welcome) or the shell's lazy parts (the root notices, the command palette) - * also import move into one chunk per set of those users. A settings or welcome - * cold open then downloads only the chat code it uses, and a chat cold open - * fetches those few extra chunks in the same wave. index.html preloads main's - * static graph; the router preloads the initial location's route chunks - * alongside authentication. - * The bootstrap entry keeps its own modules, so it never statically imports - * a startup chunk and can still report a failed load. - */ -// Transformed code size below which a set of shared chat modules joins main. +// Group static startup graphs without pulling chat-only code into other routes. +// Bootstrap stays separate so it can report main's chunk-load failures. const MAIN_SHARED_SET_SIZE = 100_000; const sharedChunkName = (owner: string | undefined) => owner === undefined || owner in STARTUP_MODULES ? null : owner; @@ -83,7 +63,6 @@ export function startupChunksPlugin(): Plugin { includeDependenciesRecursively: false, })), { - // The chat modules other pages also render; see buildEnd. name: (id: string) => sharedChunkName(startupModuleOwners.get(normalizePath(id))), debugName: "startup-shared", @@ -127,9 +106,7 @@ export function startupChunksPlugin(): Plugin { } const codeLength = (id: string) => this.getModuleInfo(moduleIds.get(id) ?? id)?.code?.length ?? 0; - // Other pages and the app shell also load chat modules: route pages outside - // the chat layout, grouped by family since settings.general renders inside - // settings, and the shell's lazy parts, such as the root notices. + // Group nested routes together: settings.general renders inside settings. const startupEntries = new Set( Object.values(STARTUP_MODULES).map((entry) => normalizePath(NodePath.join(root, entry))), ); @@ -152,9 +129,7 @@ export function startupChunksPlugin(): Plugin { ([family, entries]) => [NodePath.basename(family).split(".")[0], staticClosure(...entries)] as const, ); - // Each set of families that uses a chat module gets one chunk, so a page or - // lazy part downloads only the chat code it uses. A set's chunk imports only - // chunks of its supersets, which every family in it also needs. + // Shared chunks import only supersets of their users, avoiding unrelated chat code. const setSizes = new Map(); for (const [id, owner] of owners) { if (owner === "main") continue; @@ -164,9 +139,7 @@ export function startupChunksPlugin(): Plugin { owners.set(id, set); setSizes.set(set, (setSizes.get(set) ?? 0) + codeLength(id)); } - // Small sets join main instead of adding requests to every chat cold open. - // A module moves only when everything it imports is in main or moves too, - // so main never imports a shared chunk. + // Absorb small sets only when their imports move too, so main stays self-contained. const toMain = new Set( [...owners].flatMap(([id, owner]) => (setSizes.get(owner) ?? Infinity) < MAIN_SHARED_SET_SIZE ? [id] : [], @@ -209,7 +182,6 @@ export function startupChunksPlugin(): Plugin { } return files; }; - // index.html already loads the bootstrap entry and its imports. const bootstrapFiles = importClosure( chunks.filter( (chunk) => chunk.facadeModuleId === normalizePath(NodePath.join(root, "index.html")), @@ -231,8 +203,7 @@ export function startupChunksPlugin(): Plugin { "The boot entry must catch main's chunk load failures before importing app code.", ); } - // The main entry can become a facade after grouping. Preload its - // static dependencies only; selected routes retain their own preload lists. + // Main may become a facade; routes retain their own preload lists. const files = [ ...importClosure( chunks.filter( @@ -244,7 +215,6 @@ export function startupChunksPlugin(): Plugin { const chunk = bundle[file]; return chunk?.type === "chunk" ? chunk : undefined; }; - // Largest first: those downloads take longest. startupFiles = files.toSorted( (left, right) => (chunkAt(right)?.code.length ?? 0) - (chunkAt(left)?.code.length ?? 0), ); @@ -276,13 +246,7 @@ export function startupChunksPlugin(): Plugin { }; } -/** - * lucide-react/dynamic imports each of ~1,900 icons lazily. When the app also - * imports one of those icons statically, Rolldown keeps it as a dynamic entry - * in a chunk of its own, which put ~140 one-icon files on the startup path. - * A query suffix gives the lazy copies their own module ids, so static imports - * bundle normally and only icons picked at runtime load as separate files. - */ +// Separate dynamic icon ids so static icons don't each become a startup chunk. export function lucideDynamicIconsPlugin(): Plugin { const suffix = "?lucide-dynamic"; return { From 1da5e928f34cea7572b681b34458b88cd5d1c361 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:03:16 +0000 Subject: [PATCH 070/143] refactor(web): simplify composer send dispatch --- apps/web/src/components/ChatView.logic.ts | 32 ---- apps/web/src/components/ChatView.tsx | 175 +++++++++------------- 2 files changed, 72 insertions(+), 135 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 086303a58dc9f..1e79964caa0a9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -23,7 +23,6 @@ import { type ThreadId, type ThreadLinkedPullRequest, type RunId, - type RuntimeMode, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; @@ -482,37 +481,6 @@ export function startNewThreadForProject( return true; } -/** The thread settings a send writes before its turn starts, or null when none changed. */ -export function resolveThreadSettingsUpdatesForNextTurn( - thread: { - readonly modelSelection: ModelSelection; - readonly branch: string | null; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; - }, - input: { - readonly branch?: string; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode; - }, -): { - readonly metadataUpdate: ReturnType; - readonly runtimeMode: RuntimeMode | null; - readonly interactionMode: ProviderInteractionMode | null; -} | null { - const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: thread.modelSelection, - currentBranch: thread.branch, - ...(input.branch ? { nextBranch: input.branch } : {}), - }); - const runtimeMode = input.runtimeMode !== thread.runtimeMode ? input.runtimeMode : null; - const interactionMode = - input.interactionMode !== thread.interactionMode ? input.interactionMode : null; - return metadataUpdate === null && runtimeMode === null && interactionMode === null - ? null - : { metadataUpdate, runtimeMode, interactionMode }; -} - export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ba3a6e263b15..987be78ebfe8f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -510,7 +510,6 @@ import { DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, - isMobileComposerTransitionEnabled, runMobileComposerTransition, } from "./chat/draftHeroTransition"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -553,7 +552,7 @@ import { getAntigravitySendBlockReason, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, - resolveThreadSettingsUpdatesForNextTurn, + resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -6373,15 +6372,16 @@ export default function ChatView(props: ChatViewProps) { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }): Promise> => { - const updates = serverThread - ? resolveThreadSettingsUpdatesForNextTurn(serverThread, input) - : null; - if (!updates) { + if (!serverThread) { return AsyncResult.success(undefined); } let result: AtomCommandResult = AsyncResult.success(undefined); - const { metadataUpdate } = updates; + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: serverThread.modelSelection, + currentBranch: serverThread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), + }); if (metadataUpdate) { result = mapAtomCommandResult( await updateThreadMetadata({ @@ -6398,13 +6398,13 @@ export default function ChatView(props: ChatViewProps) { } } - if (updates.runtimeMode !== null) { + if (input.runtimeMode !== serverThread.runtimeMode) { result = mapAtomCommandResult( await setThreadRuntimeMode({ environmentId, input: { threadId: input.threadId, - runtimeMode: updates.runtimeMode, + runtimeMode: input.runtimeMode, createdAt: input.createdAt, }, }), @@ -6415,13 +6415,13 @@ export default function ChatView(props: ChatViewProps) { } } - if (updates.interactionMode !== null) { + if (input.interactionMode !== serverThread.interactionMode) { result = mapAtomCommandResult( await setThreadInteractionMode({ environmentId, input: { threadId: input.threadId, - interactionMode: updates.interactionMode, + interactionMode: input.interactionMode, createdAt: input.createdAt, }, }), @@ -9545,33 +9545,11 @@ export default function ChatView(props: ChatViewProps) { } } - const dockDraftHeroThreadKey = - multipleModelSelections === null && - shouldDockDraftHeroForSubmission({ isDraftHeroState, activeThreadKey, submissionIntent }) - ? activeThreadKey - : null; - // A plain send can dispatch before the dock and optimistic render. - // Mobile view transitions still snapshot the hero before it changes. - const dispatchBeforeRender = + if ( multipleModelSelections === null && - submissionIntent !== "background" && - composerAttachmentsSnapshot.length === 0 && - !compactBeforeSend && - (!isServerThread || - serverThread === null || - resolveThreadSettingsUpdatesForNextTurn(serverThread, { - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode: sendInteractionMode, - }) === null) && - !(dockDraftHeroThreadKey && isMobileComposerTransitionEnabled(panelAnimationsActive)); - if (dockDraftHeroThreadKey && dispatchBeforeRender) { - // Batched with the optimistic turn below; the rect is read before React renders any of it. - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(dockDraftHeroThreadKey); - } else if (dockDraftHeroThreadKey) { + shouldDockDraftHeroForSubmission({ isDraftHeroState, activeThreadKey, submissionIntent }) && + activeThreadKey + ) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; @@ -9580,7 +9558,7 @@ export default function ChatView(props: ChatViewProps) { () => { flushSync(() => { captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(dockDraftHeroThreadKey); + setDockedDraftHeroThreadKey(activeThreadKey); }); resolveDockStarted?.(); }, @@ -10021,7 +9999,54 @@ export default function ChatView(props: ChatViewProps) { ctxSelectedModelSelection.options, ); - const startTurnForSend = (turnAttachments: Awaited) => { + let failure: AtomCommandResult | null = null; + + if (failure === null && isServerThread) { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: sendInteractionMode, + }); + if (settingsResult._tag === "Failure") { + failure = settingsResult; + } + } + + const turnAttachmentsResult = await settlePromise(async () => { + const turnAttachments = await turnAttachmentsPromise; + const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; + if (liveFileBlockReason !== null) { + throw new Error(liveFileBlockReason); + } + return turnAttachments; + }); + if (failure === null && turnAttachmentsResult._tag === "Failure") { + failure = turnAttachmentsResult; + } + + if (failure === null && compactBeforeSend) { + const compactResult = await startThreadTurn({ + environmentId, + input: { + threadId: threadIdForSend, + message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, + modelSelection: ctxSelectedModelSelection, + runtimeMode, + interactionMode: sendInteractionMode, + }, + }); + if (compactResult._tag === "Failure") { + failure = compactResult; + } + } + + let backgroundDraftOpened = false; + let turnStartSucceeded = false; + if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { @@ -10051,7 +10076,12 @@ export default function ChatView(props: ChatViewProps) { : {}), } : undefined; - return startThreadTurn({ + const backgroundThreadRef = + submissionIntent === "background" && isLocalDraftThread + ? scopeThreadRef(environmentId, threadIdForSend) + : null; + if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); + const startPromise = startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -10059,10 +10089,10 @@ export default function ChatView(props: ChatViewProps) { messageId: messageIdForSend, role: "user", text: outgoingMessageText, - attachments: turnAttachments, + attachments: turnAttachmentsResult.value, ...(() => { const context = buildOutgoingMessageContext( - turnAttachments.map((attachment, index) => + turnAttachmentsResult.value.map((attachment, index) => "id" in attachment && attachment.id !== undefined ? attachment.id : composerAttachmentsSnapshot[index]!.id, @@ -10096,67 +10126,6 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); - }; - // Nothing above awaited, so React has not rendered the state queued for - // this send yet and the command reaches the socket first. - const earlyTurnStart = dispatchBeforeRender ? startTurnForSend([]) : null; - - let failure: AtomCommandResult | null = null; - - if (failure === null && isServerThread && !dispatchBeforeRender) { - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode: sendInteractionMode, - }); - if (settingsResult._tag === "Failure") { - failure = settingsResult; - } - } - - const turnAttachmentsResult = dispatchBeforeRender - ? AsyncResult.success>([]) - : await settlePromise(async () => { - const turnAttachments = await turnAttachmentsPromise; - const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; - if (liveFileBlockReason !== null) { - throw new Error(liveFileBlockReason); - } - return turnAttachments; - }); - if (failure === null && turnAttachmentsResult._tag === "Failure") { - failure = turnAttachmentsResult; - } - - if (failure === null && compactBeforeSend) { - const compactResult = await startThreadTurn({ - environmentId, - input: { - threadId: threadIdForSend, - message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, - modelSelection: ctxSelectedModelSelection, - runtimeMode, - interactionMode: sendInteractionMode, - }, - }); - if (compactResult._tag === "Failure") { - failure = compactResult; - } - } - - let backgroundDraftOpened = false; - let turnStartSucceeded = false; - if (failure === null && turnAttachmentsResult._tag === "Success") { - const backgroundThreadRef = - submissionIntent === "background" && isLocalDraftThread - ? scopeThreadRef(environmentId, threadIdForSend) - : null; - if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); - const startPromise = earlyTurnStart ?? startTurnForSend(turnAttachmentsResult.value); if (backgroundThreadRef) { markPromotedDraftThreadByRef(backgroundThreadRef); try { From 50762a583994879de241c2d14d94f050edb09108 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:03:36 +0000 Subject: [PATCH 071/143] refactor(web): simplify thread details loading --- apps/web/src/components/ChatView.tsx | 24 +-- .../chat/ThreadDetailsPanel.test.tsx | 9 +- .../components/chat/ThreadDetailsPanel.tsx | 163 +++++++++--------- 3 files changed, 94 insertions(+), 102 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 987be78ebfe8f..997187835cc83 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -463,8 +463,7 @@ import { RightPanelMaximizeControl, } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import type { ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; -import { ThreadDetailsCard } from "./chat/ThreadDetailsCard"; +import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { type EnvironmentOption, @@ -748,27 +747,6 @@ const ThreadPreviewMiniPlayer = lazy(() => default: module.ThreadPreviewMiniPlayer, })), ); -const ThreadDetailsPanelContent = lazy(() => - import("./chat/ThreadDetailsPanel").then((module) => ({ - default: module.ThreadDetailsPanelContent, - })), -); -function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { - return ( - - {(density) => ( - - - - )} - - ); -} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx index f3a62e95e7902..a9f1867f39d2e 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx @@ -27,7 +27,12 @@ vi.mock("./ThreadAutomationsPanel", () => ({ vi.mock("./ThreadRelationshipsControl", () => ({ ThreadRelationshipsPanel: () => null, })); -import { ThreadDetailsPanelContent, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; +vi.mock("./ThreadDetailsCard", () => ({ + ThreadDetailsCard: ({ children }: { children: (density: "full") => React.ReactNode }) => + children("full"), +})); + +import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; describe("ThreadDetailsPanel", () => { beforeEach(() => { @@ -75,7 +80,7 @@ describe("ThreadDetailsPanel", () => { onDeleteProjectScript: vi.fn() as ThreadDetailsPanelProps["onDeleteProjectScript"], }; - renderToStaticMarkup(); + renderToStaticMarkup(); expect(testState.useT3ProjectFileScripts).toHaveBeenCalledWith(environmentId, gitCwd); expect(testState.projectScriptsControl).toHaveBeenCalledWith( diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 44ca5df415488..c5efa89130862 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -16,7 +16,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { memo, useMemo, type ComponentProps } from "react"; -import type { ThreadDetailsCard } from "./ThreadDetailsCard"; +import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; @@ -64,10 +64,7 @@ export interface ThreadDetailsPanelProps extends Pick< onDeleteProjectScript: (scriptId: string) => Promise; } -export function ThreadDetailsPanelContent( - props: ThreadDetailsPanelProps & { readonly density: "full" | "compact" | "essential" }, -) { - const { density } = props; +export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { const fileScripts = useT3ProjectFileScripts( props.environmentId, props.activeProjectScripts ? props.gitCwd : null, @@ -100,84 +97,96 @@ export function ThreadDetailsPanelContent( }; return ( - <> - -
- {density === "full" ? ( - + + {(density) => ( + <> + +
+ {density === "full" ? ( + + ) : null} + + {density !== "essential" && props.showOpenInPicker ? ( + + ) : null} + + {props.activeProjectScripts ? ( + + ) : null} +
+
+ + {props.gitCwd ? ( + +
+ {props.isGitRepo ? ( + + ) : null} + {props.activeProjectName ? ( + + ) : null} +
+
) : null} - {density !== "essential" && props.showOpenInPicker ? ( - + {density === "full" && !props.draftId ? ( + ) : null} - {props.activeProjectScripts ? ( - ) : null} -
-
- - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} -
-
- ) : null} - - {density === "full" && !props.draftId ? ( - - ) : null} - - {density === "full" && !props.draftId ? ( - - ) : null} - + + )} +
); } From 56aecdcf012d1ad82a374ee8f170994b502f14e7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:04:08 +0000 Subject: [PATCH 072/143] refactor(web): drop precompressed asset sidecars --- apps/server/src/http.test.ts | 34 ++++++++-------------- apps/server/src/http.ts | 33 --------------------- apps/web/vite.config.ts | 3 -- apps/web/vite/precompress.ts | 56 ------------------------------------ 4 files changed, 12 insertions(+), 114 deletions(-) delete mode 100644 apps/web/vite/precompress.ts diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 39f0fea22ef56..e28619ba864d3 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,6 +1,5 @@ import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; -import * as NodeZlib from "node:zlib"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -215,40 +214,37 @@ it.layer( Layer.provideMerge(NodeServices.layer), ), )("static HTTP responses", (it) => { - it.effect("serves precompressed manifest assets and revalidates other files", () => + it.effect("compresses manifest assets and revalidates other files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compressed-" }); + const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compression-" }); yield* fs.makeDirectory(path.join(staticDir, "assets")); - const body = 'export const build = "precompressed";\n'.repeat(100); + const body = 'export const build = "compressed";\n'.repeat(100); const asset = "assets/app-1234abcd.js"; - const br = NodeZlib.brotliCompressSync(body); - const gz = NodeZlib.gzipSync(body); yield* fs.writeFileString(path.join(staticDir, asset), body); - yield* fs.writeFile(path.join(staticDir, `${asset}.br`), br); - yield* fs.writeFile(path.join(staticDir, `${asset}.gz`), gz); yield* fs.writeFileString( path.join(staticDir, "vite-manifest.json"), JSON.stringify({ "src/app.ts": { file: asset, isEntry: true } }), ); - // A hash-like filename and a compressed copy alone cannot make a custom file immutable. + // A hash-like filename alone cannot make a custom file immutable. const custom = "assets/custom-1234abcd.js"; yield* fs.writeFileString(path.join(staticDir, custom), body); - yield* fs.writeFile(path.join(staticDir, `${custom}.br`), br); const request = yield* makeStaticRequest(staticDir); - for (const [acceptEncoding, encoding, length] of [ - ["br, gzip", "br", br.byteLength], - ["br;q=0, gzip", "gzip", gz.byteLength], - ["identity", undefined, Buffer.byteLength(body)], + for (const [acceptEncoding, encoding] of [ + ["br, gzip", "br"], + ["br;q=0, gzip", "gzip"], + ["identity", undefined], ] as const) { const response = yield* request(`/${asset}`, { headers: { "accept-encoding": acceptEncoding }, }); expect(response.status).toBe(200); expect(response.headers["content-encoding"]).toBe(encoding); - expect(response.headers["content-length"]).toBe(String(length)); + expect(response.headers["content-length"]).toBe( + encoding === undefined ? String(Buffer.byteLength(body)) : undefined, + ); expect(response.headers["content-type"]).toContain("javascript"); expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); expect(response.headers["vary"]).toBe("Accept-Encoding"); @@ -258,7 +254,7 @@ it.layer( headers: { "accept-encoding": acceptEncoding }, }); expect(head.headers["content-encoding"]).toBe(encoding); - expect(head.headers["content-length"]).toBe(String(length)); + expect(head.headers["content-length"]).toBe(response.headers["content-length"]); expect(yield* head.text).toBe(""); const unchanged = yield* request(`/${asset}`, { headers: { @@ -274,12 +270,6 @@ it.layer( expect(unlisted.headers["cache-control"]).toBe("no-cache"); expect(unlisted.headers["content-length"]).toBeUndefined(); expect(yield* unlisted.text).toBe(body); - - yield* fs.remove(path.join(staticDir, `${asset}.br`)); - const fallback = yield* request(`/${asset}`, { headers: { "accept-encoding": "br, gzip" } }); - expect(fallback.headers["content-encoding"]).toBe("gzip"); - expect(fallback.headers["content-length"]).toBe(String(gz.byteLength)); - expect(yield* fallback.text).toBe(body); }), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 1b552cd058459..50f608a41af8c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -571,26 +571,6 @@ const streamStaticFile = (file: FileSystem.File, size: bigint) => }), ); -/** Match the compression middleware's preference and reject malformed quality values. */ -function acceptedStaticEncodings(header: string | undefined): ReadonlyArray<"br" | "gzip"> { - if (!header?.trim()) return []; - const accepted = new Map(); - for (const part of header.split(",")) { - const member = part - .trim() - .toLowerCase() - .replace(/[ \t]*;[ \t]*/g, ";"); - const match = /^([a-z0-9!#$%&'*+.^_`|~-]+)(?:;q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/.exec( - member, - ); - if (!match) return []; - accepted.set(match[1]!, match[2] === undefined ? 1 : Number(match[2])); - } - return (["br", "gzip"] as const).filter( - (encoding) => (accepted.get(encoding) ?? accepted.get("*") ?? 0) > 0, - ); -} - const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; @@ -707,19 +687,6 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; - // Only manifest-backed hashed assets can trust an adjacent compressed copy. - // Custom static files may be overwritten without updating their sidecars. - if (immutable) { - for (const encoding of acceptedStaticEncodings(request.headers["accept-encoding"])) { - const compressed = yield* openStaticFile(`${filePath}.${encoding === "br" ? "br" : "gz"}`); - if (!compressed) continue; - return HttpServerResponse.stream(streamStaticFile(compressed.file, compressed.info.size), { - headers: { ...headers, "Content-Encoding": encoding, Vary: "Accept-Encoding" }, - contentType, - contentLength: Number(compressed.info.size), - }); - } - } // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. // HEAD still passes through compression, which selects headers without reading the stream. return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e9e3bedd8c62e..7f48c7f5b41a1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,7 +16,6 @@ import { import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; -import { precompressPlugin } from "./vite/precompress"; import { lucideDynamicIconsPlugin, startupChunksPlugin } from "./vite/startupChunks"; import { tailwindPlugins } from "./vite/tailwind"; @@ -188,8 +187,6 @@ export default defineConfig(() => { tailwindPlugins(bundledDev), lucideDynamicIconsPlugin(), startupChunksPlugin(), - // Vercel compresses the hosted app itself; the T3 server serves these copies. - ...(process.env.VERCEL ? [] : [precompressPlugin()]), ], optimizeDeps: { include: [ diff --git a/apps/web/vite/precompress.ts b/apps/web/vite/precompress.ts deleted file mode 100644 index aea1d4e4d3a44..0000000000000 --- a/apps/web/vite/precompress.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Vite build hooks run outside an Effect runtime. -import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; -import * as NodeUtil from "node:util"; -import * as NodeZlib from "node:zlib"; - -import type { Plugin } from "vite-plus"; - -const brotli = NodeUtil.promisify(NodeZlib.brotliCompress); -const gzip = NodeUtil.promisify(NodeZlib.gzip); - -// Same types and floor as the server's on-the-fly compression. -const COMPRESSIBLE_FILE = /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/; -const MIN_BYTES = 1024; - -// Only hashed assets get sidecars, so compressed copies cannot describe stale content. -export function precompressPlugin(): Plugin { - return { - name: "t3code:precompress", - apply: "build", - async writeBundle(options, bundle) { - const outDir = options.dir; - if (!outDir) return; - const files = Object.keys(bundle).filter( - (fileName) => fileName.startsWith("assets/") && COMPRESSIBLE_FILE.test(fileName), - ); - await Promise.all( - files.map(async (fileName) => { - const filePath = NodePath.join(outDir, fileName); - const source = await NodeFSP.readFile(filePath); - if (source.byteLength < MIN_BYTES) return; - const [br, gz] = await Promise.all([ - brotli(source, { - params: { - [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 11, - [NodeZlib.constants.BROTLI_PARAM_MODE]: fileName.endsWith(".wasm") - ? NodeZlib.constants.BROTLI_MODE_GENERIC - : NodeZlib.constants.BROTLI_MODE_TEXT, - [NodeZlib.constants.BROTLI_PARAM_SIZE_HINT]: source.byteLength, - }, - }), - gzip(source, { level: 9 }), - ]); - await Promise.all([ - br.byteLength < source.byteLength - ? NodeFSP.writeFile(`${filePath}.br`, br) - : Promise.resolve(), - gz.byteLength < source.byteLength - ? NodeFSP.writeFile(`${filePath}.gz`, gz) - : Promise.resolve(), - ]); - }), - ); - }, - }; -} From 243ad96d3dda79fa9521c0bda17deab0bfc98b39 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:04:20 +0000 Subject: [PATCH 073/143] refactor(web): simplify snooze calendar loading --- .../web/src/components/CustomSnoozeDialog.tsx | 100 ++++++------------ 1 file changed, 33 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 2cca9144b9ae5..670df0e3e12e8 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; +import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; @@ -31,9 +32,6 @@ import { DialogFooter, } from "./ui/dialog"; -// react-day-picker and date-fns load with the dialog, not with the app. -const loadCalendar = () => import("./ui/calendar"); - type SnoozeChoice = { readonly snoozedUntil: string }; type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; const useRequest = create<{ request: Request | null }>(() => ({ request: null })); @@ -57,13 +55,6 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); - // Fetch the calendar while the user reads the dialog. Base UI sizes a popover once, as it - // opens, so the date picker waits for the calendar instead of opening around a placeholder. - // Chromium caches a failed dynamic import, so a failed load swaps in a typed date field. - const [calendar, setCalendar] = useState> | null>(null); - const [calendarFailed, setCalendarFailed] = useState(false); - useEffect(() => void loadCalendar().then(setCalendar, () => setCalendarFailed(true)), []); - const Calendar = calendar?.Calendar; const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const [mode, setMode] = useState("date"); const [date, setDate] = useState(initial); @@ -122,59 +113,39 @@ function CustomSnoozeDialog() {
- {calendarFailed ? ( - { - setDate(new Date(`${event.target.value}T00:00:00`)); - setError(null); - }} - /> - ) : ( - + + } > - - } - > - {date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - })} - - - - {Calendar && ( - { - setDate(selected); - setCalendarOpen(false); - setError(null); - }} - /> - )} - - - )} + {date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + { + setDate(selected); + setCalendarOpen(false); + setError(null); + }} + /> + +
- {mode === "date" && calendarFailed && ( -

- The calendar could not load. Type the date instead. -

- )} {error && (

{error} From 5b6d40d6ba0645b6235b5d31d55d071a296b5afb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:04:32 +0000 Subject: [PATCH 074/143] refactor(web): drop speculative primary discovery --- apps/web/src/connection/platform.ts | 50 ++++------------------------- apps/web/src/routes/__root.tsx | 2 -- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 8196d75ff1fb9..edca35eb3a069 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -28,7 +28,6 @@ import { type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, type EnvironmentId, - type ExecutionEnvironmentDescriptor, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -343,52 +342,15 @@ const layerCapabilities = Layer.effectContext( }), ); -let primaryDescriptorPrefetchStarted = false; -let primaryDescriptorPrefetch: { - readonly httpBaseUrl: string; - readonly startedAt: number; - readonly settled: Promise; -} | null = null; - -// Start discovery before route chunks occupy the browser's connections. -export function prefetchPrimaryEnvironmentDescriptor(): void { - if (primaryDescriptorPrefetchStarted) return; - primaryDescriptorPrefetchStarted = true; - const read = readPrimaryEnvironmentTargetResult(); - if (read._tag === "Failure" || read.target === null) return; - const httpBaseUrl = read.target.target.httpBaseUrl; - primaryDescriptorPrefetch = { - httpBaseUrl, - startedAt: Date.now(), - settled: Effect.runPromise( - fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - ), - ).catch(() => null), - }; -} - -const PREFETCHED_DESCRIPTOR_MAX_AGE_MS = 10_000; - const loadPrimaryConnectionRegistration = Effect.fn( "web.connectionPlatform.loadPrimaryConnectionRegistration", )(function* (resolved: PrimaryEnvironmentTarget) { - const prefetch = primaryDescriptorPrefetch; - primaryDescriptorPrefetch = null; - const now = yield* Clock.currentTimeMillis; - const prefetched = - prefetch?.httpBaseUrl === resolved.target.httpBaseUrl && - now - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS - ? yield* Effect.promise(() => prefetch.settled) - : null; - const descriptor = - prefetched ?? - (yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolved.target.httpBaseUrl, - }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - Effect.mapError(mapRemoteEnvironmentError), - )); + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + Effect.mapError(mapRemoteEnvironmentError), + ); return new PrimaryConnectionRegistration({ target: new PrimaryConnectionTarget({ environmentId: descriptor.environmentId, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 47a58684e766c..8a4f3bd9782d7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -79,7 +79,6 @@ import { } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; import { startEnvironmentShells } from "../state/shell"; -import { prefetchPrimaryEnvironmentDescriptor } from "../connection/platform"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -132,7 +131,6 @@ export const Route = createRootRoute({ } const authGate = resolveInitialServerAuthGateState(); - prefetchPrimaryEnvironmentDescriptor(); const authGateState = await authGate; // Connect and read cached shells while the route chunks still load. if (authGateState.status === "authenticated") startEnvironmentShells(); From d6ec5113b61bd562c84bed0485c8e87411bc637a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:09:27 +0000 Subject: [PATCH 075/143] refactor(web): keep the mobile transition gate private --- apps/web/src/components/chat/draftHeroTransition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/draftHeroTransition.ts b/apps/web/src/components/chat/draftHeroTransition.ts index ddd7e4d5f623f..b9084bde8d9e6 100644 --- a/apps/web/src/components/chat/draftHeroTransition.ts +++ b/apps/web/src/components/chat/draftHeroTransition.ts @@ -37,7 +37,7 @@ export async function waitForDraftHeroTransition(): Promise { ]); } -export function isMobileComposerTransitionEnabled(active: boolean): boolean { +function isMobileComposerTransitionEnabled(active: boolean): boolean { if (!active || typeof document === "undefined" || typeof window === "undefined") return false; const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; const prefersReducedMotion = From 253309092fbb4a0ad789c68205d3cd1d0c3c73f0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:21:42 +0000 Subject: [PATCH 076/143] refactor(web): retain descriptor prefetch for startup --- apps/web/src/connection/platform.ts | 50 +++++++++++++++++++++++++---- apps/web/src/routes/__root.tsx | 2 ++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index edca35eb3a069..8196d75ff1fb9 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -28,6 +28,7 @@ import { type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, type EnvironmentId, + type ExecutionEnvironmentDescriptor, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -342,15 +343,52 @@ const layerCapabilities = Layer.effectContext( }), ); +let primaryDescriptorPrefetchStarted = false; +let primaryDescriptorPrefetch: { + readonly httpBaseUrl: string; + readonly startedAt: number; + readonly settled: Promise; +} | null = null; + +// Start discovery before route chunks occupy the browser's connections. +export function prefetchPrimaryEnvironmentDescriptor(): void { + if (primaryDescriptorPrefetchStarted) return; + primaryDescriptorPrefetchStarted = true; + const read = readPrimaryEnvironmentTargetResult(); + if (read._tag === "Failure" || read.target === null) return; + const httpBaseUrl = read.target.target.httpBaseUrl; + primaryDescriptorPrefetch = { + httpBaseUrl, + startedAt: Date.now(), + settled: Effect.runPromise( + fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + ), + ).catch(() => null), + }; +} + +const PREFETCHED_DESCRIPTOR_MAX_AGE_MS = 10_000; + const loadPrimaryConnectionRegistration = Effect.fn( "web.connectionPlatform.loadPrimaryConnectionRegistration", )(function* (resolved: PrimaryEnvironmentTarget) { - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolved.target.httpBaseUrl, - }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - Effect.mapError(mapRemoteEnvironmentError), - ); + const prefetch = primaryDescriptorPrefetch; + primaryDescriptorPrefetch = null; + const now = yield* Clock.currentTimeMillis; + const prefetched = + prefetch?.httpBaseUrl === resolved.target.httpBaseUrl && + now - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS + ? yield* Effect.promise(() => prefetch.settled) + : null; + const descriptor = + prefetched ?? + (yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + Effect.mapError(mapRemoteEnvironmentError), + )); return new PrimaryConnectionRegistration({ target: new PrimaryConnectionTarget({ environmentId: descriptor.environmentId, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8a4f3bd9782d7..47a58684e766c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -79,6 +79,7 @@ import { } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; import { startEnvironmentShells } from "../state/shell"; +import { prefetchPrimaryEnvironmentDescriptor } from "../connection/platform"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -131,6 +132,7 @@ export const Route = createRootRoute({ } const authGate = resolveInitialServerAuthGateState(); + prefetchPrimaryEnvironmentDescriptor(); const authGateState = await authGate; // Connect and read cached shells while the route chunks still load. if (authGateState.status === "authenticated") startEnvironmentShells(); From 97ee9be653aba24a77c33dd70a6774468e0c4100 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:36:28 +0000 Subject: [PATCH 077/143] refactor(web): retain lazy details for startup --- apps/web/src/components/ChatView.tsx | 24 ++- .../chat/ThreadDetailsPanel.test.tsx | 9 +- .../components/chat/ThreadDetailsPanel.tsx | 163 +++++++++--------- 3 files changed, 102 insertions(+), 94 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 997187835cc83..987be78ebfe8f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -463,7 +463,8 @@ import { RightPanelMaximizeControl, } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; +import type { ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; +import { ThreadDetailsCard } from "./chat/ThreadDetailsCard"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { type EnvironmentOption, @@ -747,6 +748,27 @@ const ThreadPreviewMiniPlayer = lazy(() => default: module.ThreadPreviewMiniPlayer, })), ); +const ThreadDetailsPanelContent = lazy(() => + import("./chat/ThreadDetailsPanel").then((module) => ({ + default: module.ThreadDetailsPanelContent, + })), +); +function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { + return ( + + {(density) => ( + + + + )} + + ); +} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx index a9f1867f39d2e..f3a62e95e7902 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx @@ -27,12 +27,7 @@ vi.mock("./ThreadAutomationsPanel", () => ({ vi.mock("./ThreadRelationshipsControl", () => ({ ThreadRelationshipsPanel: () => null, })); -vi.mock("./ThreadDetailsCard", () => ({ - ThreadDetailsCard: ({ children }: { children: (density: "full") => React.ReactNode }) => - children("full"), -})); - -import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; +import { ThreadDetailsPanelContent, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; describe("ThreadDetailsPanel", () => { beforeEach(() => { @@ -80,7 +75,7 @@ describe("ThreadDetailsPanel", () => { onDeleteProjectScript: vi.fn() as ThreadDetailsPanelProps["onDeleteProjectScript"], }; - renderToStaticMarkup(); + renderToStaticMarkup(); expect(testState.useT3ProjectFileScripts).toHaveBeenCalledWith(environmentId, gitCwd); expect(testState.projectScriptsControl).toHaveBeenCalledWith( diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index c5efa89130862..44ca5df415488 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -16,7 +16,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { memo, useMemo, type ComponentProps } from "react"; -import { ThreadDetailsCard } from "./ThreadDetailsCard"; +import type { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; @@ -64,7 +64,10 @@ export interface ThreadDetailsPanelProps extends Pick< onDeleteProjectScript: (scriptId: string) => Promise; } -export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { +export function ThreadDetailsPanelContent( + props: ThreadDetailsPanelProps & { readonly density: "full" | "compact" | "essential" }, +) { + const { density } = props; const fileScripts = useT3ProjectFileScripts( props.environmentId, props.activeProjectScripts ? props.gitCwd : null, @@ -97,96 +100,84 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { }; return ( - - {(density) => ( - <> - -

- {density === "full" ? ( - - ) : null} - - {density !== "essential" && props.showOpenInPicker ? ( - - ) : null} - - {props.activeProjectScripts ? ( - - ) : null} -
- - - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} -
-
+ <> + +
+ {density === "full" ? ( + ) : null} - {density === "full" && !props.draftId ? ( - + {density !== "essential" && props.showOpenInPicker ? ( + ) : null} - {density === "full" && !props.draftId ? ( - ) : null} - - )} - +
+
+ + {props.gitCwd ? ( + +
+ {props.isGitRepo ? ( + + ) : null} + {props.activeProjectName ? ( + + ) : null} +
+
+ ) : null} + + {density === "full" && !props.draftId ? ( + + ) : null} + + {density === "full" && !props.draftId ? ( + + ) : null} + ); } From 2b641c28a68d7ea4592afa2cb7985223f9f8285c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:36:40 +0000 Subject: [PATCH 078/143] refactor(web): retain asset sidecars for startup --- apps/server/src/http.test.ts | 34 ++++++++++++++-------- apps/server/src/http.ts | 33 +++++++++++++++++++++ apps/web/vite.config.ts | 3 ++ apps/web/vite/precompress.ts | 56 ++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 apps/web/vite/precompress.ts diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index e28619ba864d3..39f0fea22ef56 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; +import * as NodeZlib from "node:zlib"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -214,37 +215,40 @@ it.layer( Layer.provideMerge(NodeServices.layer), ), )("static HTTP responses", (it) => { - it.effect("compresses manifest assets and revalidates other files", () => + it.effect("serves precompressed manifest assets and revalidates other files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compression-" }); + const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compressed-" }); yield* fs.makeDirectory(path.join(staticDir, "assets")); - const body = 'export const build = "compressed";\n'.repeat(100); + const body = 'export const build = "precompressed";\n'.repeat(100); const asset = "assets/app-1234abcd.js"; + const br = NodeZlib.brotliCompressSync(body); + const gz = NodeZlib.gzipSync(body); yield* fs.writeFileString(path.join(staticDir, asset), body); + yield* fs.writeFile(path.join(staticDir, `${asset}.br`), br); + yield* fs.writeFile(path.join(staticDir, `${asset}.gz`), gz); yield* fs.writeFileString( path.join(staticDir, "vite-manifest.json"), JSON.stringify({ "src/app.ts": { file: asset, isEntry: true } }), ); - // A hash-like filename alone cannot make a custom file immutable. + // A hash-like filename and a compressed copy alone cannot make a custom file immutable. const custom = "assets/custom-1234abcd.js"; yield* fs.writeFileString(path.join(staticDir, custom), body); + yield* fs.writeFile(path.join(staticDir, `${custom}.br`), br); const request = yield* makeStaticRequest(staticDir); - for (const [acceptEncoding, encoding] of [ - ["br, gzip", "br"], - ["br;q=0, gzip", "gzip"], - ["identity", undefined], + for (const [acceptEncoding, encoding, length] of [ + ["br, gzip", "br", br.byteLength], + ["br;q=0, gzip", "gzip", gz.byteLength], + ["identity", undefined, Buffer.byteLength(body)], ] as const) { const response = yield* request(`/${asset}`, { headers: { "accept-encoding": acceptEncoding }, }); expect(response.status).toBe(200); expect(response.headers["content-encoding"]).toBe(encoding); - expect(response.headers["content-length"]).toBe( - encoding === undefined ? String(Buffer.byteLength(body)) : undefined, - ); + expect(response.headers["content-length"]).toBe(String(length)); expect(response.headers["content-type"]).toContain("javascript"); expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); expect(response.headers["vary"]).toBe("Accept-Encoding"); @@ -254,7 +258,7 @@ it.layer( headers: { "accept-encoding": acceptEncoding }, }); expect(head.headers["content-encoding"]).toBe(encoding); - expect(head.headers["content-length"]).toBe(response.headers["content-length"]); + expect(head.headers["content-length"]).toBe(String(length)); expect(yield* head.text).toBe(""); const unchanged = yield* request(`/${asset}`, { headers: { @@ -270,6 +274,12 @@ it.layer( expect(unlisted.headers["cache-control"]).toBe("no-cache"); expect(unlisted.headers["content-length"]).toBeUndefined(); expect(yield* unlisted.text).toBe(body); + + yield* fs.remove(path.join(staticDir, `${asset}.br`)); + const fallback = yield* request(`/${asset}`, { headers: { "accept-encoding": "br, gzip" } }); + expect(fallback.headers["content-encoding"]).toBe("gzip"); + expect(fallback.headers["content-length"]).toBe(String(gz.byteLength)); + expect(yield* fallback.text).toBe(body); }), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 50f608a41af8c..1b552cd058459 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -571,6 +571,26 @@ const streamStaticFile = (file: FileSystem.File, size: bigint) => }), ); +/** Match the compression middleware's preference and reject malformed quality values. */ +function acceptedStaticEncodings(header: string | undefined): ReadonlyArray<"br" | "gzip"> { + if (!header?.trim()) return []; + const accepted = new Map(); + for (const part of header.split(",")) { + const member = part + .trim() + .toLowerCase() + .replace(/[ \t]*;[ \t]*/g, ";"); + const match = /^([a-z0-9!#$%&'*+.^_`|~-]+)(?:;q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/.exec( + member, + ); + if (!match) return []; + accepted.set(match[1]!, match[2] === undefined ? 1 : Number(match[2])); + } + return (["br", "gzip"] as const).filter( + (encoding) => (accepted.get(encoding) ?? accepted.get("*") ?? 0) > 0, + ); +} + const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; @@ -687,6 +707,19 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; + // Only manifest-backed hashed assets can trust an adjacent compressed copy. + // Custom static files may be overwritten without updating their sidecars. + if (immutable) { + for (const encoding of acceptedStaticEncodings(request.headers["accept-encoding"])) { + const compressed = yield* openStaticFile(`${filePath}.${encoding === "br" ? "br" : "gz"}`); + if (!compressed) continue; + return HttpServerResponse.stream(streamStaticFile(compressed.file, compressed.info.size), { + headers: { ...headers, "Content-Encoding": encoding, Vary: "Accept-Encoding" }, + contentType, + contentLength: Number(compressed.info.size), + }); + } + } // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. // HEAD still passes through compression, which selects headers without reading the stream. return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7f48c7f5b41a1..e9e3bedd8c62e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,6 +16,7 @@ import { import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; +import { precompressPlugin } from "./vite/precompress"; import { lucideDynamicIconsPlugin, startupChunksPlugin } from "./vite/startupChunks"; import { tailwindPlugins } from "./vite/tailwind"; @@ -187,6 +188,8 @@ export default defineConfig(() => { tailwindPlugins(bundledDev), lucideDynamicIconsPlugin(), startupChunksPlugin(), + // Vercel compresses the hosted app itself; the T3 server serves these copies. + ...(process.env.VERCEL ? [] : [precompressPlugin()]), ], optimizeDeps: { include: [ diff --git a/apps/web/vite/precompress.ts b/apps/web/vite/precompress.ts new file mode 100644 index 0000000000000..aea1d4e4d3a44 --- /dev/null +++ b/apps/web/vite/precompress.ts @@ -0,0 +1,56 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite build hooks run outside an Effect runtime. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import * as NodeZlib from "node:zlib"; + +import type { Plugin } from "vite-plus"; + +const brotli = NodeUtil.promisify(NodeZlib.brotliCompress); +const gzip = NodeUtil.promisify(NodeZlib.gzip); + +// Same types and floor as the server's on-the-fly compression. +const COMPRESSIBLE_FILE = /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/; +const MIN_BYTES = 1024; + +// Only hashed assets get sidecars, so compressed copies cannot describe stale content. +export function precompressPlugin(): Plugin { + return { + name: "t3code:precompress", + apply: "build", + async writeBundle(options, bundle) { + const outDir = options.dir; + if (!outDir) return; + const files = Object.keys(bundle).filter( + (fileName) => fileName.startsWith("assets/") && COMPRESSIBLE_FILE.test(fileName), + ); + await Promise.all( + files.map(async (fileName) => { + const filePath = NodePath.join(outDir, fileName); + const source = await NodeFSP.readFile(filePath); + if (source.byteLength < MIN_BYTES) return; + const [br, gz] = await Promise.all([ + brotli(source, { + params: { + [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 11, + [NodeZlib.constants.BROTLI_PARAM_MODE]: fileName.endsWith(".wasm") + ? NodeZlib.constants.BROTLI_MODE_GENERIC + : NodeZlib.constants.BROTLI_MODE_TEXT, + [NodeZlib.constants.BROTLI_PARAM_SIZE_HINT]: source.byteLength, + }, + }), + gzip(source, { level: 9 }), + ]); + await Promise.all([ + br.byteLength < source.byteLength + ? NodeFSP.writeFile(`${filePath}.br`, br) + : Promise.resolve(), + gz.byteLength < source.byteLength + ? NodeFSP.writeFile(`${filePath}.gz`, gz) + : Promise.resolve(), + ]); + }), + ); + }, + }; +} From c3e6a9425f02416e658b5b60959e40d7c510bd87 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:37:07 +0000 Subject: [PATCH 079/143] refactor(web): drop speculative descriptor discovery --- apps/web/src/connection/platform.ts | 50 ++++------------------------- apps/web/src/routes/__root.tsx | 2 -- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 8196d75ff1fb9..edca35eb3a069 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -28,7 +28,6 @@ import { type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, type EnvironmentId, - type ExecutionEnvironmentDescriptor, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -343,52 +342,15 @@ const layerCapabilities = Layer.effectContext( }), ); -let primaryDescriptorPrefetchStarted = false; -let primaryDescriptorPrefetch: { - readonly httpBaseUrl: string; - readonly startedAt: number; - readonly settled: Promise; -} | null = null; - -// Start discovery before route chunks occupy the browser's connections. -export function prefetchPrimaryEnvironmentDescriptor(): void { - if (primaryDescriptorPrefetchStarted) return; - primaryDescriptorPrefetchStarted = true; - const read = readPrimaryEnvironmentTargetResult(); - if (read._tag === "Failure" || read.target === null) return; - const httpBaseUrl = read.target.target.httpBaseUrl; - primaryDescriptorPrefetch = { - httpBaseUrl, - startedAt: Date.now(), - settled: Effect.runPromise( - fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - ), - ).catch(() => null), - }; -} - -const PREFETCHED_DESCRIPTOR_MAX_AGE_MS = 10_000; - const loadPrimaryConnectionRegistration = Effect.fn( "web.connectionPlatform.loadPrimaryConnectionRegistration", )(function* (resolved: PrimaryEnvironmentTarget) { - const prefetch = primaryDescriptorPrefetch; - primaryDescriptorPrefetch = null; - const now = yield* Clock.currentTimeMillis; - const prefetched = - prefetch?.httpBaseUrl === resolved.target.httpBaseUrl && - now - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS - ? yield* Effect.promise(() => prefetch.settled) - : null; - const descriptor = - prefetched ?? - (yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolved.target.httpBaseUrl, - }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - Effect.mapError(mapRemoteEnvironmentError), - )); + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + Effect.mapError(mapRemoteEnvironmentError), + ); return new PrimaryConnectionRegistration({ target: new PrimaryConnectionTarget({ environmentId: descriptor.environmentId, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 47a58684e766c..8a4f3bd9782d7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -79,7 +79,6 @@ import { } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; import { startEnvironmentShells } from "../state/shell"; -import { prefetchPrimaryEnvironmentDescriptor } from "../connection/platform"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -132,7 +131,6 @@ export const Route = createRootRoute({ } const authGate = resolveInitialServerAuthGateState(); - prefetchPrimaryEnvironmentDescriptor(); const authGateState = await authGate; // Connect and read cached shells while the route chunks still load. if (authGateState.status === "authenticated") startEnvironmentShells(); From de797a6b36a60cbb3df2af0619a4878ed6c3dc81 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:42:30 +0000 Subject: [PATCH 080/143] refactor(web): retain lazy calendar for startup --- .../web/src/components/CustomSnoozeDialog.tsx | 100 ++++++++++++------ 1 file changed, 67 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 670df0e3e12e8..2cca9144b9ae5 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -8,7 +8,6 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; -import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; @@ -32,6 +31,9 @@ import { DialogFooter, } from "./ui/dialog"; +// react-day-picker and date-fns load with the dialog, not with the app. +const loadCalendar = () => import("./ui/calendar"); + type SnoozeChoice = { readonly snoozedUntil: string }; type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; const useRequest = create<{ request: Request | null }>(() => ({ request: null })); @@ -55,6 +57,13 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); + // Fetch the calendar while the user reads the dialog. Base UI sizes a popover once, as it + // opens, so the date picker waits for the calendar instead of opening around a placeholder. + // Chromium caches a failed dynamic import, so a failed load swaps in a typed date field. + const [calendar, setCalendar] = useState> | null>(null); + const [calendarFailed, setCalendarFailed] = useState(false); + useEffect(() => void loadCalendar().then(setCalendar, () => setCalendarFailed(true)), []); + const Calendar = calendar?.Calendar; const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const [mode, setMode] = useState("date"); const [date, setDate] = useState(initial); @@ -113,39 +122,59 @@ function CustomSnoozeDialog() {
- - - } + {calendarFailed ? ( + { + setDate(new Date(`${event.target.value}T00:00:00`)); + setError(null); + }} + /> + ) : ( + - {date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - })} - - - - { - setDate(selected); - setCalendarOpen(false); - setError(null); - }} - /> - - + + } + > + {date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + {Calendar && ( + { + setDate(selected); + setCalendarOpen(false); + setError(null); + }} + /> + )} + + + )}
+ {mode === "date" && calendarFailed && ( +

+ The calendar could not load. Type the date instead. +

+ )} {error && (

{error} From fcc9fda0a9e1217009a1771d68b98003ad709185 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 14 Oct 2026 22:57:46 +0000 Subject: [PATCH 081/143] revert(web): keep the speculative primary descriptor request --- apps/web/src/connection/platform.ts | 50 +++++++++++++++++++++++++---- apps/web/src/routes/__root.tsx | 2 ++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index edca35eb3a069..8196d75ff1fb9 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -28,6 +28,7 @@ import { type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, type EnvironmentId, + type ExecutionEnvironmentDescriptor, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -342,15 +343,52 @@ const layerCapabilities = Layer.effectContext( }), ); +let primaryDescriptorPrefetchStarted = false; +let primaryDescriptorPrefetch: { + readonly httpBaseUrl: string; + readonly startedAt: number; + readonly settled: Promise; +} | null = null; + +// Start discovery before route chunks occupy the browser's connections. +export function prefetchPrimaryEnvironmentDescriptor(): void { + if (primaryDescriptorPrefetchStarted) return; + primaryDescriptorPrefetchStarted = true; + const read = readPrimaryEnvironmentTargetResult(); + if (read._tag === "Failure" || read.target === null) return; + const httpBaseUrl = read.target.target.httpBaseUrl; + primaryDescriptorPrefetch = { + httpBaseUrl, + startedAt: Date.now(), + settled: Effect.runPromise( + fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + ), + ).catch(() => null), + }; +} + +const PREFETCHED_DESCRIPTOR_MAX_AGE_MS = 10_000; + const loadPrimaryConnectionRegistration = Effect.fn( "web.connectionPlatform.loadPrimaryConnectionRegistration", )(function* (resolved: PrimaryEnvironmentTarget) { - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolved.target.httpBaseUrl, - }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - Effect.mapError(mapRemoteEnvironmentError), - ); + const prefetch = primaryDescriptorPrefetch; + primaryDescriptorPrefetch = null; + const now = yield* Clock.currentTimeMillis; + const prefetched = + prefetch?.httpBaseUrl === resolved.target.httpBaseUrl && + now - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS + ? yield* Effect.promise(() => prefetch.settled) + : null; + const descriptor = + prefetched ?? + (yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + Effect.mapError(mapRemoteEnvironmentError), + )); return new PrimaryConnectionRegistration({ target: new PrimaryConnectionTarget({ environmentId: descriptor.environmentId, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8a4f3bd9782d7..47a58684e766c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -79,6 +79,7 @@ import { } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; import { startEnvironmentShells } from "../state/shell"; +import { prefetchPrimaryEnvironmentDescriptor } from "../connection/platform"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -131,6 +132,7 @@ export const Route = createRootRoute({ } const authGate = resolveInitialServerAuthGateState(); + prefetchPrimaryEnvironmentDescriptor(); const authGateState = await authGate; // Connect and read cached shells while the route chunks still load. if (authGateState.status === "authenticated") startEnvironmentShells(); From 23f61807ce5e25a6d3208b7ab8c3f7817b8cf5b2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:54:24 +0000 Subject: [PATCH 082/143] refactor(web): drop lazy palette boundary --- apps/web/src/commandPaletteBus.ts | 14 +- apps/web/src/components/CommandPalette.tsx | 244 ++++++++++++-- .../web/src/components/CommandPaletteHost.tsx | 318 ------------------ apps/web/src/routes/__root.tsx | 10 +- 4 files changed, 227 insertions(+), 359 deletions(-) delete mode 100644 apps/web/src/components/CommandPaletteHost.tsx diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 380b9ee28a594..7bf122c468177 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -31,21 +31,9 @@ export function onOpenCommandPalette( return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); } -/** Element id of the mounted palette popup. An id lookup is O(1); keydown handlers call this per key. */ -export const COMMAND_PALETTE_ELEMENT_ID = "t3-command-palette"; - -let commandPaletteRequested = false; - -/** The palette host marks an open as soon as it is requested, before the dialog's chunk loads. */ -export function setCommandPaletteRequested(requested: boolean): void { - commandPaletteRequested = requested; -} - /** Read at event time so consumers do not subscribe to transient dialog state. */ export function isCommandPaletteOpen(): boolean { return ( - commandPaletteRequested || - (typeof document !== "undefined" && - document.getElementById(COMMAND_PALETTE_ELEMENT_ID) !== null) + typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null ); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 006d0595cdc0e..13a9f9147b2a3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -70,21 +70,22 @@ import { LinkIcon, MessageSquareIcon, MonitorIcon, + MoonIcon, PaletteIcon, RotateCcwIcon, SettingsIcon, SquarePenIcon, + SunIcon, TextSearchIcon, } from "lucide-react"; import { requestThreadFindOpen } from "./chat/threadFindActionBus"; import { - lazy, - Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, + useReducer, useRef, useState, type KeyboardEvent, @@ -143,8 +144,14 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; -import { COMMAND_PALETTE_ELEMENT_ID } from "../commandPaletteBus"; -import { PULL_REQUESTS_PANEL_REF, useRightPanelStore } from "../rightPanelStore"; +import { onOpenCommandPalette } from "../commandPaletteBus"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { + PULL_REQUESTS_PANEL_REF, + selectActiveRightPanel, + useRightPanelStore, +} from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, @@ -153,6 +160,7 @@ import { isWindowsPlatform, newProjectId, } from "../lib/utils"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { @@ -184,14 +192,10 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + reduceCommandPaletteUiState, type SearchOverlayMode, } from "./CommandPalette.logic"; -import { APPEARANCE_OPTIONS, notifyThemeSaveFailure } from "./CommandPaletteHost"; -import { - orderItemsByPreferredIds, - pullRequestMatchesSearchQuery, - sortLogicalProjectsForSidebar, -} from "./Sidebar.logic"; +import { orderItemsByPreferredIds, pullRequestMatchesSearchQuery, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { @@ -204,6 +208,7 @@ import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; +import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { @@ -223,13 +228,13 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; -import { CommandDialogPopup, CommandFooterAction } from "./ui/command"; +import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useComposerHandleContext } from "../composerHandleContext"; +import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -333,18 +338,214 @@ function errorMessage(error: unknown): string { return "An error occurred."; } -// Content search highlights matches with shiki; it loads when that mode opens. -const ProjectContentSearchDialog = lazy(() => - import("./search/ProjectContentSearchDialog").then((module) => ({ - default: module.ProjectContentSearchDialog, - })), -); +const OVERLAY_MODE_BY_COMMAND = { + "commandPalette.toggle": "command", + "filePicker.toggle": "files", + "projectSearch.toggle": "content", +} as const satisfies Partial>; + +function overlayModeForCommand(command: string | null): SearchOverlayMode | null { + if (command === null) return null; + return command in OVERLAY_MODE_BY_COMMAND + ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] + : null; +} + +const APPEARANCE_OPTIONS = [ + { mode: "system", label: "System", icon: MonitorIcon }, + { mode: "light", label: "Light", icon: SunIcon }, + { mode: "dark", label: "Dark", icon: MoonIcon }, +] as const; + +function notifyThemeSaveFailure(): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't save theme selection", + description: "Try again.", + }), + ); +} function projectFavicon(project: Project) { return ; } -export function CommandPaletteDialog(props: { +export function CommandPalette({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { + open: false, + mode: "command", + openIntent: null, + }); + const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); + const toggleMode = useCallback( + (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), + [], + ); + const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); + const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme(); + const composerHandleRef = useRef(null); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const terminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); + + useEffect(() => { + if (!state.open || state.mode === "command") return; + const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.isComposing || event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + toggleMode("command"); + }; + window.addEventListener("keydown", onEscapeKeyDown, true); + return () => window.removeEventListener("keydown", onEscapeKeyDown, true); + }, [state.mode, state.open, toggleMode]); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented) return; + // Resolve with the complete shortcut context so customized bindings + // using any documented `when` condition (e.g. previewFocus) work. + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, + modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false, + }, + }); + if (command === "appearance.cycle") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + const nextMode = + appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system"; + if (!setAppearanceMode(nextMode)) { + notifyThemeSaveFailure(); + } else { + toastManager.add({ + id: "appearance-cycle", + title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`, + timeout: 1500, + }); + } + return; + } + if (command === "theme.select") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + dispatch({ _tag: "OpenChangeTheme" }); + return; + } + if (command === "themeEditor.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + return; + } + if (command === "usage.open") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + void navigate({ to: "/usage" }); + return; + } + const mode = overlayModeForCommand(command); + if (mode === null) { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleMode(mode); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [ + appearanceMode, + keybindings, + navigate, + previewOpen, + resolvedTheme, + setAppearanceMode, + setOpen, + terminalOpen, + theme, + themeHalves, + toggleMode, + ]); + + useEffect( + () => + onOpenCommandPalette((detail) => { + if (detail.open === "new-thread-in") { + openNewThreadIn(); + } else if (detail.open === "add-project") { + openAddProject(); + } else if (detail.query !== undefined) { + dispatch({ + _tag: "OpenSearch", + query: detail.query, + ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), + }); + } else { + setOpen(true); + } + }), + [openAddProject, openNewThreadIn, setOpen], + ); + + return ( + + { + if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { + eventDetails.cancel(); + toggleMode("command"); + return; + } + setOpen(open); + }} + > + {/* Block background focus calls for the entire time the palette is open. */} +

+ {children} +
+ + + + ); +} + +function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; @@ -363,7 +564,6 @@ export function CommandPaletteDialog(props: { : "Command palette" } className={cn("overflow-hidden", props.mode === "content" && "h-105")} - id={COMMAND_PALETTE_ELEMENT_ID} data-command-palette="true" data-palette-mode={props.mode} data-testid="command-palette" @@ -378,9 +578,7 @@ export function CommandPaletteDialog(props: { {props.mode === "files" ? ( ) : props.mode === "content" ? ( - - - + ) : ( import("./CommandPalette"); -const lazyCommandPaletteDialog = () => - lazy(() => loadCommandPalette().then((module) => ({ default: module.CommandPaletteDialog }))); -const COMMAND_PALETTE_PRELOAD_DELAY_MS = 3_000; - -// Close on import failure so it cannot replace the app with a route error. -function CommandPaletteUnavailable({ onUnavailable }: { onUnavailable: () => void }) { - useEffect(() => onUnavailable(), [onUnavailable]); - return null; -} - -const OVERLAY_MODE_BY_COMMAND = { - "commandPalette.toggle": "command", - "filePicker.toggle": "files", - "projectSearch.toggle": "content", -} as const satisfies Partial>; - -function overlayModeForCommand(command: string | null): SearchOverlayMode | null { - if (command === null) return null; - return command in OVERLAY_MODE_BY_COMMAND - ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] - : null; -} - -export const APPEARANCE_OPTIONS = [ - { mode: "system", label: "System", icon: MonitorIcon }, - { mode: "light", label: "Light", icon: SunIcon }, - { mode: "dark", label: "Dark", icon: MoonIcon }, -] as const; - -export function notifyThemeSaveFailure(): void { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't save theme selection", - description: "Try again.", - }), - ); -} - -export function CommandPaletteHost({ children }: { children: ReactNode }) { - const navigate = useNavigate(); - const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { - open: false, - mode: "command", - openIntent: null, - }); - // Stays mounted after the first open so closing still animates out. - const [CommandPaletteDialog, setCommandPaletteDialog] = useState | null>(null); - if (state.open && CommandPaletteDialog === null) { - setCommandPaletteDialog(() => lazyCommandPaletteDialog()); - } - const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); - const closeUnavailablePalette = useCallback(() => { - setCommandPaletteDialog(null); - setOpen(false); - }, [setOpen]); - const toggleMode = useCallback( - (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), - [], - ); - const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); - const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); - const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme(); - const composerHandleRef = useRef(null); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const terminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const previewOpen = useRightPanelStore((state) => - routeThreadRef - ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" - : false, - ); - - // Block background shortcuts while the palette's chunk loads too. - useLayoutEffect(() => { - setCommandPaletteRequested(state.open); - return () => setCommandPaletteRequested(false); - }, [state.open]); - - useEffect(() => { - let idleCallback: number | null = null; - const timeout = window.setTimeout(() => { - const preload = () => void loadCommandPalette().catch(() => undefined); - if (typeof window.requestIdleCallback === "function") { - idleCallback = window.requestIdleCallback(preload); - } else { - preload(); - } - }, COMMAND_PALETTE_PRELOAD_DELAY_MS); - return () => { - window.clearTimeout(timeout); - if (idleCallback !== null) window.cancelIdleCallback(idleCallback); - }; - }, []); - - // Buffer query input while the chunk loads and the app behind it is inert. - const loadingQuery = - state.openIntent === null - ? "" - : state.openIntent.kind === "search" - ? state.openIntent.query - : null; - useLayoutEffect(() => { - if (!state.open || state.mode !== "command" || loadingQuery === null) return; - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (document.getElementById(COMMAND_PALETTE_ELEMENT_ID) !== null) return; - if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) return; - if (event.key !== "Escape" && event.key !== "Backspace" && event.key.length !== 1) return; - event.preventDefault(); - event.stopPropagation(); - if (event.key === "Escape") { - setOpen(false); - return; - } - const query = - event.key === "Backspace" ? loadingQuery.slice(0, -1) : loadingQuery + event.key; - dispatch({ _tag: "OpenSearch", query }); - }; - window.addEventListener("keydown", onKeyDown, true); - return () => window.removeEventListener("keydown", onKeyDown, true); - }, [loadingQuery, setOpen, state.mode, state.open]); - - useEffect(() => { - if (!state.open || state.mode === "command") return; - const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.isComposing || event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - toggleMode("command"); - }; - window.addEventListener("keydown", onEscapeKeyDown, true); - return () => window.removeEventListener("keydown", onEscapeKeyDown, true); - }, [state.mode, state.open, toggleMode]); - - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented) return; - // Resolve with the complete shortcut context so customized bindings - // using any documented `when` condition (e.g. previewFocus) work. - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - terminalOpen, - previewFocus: isPreviewFocused(), - previewOpen, - modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false, - }, - }); - if (command === "appearance.cycle") { - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - const nextMode = - appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system"; - if (!setAppearanceMode(nextMode)) { - notifyThemeSaveFailure(); - } else { - toastManager.add({ - id: "appearance-cycle", - title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`, - timeout: 1500, - }); - } - return; - } - if (command === "theme.select") { - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - dispatch({ _tag: "OpenChangeTheme" }); - return; - } - if (command === "themeEditor.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleThemeEditorForTheme({ - theme, - themeHalves, - initialAppearance: resolvedTheme, - }); - return; - } - if (command === "usage.open") { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - void navigate({ to: "/usage" }); - return; - } - const mode = overlayModeForCommand(command); - if (mode === null) { - return; - } - event.preventDefault(); - event.stopPropagation(); - toggleMode(mode); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [ - appearanceMode, - keybindings, - navigate, - previewOpen, - resolvedTheme, - setAppearanceMode, - setOpen, - terminalOpen, - theme, - themeHalves, - toggleMode, - ]); - - useEffect( - () => - onOpenCommandPalette((detail) => { - if (detail.open === "new-thread-in") { - openNewThreadIn(); - } else if (detail.open === "add-project") { - openAddProject(); - } else if (detail.query !== undefined) { - dispatch({ - _tag: "OpenSearch", - query: detail.query, - ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), - }); - } else { - setOpen(true); - } - }), - [openAddProject, openNewThreadIn, setOpen], - ); - - return ( - - { - if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { - eventDetails.cancel(); - toggleMode("command"); - return; - } - setOpen(open); - }} - > - {/* Block background focus calls for the entire time the palette is open. */} -
- {children} -
- {CommandPaletteDialog ? ( - } - > - - - - - ) : null} -
-
- ); -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 47a58684e766c..5b7799ccb349d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,7 +17,7 @@ import { lazy, Suspense, useEffect, useEffectEvent, useMemo, useRef, useState } import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; -import { CommandPaletteHost } from "../components/CommandPaletteHost"; +import { CommandPalette } from "../components/CommandPalette"; import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning"; @@ -229,11 +229,11 @@ function RootRouteView() { - + - + ); @@ -249,11 +249,11 @@ function RootRouteView() { } const appShell = ( - + - + ); // FirstRunGate holds back everything below it — including EventRouter, From be106063ab535d2543a4762f19b33bffa6edd292 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:54:37 +0000 Subject: [PATCH 083/143] refactor(web): drop lazy thread details boundary --- apps/web/src/components/ChatView.tsx | 24 +-- .../chat/ThreadDetailsPanel.test.tsx | 9 +- .../components/chat/ThreadDetailsPanel.tsx | 163 +++++++++--------- 3 files changed, 94 insertions(+), 102 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 987be78ebfe8f..997187835cc83 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -463,8 +463,7 @@ import { RightPanelMaximizeControl, } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import type { ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; -import { ThreadDetailsCard } from "./chat/ThreadDetailsCard"; +import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { type EnvironmentOption, @@ -748,27 +747,6 @@ const ThreadPreviewMiniPlayer = lazy(() => default: module.ThreadPreviewMiniPlayer, })), ); -const ThreadDetailsPanelContent = lazy(() => - import("./chat/ThreadDetailsPanel").then((module) => ({ - default: module.ThreadDetailsPanelContent, - })), -); -function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { - return ( - - {(density) => ( - - - - )} - - ); -} const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx index f3a62e95e7902..a9f1867f39d2e 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx @@ -27,7 +27,12 @@ vi.mock("./ThreadAutomationsPanel", () => ({ vi.mock("./ThreadRelationshipsControl", () => ({ ThreadRelationshipsPanel: () => null, })); -import { ThreadDetailsPanelContent, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; +vi.mock("./ThreadDetailsCard", () => ({ + ThreadDetailsCard: ({ children }: { children: (density: "full") => React.ReactNode }) => + children("full"), +})); + +import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; describe("ThreadDetailsPanel", () => { beforeEach(() => { @@ -75,7 +80,7 @@ describe("ThreadDetailsPanel", () => { onDeleteProjectScript: vi.fn() as ThreadDetailsPanelProps["onDeleteProjectScript"], }; - renderToStaticMarkup(); + renderToStaticMarkup(); expect(testState.useT3ProjectFileScripts).toHaveBeenCalledWith(environmentId, gitCwd); expect(testState.projectScriptsControl).toHaveBeenCalledWith( diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 44ca5df415488..c5efa89130862 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -16,7 +16,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { memo, useMemo, type ComponentProps } from "react"; -import type { ThreadDetailsCard } from "./ThreadDetailsCard"; +import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; @@ -64,10 +64,7 @@ export interface ThreadDetailsPanelProps extends Pick< onDeleteProjectScript: (scriptId: string) => Promise; } -export function ThreadDetailsPanelContent( - props: ThreadDetailsPanelProps & { readonly density: "full" | "compact" | "essential" }, -) { - const { density } = props; +export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { const fileScripts = useT3ProjectFileScripts( props.environmentId, props.activeProjectScripts ? props.gitCwd : null, @@ -100,84 +97,96 @@ export function ThreadDetailsPanelContent( }; return ( - <> - -
- {density === "full" ? ( - + + {(density) => ( + <> + +
+ {density === "full" ? ( + + ) : null} + + {density !== "essential" && props.showOpenInPicker ? ( + + ) : null} + + {props.activeProjectScripts ? ( + + ) : null} +
+
+ + {props.gitCwd ? ( + +
+ {props.isGitRepo ? ( + + ) : null} + {props.activeProjectName ? ( + + ) : null} +
+
) : null} - {density !== "essential" && props.showOpenInPicker ? ( - + {density === "full" && !props.draftId ? ( + ) : null} - {props.activeProjectScripts ? ( - ) : null} -
-
- - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} -
-
- ) : null} - - {density === "full" && !props.draftId ? ( - - ) : null} - - {density === "full" && !props.draftId ? ( - - ) : null} - + + )} + ); } From 0a683cce4d5ae7098086897603f7868732389e6a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:54:39 +0000 Subject: [PATCH 084/143] refactor(web): drop lazy snooze calendar boundary --- .../web/src/components/CustomSnoozeDialog.tsx | 100 ++++++------------ 1 file changed, 33 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 2cca9144b9ae5..670df0e3e12e8 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; +import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; @@ -31,9 +32,6 @@ import { DialogFooter, } from "./ui/dialog"; -// react-day-picker and date-fns load with the dialog, not with the app. -const loadCalendar = () => import("./ui/calendar"); - type SnoozeChoice = { readonly snoozedUntil: string }; type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; const useRequest = create<{ request: Request | null }>(() => ({ request: null })); @@ -57,13 +55,6 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); - // Fetch the calendar while the user reads the dialog. Base UI sizes a popover once, as it - // opens, so the date picker waits for the calendar instead of opening around a placeholder. - // Chromium caches a failed dynamic import, so a failed load swaps in a typed date field. - const [calendar, setCalendar] = useState> | null>(null); - const [calendarFailed, setCalendarFailed] = useState(false); - useEffect(() => void loadCalendar().then(setCalendar, () => setCalendarFailed(true)), []); - const Calendar = calendar?.Calendar; const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const [mode, setMode] = useState("date"); const [date, setDate] = useState(initial); @@ -122,59 +113,39 @@ function CustomSnoozeDialog() {
- {calendarFailed ? ( - { - setDate(new Date(`${event.target.value}T00:00:00`)); - setError(null); - }} - /> - ) : ( - + + } > - - } - > - {date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - })} - - - - {Calendar && ( - { - setDate(selected); - setCalendarOpen(false); - setError(null); - }} - /> - )} - - - )} + {date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + { + setDate(selected); + setCalendarOpen(false); + setError(null); + }} + /> + +
- {mode === "date" && calendarFailed && ( -

- The calendar could not load. Type the date instead. -

- )} {error && (

{error} From afa537d6ba2dd443d090e3140912403f0da271e6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:54:53 +0000 Subject: [PATCH 085/143] refactor(web): drop precompressed asset builds --- apps/web/vite.config.ts | 2 -- apps/web/vite/precompress.ts | 56 ------------------------------------ 2 files changed, 58 deletions(-) delete mode 100644 apps/web/vite/precompress.ts diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e9e3bedd8c62e..60901de2632d5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,7 +16,6 @@ import { import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; -import { precompressPlugin } from "./vite/precompress"; import { lucideDynamicIconsPlugin, startupChunksPlugin } from "./vite/startupChunks"; import { tailwindPlugins } from "./vite/tailwind"; @@ -189,7 +188,6 @@ export default defineConfig(() => { lucideDynamicIconsPlugin(), startupChunksPlugin(), // Vercel compresses the hosted app itself; the T3 server serves these copies. - ...(process.env.VERCEL ? [] : [precompressPlugin()]), ], optimizeDeps: { include: [ diff --git a/apps/web/vite/precompress.ts b/apps/web/vite/precompress.ts deleted file mode 100644 index aea1d4e4d3a44..0000000000000 --- a/apps/web/vite/precompress.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Vite build hooks run outside an Effect runtime. -import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; -import * as NodeUtil from "node:util"; -import * as NodeZlib from "node:zlib"; - -import type { Plugin } from "vite-plus"; - -const brotli = NodeUtil.promisify(NodeZlib.brotliCompress); -const gzip = NodeUtil.promisify(NodeZlib.gzip); - -// Same types and floor as the server's on-the-fly compression. -const COMPRESSIBLE_FILE = /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/; -const MIN_BYTES = 1024; - -// Only hashed assets get sidecars, so compressed copies cannot describe stale content. -export function precompressPlugin(): Plugin { - return { - name: "t3code:precompress", - apply: "build", - async writeBundle(options, bundle) { - const outDir = options.dir; - if (!outDir) return; - const files = Object.keys(bundle).filter( - (fileName) => fileName.startsWith("assets/") && COMPRESSIBLE_FILE.test(fileName), - ); - await Promise.all( - files.map(async (fileName) => { - const filePath = NodePath.join(outDir, fileName); - const source = await NodeFSP.readFile(filePath); - if (source.byteLength < MIN_BYTES) return; - const [br, gz] = await Promise.all([ - brotli(source, { - params: { - [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 11, - [NodeZlib.constants.BROTLI_PARAM_MODE]: fileName.endsWith(".wasm") - ? NodeZlib.constants.BROTLI_MODE_GENERIC - : NodeZlib.constants.BROTLI_MODE_TEXT, - [NodeZlib.constants.BROTLI_PARAM_SIZE_HINT]: source.byteLength, - }, - }), - gzip(source, { level: 9 }), - ]); - await Promise.all([ - br.byteLength < source.byteLength - ? NodeFSP.writeFile(`${filePath}.br`, br) - : Promise.resolve(), - gz.byteLength < source.byteLength - ? NodeFSP.writeFile(`${filePath}.gz`, gz) - : Promise.resolve(), - ]); - }), - ); - }, - }; -} From 9d919c4b72689fa25e15937123833899e70d6a46 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:55:56 +0000 Subject: [PATCH 086/143] refactor(web): drop unmeasured palette caches --- .../src/components/CommandPalette.logic.ts | 19 +-- apps/web/src/components/CommandPalette.tsx | 160 +++++++----------- 2 files changed, 61 insertions(+), 118 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index c835f9105f285..0ba7a9d78f8f8 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -390,27 +390,12 @@ export function buildThreadActionItems(); - -function normalizeSearchTerm(term: string): string { - let normalized = normalizedTermCache.get(term); - if (normalized === undefined) { - if (normalizedTermCache.size >= NORMALIZED_TERM_CACHE_LIMIT) normalizedTermCache.clear(); - normalized = normalizeSearchText(term); - normalizedTermCache.set(term, normalized); - } - return normalized; -} - function rankSearchFieldMatch( field: string, normalizedQuery: string, queryTokens: ReadonlyArray, ): number { - const normalizedField = normalizeSearchTerm(field); + const normalizedField = normalizeSearchText(field); if ( normalizedField.length === 0 || !queryTokens.every((token) => normalizedField.includes(token)) @@ -507,7 +492,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 = normalizeSearchText(item.searchTerms.join(" ")); 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 13a9f9147b2a3..6596e451e3fc6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -195,7 +195,7 @@ import { reduceCommandPaletteUiState, type SearchOverlayMode, } from "./CommandPalette.logic"; -import { orderItemsByPreferredIds, pullRequestMatchesSearchQuery, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; +import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { @@ -242,7 +242,7 @@ import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, } from "../sidebarProjectGrouping"; -import type { Project, SidebarThreadSummary } from "../types"; +import type { Project } from "../types"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; @@ -1330,85 +1330,15 @@ function OpenCommandPaletteDialog(props: { startScratchThread, ]); - const threadItemOptions = useMemo( - () => ({ - ...(activeThreadId ? { activeThreadId } : {}), - projectTitleById, - sortOrder: clientSettings.sidebarThreadSortOrder, - icon: , - renderLeadingContent: (thread: (typeof threads)[number]) => ( - - ), - renderTrailingContent: (thread: (typeof threads)[number]) => ( - - ), - renderDescription: ( - thread: (typeof threads)[number], - { projectTitle }: { projectTitle: string | undefined }, - ) => { - const modelInstanceId = - thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = - providerEntryByEnvironmentAndInstanceId.get( - `${thread.environmentId}:${modelInstanceId}`, - ) ?? null; - return ( - - ); - }, - runThread: async (thread: Pick) => { - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, - }), - [ - activeThreadId, - clientSettings.sidebarThreadSortOrder, - navigate, - projectByKey, - projectEnvironmentLocationById, - projectTitleById, - providerEntryByEnvironmentAndInstanceId, - ], - ); - const baseThreadItems = useMemo( - () => buildThreadActionItems({ threads, ...threadItemOptions }), - [threadItemOptions, threads], - ); - // Message and PR number matches arrive per keystroke. Only the matched threads - // get new items; the rest keep their identity, so their rows skip re-rendering. - const allThreadItems = useMemo(() => { - if (threadContentMatchByKey.size === 0 && threadSearchQuery.trim().length === 0) { - return baseThreadItems; - } - const matchedThreads = threads.filter( - (thread) => - threadContentMatchByKey.has( - threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), - ) || pullRequestMatchesSearchQuery(thread, threadSearchQuery), - ); - if (matchedThreads.length === 0) return baseThreadItems; - const matchedItems = new Map( + const allThreadItems = useMemo( + () => buildThreadActionItems({ - threads: matchedThreads, - ...threadItemOptions, + threads, + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: clientSettings.sidebarThreadSortOrder, + icon: , + renderLeadingContent: (thread) => , renderTrailingContent: (thread) => ( <> ), + renderDescription: (thread, { projectTitle }) => { + const modelInstanceId = + thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = + providerEntryByEnvironmentAndInstanceId.get( + `${thread.environmentId}:${modelInstanceId}`, + ) ?? null; + return ( + + ); + }, getContentMatch: (thread) => { const match = threadContentMatchByKey.get( threadSearchMatchKey({ @@ -1434,17 +1390,28 @@ function OpenCommandPaletteDialog(props: { } : undefined; }, - }).map((item) => [item.value, item]), - ); - return baseThreadItems.map((item) => matchedItems.get(item.value) ?? item); - }, [ - baseThreadItems, - threadContentMatchByKey, - threadItemOptions, - threadSearch.query, - threadSearchQuery, - threads, - ]); + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }), + [ + activeThreadId, + clientSettings.sidebarThreadSortOrder, + navigate, + projectCwdById, + projectByKey, + projectEnvironmentLocationById, + projectTitleById, + providerEntryByEnvironmentAndInstanceId, + threadContentMatchByKey, + threadSearch.query, + threadSearchQuery, + threads, + ], + ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); const pushPaletteView = useCallback( @@ -3516,15 +3483,6 @@ function OpenCommandPaletteDialog(props: { ) : null; - // 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; - const executeLatestItem = useCallback( - (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => executeItemRef.current(item), - [], - ); - const footerActionLabel = newProjectFlow !== null ? highlightedItemValue === null @@ -3652,7 +3610,7 @@ function OpenCommandPaletteDialog(props: { highlightedItemValue={highlightedItemValue} isActionsOnly={isActionsOnly} keybindings={keybindings} - onExecuteItem={executeLatestItem} + onExecuteItem={executeItem} {...(addProjectCloneFlow?.step === "repository" ? { emptyStateMessage: From 25f5bac70ee5031c83cedcc46aee84c2ee220c86 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:55:57 +0000 Subject: [PATCH 087/143] refactor(web): drop effective shortcut cache --- apps/web/src/keybindings.ts | 64 ++++++++----------------------------- 1 file changed, 13 insertions(+), 51 deletions(-) diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 6831ac9649075..92affc39e5250 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -106,39 +106,16 @@ export function shortcutConflictKey( ].join("|"); } -type EffectiveShortcutsByCommand = ReadonlyMap; - -// Configs are immutable, so labels and keydowns can reuse their shortcut index. -const effectiveShortcutIndex = new WeakMap< - ResolvedKeybindingsConfig, - Map ->(); - -function effectiveShortcutIndexKey(platform: string, context: ShortcutMatchContext): string { - // `when` clauses only test truthiness, so the truthy names identify the context. - let key = platform; - for (const name of Object.keys(context).sort()) { - if (context[name]) key += `|${name}`; - } - return key; -} - -function effectiveShortcutsByCommand( +export function effectiveShortcutsForCommand( keybindings: ResolvedKeybindingsConfig, - platform: string, - context: ShortcutMatchContext, -): EffectiveShortcutsByCommand { - let byContext = effectiveShortcutIndex.get(keybindings); - if (!byContext) { - byContext = new Map(); - effectiveShortcutIndex.set(keybindings, byContext); - } - const key = effectiveShortcutIndexKey(platform, context); - const cached = byContext.get(key); - if (cached) return cached; - + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): KeybindingShortcut[] { + const platform = resolvePlatform(options); + const context = resolveContext(options); const claimedShortcuts = new Set(); - const byCommand = new Map(); + const effective: KeybindingShortcut[] = []; + for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; if (!binding) continue; @@ -150,22 +127,12 @@ function effectiveShortcutsByCommand( } claimedShortcuts.add(conflictKey); - const shortcuts = byCommand.get(binding.command); - if (shortcuts) shortcuts.push(binding.shortcut); - else byCommand.set(binding.command, [binding.shortcut]); + if (binding.command === command) { + effective.push(binding.shortcut); + } } - byContext.set(key, byCommand); - return byCommand; -} -export function effectiveShortcutsForCommand( - keybindings: ResolvedKeybindingsConfig, - command: KeybindingCommand, - options?: ShortcutMatchOptions, -): KeybindingShortcut[] { - const platform = resolvePlatform(options); - const context = resolveContext(options); - return [...(effectiveShortcutsByCommand(keybindings, platform, context).get(command) ?? [])]; + return effective; } function findEffectiveShortcutForCommand( @@ -173,12 +140,7 @@ function findEffectiveShortcutForCommand( command: KeybindingCommand, options?: ShortcutMatchOptions, ): KeybindingShortcut | null { - const shortcuts = effectiveShortcutsByCommand( - keybindings, - resolvePlatform(options), - resolveContext(options), - ).get(command); - return shortcuts?.[0] ?? null; + return effectiveShortcutsForCommand(keybindings, command, options)[0] ?? null; } function matchesCommandShortcut( From 1b49644a759f02a66d62e8f123537d4a96fac4ab Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:56:24 +0000 Subject: [PATCH 088/143] refactor(web): drop lazy onboarding split --- apps/web/src/cloud/connectOnboarding.ts | 56 ----------------- apps/web/src/cloud/managedAuth.test.ts | 63 ------------------- apps/web/src/cloud/managedAuth.tsx | 6 +- .../cloud/ConnectOnboardingDialog.tsx | 42 ++++++++----- apps/web/src/routes/__root.tsx | 34 ++-------- 5 files changed, 35 insertions(+), 166 deletions(-) delete mode 100644 apps/web/src/cloud/connectOnboarding.ts diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts deleted file mode 100644 index 1b54a4af3c6e2..0000000000000 --- a/apps/web/src/cloud/connectOnboarding.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as Schema from "effect/Schema"; -import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; - -/** - * Accounts that opted out of the post-sign-in T3 Connect onboarding wizard - * ("Don't show this again"). The wizard otherwise shows on every sign-in, - * since sign-out clears the connected environments. - */ -export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1"; - -export const ConnectOnboardingOptOutSchema = Schema.Struct({ - optOutAccounts: Schema.Array(Schema.String), -}); - -export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type; - -export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = { - optOutAccounts: [], -}; - -/** Lets startup observe sign-ins without importing Clerk. */ -export interface ManagedAccountState { - readonly isLoaded: boolean; - readonly isSignedIn: boolean | undefined; - readonly userId: string | null | undefined; -} - -export const ManagedAccountContext = createContext(null); - -// Observe outside the lazy wizard so sign-ins during its load aren't lost. -// A restored session must not re-prompt; account switches clear relay connections. -export function useConnectOnboardingRequest() { - const account = useContext(ManagedAccountContext); - const isLoaded = account?.isLoaded ?? false; - const isSignedIn = account?.isSignedIn; - const userId = account?.userId; - const [requestedAccount, setRequestedAccount] = useState(null); - const observedAccountRef = useRef(undefined); - - useEffect(() => { - if (!isLoaded) return; - // An incomplete signed-in snapshot must not look like a fresh sign-in later. - if (isSignedIn && !userId) return; - const previousAccount = observedAccountRef.current; - const nextAccount = isSignedIn && userId ? userId : null; - observedAccountRef.current = nextAccount; - if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) { - setRequestedAccount(nextAccount); - } - }, [isLoaded, isSignedIn, userId]); - - const clearRequestedAccount = useCallback(() => setRequestedAccount(null), []); - return { requestedAccount, clearRequestedAccount }; -} - -export type ConnectOnboardingRequest = ReturnType; diff --git a/apps/web/src/cloud/managedAuth.test.ts b/apps/web/src/cloud/managedAuth.test.ts index 8bd32c6ae91c3..63001bdb184da 100644 --- a/apps/web/src/cloud/managedAuth.test.ts +++ b/apps/web/src/cloud/managedAuth.test.ts @@ -1,16 +1,8 @@ import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import * as Effect from "effect/Effect"; -import { act, createElement, useLayoutEffect } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { - ManagedAccountContext, - type ConnectOnboardingRequest, - type ManagedAccountState, - useConnectOnboardingRequest, -} from "./connectOnboarding"; import { activateManagedRelayAuthentication, deactivateManagedRelayAuthentication, @@ -64,58 +56,3 @@ describe("managed relay authentication", () => { expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2"); }); }); - -describe("connect onboarding sign-in requests", () => { - let renderer: ReactTestRenderer | null = null; - let request: ConnectOnboardingRequest | null = null; - - function RequestProbe() { - const value = useConnectOnboardingRequest(); - useLayoutEffect(() => { - request = value; - }); - return null; - } - - const render = (account: ManagedAccountState) => - act(() => { - const tree = createElement( - ManagedAccountContext, - { value: account }, - createElement(RequestProbe), - ); - if (renderer === null) renderer = create(tree); - else renderer.update(tree); - }); - - afterEach(async () => { - await act(() => renderer?.unmount()); - renderer = null; - request = null; - vi.unstubAllGlobals(); - }); - - it("does not request the wizard for a restored session", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - await render({ isLoaded: false, isSignedIn: undefined, userId: undefined }); - await render({ isLoaded: true, isSignedIn: true, userId: null }); - await render({ isLoaded: true, isSignedIn: true, userId: "account-1" }); - - expect(request?.requestedAccount).toBeNull(); - }); - - // The wizard chunk loads lazily, so the request waits until it is taken. - it("holds each in-session sign-in until the wizard takes it", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - await render({ isLoaded: true, isSignedIn: false, userId: null }); - await render({ isLoaded: true, isSignedIn: true, userId: "account-1" }); - await render({ isLoaded: true, isSignedIn: true, userId: "account-1" }); - expect(request?.requestedAccount).toBe("account-1"); - - await act(() => request?.clearRequestedAccount()); - expect(request?.requestedAccount).toBeNull(); - - await render({ isLoaded: true, isSignedIn: true, userId: "account-2" }); - expect(request?.requestedAccount).toBe("account-2"); - }); -}); diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index edad78989e004..f9bf1c966f40b 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -6,13 +6,12 @@ import { settlePromise, } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; -import { useEffect, useMemo, useRef, type ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { environmentCatalog } from "../connection/catalog"; import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useAtomCommand } from "../state/use-atom-command"; -import { ManagedAccountContext } from "./connectOnboarding"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; export function deactivateManagedRelayAuthentication(): void { @@ -105,6 +104,5 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac useEffect(() => () => deactivateManagedRelayAuthentication(), []); - const account = useMemo(() => ({ isLoaded, isSignedIn, userId }), [isLoaded, isSignedIn, userId]); - return {children}; + return children; } diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index ed0a1e84999e1..f793a127f43bc 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -13,7 +13,6 @@ import { CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, - type ConnectOnboardingRequest, } from "~/cloud/connectOnboarding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; @@ -40,25 +39,21 @@ import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } fro * current session is authorized to manage the relay link, then lists the * account's T3 Connect environments so every device can be connected right * away. A cold load with a restored session does not count as a sign-in. - * `useConnectOnboardingRequest` observes the sign-ins outside this lazy chunk. */ -export function ConnectOnboardingDialog(props: ConnectOnboardingRequest) { +export function ConnectOnboardingDialog() { if (!hasCloudPublicConfig()) return null; - return ; + return ; } type OnboardingStep = "publish" | "devices"; const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial()); -function ConfiguredConnectOnboardingDialog({ - requestedAccount, - clearRequestedAccount, -}: ConnectOnboardingRequest) { +function ConfiguredConnectOnboardingDialog() { // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as // signed-out, or its later activation would look like a fresh sign-in. - const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const { isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const [optOutState, setOptOutState] = useLocalStorage( CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, @@ -88,6 +83,7 @@ function ConfiguredConnectOnboardingDialog({ ? ["publish", "devices"] : ["devices"]; + const [requestedAccount, setRequestedAccount] = useState(null); const [openForAccount, setOpenForAccount] = useState(null); const [step, setStep] = useState("devices"); const [exposeEnvironment, setExposeEnvironment] = useState(true); @@ -95,6 +91,7 @@ function ConfiguredConnectOnboardingDialog({ const [dontShowAgain, setDontShowAgain] = useState(false); const [isApplying, setIsApplying] = useState(false); const prefilledFromLinkStateRef = useRef(false); + const observedAccountRef = useRef(undefined); useEffect(() => { if (step === "publish" && sessionScopesKnown && !showPublishStep && !isApplying) { @@ -104,6 +101,24 @@ function ConfiguredConnectOnboardingDialog({ const optOutAccounts = optOutState.optOutAccounts; + // Every sign-in or account switch that completes during this session + // requests the wizard — account transitions clear the connected relay + // environments, so each new session starts with no devices to reach. A cold + // load observes undefined → account and must not re-prompt. + useEffect(() => { + if (!isLoaded) return; + // A loaded-but-incomplete snapshot (signed in, user id not yet populated) + // must not be recorded as signed-out — the next render would then look + // like a fresh sign-in on a cold load. + if (isSignedIn && !userId) return; + const previousAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) { + setRequestedAccount(nextAccount); + } + }, [isLoaded, isSignedIn, userId]); + // A manageable session implies a primary environment, so when the scopes // allow publishing, wait for the connection target too — otherwise the // wizard could open on the devices step moments before the publish step @@ -115,11 +130,11 @@ function ConfiguredConnectOnboardingDialog({ useEffect(() => { if (requestedAccount === null || openForAccount !== null) return; if (optOutAccounts.includes(requestedAccount)) { - clearRequestedAccount(); + setRequestedAccount(null); return; } if (!sessionScopesKnown || !publishStepDecided) return; - clearRequestedAccount(); + setRequestedAccount(null); prefilledFromLinkStateRef.current = false; setExposeEnvironment(true); setPublishAgentActivity(true); @@ -128,7 +143,6 @@ function ConfiguredConnectOnboardingDialog({ setOpenForAccount(requestedAccount); }, [ canManageRelay, - clearRequestedAccount, controller.linkState.target, openForAccount, optOutAccounts, @@ -144,9 +158,9 @@ function ConfiguredConnectOnboardingDialog({ setOpenForAccount(null); } if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) { - clearRequestedAccount(); + setRequestedAccount(null); } - }, [clearRequestedAccount, isSignedIn, openForAccount, requestedAccount, userId]); + }, [isSignedIn, openForAccount, requestedAccount, userId]); // Toggles default on, but an environment that is already linked should show // its actual configuration instead of silently proposing to rewrite it. diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5b7799ccb349d..98c5e97352585 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -12,7 +12,7 @@ import { useRouter, } from "@tanstack/react-router"; import { Check, Copy } from "lucide"; -import { lazy, Suspense, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; @@ -53,7 +53,8 @@ import { } from "../components/ui/toast"; import { isElectron } from "../env"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; -import { useConnectOnboardingRequest } from "../cloud/connectOnboarding"; +import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; +import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; @@ -89,29 +90,6 @@ import { getDesktopSnapShotBridge } from "../lib/desktopSnapShot"; import { installDesktopPasteAsText } from "../lib/desktopPasteAsText"; import { shouldResumeSnapShotSetupOnStartup } from "../lib/snapShotSetupResume"; -// The preload-error guard runs first and may resolve an import to undefined. -// Persistent optional-chunk failures must leave the shell usable. -const renderNothing = () => null; -const ConnectOnboardingDialog = lazy(() => - import("../components/cloud/ConnectOnboardingDialog") - .then((module) => ({ default: module?.ConnectOnboardingDialog ?? renderNothing })) - .catch(() => ({ default: renderNothing })), -); -const NightlyMobileBetaNotice = lazy(() => - import("../components/NightlyMobileBeta") - .then((module) => ({ default: module?.NightlyMobileBetaNotice ?? renderNothing })) - .catch(() => ({ default: renderNothing })), -); - -function ConnectOnboarding() { - const request = useConnectOnboardingRequest(); - return ( - - - - ); -} - export const Route = createRootRoute({ beforeLoad: async ({ location }) => { if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { @@ -279,7 +257,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} - {hasCloudPublicConfig() ? : null} + {hasCloudPublicConfig() ? : null} @@ -298,9 +276,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {isElectron ? : null} {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} - - - + {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} From daea839f8691f3d78067a9ff9649dcc00de23bbc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:56:26 +0000 Subject: [PATCH 089/143] refactor(web): drop route family repartition --- apps/web/vite/startupChunks.ts | 73 ---------------------------------- 1 file changed, 73 deletions(-) diff --git a/apps/web/vite/startupChunks.ts b/apps/web/vite/startupChunks.ts index ba17c6d898d1e..f133292f23302 100644 --- a/apps/web/vite/startupChunks.ts +++ b/apps/web/vite/startupChunks.ts @@ -13,9 +13,6 @@ const STARTUP_MODULES = { }; // Group static startup graphs without pulling chat-only code into other routes. // Bootstrap stays separate so it can report main's chunk-load failures. -const MAIN_SHARED_SET_SIZE = 100_000; -const sharedChunkName = (owner: string | undefined) => - owner === undefined || owner in STARTUP_MODULES ? null : owner; export function startupChunksPlugin(): Plugin { let root = process.cwd(); @@ -62,17 +59,6 @@ export function startupChunksPlugin(): Plugin { // Otherwise excluded bootstrap helpers are recaptured through main's imports. includeDependenciesRecursively: false, })), - { - name: (id: string) => - sharedChunkName(startupModuleOwners.get(normalizePath(id))), - debugName: "startup-shared", - tags: ["$initial" as const], - test: (id: string) => - !id.startsWith("\0") && - sharedChunkName(startupModuleOwners.get(normalizePath(id))) !== null, - entriesAware: false, - includeDependenciesRecursively: false, - }, ], }, }, @@ -104,65 +90,6 @@ export function startupChunksPlugin(): Plugin { if (!bootstrapModules.has(id) && !owners.has(id)) owners.set(id, name); } } - const codeLength = (id: string) => - this.getModuleInfo(moduleIds.get(id) ?? id)?.code?.length ?? 0; - // Group nested routes together: settings.general renders inside settings. - const startupEntries = new Set( - Object.values(STARTUP_MODULES).map((entry) => normalizePath(NodePath.join(root, entry))), - ); - const routesDir = normalizePath(NodePath.join(root, "src/routes/")); - const families = new Map>(); - for (const id of staticClosure(normalizePath(NodePath.join(root, STARTUP_MODULES.main)))) { - for (const lazyId of this.getModuleInfo(moduleIds.get(id) ?? id)?.dynamicallyImportedIds ?? - []) { - const entry = normalizePath(lazyId); - const route = entry.startsWith(routesDir) - ? entry.slice(routesDir.length).split(/[.?]/)[0] - : undefined; - // `_` routes render inside the chat layout. - if (startupEntries.has(entry) || route?.startsWith("_")) continue; - const family = route ?? entry; - families.set(family, [...(families.get(family) ?? []), entry]); - } - } - const familyGraphs = [...families].map( - ([family, entries]) => - [NodePath.basename(family).split(".")[0], staticClosure(...entries)] as const, - ); - // Shared chunks import only supersets of their users, avoiding unrelated chat code. - const setSizes = new Map(); - for (const [id, owner] of owners) { - if (owner === "main") continue; - const users = familyGraphs.filter(([, graph]) => graph.has(id)).map(([family]) => family); - if (users.length === 0) continue; - const set = `${[...new Set(users)].sort().join("-")}-shared`; - owners.set(id, set); - setSizes.set(set, (setSizes.get(set) ?? 0) + codeLength(id)); - } - // Absorb small sets only when their imports move too, so main stays self-contained. - const toMain = new Set( - [...owners].flatMap(([id, owner]) => - (setSizes.get(owner) ?? Infinity) < MAIN_SHARED_SET_SIZE ? [id] : [], - ), - ); - for (let changed = true; changed;) { - changed = false; - for (const id of toMain) { - const imports = this.getModuleInfo(moduleIds.get(id) ?? id)?.importedIds ?? []; - if ( - imports.some((dependency) => { - const owner = owners.get(normalizePath(dependency)); - return ( - owner !== undefined && owner !== "main" && !toMain.has(normalizePath(dependency)) - ); - }) - ) { - toMain.delete(id); - changed = true; - } - } - } - for (const id of toMain) owners.set(id, "main"); startupModuleOwners = owners; }, generateBundle: { From 9db4a5fe3adfd0d42eb78e410726ec7750a1d5ea Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:56:46 +0000 Subject: [PATCH 090/143] refactor(web): retain onboarding opt out state --- apps/web/src/cloud/connectOnboarding.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/web/src/cloud/connectOnboarding.ts diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts new file mode 100644 index 0000000000000..7d61e11c6c6a1 --- /dev/null +++ b/apps/web/src/cloud/connectOnboarding.ts @@ -0,0 +1,18 @@ +import * as Schema from "effect/Schema"; + +/** + * Accounts that opted out of the post-sign-in T3 Connect onboarding wizard + * ("Don't show this again"). The wizard otherwise shows on every sign-in, + * since sign-out clears the connected environments. + */ +export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1"; + +export const ConnectOnboardingOptOutSchema = Schema.Struct({ + optOutAccounts: Schema.Array(Schema.String), +}); + +export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type; + +export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = { + optOutAccounts: [], +}; From 2d2840d1b81b534a5fd9a23acbf1541dee6aea1e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:02:11 +0000 Subject: [PATCH 091/143] refactor(web): drop speculative descriptor discovery --- apps/web/src/connection/platform.ts | 50 ++++------------------------- apps/web/src/routes/__root.tsx | 2 -- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 8196d75ff1fb9..edca35eb3a069 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -28,7 +28,6 @@ import { type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, type EnvironmentId, - type ExecutionEnvironmentDescriptor, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -343,52 +342,15 @@ const layerCapabilities = Layer.effectContext( }), ); -let primaryDescriptorPrefetchStarted = false; -let primaryDescriptorPrefetch: { - readonly httpBaseUrl: string; - readonly startedAt: number; - readonly settled: Promise; -} | null = null; - -// Start discovery before route chunks occupy the browser's connections. -export function prefetchPrimaryEnvironmentDescriptor(): void { - if (primaryDescriptorPrefetchStarted) return; - primaryDescriptorPrefetchStarted = true; - const read = readPrimaryEnvironmentTargetResult(); - if (read._tag === "Failure" || read.target === null) return; - const httpBaseUrl = read.target.target.httpBaseUrl; - primaryDescriptorPrefetch = { - httpBaseUrl, - startedAt: Date.now(), - settled: Effect.runPromise( - fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - ), - ).catch(() => null), - }; -} - -const PREFETCHED_DESCRIPTOR_MAX_AGE_MS = 10_000; - const loadPrimaryConnectionRegistration = Effect.fn( "web.connectionPlatform.loadPrimaryConnectionRegistration", )(function* (resolved: PrimaryEnvironmentTarget) { - const prefetch = primaryDescriptorPrefetch; - primaryDescriptorPrefetch = null; - const now = yield* Clock.currentTimeMillis; - const prefetched = - prefetch?.httpBaseUrl === resolved.target.httpBaseUrl && - now - prefetch.startedAt <= PREFETCHED_DESCRIPTOR_MAX_AGE_MS - ? yield* Effect.promise(() => prefetch.settled) - : null; - const descriptor = - prefetched ?? - (yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolved.target.httpBaseUrl, - }).pipe( - Effect.provide(PrimaryEnvironmentHttpLayer.layer), - Effect.mapError(mapRemoteEnvironmentError), - )); + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provide(PrimaryEnvironmentHttpLayer.layer), + Effect.mapError(mapRemoteEnvironmentError), + ); return new PrimaryConnectionRegistration({ target: new PrimaryConnectionTarget({ environmentId: descriptor.environmentId, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 98c5e97352585..cb1557df2eb36 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -80,7 +80,6 @@ import { } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; import { startEnvironmentShells } from "../state/shell"; -import { prefetchPrimaryEnvironmentDescriptor } from "../connection/platform"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -110,7 +109,6 @@ export const Route = createRootRoute({ } const authGate = resolveInitialServerAuthGateState(); - prefetchPrimaryEnvironmentDescriptor(); const authGateState = await authGate; // Connect and read cached shells while the route chunks still load. if (authGateState.status === "authenticated") startEnvironmentShells(); From 39282e29749b71b6868f366feb9ed4118b04e083 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:03:19 +0000 Subject: [PATCH 092/143] refactor(web): drop composer document cache --- apps/web/src/composer-rich-text-doc.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index b9f0bce62c6fc..8c376fb5d93f7 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1354,24 +1354,10 @@ export function serializeSelection(doc: ProseMirrorNode, from: number, to: numbe depth -= 1; } } - return serializeEditorDocUncached(doc.type.create(null, content)).value; + return serializeEditorDoc(doc.type.create(null, content)).value; } -// ProseMirror docs are immutable, so a doc serializes the same way every time. -// One keystroke asks for the same doc several times (update and selection -// listeners, cursor reads), and only the first pays for the walk. -const serializedDocs = new WeakMap(); - -/** The returned map is shared between callers and must not be mutated. */ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { - const cached = serializedDocs.get(doc); - if (cached) return cached; - const map = serializeEditorDocUncached(doc); - serializedDocs.set(doc, map); - return map; -} - -function serializeEditorDocUncached(doc: ProseMirrorNode): RichDocMap { const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; doc.content.forEach((node) => { From dc3b58e3aeb0be76a4ef4ed557e9559eb4cb854e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:15 +0000 Subject: [PATCH 093/143] refactor(web): drop media query cache --- apps/web/src/hooks/useMediaQuery.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index cd5f1af198d89..e7e007b0b7c6d 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -61,26 +61,13 @@ export type MediaQueryInput = { pointer?: "coarse" | "fine"; }; -// A MediaQueryList stays live, so one per query serves every render. -// matchMedia() on each render was a measurable share of a keystroke. -const mediaQueryLists = new Map(); - -function mediaQueryList(mediaQuery: string): MediaQueryList { - let mql = mediaQueryLists.get(mediaQuery); - if (!mql) { - mql = window.matchMedia(mediaQuery); - mediaQueryLists.set(mediaQuery, mql); - } - return mql; -} - export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): boolean { const mediaQuery = parseQuery(query); const subscribe = useCallback( (callback: () => void) => { if (typeof window === "undefined") return () => {}; - const mql = mediaQueryList(mediaQuery); + const mql = window.matchMedia(mediaQuery); mql.addEventListener("change", callback); return () => mql.removeEventListener("change", callback); }, @@ -89,7 +76,7 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string const getSnapshot = useCallback(() => { if (typeof window === "undefined") return false; - return mediaQueryList(mediaQuery).matches; + return window.matchMedia(mediaQuery).matches; }, [mediaQuery]); return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); From 29c7bb5b316c2210c286fe1c4dd218378ce301cf Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:20 +0000 Subject: [PATCH 094/143] refactor(web): drop changed file tree cache --- apps/web/src/lib/turnDiffTree.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index 884cc67ec0d31..cd9bfc831fbb3 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -30,9 +30,7 @@ interface MutableDirectoryNode { files: TurnDiffTreeFileNode[]; } -// One collator for every comparison: localeCompare with options builds a new -// collator per call, which dominated building a large turn tree. -const NAME_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" }; function normalizePathSegments(pathValue: string): string[] { return pathValue @@ -42,7 +40,7 @@ function normalizePathSegments(pathValue: string): string[] { } function compareByName(a: { name: string }, b: { name: string }): number { - return NAME_COLLATOR.compare(a.name, b.name); + return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS); } function readStat(file: TurnDiffFileChange): TurnDiffStat | null { @@ -112,20 +110,7 @@ export function summarizeTurnDiffStats(files: ReadonlyArray) ); } -// A turn's file list is immutable, and its tree card remounts whenever the -// timeline virtualizes it back into view, so the tree is built once per list. -const treeByFiles = new WeakMap, TurnDiffTreeNode[]>(); - -/** The returned nodes are shared between callers and must not be mutated. */ export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { - const cached = treeByFiles.get(files); - if (cached) return cached; - const tree = buildTurnDiffTreeUncached(files); - treeByFiles.set(files, tree); - return tree; -} - -function buildTurnDiffTreeUncached(files: ReadonlyArray): TurnDiffTreeNode[] { const root: MutableDirectoryNode = { name: "", path: "", From 84024af3ea449c1819f710093f6038a0f7d372b5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:24 +0000 Subject: [PATCH 095/143] refactor(web): drop asynchronous favicon encoding --- apps/web/src/assets/projectFaviconCache.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts index 2eb1cbf4500a1..b44e919af4b3d 100644 --- a/apps/web/src/assets/projectFaviconCache.ts +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -4,7 +4,6 @@ import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, } from "@t3tools/client-runtime/project-favicon-cache"; -import { readFileAsDataUrl } from "../lib/imageCompression"; const DATABASE_NAME = "t3code:project-favicons"; const DATABASE_VERSION = 2; @@ -63,14 +62,7 @@ async function downscaleProjectFavicon( if (!context) throw new Error("Canvas is unavailable."); context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); - // Thumbnail encoding runs asynchronously so it cannot hold up the first keystroke. - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, "image/webp", 0.85), - ); - signal.throwIfAborted(); - if (!blob) throw new Error("Project icon thumbnail could not be encoded."); - const dataUrl = await readFileAsDataUrl(blob); - signal.throwIfAborted(); + const dataUrl = canvas.toDataURL("image/webp", 0.85); if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; } throw new Error("Project icon thumbnail exceeds the cache limit."); From d6672a9ff44c26ed5752ec0e52cf9614f3aa189e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:27 +0000 Subject: [PATCH 096/143] refactor(web): drop monogram segmenter fast path --- apps/web/src/components/ProjectMonogram.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx index 885b67073f88f..04a44e166ea2b 100644 --- a/apps/web/src/components/ProjectMonogram.tsx +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -2,14 +2,7 @@ import type { ProjectIconColor } from "@t3tools/contracts"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; -// Creating a Segmenter loads ICU data (12-16 ms). Printable ASCII has one grapheme -// per character, so only other monograms need it. -let monogramSegmenter: Intl.Segmenter | null = null; -function graphemeCount(text: string): number { - if (/^[\x20-\x7e]*$/.test(text)) return text.length; - monogramSegmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" }); - return Array.from(monogramSegmenter.segment(text)).length; -} +const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); export function ProjectMonogram({ text, @@ -47,7 +40,7 @@ export function ProjectMonogram({ className="font-mono" fontSize="8.25" fontWeight="700" - textLength={graphemeCount(text) === 1 ? 6 : 12} + textLength={Array.from(monogramSegmenter.segment(text)).length === 1 ? 6 : 12} lengthAdjust="spacingAndGlyphs" textRendering="geometricPrecision" > From 08d40b0f15dfe71831a02776caa3e9d7864ae3f2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:31 +0000 Subject: [PATCH 097/143] refactor(web): drop empty prompt measurement fast path --- apps/web/src/components/chat/useComposerMultilinePrompt.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.ts b/apps/web/src/components/chat/useComposerMultilinePrompt.ts index 7f5615ceb7b35..10353cf5ff267 100644 --- a/apps/web/src/components/chat/useComposerMultilinePrompt.ts +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.ts @@ -30,11 +30,8 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { const next = measureComposerMultilinePrompt(body); if (next !== null) setIsMultiline(next); }; + measure(); const editor = body.querySelector('[data-testid="composer-editor"]'); - // An empty prompt is one line. Skipping the read keeps a fresh composer from forcing - // a layout mid-commit; the observers below still measure once layout settles. - if (editor && editor.childElementCount <= 1 && editor.textContent === "") setIsMultiline(false); - else measure(); const stopObserving = observeResize(editor ? [body, editor] : body, measure); const mutationObserver = new MutationObserver(measure); mutationObserver.observe(body, { childList: true, characterData: true, subtree: true }); From e428526bc18ab7ab80729f37aa9534074475cd9d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:34 +0000 Subject: [PATCH 098/143] refactor(web): drop resize observer first paint shortcut --- apps/web/src/components/RightPanelTabs.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f4558fed87b8b..5c8323a7ae34d 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1122,13 +1122,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (!viewport) return; const content = viewport.firstElementChild; - // The first delivery reports the initial sizes once layout has run, so - // there is no synchronous read here forcing an early layout. const stopObserving = observeResize( content ? [viewport, content] : viewport, updateTabScrollState, ); viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); + updateTabScrollState(); return () => { stopObserving(); From 25e0b304f51a76578bee9abd55fce263d46d4e9e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:38 +0000 Subject: [PATCH 099/143] refactor(web): drop client trace interval change --- apps/web/src/observability/clientTracing.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 2d6f761b6a63b..8dbdbe55d891f 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -13,9 +13,7 @@ import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer" import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; -// Every export is a POST the page serializes and the server writes, and boot -// and background polling produce spans every second. -const DEFAULT_EXPORT_INTERVAL_MS = 5_000; +const DEFAULT_EXPORT_INTERVAL_MS = 1_000; const CLIENT_TRACING_RESOURCE = { serviceName: "t3code-web", attributes: { From b293001bcbbd786e11b1b0986c715c457af10823 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:44 +0000 Subject: [PATCH 100/143] refactor(web): drop parallel terminal initialization --- apps/web/src/terminal/ghostty/runtime.ts | 45 ++++++++---------------- apps/web/src/terminal/ghostty/surface.ts | 16 ++++----- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index b74ac2d502165..ad06389def55c 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -19,24 +19,6 @@ type TypeLayouts = Readonly>; const textDecoder = new TextDecoder(); -/** - * Compile while the bytes download when the server labels the file as WASM; - * otherwise (some static hosts and custom protocols) fall back to buffering. - */ -async function compileWasm(url: string, label: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Unable to load ${label} (${response.status})`); - } - if ( - typeof WebAssembly.compileStreaming === "function" && - response.headers.get("content-type")?.split(";")[0]?.trim() === "application/wasm" - ) { - return WebAssembly.compileStreaming(response); - } - return WebAssembly.compile(await response.arrayBuffer()); -} - export class GhosttyRuntime { readonly memory: WebAssembly.Memory; readonly layouts: TypeLayouts; @@ -62,12 +44,10 @@ 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"), - ]); + const response = await fetch(ghosttyWasmUrl); + if (!response.ok) { + throw new Error(`Unable to load libghostty-vt (${response.status})`); + } let instance: WebAssembly.Instance | undefined; const imports = { env: { @@ -80,9 +60,10 @@ export class GhosttyRuntime { }, }, }; - instance = await WebAssembly.instantiate(module, imports); - const runtime = new GhosttyRuntime(instance); - await runtime.installWritePtyTrampoline(trampolineModule); + const result = await WebAssembly.instantiate(await response.arrayBuffer(), imports); + instance = result.instance; + const runtime = new GhosttyRuntime(result.instance); + await runtime.installWritePtyTrampoline(); return runtime; } @@ -212,8 +193,12 @@ export class GhosttyRuntime { } } - private async installWritePtyTrampoline(module: WebAssembly.Module): Promise { - const trampolineInstance = await WebAssembly.instantiate(module, { + private async installWritePtyTrampoline(): Promise { + const response = await fetch(ghosttyWritePtyWasmUrl); + if (!response.ok) { + throw new Error(`Unable to load the libghostty-vt PTY trampoline (${response.status})`); + } + const result = await WebAssembly.instantiate(await response.arrayBuffer(), { env: { t3_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => { const writer = this.ptyWriters.get(userdata); @@ -222,7 +207,7 @@ export class GhosttyRuntime { }, }, }); - const trampoline = trampolineInstance.exports.ghostty_write_pty; + const trampoline = result.instance.exports.ghostty_write_pty; const table = this.exports.__indirect_function_table; if (typeof trampoline !== "function" || !(table instanceof WebAssembly.Table)) { throw new Error("libghostty-vt did not expose its callback table"); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 4675e4ce04cd3..39ae0ebb024ae 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -14,7 +14,6 @@ import { type GhosttyCellRange, type GhosttyCellMetrics, } from "./renderer"; -import { loadGhosttyRuntime } from "./runtime"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; import { observeResize } from "../../lib/observeResize"; @@ -723,13 +722,14 @@ export class GhosttyTerminalSurface { context.fillStyle = `rgb(${options.theme.background.r}, ${options.theme.background.g}, ${options.theme.background.b})`; context.fillRect(0, 0, canvas.width, canvas.height); const fontSize = terminalFontSize(options.font?.size); - // Cell metrics must come from the faces that will render; measuring before - // the bundled webfonts load would size the grid from a fallback font. - const [, fontFamily] = await Promise.all([ - ensureTerminalSymbolsFont(), - loadTerminalFontFamily(options.font?.family, fontSize), - loadGhosttyRuntime(), - ]); + try { + // Cell metrics must come from the faces that will render; measuring before + // the bundled webfonts load would size the grid from a fallback font. + await ensureTerminalSymbolsFont(); + } catch { + // Metrics fall back to whichever faces are already available. + } + const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize); const metrics = measureGhosttyCell(context, fontSize, fontFamily); const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); const core = await GhosttyTerminalCore.create( From db8b9251592b8e9b26ee784797758e5c7b50ab9d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:48 +0000 Subject: [PATCH 101/143] refactor(web): drop font preview visibility gate --- .../settings/SettingsFontPreviews.tsx | 35 +++---------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index c77c81bf9a0ae..4715583e4005c 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -1,5 +1,5 @@ import { preloadPatchFile } from "@pierre/diffs/ssr"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; import { EMPTY_COMPOSER_CONTEXT_RECORDS } from "../composerContextPresentation"; import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; @@ -17,27 +17,6 @@ import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; const EMPTY_SKILLS: ReadonlyArray = []; -// Below-fold previews defer their grammars, fonts, and WASM until visible. -function useRevealed(ref: RefObject): boolean { - const [revealed, setRevealed] = useState(false); - useEffect(() => { - const element = ref.current; - if (revealed || element === null) return; - if (typeof IntersectionObserver === "undefined") { - setRevealed(true); - return; - } - const observer = new IntersectionObserver((entries) => { - if (!entries.some((entry) => entry.isIntersecting)) return; - observer.disconnect(); - setRevealed(true); - }); - observer.observe(element); - return () => observer.disconnect(); - }, [ref, revealed]); - return revealed; -} - // 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. @@ -146,11 +125,8 @@ function StaticDiffHtml({ html }: { html: string }) { 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); @@ -158,8 +134,8 @@ export function CodeFontPreview() { return () => { cancelled = true; }; - }, [revealed, themeName]); - if (htmlByFile === null) return

; + }, [themeName]); + if (htmlByFile === null) return null; return (
{htmlByFile.map((html) => ( @@ -208,7 +184,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu const surfaceRef = useRef(null); const fontRef = useRef({ family, size }); const { theme, resolvedTheme } = useTheme(); - const revealed = useRevealed(mountRef); useEffect(() => { const current = fontRef.current; @@ -228,7 +203,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu useEffect(() => { const mount = mountRef.current; - if (!mount || !revealed) return; + 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. @@ -286,7 +261,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu surfaceRef.current?.dispose(); surfaceRef.current = null; }; - }, [revealed]); + }, []); return (
Date: Thu, 15 Oct 2026 02:04:53 +0000 Subject: [PATCH 102/143] refactor(web): drop lazy proof signing --- apps/web/src/cloud/dpop.ts | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 5ea87f6c98f6c..2569108563b3a 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -8,7 +8,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import type { JWK } from "jose"; +import { importJWK, SignJWT, type JWK } from "jose"; export interface BrowserDpopKey { readonly privateKey: CryptoKey; @@ -26,9 +26,6 @@ const DPOP_DATABASE_VERSION = 1; const DPOP_KEY_STORE_NAME = "keys"; const DPOP_KEY_ID = "relay-dpop-proof-key"; const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); -// jose only signs proofs for T3 Connect, so local-only sessions never load it. -const loadJoseKeyImport = () => import("jose/key/import"); -const loadJoseJwtSign = () => import("jose/jwt/sign"); export const layer = Layer.succeed( Crypto.Crypto, @@ -135,11 +132,7 @@ export const generateBrowserDpopKey = Effect.gen(function* () { ), ); const privateKey = yield* Effect.tryPromise({ - try: () => - loadJoseKeyImport().then( - ({ importJWK }) => - importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, - ), + try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, catch: (cause) => dpopError("Could not import DPoP private key.", cause), }); return { @@ -172,21 +165,19 @@ export function createBrowserDpopProof(input: { ); const proof = yield* Effect.tryPromise({ try: () => - loadJoseJwtSign().then(({ SignJWT }) => - new SignJWT({ - htm: input.method.toUpperCase(), - htu: normalizedUrl.toString(), - jti, - ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + new SignJWT({ + htm: input.method.toUpperCase(), + htu: normalizedUrl.toString(), + jti, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + }) + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: input.proofKey.publicJwk, }) - .setProtectedHeader({ - typ: "dpop+jwt", - alg: "ES256", - jwk: input.proofKey.publicJwk, - }) - .setIssuedAt() - .sign(input.proofKey.privateKey), - ), + .setIssuedAt() + .sign(input.proofKey.privateKey), catch: (cause) => dpopError("Could not sign DPoP proof.", cause), }); return { proof, thumbprint: input.proofKey.thumbprint }; From ac7a47c5c10606708ac385a80a2a90b557bf7f5c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:57 +0000 Subject: [PATCH 103/143] refactor(web): drop sidebar route preloads --- .../src/components/sidebar/SidebarChrome.tsx | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 2faf66cb9265a..ae05529374c38 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,7 +1,7 @@ import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -147,26 +147,17 @@ function SidebarUtilityItem({ icon, label, onClick, - onIntent, }: { icon: ReactNode; label: string; onClick: () => void; - /** Hover or focus: start loading the destination before the click. */ - onIntent?: () => void; }) { return ( + {icon} } @@ -201,18 +192,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { closeMobileSidebar(); void navigate({ to: "/settings" }); }, [closeMobileSidebar, navigate]); - // These are buttons, not Links, so the router's hover preloading misses - // them. Opening a page renders synchronously before its chunks are fetched; - // fetching on intent takes the download off that path. - const router = useRouter(); - const preloadSettings = useCallback(() => { - void router.preloadRoute({ to: "/settings/general" }).catch(() => undefined); - }, [router]); - const preloadPullRequests = useCallback(() => { - void router - .preloadRoute({ to: "/pull-requests", search: readPullRequestListPreferences() }) - .catch(() => undefined); - }, [router]); const handleUsageClick = useCallback(() => { if (isMobile) { @@ -241,14 +220,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { icon={} label="Settings" onClick={handleSettingsClick} - onIntent={preloadSettings} /> {pullRequestsSupported ? ( } label="Pull Requests" onClick={handlePullRequestsClick} - onIntent={preloadPullRequests} /> ) : null} Date: Thu, 15 Oct 2026 02:05:03 +0000 Subject: [PATCH 104/143] refactor(web): drop lazy sidebar navigation --- apps/web/src/components/AppSidebarLayout.tsx | 33 +++----------------- apps/web/src/routes/settings.tsx | 7 ----- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 396947dd576f1..fd56ee8ca5ac9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,12 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { - lazy, - Suspense, useEffect, useState, useSyncExternalStore, - type ComponentType, type CSSProperties, type ReactNode, } from "react"; @@ -34,8 +31,10 @@ import { usePanelAnimationSettings, usePanelNavigationSuppression, } from "../panelAnimations"; +import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; @@ -58,26 +57,6 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -// Only the sidebar the legacy setting picks should be in the startup graph. -// While a sidebar chunk loads, or if it fails, the column keeps its chrome -// header; a stale deploy gets its `vite:preloadError` reload first. -function SidebarChromeShell() { - return ; -} -const renderNoSettingsNav = (_props: { pathname: string }) => null; -const LegacyThreadSidebar = lazy(() => - import("./LegacySidebar") - .then((module) => ({ default: module?.default ?? SidebarChromeShell })) - .catch(() => ({ default: SidebarChromeShell })), -); -/** The settings route loads the nav alongside its own chunks instead of after its first paint. */ -export const loadSettingsSidebarNav = () => import("./settings/SettingsSidebarNav"); -const SettingsSidebarNav = lazy>(() => - loadSettingsSidebarNav() - .then((module) => ({ default: module?.SettingsSidebarNav ?? renderNoSettingsNav })) - .catch(() => ({ default: renderNoSettingsNav })), -); - const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; function subscribeToViewportWidth(onChange: () => void): () => void { @@ -363,14 +342,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {isOnSettings ? ( <> - - - + ) : legacySidebarEnabled ? ( - }> - - + ) : ( )} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 0f1e95ff9a031..a1e02c12d9292 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -5,7 +5,6 @@ import { Button } from "../components/ui/button"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; -import { loadSettingsSidebarNav } from "../components/AppSidebarLayout"; import { SidebarInset } from "../components/ui/sidebar"; import { useNavigateToMainApp } from "../components/sidebar/mainAppLocation"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; @@ -191,11 +190,5 @@ export const Route = createFileRoute("/settings")({ throw redirect({ to: "/settings/general", replace: true }); } }, - // The app sidebar renders the settings nav lazily. A failed load only drops the nav. - loader: () => - loadSettingsSidebarNav().then( - () => undefined, - () => undefined, - ), component: SettingsRouteLayout, }); From 409449f193bc9c5796ba25d5bbaac3b33599b840 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:07:36 +0000 Subject: [PATCH 105/143] refactor(web): drop draft motion subscription optimization --- apps/web/src/components/Sidebar.tsx | 62 ++++++++++------------------- 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4318a2a378454..ce1b542cc76e2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -102,7 +102,6 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; -import { useShallow } from "zustand/react/shallow"; import { useParams, useRouter } from "@tanstack/react-router"; import { useRightPanelStore } from "../rightPanelStore"; @@ -2716,35 +2715,25 @@ export default function Sidebar() { // (every non-promoted session with content); it can overcount by one for // an open never-left draft, which only softens the empty state. const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null; - const [visibleDraftSessionCount, inactiveDraftSessionCount, activeDraftSessionPresent] = - useComposerDraftStore( - useShallow((store) => { - let count = 0; - let inactiveCount = 0; - let activePresent = false; - for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { - if (session.promotedTo != null) { - continue; - } - if ( - scopedProjectKeys !== null && - !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) - ) { - continue; - } - const isActive = draftKey === routeDraftIdForRows; - // The active row is frozen or absent while typing. Its live session still - // controls send/discard removal, independently of whether it has content. - if (isActive) activePresent = true; - if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { - continue; - } - count += 1; - if (!isActive) inactiveCount += 1; - } - return [count, inactiveCount, activePresent] as const; - }), - ); + const visibleDraftSessionCount = useComposerDraftStore((store) => { + let count = 0; + for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if (!composerDraftHasUserContent(store.draftsByThreadKey[draftKey])) { + continue; + } + if ( + scopedProjectKeys !== null && + !scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + ) { + continue; + } + count += 1; + } + return count; + }); // Scope flips drop the selection: rows selected under the old scope may be // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { @@ -3895,28 +3884,19 @@ export default function Sidebar() { [sidebarListItems], ); const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0; - // The rows that arrive with the first cached or live shell are the initial - // list, so they paint in place instead of fading in. - const listMotionArmedRef = useRef(false); useLayoutEffect(() => { // Drag release clears the baseline, so its commit cannot replay the // sortable preview; rows glide from their released positions instead. // Later thread actions can animate while writes settle. // Draft navigation can reveal a frozen row without changing the draft count. - // Typing into a never-left draft changes the empty-state count, not the rows. void sidebarListOrderKey; - listMotionRef.current?.update( - listMotionArmedRef.current && !listMotionPaused && sidebarListHasRows, - ); - listMotionArmedRef.current = shellsBootstrapped; + listMotionRef.current?.update(!listMotionPaused && sidebarListHasRows); }, [ listMotionPaused, routeDraftIdForRows, - shellsBootstrapped, sidebarListHasRows, sidebarListOrderKey, - inactiveDraftSessionCount, - activeDraftSessionPresent, + visibleDraftSessionCount, ]); const handleThreadDragOver = useCallback( (event: DragOverEvent) => { From e402f41b12c8f151d4fc6bd71c99ef75c8311aac Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:09:12 +0000 Subject: [PATCH 106/143] test(web): trim chunk reload regression cases --- apps/web/src/lib/chunkReloadGuard.test.ts | 73 +++++------------------ 1 file changed, 16 insertions(+), 57 deletions(-) diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 7816d77740ccf..1804aebb6d0ac 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -45,66 +45,25 @@ describe("reloadOnceForChunkLoadError", () => { expect(reload).toHaveBeenCalledTimes(1); }); - it("reloads for each deploy that fails a chunk, however soon after the last reload", () => { + it("gives later builds their own reloads after the previous build exhausts its limit", () => { const storage = createStorageStub(); const reload = vi.fn(); - - // Deploy B fails a chunk of the open build, then deploy C, seconds later, - // fails a chunk of the build B reload loaded. - expect( - reloadOnceForChunkLoadError(BUILD_A, failedImport("settings-a.js"), () => storage, reload), - ).toBe(true); - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("diff-b.js"), () => storage, reload), - ).toBe(true); - expect(reload).toHaveBeenCalledTimes(2); - - // A chunk of build B that keeps failing still stops after its one reload. - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("diff-b.js"), () => storage, reload), - ).toBe(false); - expect(reload).toHaveBeenCalledTimes(2); - }); - - it("stops reloading once a build has used its reloads", () => { - const storage = createStorageStub(); - const reload = vi.fn(); - - for (let index = 0; index < 20; index += 1) { - reloadOnceForChunkLoadError( - BUILD_B, - failedImport(`chunk-${index}.js`), - () => storage, - reload, - ); + for (const build of [BUILD_A, BUILD_B]) { + for (let index = 0; index < 20; index += 1) { + expect( + reloadOnceForChunkLoadError( + build, + failedImport(`chunk-${index}.js`), + () => storage, + reload, + ), + ).toBe(true); + } + expect( + reloadOnceForChunkLoadError(build, failedImport("chunk-20.js"), () => storage, reload), + ).toBe(false); } - - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("chunk-20.js"), () => storage, reload), - ).toBe(false); - expect(reload).toHaveBeenCalledTimes(20); - }); - - it("gives each new build its own reloads, however many earlier builds used", () => { - const storage = createStorageStub(); - const reload = vi.fn(); - - for (let index = 0; index < 20; index += 1) { - reloadOnceForChunkLoadError( - BUILD_A, - failedImport(`chunk-${index}.js`), - () => storage, - reload, - ); - } - - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("chunk-0.js"), () => storage, reload), - ).toBe(true); - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("chunk-0.js"), () => storage, reload), - ).toBe(false); - expect(reload).toHaveBeenCalledTimes(21); + expect(reload).toHaveBeenCalledTimes(40); }); it("never reloads when storage is blocked, so a persistent failure cannot loop", () => { From ed536a168ccd38470b54d51399dec4e499090c2e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:11:04 +0000 Subject: [PATCH 107/143] test(web): simplify scoped palette and reload regressions --- .../components/CommandPalette.logic.test.ts | 21 ++------ apps/web/src/lib/chunkReloadGuard.test.ts | 50 +++++++------------ 2 files changed, 22 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 7c3868a994e11..5ad00e3241cd5 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -368,31 +368,18 @@ describe("buildProjectActionItems", () => { describe("buildThreadActionItems", () => { it("keeps equal thread ids in different environments distinct and navigable", async () => { - const local = makeThread({ title: "Local work" }); - const remote = makeThread({ - id: local.id, - environmentId: EnvironmentId.make("environment-remote"), - title: "Remote work", - }); + const local = makeThread(); + const remote = makeThread({ id: local.id, environmentId: EnvironmentId.make("remote") }); const runThread = vi.fn(async () => {}); const items = buildThreadActionItems({ threads: [local, remote], projectTitleById: new Map(), sortOrder: "updated_at", icon: null, - getContentMatch: (thread) => - thread.environmentId === remote.environmentId - ? { source: "assistant", snippet: "remote message", query: "message" } - : undefined, runThread, }); - const remoteItem = items.find((item) => item.threadContentMatch); - const matches = new Map(remoteItem ? [[remoteItem.value, remoteItem]] : []); - const merged = items.map((item) => matches.get(item.value) ?? item); - expect(merged.map((item) => item.title)).toEqual(["Local work", "Remote work"]); - expect(new Set(merged.map((item) => item.value)).size).toBe(2); - await merged[0]?.run(); - await merged[1]?.run(); + expect(new Set(items.map((item) => item.value)).size).toBe(2); + for (const item of items) await item.run(); expect(runThread.mock.calls).toEqual([[local], [remote]]); }); diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 1804aebb6d0ac..6df2374b2471a 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -20,48 +20,36 @@ function createStorageStub(): Storage { }; } -const BUILD_A = "https://app.t3.codes/assets/index-A.js"; -const BUILD_B = "https://app.t3.codes/assets/index-B.js"; -const failedImport = (asset: string) => - `TypeError: Failed to fetch dynamically imported module: https://app.t3.codes/assets/${asset}`; - describe("reloadOnceForChunkLoadError", () => { - it("reloads once for a chunk that fails on every boot, then lets it surface", () => { + it("reloads on the first failure and lets the second one surface", () => { const storage = createStorageStub(); const reload = vi.fn(); - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), () => storage, reload), - ).toBe(true); + expect(reloadOnceForChunkLoadError("a", "chunk", () => storage, reload)).toBe(true); expect(reload).toHaveBeenCalledTimes(1); - // Every later boot requests the same failing chunk during its first render. - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), () => storage, reload), - ).toBe(false); - expect( - reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), () => storage, reload), - ).toBe(false); + expect(reloadOnceForChunkLoadError("a", "chunk", () => storage, reload)).toBe(false); expect(reload).toHaveBeenCalledTimes(1); }); - it("gives later builds their own reloads after the previous build exhausts its limit", () => { + it("gives each build its own reload", () => { + const storage = createStorageStub(); + const reload = vi.fn(); + + reloadOnceForChunkLoadError("a", "chunk", () => storage, reload); + + expect(reloadOnceForChunkLoadError("b", "chunk", () => storage, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it("bounds failures per build and resets the limit for the next build", () => { const storage = createStorageStub(); const reload = vi.fn(); - for (const build of [BUILD_A, BUILD_B]) { + for (const build of ["a", "b"]) { for (let index = 0; index < 20; index += 1) { - expect( - reloadOnceForChunkLoadError( - build, - failedImport(`chunk-${index}.js`), - () => storage, - reload, - ), - ).toBe(true); + expect(reloadOnceForChunkLoadError(build, String(index), () => storage, reload)).toBe(true); } - expect( - reloadOnceForChunkLoadError(build, failedImport("chunk-20.js"), () => storage, reload), - ).toBe(false); + expect(reloadOnceForChunkLoadError(build, "20", () => storage, reload)).toBe(false); } expect(reload).toHaveBeenCalledTimes(40); }); @@ -72,9 +60,7 @@ describe("reloadOnceForChunkLoadError", () => { throw new DOMException("blocked", "SecurityError"); }; - expect(reloadOnceForChunkLoadError(BUILD_B, failedImport("chat-1.js"), blocked, reload)).toBe( - false, - ); + expect(reloadOnceForChunkLoadError("a", "chunk", blocked, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); }); }); From 398a1bd633e915bbefa6b3e4cd0b67f24a107a7d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:13:42 +0000 Subject: [PATCH 108/143] refactor(web): drop draft transition gate --- .../components/chat/draftHeroTransition.ts | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/draftHeroTransition.ts b/apps/web/src/components/chat/draftHeroTransition.ts index b9084bde8d9e6..958b7374c1a84 100644 --- a/apps/web/src/components/chat/draftHeroTransition.ts +++ b/apps/web/src/components/chat/draftHeroTransition.ts @@ -37,30 +37,25 @@ export async function waitForDraftHeroTransition(): Promise { ]); } -function isMobileComposerTransitionEnabled(active: boolean): boolean { - if (!active || typeof document === "undefined" || typeof window === "undefined") return false; - const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; - const prefersReducedMotion = - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; - return ( - mobileViewport && - !prefersReducedMotion && - Boolean((document as ComposerViewTransitionDocument).startViewTransition) - ); -} - export async function runMobileComposerTransition( update: () => void | Promise, options: { active: boolean; durationMs: number }, ): Promise { - if (!isMobileComposerTransitionEnabled(options.active)) { + if (typeof document === "undefined" || typeof window === "undefined") { await update(); return; } const transitionDocument = document as ComposerViewTransitionDocument; - const startViewTransition = transitionDocument.startViewTransition; - if (!startViewTransition) { + const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; + const prefersReducedMotion = + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + if ( + !options.active || + !mobileViewport || + prefersReducedMotion || + !transitionDocument.startViewTransition + ) { await update(); return; } @@ -78,7 +73,7 @@ export async function runMobileComposerTransition( ); transitionDocument.documentElement.dataset.mobileComposerRouteTransition = "true"; try { - const transition = startViewTransition.call(transitionDocument, runUpdate); + const transition = transitionDocument.startViewTransition(runUpdate); transitionFinished = transition.finished.catch(() => undefined); activeMobileComposerTransition = transitionFinished; try { From 24e9224cf37aee0649ecf38e5fc1c1076de1f533 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:13:45 +0000 Subject: [PATCH 109/143] refactor(web): drop details identity memo --- apps/web/src/components/chat/ThreadDetailsPanel.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index c5efa89130862..d6686df770875 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -15,7 +15,7 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { memo, useMemo, type ComponentProps } from "react"; +import { memo, type ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; @@ -69,10 +69,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { props.environmentId, props.activeProjectScripts ? props.gitCwd : null, ); - const activeThreadRef = useMemo( - () => ({ environmentId: props.environmentId, threadId: props.threadId }), - [props.environmentId, props.threadId], - ); + const activeThreadRef = { environmentId: props.environmentId, threadId: props.threadId }; const branchToolbarProps = { showGitControls: props.isGitRepo, environmentId: props.environmentId, From 00794808bf4c550b6611742b3349e6fc07ccda5e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:13:47 +0000 Subject: [PATCH 110/143] refactor(web): drop git control subscription gating --- apps/web/src/components/GitActionsControl.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5f9f1bcc3901a..8164537ee3a24 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -460,8 +460,6 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu interface PublishRepositoryDialogProps { readonly open: boolean; - /** Preload discovery only for checkouts that offer publishing. */ - readonly publishAvailable: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; /** Thread the dialog was opened from, so the new repository can open beside it. */ @@ -473,7 +471,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const openLink = useOpenLink(props.threadRef); const navigate = useNavigate(); const sourceControlDiscovery = useEnvironmentQuery( - props.environmentId === null || !(props.open || props.publishAvailable) + props.environmentId === null ? null : sourceControlEnvironment.discovery({ environmentId: props.environmentId, @@ -1034,10 +1032,8 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - // The projection is only a fallback for a thread without a shell. Reading it - // while the shell exists re-rendered this control on every streamed delta. const activeServerThreadProjection = useThreadProjection( - activeDraftThread === null && activeServerThreadShell === null ? activeThreadRef : null, + activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef, ); const activeServerThread = activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null; @@ -2108,7 +2104,6 @@ export default function GitActionsControl({ Date: Thu, 15 Oct 2026 03:16:28 +0000 Subject: [PATCH 111/143] refactor(web): simplify measured asset precompression --- apps/web/vite.config.ts | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 60901de2632d5..83a805986a8b1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite build hooks run outside an Effect runtime. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; import * as NodeZlib from "node:zlib"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; @@ -157,6 +161,52 @@ const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") .filter((entry) => entry.length > 0); const allowedHosts = [".ts.net", ...configuredAllowedHosts]; +// Only hashed assets get sidecars, so they cannot describe stale content. +function precompressPlugin(): Plugin { + const brotli = NodeUtil.promisify(NodeZlib.brotliCompress); + const gzip = NodeUtil.promisify(NodeZlib.gzip); + return { + name: "t3code:precompress", + apply: "build", + async writeBundle(options, bundle) { + if (!options.dir) return; + const outDir = options.dir; + const files = Object.keys(bundle).filter( + (file) => + file.startsWith("assets/") && /\.(?:js|mjs|css|json|svg|wasm|txt|xml)$/.test(file), + ); + await Promise.all( + files.map(async (file) => { + const path = NodePath.join(outDir, file); + const source = await NodeFSP.readFile(path); + if (source.byteLength < 1024) return; + const copies = [ + [ + ".br", + brotli(source, { + params: { + [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 11, + [NodeZlib.constants.BROTLI_PARAM_MODE]: file.endsWith(".wasm") + ? NodeZlib.constants.BROTLI_MODE_GENERIC + : NodeZlib.constants.BROTLI_MODE_TEXT, + [NodeZlib.constants.BROTLI_PARAM_SIZE_HINT]: source.byteLength, + }, + }), + ], + [".gz", gzip(source, { level: 9 })], + ] as const; + await Promise.all( + copies.map(async ([suffix, compressed]) => { + const copy = await compressed; + if (copy.byteLength < source.byteLength) await NodeFSP.writeFile(path + suffix, copy); + }), + ); + }), + ); + }, + }; +} + export default defineConfig(() => { return { assetsInclude: ["**/*.wasm"], @@ -188,6 +238,7 @@ export default defineConfig(() => { lucideDynamicIconsPlugin(), startupChunksPlugin(), // Vercel compresses the hosted app itself; the T3 server serves these copies. + ...(process.env.VERCEL ? [] : [precompressPlugin()]), ], optimizeDeps: { include: [ From 29ef4aab592cd0c6fae5254b2d0f6262a776dfe7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:52:23 +0000 Subject: [PATCH 112/143] refactor(snapshot): drop checkpoint file-list compaction --- .../src/orchestration-v2/ThreadStream.ts | 18 +--- .../boundedSnapshotTransport.test.ts | 97 ------------------- apps/server/src/orchestration-v2/http.ts | 1 - apps/server/src/ws.ts | 2 - .../state/boundedThreadSnapshotHttp.test.ts | 1 - .../src/state/boundedThreadSnapshotHttp.ts | 15 +-- .../src/state/threads-sync.test.ts | 7 -- packages/client-runtime/src/state/threads.ts | 6 +- packages/contracts/src/environmentHttp.ts | 6 +- packages/contracts/src/orchestrationV2.ts | 14 --- .../orchestrationV2BoundedSnapshot.test.ts | 65 ------------- .../src/orchestrationV2BoundedSnapshot.ts | 94 +----------------- 12 files changed, 12 insertions(+), 314 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 47e944d1711fe..fad28abf677ab 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -3,10 +3,7 @@ import type { OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; -import { - omitCheckpointItemFiles, - omitLocalVisibleTurnItems, -} from "@t3tools/shared/orchestrationV2BoundedSnapshot"; +import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import { buildBoundedThreadProjection, @@ -55,27 +52,18 @@ type ThreadSnapshotStreamItem = Extract< export function boundedSnapshotResponseFields(input: { readonly bounded: BoundedProjectionResult; readonly compactTurnItems: boolean; - readonly compactCheckpointItems?: boolean; }) { const { bounded } = input; const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(bounded.projection) : null; - // After the turn-item compaction, which matches rows to items by identity. - const compactCheckpoints = - input.compactCheckpointItems === true - ? omitCheckpointItemFiles(compactProjection ?? bounded.projection) - : null; return { - projection: compactCheckpoints?.projection ?? compactProjection ?? bounded.projection, + projection: compactProjection ?? bounded.projection, historyCursor: bounded.historyCursor, hasMoreHistory: bounded.hasMoreHistory, latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, payloadBudgetExceeded: bounded.payloadBudgetExceeded, ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), - ...(compactCheckpoints === null - ? {} - : { checkpointFilesOmittedItemIds: compactCheckpoints.itemIds }), }; } @@ -84,7 +72,6 @@ export function buildBoundedThreadStreamSnapshot(input: { readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; readonly compactTurnItems?: boolean; - readonly compactCheckpointItems?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -96,7 +83,6 @@ export function buildBoundedThreadStreamSnapshot(input: { ...boundedSnapshotResponseFields({ bounded, compactTurnItems: input.compactTurnItems === true, - compactCheckpointItems: input.compactCheckpointItems === true, }), }; } diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index b35971777fab7..b6474d5cb8c8e 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -2,9 +2,6 @@ import { assert, describe, it } from "@effect/vitest"; import { AuthOrchestrationReadScope, AuthSessionId, - CheckpointId, - CheckpointRef, - CheckpointScopeId, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi, @@ -204,68 +201,6 @@ function itemUpdated( }; } -const forkCheckpointId = CheckpointId.make("checkpoint:compact-transport:fork"); -const forkCheckpointItemId = TurnItemId.make(`turn-item:${FORK}:checkpoint`); -const checkpointFiles = [ - { path: "src/a.ts", kind: "modified", additions: 3, deletions: 1 }, - { path: "src/b.ts", kind: "added", additions: 10, deletions: 0 }, -]; - -// A captured checkpoint and its turn item, which repeats the checkpoint's files. -function forkCheckpoint(at: DateTime.Utc): ReadonlyArray { - const scopeId = CheckpointScopeId.make(`checkpoint-scope:${FORK}`); - const nodeId = NodeId.make(`node:${FORK}:checkpoint`); - return [ - { - id: EventId.make(`event:${FORK}:checkpoint-captured`), - type: "checkpoint.captured", - threadId: FORK, - nodeId, - occurredAt: at, - payload: { - id: forkCheckpointId, - threadId: FORK, - scopeId, - runId: null, - nodeId, - parentCheckpointId: null, - ordinalWithinScope: 1, - appRunOrdinal: null, - ref: CheckpointRef.make("refs/t3/compact-transport/1"), - status: "ready", - files: checkpointFiles, - capturedAt: at, - }, - }, - { - id: EventId.make(`event:${FORK}:checkpoint-item`), - type: "turn-item.updated", - threadId: FORK, - occurredAt: at, - payload: { - id: forkCheckpointItemId, - threadId: FORK, - runId: null, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 5, - status: "completed", - title: null, - startedAt: at, - completedAt: at, - updatedAt: at, - type: "checkpoint", - checkpointId: forkCheckpointId, - scopeId, - files: checkpointFiles, - }, - }, - ]; -} - const seed = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const at = yield* DateTime.now; @@ -275,7 +210,6 @@ const seed = Effect.gen(function* () { ...[1, 2, 3].map((n) => itemUpdated(PARENT, parentRun, `parent-${n}`, n, at)), threadCreated(FORK, at, parentRun), ...[1, 2, 3, 4].map((n) => itemUpdated(FORK, null, `fork-${n}`, n, at)), - ...forkCheckpoint(at), threadCreated(LONG, at), runCreated(LONG, longRun, at), // The request sits far outside the recent window; its result is inside it. @@ -418,20 +352,6 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { const { projection: _legacyProjection, ...legacyRest } = legacy; assert.deepStrictEqual(compactRest, legacyRest); assert.deepStrictEqual(boundedSnapshotProjection(compact), legacy.projection); - - // Checkpoint items are compacted only when that opt-in is exact too. - assert.notInclude(compactBody, "checkpointFilesOmittedItemIds"); - const bothBody = await ( - await get(`${base}?compactTurnItems=1&compactCheckpointItems=1`) - ).text(); - const both = decodeBounded(bothBody); - assert.deepStrictEqual(boundedSnapshotProjection(both), legacy.projection); - if (threadId === FORK) { - assert.deepStrictEqual(both.checkpointFilesOmittedItemIds, [forkCheckpointItemId]); - assert.isBelow(bothBody.length, compactBody.length); - } else { - assert.isUndefined(both.checkpointFilesOmittedItemIds); - } return { legacy, compact }; }).pipe( Effect.tap(({ legacy }) => @@ -447,7 +367,6 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { "local", "local", "local", - "local", ]); } else { const ids = itemIds(legacy.projection); @@ -494,22 +413,6 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { }, { ...legacy.decoded, turnItemsOmitLocalVisible: undefined }, ); - - const compactCheckpoints = yield* firstSnapshot({ - threadId, - acceptBoundedSnapshot: true, - acceptCompactTurnItems: true, - acceptCompactCheckpointItems: true, - }); - assert.isUndefined(compact.decoded.checkpointFilesOmittedItemIds); - assert.deepStrictEqual( - compactCheckpoints.decoded.checkpointFilesOmittedItemIds, - threadId === FORK ? [forkCheckpointItemId] : undefined, - ); - assert.deepStrictEqual( - boundedSnapshotProjection(compactCheckpoints.decoded), - legacy.decoded.projection, - ); }), ); }); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index d5a924c9d9d99..8f67002333ba9 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -194,7 +194,6 @@ export const layer = HttpApiBuilder.group( ...boundedSnapshotResponseFields({ bounded, compactTurnItems: args.query.compactTurnItems === "1", - compactCheckpointItems: args.query.compactCheckpointItems === "1", }), }; }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dc7f7abe612df..869404ebccf24 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -758,7 +758,6 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; - readonly acceptCompactCheckpointItems?: boolean; readonly acceptThreadFieldEvents?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -886,7 +885,6 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? buildBoundedThreadStreamSnapshot({ ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, - compactCheckpointItems: input.acceptCompactCheckpointItems === true, }) : { kind: "snapshot" as const, diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index aea869aefe458..906f06fb95aa2 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -134,7 +134,6 @@ describe("boundedThreadSnapshotLoader", () => { const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; const result = yield* loader.load(PREPARED, THREAD_ID); expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); - expect(new URL(urls[0]!).searchParams.get("compactCheckpointItems")).toBe("1"); expect(result._tag).toBe("present"); if (result._tag !== "present") return; expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index ac8f6adb8f345..9f9b896b42c15 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -20,8 +20,7 @@ const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; /** * Load a bounded recent-window thread snapshot over HTTP. Opts into compact - * turnItems and checkpoint items and restores them, so callers always see the - * full bounded shape. + * turnItems and restores them, so callers always see the full bounded shape. * Older servers ignore the query and send the full shape. */ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( @@ -37,7 +36,7 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }) { const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1", compactCheckpointItems: "1" }, + query: { compactTurnItems: "1" }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, @@ -51,14 +50,10 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( headers: withOrchestrationProtocolHeader(headers), }), }).pipe( - // Drop the markers with the restore so nothing can restore twice. - Effect.map(({ turnItemsOmitLocalVisible, checkpointFilesOmittedItemIds, ...snapshot }) => ({ + // Drop the marker with the restore so nothing can restore twice. + Effect.map(({ turnItemsOmitLocalVisible, ...snapshot }) => ({ ...snapshot, - projection: boundedSnapshotProjection({ - ...snapshot, - turnItemsOmitLocalVisible, - checkpointFilesOmittedItemIds, - }), + projection: boundedSnapshotProjection({ ...snapshot, turnItemsOmitLocalVisible }), })), ); }); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 0d9f9f59b14ff..b78377b2b1eca 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -123,7 +123,6 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const lastRequestCompletionMarker = yield* Ref.make(false); const lastAcceptBoundedSnapshot = yield* Ref.make(undefined); const lastAcceptCompactTurnItems = yield* Ref.make(undefined); - const lastAcceptCompactCheckpointItems = yield* Ref.make(undefined); const wakeups = yield* Queue.unbounded(); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); @@ -142,7 +141,6 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly requestCompletionMarker?: true; readonly acceptBoundedSnapshot?: true; readonly acceptCompactTurnItems?: true; - readonly acceptCompactCheckpointItems?: true; }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( @@ -152,9 +150,6 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), Effect.andThen(Ref.set(lastAcceptBoundedSnapshot, input.acceptBoundedSnapshot)), Effect.andThen(Ref.set(lastAcceptCompactTurnItems, input.acceptCompactTurnItems)), - Effect.andThen( - Ref.set(lastAcceptCompactCheckpointItems, input.acceptCompactCheckpointItems), - ), Effect.as(streamFrom(inputs)), ), ), @@ -271,7 +266,6 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o lastRequestCompletionMarker, lastAcceptBoundedSnapshot, lastAcceptCompactTurnItems, - lastAcceptCompactCheckpointItems, supervisorState, supervisorSession, savedThreads, @@ -1055,7 +1049,6 @@ describe("EnvironmentThreads", () => { expect(Option.getOrThrow(seeded.data).turnItems).toEqual([...local, retainedRequest]); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); expect(yield* Ref.get(harness.lastAcceptCompactTurnItems)).toBe(true); - expect(yield* Ref.get(harness.lastAcceptCompactCheckpointItems)).toBe(true); // Live updates reduce against the restored list, not the compact one. const updated = { ...local[1]!, output: "updated", ordinal: 2 }; diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 74e88fe4f4b6b..7455b29d4e741 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -889,11 +889,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), ...(acceptBoundedSnapshot - ? { - acceptBoundedSnapshot: true as const, - acceptCompactTurnItems: true as const, - acceptCompactCheckpointItems: true as const, - } + ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const } : {}), }; }), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index b78598281cb3a..fe7a9e4f9461b 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -589,13 +589,11 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ }); /** - * `compactTurnItems=1` opts into `turnItemsOmitLocalVisible`, and - * `compactCheckpointItems=1` into `checkpointFilesOmittedItemIds`. Other values - * are ignored, and older servers ignore the whole query. + * `compactTurnItems=1` opts into `turnItemsOmitLocalVisible`. Other values are + * ignored, and older servers ignore the whole query. */ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ compactTurnItems: Schema.optionalKey(Schema.String), - compactCheckpointItems: Schema.optionalKey(Schema.String), }); const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1f1011cac1086..f2d42d15e79b6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3421,11 +3421,6 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ * that repeat local visible rows. See `turnItemsOmitLocalVisible`. */ acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), - /** - * Allows bounded snapshot fallbacks to send checkpoint turn items without the - * file list their checkpoint already carries. See `checkpointFilesOmittedItemIds`. - */ - acceptCompactCheckpointItems: Schema.optionalKey(Schema.Boolean), /** Accepts compact read-watermark and pull-request-link events. */ acceptThreadFieldEvents: Schema.optionalKey(Schema.Boolean), }); @@ -3473,13 +3468,6 @@ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ * with `boundedSnapshotProjection` before using the projection. */ turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), - /** - * Set only for clients that opted in: these checkpoint turn items are sent - * with empty `files`, which equal their checkpoint's `files` in - * `projection.checkpoints`. Clients must restore them with - * `boundedSnapshotProjection` before using the projection. - */ - checkpointFilesOmittedItemIds: Schema.optionalKey(Schema.Array(TurnItemId)), }); export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type; @@ -3567,8 +3555,6 @@ export const OrchestrationV2ThreadStreamItem = Schema.Union([ payloadBudgetExceeded: Schema.optionalKey(Schema.Boolean), /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), - /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ - checkpointFilesOmittedItemIds: Schema.optionalKey(Schema.Array(TurnItemId)), }), Schema.Struct({ kind: Schema.Literal("event"), diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts index 6bc52c2f2aea0..cf231c0617ef8 100644 --- a/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts @@ -10,7 +10,6 @@ import { describe, expect, it } from "vite-plus/test"; import { boundedSnapshotProjection, - omitCheckpointItemFiles, omitLocalVisibleTurnItems, restoreLocalVisibleTurnItems, } from "./orchestrationV2BoundedSnapshot.ts"; @@ -146,67 +145,3 @@ describe("compact bounded snapshot turnItems", () => { expect(boundedSnapshotProjection({ projection: full })).toBe(full); }); }); - -describe("compact bounded snapshot checkpoint items", () => { - const files = [ - { path: "a.ts", kind: "modified", additions: 2, deletions: 1 }, - { path: "b.ts", kind: "modified", additions: 0, deletions: 4 }, - ]; - function checkpointItem(id: string, checkpointId: string, itemFiles: typeof files) { - return { - ...item(CHILD, id, 1), - type: "checkpoint", - checkpointId, - scopeId: "scope", - files: itemFiles, - } as unknown as OrchestrationV2TurnItem; - } - function withCheckpoints( - value: OrchestrationV2ThreadProjection, - checkpoints: ReadonlyArray<{ readonly id: string; readonly files: typeof files }>, - ): OrchestrationV2ThreadProjection { - return { ...value, checkpoints } as unknown as OrchestrationV2ThreadProjection; - } - - it("sends each checkpoint's files once and restores both lists exactly", () => { - const repeated = checkpointItem("checkpoint-item", "checkpoint-1", files); - // The checkpoint entity changed after its item was written, so the item keeps its own list. - const diverged = checkpointItem("diverged-item", "checkpoint-2", [files[0]!]); - const full = withCheckpoints( - projection({ - visible: [row("local", CHILD, repeated), row("local", CHILD, diverged)], - turnItems: [repeated, diverged], - }), - [ - { id: "checkpoint-1", files }, - { id: "checkpoint-2", files }, - ], - ); - - const compact = omitCheckpointItemFiles(omitLocalVisibleTurnItems(full)!)!; - expect(compact.itemIds).toEqual([TurnItemId.make("checkpoint-item")]); - expect( - compact.projection.visibleTurnItems.map( - (value) => (value.item as unknown as { files: unknown[] }).files.length, - ), - ).toEqual([0, 1]); - - const wire = JSON.parse(JSON.stringify(compact.projection)); - expect( - boundedSnapshotProjection({ - projection: wire, - turnItemsOmitLocalVisible: true, - checkpointFilesOmittedItemIds: compact.itemIds, - }), - ).toEqual(JSON.parse(JSON.stringify(full))); - }); - - it("sends nothing compact when no item repeats its checkpoint", () => { - const empty = checkpointItem("empty-item", "checkpoint-1", []); - const full = withCheckpoints( - projection({ visible: [row("local", CHILD, empty)], turnItems: [empty] }), - [{ id: "checkpoint-1", files }], - ); - expect(omitCheckpointItemFiles(full)).toBe(null); - }); -}); diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.ts index 0351decf9b48d..117306819cd26 100644 --- a/packages/shared/src/orchestrationV2BoundedSnapshot.ts +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.ts @@ -1,8 +1,6 @@ import type { OrchestrationV2ProjectedTurnItem, OrchestrationV2ThreadProjection, - OrchestrationV2TurnItem, - TurnItemId, } from "@t3tools/contracts"; /** Local timeline rows also carry their item in `projection.turnItems`. */ @@ -49,100 +47,12 @@ export function restoreLocalVisibleTurnItems( return { ...projection, turnItems: [...local, ...projection.turnItems] }; } -type CheckpointFiles = OrchestrationV2ThreadProjection["checkpoints"][number]["files"]; - -function sameCheckpointFiles(left: CheckpointFiles, right: CheckpointFiles): boolean { - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - const a = left[index]!; - const b = right[index]!; - if ( - a.path !== b.path || - a.kind !== b.kind || - a.additions !== b.additions || - a.deletions !== b.deletions || - Object.keys(a).length !== Object.keys(b).length - ) { - return false; - } - } - return true; -} - -/** - * Server half of the opt-in checkpoint compaction. A checkpoint turn item - * repeats its checkpoint's file list, which can hold thousands of entries. - * Items whose files equal those of their checkpoint in `projection.checkpoints` - * are sent with empty `files`; the returned ids tell the client which ones to - * refill with `restoreCheckpointItemFiles`. Returns null when nothing would be - * omitted. - */ -export function omitCheckpointItemFiles(projection: OrchestrationV2ThreadProjection): { - readonly projection: OrchestrationV2ThreadProjection; - readonly itemIds: ReadonlyArray; -} | null { - const checkpointFiles = new Map(); - for (const checkpoint of projection.checkpoints) { - if (checkpoint.files.length > 0) checkpointFiles.set(String(checkpoint.id), checkpoint.files); - } - if (checkpointFiles.size === 0) return null; - const itemIds = new Set(); - const compact = (item: OrchestrationV2TurnItem): OrchestrationV2TurnItem => { - if (item.type !== "checkpoint" || item.files.length === 0) return item; - const files = checkpointFiles.get(String(item.checkpointId)); - if (files === undefined || !sameCheckpointFiles(files, item.files)) return item; - itemIds.add(item.id); - return { ...item, files: [] }; - }; - const turnItems = projection.turnItems.map(compact); - const visibleTurnItems = projection.visibleTurnItems.map((row) => { - const item = compact(row.item); - return item === row.item ? row : { ...row, item }; - }); - if (itemIds.size === 0) return null; - return { - projection: { ...projection, turnItems, visibleTurnItems }, - itemIds: [...itemIds], - }; -} - -/** Client half of `omitCheckpointItemFiles`, before reducers or caches see the projection. */ -function restoreCheckpointItemFiles( - projection: OrchestrationV2ThreadProjection, - itemIds: ReadonlyArray, -): OrchestrationV2ThreadProjection { - if (itemIds.length === 0) return projection; - const omitted = new Set(itemIds.map(String)); - const checkpointFiles = new Map( - projection.checkpoints.map((checkpoint) => [String(checkpoint.id), checkpoint.files]), - ); - const restore = (item: OrchestrationV2TurnItem): OrchestrationV2TurnItem => { - if (item.type !== "checkpoint" || !omitted.has(String(item.id))) return item; - const files = checkpointFiles.get(String(item.checkpointId)); - return files === undefined ? item : { ...item, files }; - }; - return { - ...projection, - turnItems: projection.turnItems.map(restore), - visibleTurnItems: projection.visibleTurnItems.map((row) => { - const item = restore(row.item); - return item === row.item ? row : { ...row, item }; - }), - }; -} - /** The usable projection of a decoded bounded snapshot, compact or not. */ export function boundedSnapshotProjection(snapshot: { readonly projection: OrchestrationV2ThreadProjection; readonly turnItemsOmitLocalVisible?: true | undefined; - readonly checkpointFilesOmittedItemIds?: ReadonlyArray | undefined; }): OrchestrationV2ThreadProjection { - // Files first: restoring local items copies the visible rows' items. - const projection = - snapshot.checkpointFilesOmittedItemIds === undefined - ? snapshot.projection - : restoreCheckpointItemFiles(snapshot.projection, snapshot.checkpointFilesOmittedItemIds); return snapshot.turnItemsOmitLocalVisible === true - ? restoreLocalVisibleTurnItems(projection) - : projection; + ? restoreLocalVisibleTurnItems(snapshot.projection) + : snapshot.projection; } From 89720fa296a2f28484b46cdbf906c6087f5d39e2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:53:04 +0000 Subject: [PATCH 113/143] refactor(git): drop batched checkpoint and repository probes --- .../src/orchestration-v2/CheckpointService.ts | 98 +++++++++---------- apps/server/src/vcs/GitVcsDriver.test.ts | 2 +- apps/server/src/vcs/GitVcsDriver.ts | 96 ++++-------------- apps/server/src/vcs/VcsDriverRegistry.test.ts | 22 +---- apps/server/src/vcs/VcsDriverRegistry.ts | 8 +- 5 files changed, 70 insertions(+), 156 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 324777f603063..ee3316311eaca 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -394,42 +394,21 @@ export const layer: Layer.Layer< }); } - // The previous ref is read while this one is written, so the lookup - // costs no extra round of Git processes on the way to "done". - const [captured, previousExists] = yield* Effect.all( - [ - checkpointStore - .captureCheckpoint({ - cwd: input.scope.cwd, + const captured = yield* checkpointStore + .captureCheckpoint({ + cwd: input.scope.cwd, + checkpointRef, + }) + .pipe( + Effect.as(true), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint capture failed", { + scopeId: input.scope.id, checkpointRef, - }) - .pipe( - Effect.as(true), - Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint capture failed", { - scopeId: input.scope.id, - checkpointRef, - cause: String(cause), - }).pipe(Effect.as(false)), - ), - ), - checkpointStore - .hasCheckpointRef({ - cwd: input.scope.cwd, - checkpointRef: previousCheckpointRef, - }) - .pipe( - Effect.catch((cause) => - Effect.logWarning("orchestration V2 previous checkpoint ref lookup failed", { - scopeId: input.scope.id, - checkpointRef: previousCheckpointRef, - cause: String(cause), - }).pipe(Effect.as(false)), - ), - ), - ], - { concurrency: "unbounded" }, - ); + cause: String(cause), + }).pipe(Effect.as(false)), + ), + ); if (!captured) { return makeCheckpoint({ @@ -447,6 +426,20 @@ export const layer: Layer.Layer< }); } + const previousExists = yield* checkpointStore + .hasCheckpointRef({ + cwd: input.scope.cwd, + checkpointRef: previousCheckpointRef, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration V2 previous checkpoint ref lookup failed", { + scopeId: input.scope.id, + checkpointRef: previousCheckpointRef, + cause: String(cause), + }).pipe(Effect.as(false)), + ), + ); const refs = { cwd: input.scope.cwd, fromCheckpointRef: previousCheckpointRef, @@ -455,26 +448,23 @@ export const layer: Layer.Layer< // A pull or rebase can change thousands of files the turn did not write. // Keep only the files the turn's own work touched. const files = previousExists - ? yield* Effect.all( - [ - checkpointStore.diffCheckpoints({ - ...refs, - fallbackFromToHead: false, - ignoreWhitespace: false, - format: "numstat", - }), - checkpointStore.listAuthoredPaths(refs).pipe( - Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint authored paths failed", { - scopeId: input.scope.id, - checkpointRef, - cause: String(cause), - }).pipe(Effect.as(null)), - ), + ? yield* Effect.all([ + checkpointStore.diffCheckpoints({ + ...refs, + fallbackFromToHead: false, + ignoreWhitespace: false, + format: "numstat", + }), + checkpointStore.listAuthoredPaths(refs).pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint authored paths failed", { + scopeId: input.scope.id, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as(null)), ), - ], - { concurrency: "unbounded" }, - ).pipe( + ), + ]).pipe( Effect.map(([diff, authoredPaths]) => parseTurnDiffFilesFromNumstat(diff) .filter((file) => !isGitImport(file, authoredPaths)) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 22ebdd3079773..9c7a1b5350ddb 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1204,7 +1204,7 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi : input.args.includes("commit-tree") ? "commit0000\n" : input.args.includes("--git-common-dir") - ? ".git\nhead0000\n" + ? ".git\n" : ""; return { exitCode: ChildProcessSpawner.ExitCode(0), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 7940d05900a6d..05f265cd79bb2 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -585,36 +585,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { - // One process answers the usual work tree. `--is-inside-work-tree` prints - // first, so anything but "true" means no work tree. Output that does not - // parse as one path per line takes the separate probes below. - const combined = yield* gitCommand( - vcsProcess, - "GitVcsDriver.detectRepository", - cwd, - ["rev-parse", "--is-inside-work-tree", "--show-toplevel", "--git-common-dir"], - { allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 64 * 1024 }, - ); - const [insideWorkTree, rootPath, metadataPath, ...rest] = combined.stdout - .replace(/\n$/, "") - .split("\n"); - if (insideWorkTree?.trim() !== "true") { + if (!(yield* isInsideWorkTree(cwd))) { return null; } - if ( - combined.exitCode === 0 && - rootPath !== undefined && - rootPath.trim().length > 0 && - metadataPath !== undefined && - rest.length === 0 - ) { - return { - kind: "git" as const, - rootPath: rootPath.trim(), - metadataPath: metadataPath.trim() || null, - freshness: yield* nowFreshness(), - }; - } const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ "rev-parse", @@ -817,37 +790,15 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ); - // One process for both: `--verify` applies to HEAD only, so an unborn branch - // still prints the common dir and exits 1, the same answer `hasHeadCommit` gives. - const resolveGitCommonDirAndHead = (cwd: string) => + const resolveGitCommonDir = (cwd: string) => Effect.gen(function* () { - const operation = "GitVcsDriver.checkpoints.resolveGitCommonDir"; const result = yield* execute({ - operation, + operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", cwd, - args: ["rev-parse", "--git-common-dir", "--verify", "--quiet", "HEAD"], - allowNonZeroExit: true, + args: ["rev-parse", "--git-common-dir"], }); - const output = result.stdout.trimEnd(); - // HEAD is the final line; the common directory can itself contain newlines. - const gitCommonDir = ( - result.exitCode === 0 ? output.slice(0, output.lastIndexOf("\n")) : output - ).trim(); - if (gitCommonDir.length === 0 || (result.exitCode !== 0 && result.exitCode !== 1)) { - return yield* new VcsProcessExitError({ - operation, - command: "git rev-parse", - cwd, - exitCode: result.exitCode, - detail: "git rev-parse --git-common-dir failed", - }); - } - return { - gitCommonDir: path.isAbsolute(gitCommonDir) - ? gitCommonDir - : path.resolve(cwd, gitCommonDir), - headExists: result.exitCode === 0, - }; + const gitCommonDir = result.stdout.trim(); + return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); }); // Git renames loose objects and refs into place without fsync by default, so @@ -870,26 +821,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-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 = yield* resolveGitCommonDir(input.cwd); const indexId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const commitEnv: NodeJS.ProcessEnv = { @@ -909,6 +841,13 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ); yield* Effect.gen(function* () { + const headExists = yield* hasHeadCommit(input.cwd); + const sparseConfig = yield* execute({ + operation, + cwd: input.cwd, + args: ["config", "--bool", "core.sparseCheckout"], + allowNonZeroExit: true, + }); let sparseCheckout = sparseConfig.stdout.trim() === "true"; if (sparseCheckout) { const help = yield* execute({ @@ -921,8 +860,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } if (headExists) { const reusedIndex = yield* Effect.gen(function* () { - if (Option.isNone(sourceIndexPath)) return false; - const indexPath = sourceIndexPath.value; + const indexPath = yield* execute({ + operation, + cwd: input.cwd, + args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], + }); const { mtime } = 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. diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 46b08cfcc385e..d302ef106ac17 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -74,10 +74,6 @@ describe("VcsDriverRegistry", () => { stderr: "fatal: not a git repository", }; } - if (command === "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir") { - const commonDir = path.relative(input.cwd, path.join(repoDir, ".git")); - return processOutput(`true\n${repoDir}\n${commonDir}\n`); - } if (command === "rev-parse --is-inside-work-tree") return processOutput("true\n"); if (command === "rev-parse --show-toplevel") return processOutput(`${repoDir}\n`); if (command === "rev-parse --git-common-dir") { @@ -101,15 +97,13 @@ describe("VcsDriverRegistry", () => { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const first = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); const second = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); - // Auto detection resolves to git, so it reuses the same entry. - const auto = yield* registry.resolve({ cwd: repoDir }); assert.equal(first.repository.rootPath, repoDir); assert.equal(second.repository.rootPath, repoDir); - assert.equal(auto.repository.rootPath, repoDir); - assert.equal(first.repository.metadataPath, ".git"); assert.deepStrictEqual(calls, [ - "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir", + "rev-parse --is-inside-work-tree", + "rev-parse --show-toplevel", + "rev-parse --git-common-dir", ]); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), @@ -130,10 +124,7 @@ describe("VcsDriverRegistry", () => { yield* fs.remove(path.join(repoDir, ".git"), { recursive: true }); assert.equal(yield* registry.detect({ cwd: repoDir }), null); - assert.equal( - calls.filter((call) => call.startsWith("rev-parse --is-inside-work-tree")).length, - 2, - ); + assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -198,10 +189,7 @@ describe("VcsDriverRegistry", () => { yield* fs.makeDirectory(path.join(repoDir, ".git")); assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); - assert.equal( - calls.filter((call) => call.startsWith("rev-parse --is-inside-work-tree")).length, - 2, - ); + assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index fae48a30ee639..35275d819a2e5 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -45,13 +45,7 @@ function detectionCacheKey(input: { readonly cwd: string; readonly requestedKind: VcsDriverKind | "auto"; }): string { - // Auto detection only tries git (see `detectResolvedKind`), so it shares the - // git entry: status polls ask for "auto" and checkpoints for "git". - const kind = - input.requestedKind === "auto" || input.requestedKind === "unknown" - ? "git" - : input.requestedKind; - return `${kind}\0${input.cwd}`; + return `${input.requestedKind}\0${input.cwd}`; } function parseDetectionCacheKey(key: string): { From d256f55317290e0056e2d0e61181bd211d4e1e75 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:53:10 +0000 Subject: [PATCH 114/143] test(markdown): trim raw HTML equivalence fixtures --- packages/shared/src/markdownPipeline.test.ts | 87 ++++---------------- 1 file changed, 14 insertions(+), 73 deletions(-) diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 073c67758beb4..916ce3b98786f 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -18,52 +18,6 @@ function chatMarkdownTree(markdown: string, rawHtml: boolean) { return processor.runSync(processor.parse(markdown)); } -type TreeNode = { - type: string; - tagName?: string; - value?: string; - position?: unknown; - properties?: Record; - children?: TreeNode[]; -}; - -/** - * What the renderer shows: text positions and table whitespace are dropped, adjacent - * text joins, and whitespace between blocks collapses. - */ -const text = (value: string): TreeNode => ({ type: "text", value: value.trim() ? value : "\n" }); -function rendered(node: TreeNode, preserveWhitespace = false): TreeNode { - if (node.type === "text") { - return text(preserveWhitespace ? (node.value ?? "") : (node.value ?? "").replace(/\s+/g, " ")); - } - if (!node.children) return node; - const literal = preserveWhitespace || node.tagName === "pre"; - const table = ["table", "thead", "tbody", "tfoot", "tr"].includes(node.tagName ?? ""); - const children: TreeNode[] = []; - for (const child of node.children.map((child) => rendered(child, literal))) { - if (table && child.type === "text" && child.value?.trim() === "") continue; - const last = children.at(-1); - if (child.type === "text" && last?.type === "text") { - const joined = `${last.value}${child.value}`; - children[children.length - 1] = text(literal ? joined : joined.replace(/\s+/g, " ")); - } else children.push(child); - } - return { - type: node.type, - ...(node.tagName ? { tagName: node.tagName } : {}), - ...(node.properties - ? { - properties: Object.fromEntries( - Object.entries(node.properties) - .filter(([, value]) => value !== false) - .map(([key, value]) => [key, key.startsWith("data") && value === true ? "" : value]), - ), - } - : {}), - children, - }; -} - it("preserves in-app thread links through the shared Markdown sanitizer", () => { const tree = chatMarkdownTree( '[Open thread](t3-thread://v1/environment/thread)\n\nUnsafe', @@ -89,34 +43,21 @@ describe("raw HTML detection", () => { } }); - it("renders tag-free markdown the same without rehype-raw", () => { - const markdown = [ - "# Title & *emphasis*", - "", - "- [ ] task", - "- [x] done with `code` and a [link](https://example.com 'Title')", - "", - "| a | b |", - "| - | -: |", - "| 1 < 2 | x |", - "", - "> [!NOTE]", - "> An alert with ~~strike~~ and footnote[^1].", - "", - "```ts title=example.ts", - "const a = 1 < 2;", - "```", - "", - "![shot](C:\\Users\\me\\.t3\\shot.png)", - "", - "Entities & © and https://autolink.example", - "", - "[^1]: The footnote.", - ].join("\n"); + it("renders tag-free markdown without loading raw HTML", () => { + const markdown = "# Title\n\n1 < 2 and **bold** with \`code\`"; expect(markdownMayContainRawHtml(markdown)).toBe(false); - expect(rendered(chatMarkdownTree(markdown, false)).children).toEqual( - rendered(chatMarkdownTree(markdown, true)).children, - ); + expect(chatMarkdownTree(markdown, false).children).toMatchObject([ + { tagName: "h1", children: [{ value: "Title" }] }, + { + tagName: "p", + children: [ + { value: "1 < 2 and " }, + { tagName: "strong" }, + { value: " with " }, + { tagName: "code" }, + ], + }, + ]); }); }); From 8d725def9db3f2b79d9245dfbb8c11e2b65205cf Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:53:55 +0000 Subject: [PATCH 115/143] test(db): trim sweep migration fixtures --- .../Migrations/055_OrchestrationV2.test.ts | 275 +++--------------- 1 file changed, 39 insertions(+), 236 deletions(-) diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 6de5c28e1a8f5..f584584ffc0d1 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -1,23 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { - EventId, - MessageId, - NodeId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - RunId, - ThreadId, - TurnItemId, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as References from "effect/References"; import * as SqlClient from "effect/sql/SqlClient"; -import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import { migrationEntries, runMigrations } from "../Migrations.ts"; import ProjectionThreadSweepIndexes from "./063_ProjectionThreadSweepIndexes.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -154,244 +141,60 @@ layer("055_OrchestrationV2", (it) => { }), ); - it.effect("indexes sweep filters for existing threads and later payload-only writes", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 61 }); - const payload = JSON.stringify({ - settledAt: "2026-07-24T00:00:00.000Z", - settledOverride: "settled", - pinnedAt: "2026-07-23T00:00:00.000Z", - autoSettleDisabledAt: "2026-07-22T00:00:00.000Z", - forkedFrom: { type: "run", threadId: "source-thread" }, - pullRequests: [{ number: 1 }, { number: 2 }], - }); - yield* sql` - INSERT INTO orchestration_v2_projection_threads ( - thread_id, project_id, title, default_provider, provider_instance_id, - runtime_mode, interaction_mode, created_at, updated_at, payload_json - ) VALUES ( - 'sweep-thread', 'sweep-project', 'Sweep thread', 'codex', 'codex', - 'full-access', 'default', '2026-07-24T00:00:00.000Z', - '2026-07-24T00:00:00.000Z', ${payload} - ) - `; - yield* runMigrations(); - const sweep = sql<{ readonly thread_id: string; readonly pull_requests: number }>` - SELECT thread_id, json_array_length(payload_json, '$.pullRequests') AS pull_requests - FROM orchestration_v2_projection_threads - INDEXED BY orchestration_v2_projection_threads_active_idx - WHERE deleted_at IS NULL AND archived_at IS NULL - AND json_extract(payload_json, '$.settledOverride') IS NULL - AND json_extract(payload_json, '$.pinnedAt') = '2026-07-23T00:00:00.000Z' - AND CASE - WHEN json_extract(payload_json, '$.forkedFrom.type') = 'run' - THEN json_extract(payload_json, '$.forkedFrom.threadId') - ELSE NULL - END = 'source-thread' - `; - assert.deepStrictEqual(yield* sweep, []); - // An older build rewrites the payload alone; the index follows it. - yield* sql` - UPDATE orchestration_v2_projection_threads - SET payload_json = json_set( - json_remove(payload_json, '$.settledOverride'), - '$.pullRequests', - json('[{"number":1},{"number":2},{"number":3}]') - ) - WHERE thread_id = 'sweep-thread' - `; - assert.deepStrictEqual(yield* sweep, [{ thread_id: "sweep-thread", pull_requests: 3 }]); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), - ); - - // Development builds of this change indexed copied columns under the same name. - it.effect("replaces a same-named sweep index", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 62 }); - yield* sql` - CREATE INDEX orchestration_v2_projection_threads_active_idx - ON orchestration_v2_projection_threads(updated_at, thread_id) - WHERE deleted_at IS NULL AND archived_at IS NULL - `; - assert.deepStrictEqual(yield* runMigrations(), [[63, "ProjectionThreadSweepIndexes"]]); - const [index] = yield* sql<{ readonly sql: string }>` - SELECT sql FROM sqlite_master WHERE name = 'orchestration_v2_projection_threads_active_idx' - `; - assert.include(index!.sql, "json_extract(payload_json, '$.settledOverride')"); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), - ); - - it.effect("repairs indexes after an older sweep build recorded id 62", () => { - const skippedWarnings: Array = []; + it.effect.each([false, true])("repairs sweep indexes with legacy id 62: %s", (legacy) => { + const warnings: Array = []; const logger = Logger.make(({ fiber, logLevel }) => { - const skipped = fiber.getRef(References.CurrentLogAnnotations).skipped; - if (logLevel === "Warn" && skipped !== undefined) skippedWarnings.push(skipped); + if (logLevel === "Warn") + warnings.push(fiber.getRef(References.CurrentLogAnnotations).skipped); }); return Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 61 }); - yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES (62, 'ProjectionThreadSweepIndexes')`; + if (legacy) { + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (62, 'ProjectionThreadSweepIndexes')`; + } else { + yield* runMigrations({ toMigrationInclusive: 62 }); + } yield* sql`CREATE INDEX orchestration_v2_projection_threads_active_idx ON orchestration_v2_projection_threads(updated_at, thread_id)`; - yield* sql`CREATE INDEX orchestration_v2_projection_runs_failed_idx - ON orchestration_v2_projection_runs(thread_id) WHERE status = 'failed'`; + yield* sql`INSERT INTO orchestration_v2_projection_threads ( + thread_id, project_id, title, default_provider, provider_instance_id, + runtime_mode, interaction_mode, created_at, updated_at, payload_json + ) VALUES ('thread', 'project', 'Title', 'codex', 'codex', 'full-access', 'default', + '2026-07-24', '2026-07-24', '{"settledOverride":"settled","pullRequests":[{"number":1}]}')`; assert.deepStrictEqual(yield* runMigrations(), [[63, "ProjectionThreadSweepIndexes"]]); - assert.deepStrictEqual(skippedWarnings, [["62_ThreadFailureLookupIndex"]]); + assert.deepStrictEqual(warnings, legacy ? [["62_ThreadFailureLookupIndex"]] : []); yield* ProjectionThreadSweepIndexes; + assert.deepStrictEqual(yield* runMigrations(), []); + const sweep = sql`SELECT thread_id FROM orchestration_v2_projection_threads + INDEXED BY orchestration_v2_projection_threads_active_idx + WHERE deleted_at IS NULL AND archived_at IS NULL + AND json_extract(payload_json, '$.settledOverride') IS NULL + AND json_array_length(payload_json, '$.pullRequests') > 0`; + assert.deepStrictEqual(yield* sweep, []); + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_remove(payload_json, '$.settledOverride')`; + assert.deepStrictEqual(yield* sweep, [{ thread_id: "thread" }]); + yield* sql`UPDATE orchestration_v2_projection_threads + SET payload_json = json_set(payload_json, '$.pullRequests', json('[]'))`; + assert.deepStrictEqual(yield* sweep, []); assert.deepStrictEqual( - yield* sql` - SELECT name FROM sqlite_master - WHERE name IN ( - 'orchestration_v2_projection_threads_active_idx', - 'orchestration_v2_projection_runs_failed_idx', - 'orchestration_v2_projection_turn_items_failed_error_idx' - ) - ORDER BY name - `, - [ - { name: "orchestration_v2_projection_runs_failed_idx" }, - { name: "orchestration_v2_projection_threads_active_idx" }, - { name: "orchestration_v2_projection_turn_items_failed_error_idx" }, - ], - ); - - // The repaired indexes keep pull request and usage-limit sweeps working. - const store = yield* ProjectionStore.ProjectionStoreV2; - const at = DateTime.makeUnsafe("2026-10-01T00:00:00.000Z"); - const threadId = ThreadId.make("thread:peer-links"); - const runId = RunId.make("run:peer-links"); - const rootNodeId = NodeId.make("node:peer-links"); - const providerInstanceId = ProviderInstanceId.make("codex"); - const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; - const driver = ProviderDriverKind.make("codex"); - const thread = { - createdBy: "user" as const, - creationSource: "web" as const, - id: threadId, - projectId: ProjectId.make("project:peer-links"), - title: "Preview thread", - providerInstanceId, - modelSelection, - runtimeMode: "full-access" as const, - interactionMode: "default" as const, - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: at, - updatedAt: at, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }; - yield* store.apply({ - id: EventId.make("event:peer-links:thread"), - type: "thread.created", - threadId, - occurredAt: at, - payload: thread, - }); - yield* store.apply({ - id: EventId.make("event:peer-links:pull-request"), - type: "thread.pull-request-synced", - threadId, - occurredAt: at, - payload: { - ...thread, - pullRequests: [ - { - host: "github.com", - repository: "pingdotgg/t3code", - number: 7, - url: "https://github.com/pingdotgg/t3code/pull/7", - source: "agent", - linkedAt: DateTime.formatIso(at), - snapshot: null, - stack: null, - }, - ], - }, - }); - yield* store.apply({ - id: EventId.make("event:peer-links:run"), - type: "run.created", - threadId, - runId, - nodeId: rootNodeId, - driver, - providerInstanceId, - occurredAt: at, - payload: { - id: runId, - threadId, - ordinal: 1, - providerInstanceId, - modelSelection, - providerThreadId: null, - userMessageId: MessageId.make("message:peer-links"), - rootNodeId, - activeAttemptId: null, - status: "failed", - requestedAt: at, - startedAt: at, - completedAt: at, - checkpointId: null, - contextHandoffId: null, - }, - }); - yield* store.apply({ - id: EventId.make("event:peer-links:limit"), - type: "turn-item.updated", - threadId, - runId, - nodeId: rootNodeId, - driver, - occurredAt: at, - payload: { - id: TurnItemId.make("item:peer-links:limit"), - threadId, - runId, - nodeId: rootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "failed", - title: "Usage limit reached", - startedAt: at, - completedAt: at, - updatedAt: at, - type: "error", - failure: { - class: "usage_limit", - message: "Plan limit reached.", - resetAt: "2026-10-02T00:00:00.000Z", - code: "usageLimitExceeded", - retryable: null, - }, - }, - }); - assert.deepStrictEqual( - (yield* store.getThreadsWithPullRequests()).map((row) => row.id), - [threadId], + yield* sql`SELECT run_id FROM orchestration_v2_projection_runs + INDEXED BY orchestration_v2_projection_runs_failed_idx WHERE status = 'failed'`, + [], ); assert.deepStrictEqual( - (yield* store.getLimitRecoveryCandidates({ now: at, autoResume: true, snooze: false })).map( - (row) => row.id, - ), - [threadId], + yield* sql`SELECT turn_item_id FROM orchestration_v2_projection_turn_items + INDEXED BY orchestration_v2_projection_turn_items_failed_error_idx + WHERE type = 'error' AND status = 'failed'`, + [], ); }).pipe( Effect.provide( - ProjectionStore.layer.pipe( - Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), - Layer.merge(Logger.layer([logger], { mergeWithExisting: false })), + Layer.mergeAll( + NodeSqliteClient.layer({ filename: ":memory:" }), + Logger.layer([logger], { mergeWithExisting: false }), ), ), ); From e0eac01f0137750133dd6f490386b15b7f9f199e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:54:43 +0000 Subject: [PATCH 116/143] test(startup): trim first-text and backend readiness fixtures --- .../DesktopLocalEnvironmentAuth.test.ts | 70 ++++++------------- .../Adapters/CodexAdapterV2.test.ts | 61 ++++------------ 2 files changed, 34 insertions(+), 97 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 3b17490f4633b..aebf45e796069 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,10 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -240,69 +238,43 @@ describe("DesktopLocalEnvironmentAuth", () => { }).pipe(Effect.provide(TestClock.layer())), ); - // A backend that refuses the exchange until it is ready at `readyAfter`, like - // a cold start. The window opens while it starts. - const makeStartingBackend = (readyAfter: Duration.Duration) => + it.effect.each([true, false])("waits for backend readiness before exchange: %s", (ready) => Effect.gen(function* () { - const requestCount = yield* Ref.make(0); - const layerStartingPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + let requests = 0; + const pool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { list: Effect.succeed([ { id: PRIMARY_LOCAL_ENVIRONMENT_ID, - label: Effect.succeed("Windows"), - waitForReady: (timeout: Duration.Duration) => - Effect.sleep(readyAfter).pipe( - Effect.as(true), - Effect.timeoutOption(timeout), - Effect.map(Option.getOrElse(() => false)), - ), + label: Effect.succeed("Local"), + waitForReady: () => Effect.sleep("40 seconds").pipe(Effect.as(ready)), currentConfig: Effect.succeedSome(config), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); - const layerHttpClient = Layer.succeed( + const client = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => - Effect.gen(function* () { - yield* Ref.update(requestCount, (count) => count + 1); - const now = yield* Clock.currentTimeMillis; - return now < Duration.toMillis(readyAfter) - ? HttpClientResponse.fromWeb(request, new Response("", { status: 503 })) - : tokenResponse(request); + Effect.sync(() => { + requests++; + return tokenResponse(request); }), ), ); - const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + const result = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.flatMap((auth) => auth.getBearerToken), + Effect.either, + Effect.forkChild, Effect.provide( - DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(layerStartingPool, layerHttpClient)), - ), + DesktopLocalEnvironmentAuth.layer.pipe(Layer.provide(Layer.merge(pool, client))), ), ); - return { auth, requestCount }; - }); - - it.effect("waits for a backend that becomes ready after the retry window", () => - Effect.gen(function* () { - const { auth, requestCount } = yield* makeStartingBackend(Duration.seconds(40)); - - const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.seconds(41)); - - assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token"); - assert.strictEqual(yield* Ref.get(requestCount), 1); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("still fails for a backend that does not become ready", () => - Effect.gen(function* () { - const { auth } = yield* makeStartingBackend(Duration.minutes(10)); - - const fiber = yield* auth.getBearerToken.pipe(Effect.flip, Effect.forkChild); - yield* TestClock.adjust(Duration.minutes(3)); - const error = yield* Fiber.join(fiber); - - assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + assert.equal(requests, 0); + yield* TestClock.adjust("41 seconds"); + const token = yield* Fiber.join(result); + assert.equal(token._tag, ready ? "Right" : "Left"); + if (token._tag === "Right") assert.equal(token.right, "desktop-bearer-token"); + else assert.equal(token.left._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + assert.equal(requests, ready ? 1 : 0); }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 6b1522d9c57b6..75bcbabe76caf 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -224,63 +224,28 @@ describe("CodexAdapterV2 assistant message streaming", () => { }), ); - it.effect("shows an item's first text without waiting for the flush interval", () => + it.effect("flushes each item's first text before another item's pending interval", () => Effect.gen(function* () { - const updates = yield* Ref.make>([]); - const coalescer = yield* makeProviderTextDeltaCoalescer({ - flushIntervalMs: 50, - emit: (update) => Ref.update(updates, (current) => [...current, update]), - }); - - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "first" }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.deepEqual( - (yield* Ref.get(updates)).map((update) => update.text), - ["first"], - ); - - // Later deltas of the same item wait for the interval and coalesce. - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " second" }); - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " third" }); - yield* Effect.yieldNow; - assert.equal((yield* Ref.get(updates)).length, 1); - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - assert.deepEqual( - (yield* Ref.get(updates)).map((update) => update.text), - ["first", "first second third"], - ); - }), - ); - - it.effect("shows a new item's first text while another item waits for its interval", () => - Effect.gen(function* () { - const updates = yield* Ref.make>([]); + const updates: string[] = []; const coalescer = yield* makeProviderTextDeltaCoalescer({ flushIntervalMs: 50, - emit: (update) => Ref.update(updates, (current) => [...current, update]), + emit: (update) => + Effect.sync(() => { + updates.push(update.text); + }), }); - - yield* coalescer.append({ turnId: "turn-1", itemId: "reasoning-1", delta: "think" }); + yield* coalescer.append({ turnId: "turn", itemId: "reasoning", delta: "think" }); yield* Effect.yieldNow; yield* Effect.yieldNow; - // This delta schedules the interval flush; the next item's first text skips it. - yield* coalescer.append({ turnId: "turn-1", itemId: "reasoning-1", delta: "ing" }); - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "answer" }); + assert.deepEqual(updates, ["think"]); + yield* coalescer.append({ turnId: "turn", itemId: "reasoning", delta: "ing" }); + yield* coalescer.append({ turnId: "turn", itemId: "reasoning", delta: "..." }); + yield* coalescer.append({ turnId: "turn", itemId: "answer", delta: "answer" }); yield* Effect.yieldNow; yield* Effect.yieldNow; - assert.deepEqual( - (yield* Ref.get(updates)).map((update) => update.text), - ["think", "answer"], - ); - + assert.deepEqual(updates, ["think", "answer"]); yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - assert.deepEqual( - (yield* Ref.get(updates)).map((update) => update.text), - ["think", "answer", "thinking"], - ); + assert.deepEqual(updates, ["think", "answer", "thinking..."]); }), ); From ebb5751149c41d0a77a94a6f3a8dcb033a1b4d0a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:55:06 +0000 Subject: [PATCH 117/143] test(orchestration): reuse baseline startup coverage --- .../RunExecutionService.test.ts | 148 +++++------------- 1 file changed, 36 insertions(+), 112 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 3a2fd6a28a366..1815da4060ed9 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1030,7 +1030,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.each([false, true])("starts after baseline capture, including failure: %s", (fails) => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-baseline-failure"); const runId = RunId.make("run:run-execution-baseline-failure"); @@ -1045,6 +1045,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => id: CheckpointScopeId.make("checkpoint-scope:run-execution-baseline-failure"), } as OrchestrationV2CheckpointScope; const providerStarts = yield* Ref.make(0); + const order: string[] = []; const writes = yield* Ref.make< ReadonlyArray<{ events: ReadonlyArray; @@ -1057,12 +1058,26 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => McpAppModelContext.layerEmpty, Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => - Effect.fail( - new CheckpointService.CheckpointBaselineCaptureError({ - scopeId: checkpointScope.id, - ordinalWithinScope: 0, - cause: new Error("VCS process timed out"), - }), + Effect.sync(() => { + order.push("baseline:start"); + }).pipe( + Effect.andThen(Effect.yieldNow), + Effect.andThen( + Effect.sync(() => { + order.push("baseline:end"); + }), + ), + Effect.andThen( + fails + ? Effect.fail( + new CheckpointService.CheckpointBaselineCaptureError({ + scopeId: checkpointScope.id, + ordinalWithinScope: 0, + cause: new Error("VCS process timed out"), + }), + ) + : Effect.void, + ), ), }), Layer.mock(EventSink.EventSinkV2)({ @@ -1087,10 +1102,18 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => commandId: CommandId.make("command:run-execution-baseline-failure"), appThread: { id: threadId } as OrchestrationV2AppThread, providerSessionId, - session: { - events: Stream.never, - startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + session: new Proxy( + { + events: Stream.never, + startTurn: () => Ref.update(providerStarts, (count) => count + 1), + }, + { + get(target, key, receiver) { + order.push("session:" + String(key)); + return Reflect.get(target, key, receiver); + }, + }, + ) as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: runId, threadId, @@ -1131,6 +1154,8 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }); }).pipe(Effect.provide(layerTest)); + assert.deepEqual(order.slice(0, 2), ["baseline:start", "baseline:end"]); + assert.include(order, "session:startTurn"); assert.equal(yield* Ref.get(providerStarts), 1); const events = (yield* Ref.get(writes)).flatMap((write) => [...write.events]); @@ -1149,107 +1174,6 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); -// Opening a provider process already runs workspace-capable startup (Claude's -// SessionStart hooks and MCP servers), so its edits must land after the baseline. -it.effect("touches the provider session only after the baseline checkpoint exists", () => - Effect.gen(function* () { - const threadId = ThreadId.make("thread:run-execution-baseline-order"); - const providerInstanceId = ProviderInstanceId.make("codex"); - // Property reads happen synchronously, so the log is a plain array. - const order: Array = []; - const runtime = { - events: Stream.never, - startTurn: () => Effect.void, - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; - const session = new Proxy(runtime, { - get: (target, key, receiver) => { - order.push(`session.${String(key)}`); - return Reflect.get(target, key, receiver); - }, - }); - const layerTest = RunExecutionService.layer.pipe( - Layer.provide( - Layer.mergeAll( - McpAppModelContext.layerEmpty, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - captureBaseline: () => - Effect.sync(() => { - order.push("baseline:start"); - }).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen( - Effect.sync(() => { - order.push("baseline:end"); - }), - ), - ), - }), - Layer.mock(EventSink.EventSinkV2)({}), - IdAllocator.layer, - Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ - ingestNormalized: () => Effect.succeed([]), - }), - ServerSettings.layerTest(), - ), - ), - ); - - yield* Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - yield* runExecution.startRootRun({ - commandId: CommandId.make("command:run-execution-baseline-order"), - appThread: { id: threadId } as OrchestrationV2AppThread, - providerSessionId: ProviderSessionId.make("session:run-execution-baseline-order"), - session, - run: { - id: RunId.make("run:run-execution-baseline-order"), - threadId, - ordinal: 1, - providerInstanceId, - } as OrchestrationV2Run, - rootNode: { - id: NodeId.make("node:run-execution-baseline-order"), - } as OrchestrationV2ExecutionNode, - checkpointScope: { - id: CheckpointScopeId.make("checkpoint-scope:run-execution-baseline-order"), - } as OrchestrationV2CheckpointScope, - providerThread: { - id: ProviderThreadId.make("provider-thread:run-execution-baseline-order"), - driver, - } as OrchestrationV2ProviderThread, - attempt: { - id: RunAttemptId.make("attempt:run-execution-baseline-order"), - providerTurnId: null, - } as OrchestrationV2RunAttempt, - attemptId: RunAttemptId.make("attempt:run-execution-baseline-order"), - providerTurnOrdinal: 1, - message: { - messageId: MessageId.make("message:run-execution-baseline-order"), - text: "Start after the baseline.", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }); - }).pipe(Effect.provide(layerTest)); - - assert.deepEqual(order.slice(0, 2), ["baseline:start", "baseline:end"]); - assert.include(order, "session.startTurn"); - }), -); - const preparationFailureScenarios = [ "failure", "interruption", From e74e1ed35cd9777a2c7ae2073e2e1e63a3410d90 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:55:59 +0000 Subject: [PATCH 118/143] refactor(http): drop precompressed asset selection --- apps/server/src/http.test.ts | 69 ------------------------------------ apps/server/src/http.ts | 33 ----------------- 2 files changed, 102 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 39f0fea22ef56..9d3686a46692b 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,6 +1,5 @@ import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; -import * as NodeZlib from "node:zlib"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -215,74 +214,6 @@ it.layer( Layer.provideMerge(NodeServices.layer), ), )("static HTTP responses", (it) => { - it.effect("serves precompressed manifest assets and revalidates other files", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compressed-" }); - yield* fs.makeDirectory(path.join(staticDir, "assets")); - const body = 'export const build = "precompressed";\n'.repeat(100); - const asset = "assets/app-1234abcd.js"; - const br = NodeZlib.brotliCompressSync(body); - const gz = NodeZlib.gzipSync(body); - yield* fs.writeFileString(path.join(staticDir, asset), body); - yield* fs.writeFile(path.join(staticDir, `${asset}.br`), br); - yield* fs.writeFile(path.join(staticDir, `${asset}.gz`), gz); - yield* fs.writeFileString( - path.join(staticDir, "vite-manifest.json"), - JSON.stringify({ "src/app.ts": { file: asset, isEntry: true } }), - ); - // A hash-like filename and a compressed copy alone cannot make a custom file immutable. - const custom = "assets/custom-1234abcd.js"; - yield* fs.writeFileString(path.join(staticDir, custom), body); - yield* fs.writeFile(path.join(staticDir, `${custom}.br`), br); - const request = yield* makeStaticRequest(staticDir); - - for (const [acceptEncoding, encoding, length] of [ - ["br, gzip", "br", br.byteLength], - ["br;q=0, gzip", "gzip", gz.byteLength], - ["identity", undefined, Buffer.byteLength(body)], - ] as const) { - const response = yield* request(`/${asset}`, { - headers: { "accept-encoding": acceptEncoding }, - }); - expect(response.status).toBe(200); - expect(response.headers["content-encoding"]).toBe(encoding); - expect(response.headers["content-length"]).toBe(String(length)); - expect(response.headers["content-type"]).toContain("javascript"); - expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); - expect(response.headers["vary"]).toBe("Accept-Encoding"); - expect(yield* response.text).toBe(body); - const head = yield* request(`/${asset}`, { - method: "HEAD", - headers: { "accept-encoding": acceptEncoding }, - }); - expect(head.headers["content-encoding"]).toBe(encoding); - expect(head.headers["content-length"]).toBe(String(length)); - expect(yield* head.text).toBe(""); - const unchanged = yield* request(`/${asset}`, { - headers: { - "accept-encoding": acceptEncoding, - "if-none-match": response.headers["etag"]!, - }, - }); - expect(unchanged.status).toBe(304); - expect(unchanged.headers["vary"]).toBe("Accept-Encoding"); - expect(yield* unchanged.text).toBe(""); - } - const unlisted = yield* request(`/${custom}`, { headers: { "accept-encoding": "br" } }); - expect(unlisted.headers["cache-control"]).toBe("no-cache"); - expect(unlisted.headers["content-length"]).toBeUndefined(); - expect(yield* unlisted.text).toBe(body); - - yield* fs.remove(path.join(staticDir, `${asset}.br`)); - const fallback = yield* request(`/${asset}`, { headers: { "accept-encoding": "br, gzip" } }); - expect(fallback.headers["content-encoding"]).toBe("gzip"); - expect(fallback.headers["content-length"]).toBe(String(gz.byteLength)); - expect(yield* fallback.text).toBe(body); - }), - ); - it.effect("revalidates non-HTML files and returns changed contents", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 1b552cd058459..50f608a41af8c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -571,26 +571,6 @@ const streamStaticFile = (file: FileSystem.File, size: bigint) => }), ); -/** Match the compression middleware's preference and reject malformed quality values. */ -function acceptedStaticEncodings(header: string | undefined): ReadonlyArray<"br" | "gzip"> { - if (!header?.trim()) return []; - const accepted = new Map(); - for (const part of header.split(",")) { - const member = part - .trim() - .toLowerCase() - .replace(/[ \t]*;[ \t]*/g, ";"); - const match = /^([a-z0-9!#$%&'*+.^_`|~-]+)(?:;q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/.exec( - member, - ); - if (!match) return []; - accepted.set(match[1]!, match[2] === undefined ? 1 : Number(match[2])); - } - return (["br", "gzip"] as const).filter( - (encoding) => (accepted.get(encoding) ?? accepted.get("*") ?? 0) > 0, - ); -} - const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; @@ -707,19 +687,6 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; - // Only manifest-backed hashed assets can trust an adjacent compressed copy. - // Custom static files may be overwritten without updating their sidecars. - if (immutable) { - for (const encoding of acceptedStaticEncodings(request.headers["accept-encoding"])) { - const compressed = yield* openStaticFile(`${filePath}.${encoding === "br" ? "br" : "gz"}`); - if (!compressed) continue; - return HttpServerResponse.stream(streamStaticFile(compressed.file, compressed.info.size), { - headers: { ...headers, "Content-Encoding": encoding, Vary: "Accept-Encoding" }, - contentType, - contentLength: Number(compressed.info.size), - }); - } - } // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. // HEAD still passes through compression, which selects headers without reading the stream. return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { From 77cca0f858f4527ae978e110c44b8008494d1574 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:56:46 +0000 Subject: [PATCH 119/143] refactor(startup): simplify background work gate --- apps/server/src/serverActivation.ts | 69 ++++++++--------------------- 1 file changed, 18 insertions(+), 51 deletions(-) diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts index 44c5195f09753..77bd85e63a4b8 100644 --- a/apps/server/src/serverActivation.ts +++ b/apps/server/src/serverActivation.ts @@ -3,7 +3,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import type * as Fiber from "effect/Fiber"; +import * as Fiber from "effect/Fiber"; import type * as Scope from "effect/Scope"; export class ServerActivation extends Context.Reference | undefined>( @@ -34,91 +34,58 @@ export const forkParked = ( ): Effect.Effect => forkParkedFiber(effect).pipe(Effect.asVoid); export interface BackgroundStart { - /** Opens once the first client has its config, or a while after activation without one. */ readonly awaitFirstClient: Effect.Effect; - /** Waits for this caller's staggered start once startup has gone quiet. */ readonly awaitTurn: Effect.Effect; readonly markClientServed: Effect.Effect; } -/** - * Gate for startup work no client waits on: settlement and pull request - * sweeps, cache prunes, first provider probes, the boot heartbeat. Started at - * activation, that work spawned over a hundred processes and kept the event - * loop busy for seconds while the first client loaded. Unset (tests, CLI - * tools) means start now. - */ export class ServerBackgroundStart extends Context.Reference( "t3/serverBackgroundStart", { defaultValue: () => undefined }, ) {} -const FIRST_CLIENT_WAIT = Duration.seconds(10); -const QUIET_AFTER_ACTIVATION = Duration.seconds(10); -const QUIET_MAX_WAIT = Duration.seconds(30); -const TURN_STAGGER_MILLIS = 2_000; - -/** - * Provider probes start once the first client has its config. Maintenance - * starts once that client is served and the server has been active for - * `QUIET_AFTER_ACTIVATION` (at most `QUIET_MAX_WAIT` after activation), one - * job every `TURN_STAGGER_MILLIS`. Callers that arrive later start at once. - */ +// Probes wait for config delivery (at most 10s). Maintenance also waits for a +// quiet startup (10s, at most 30s), then starts one job every 2s. export const makeBackgroundStart = (activation: Effect.Effect) => Effect.gen(function* () { const clientServed = yield* Deferred.make(); - const firstClient = yield* Deferred.make(); - const quiet = yield* Deferred.make(); - const awaitClientServed = Deferred.await(clientServed); - yield* activation.pipe( + const served = Deferred.await(clientServed); + const firstClient = yield* activation.pipe( + Effect.andThen(served.pipe(Effect.timeoutOption("10 seconds"))), + Effect.forkScoped, + ); + const quiet = yield* activation.pipe( Effect.andThen( - Effect.all( - [ - awaitClientServed.pipe( - Effect.timeoutOption(FIRST_CLIENT_WAIT), - Effect.andThen(Deferred.succeed(firstClient, undefined)), - ), - Effect.all([Effect.sleep(QUIET_AFTER_ACTIVATION), awaitClientServed], { - concurrency: "unbounded", - }).pipe( - Effect.timeoutOption(QUIET_MAX_WAIT), - Effect.andThen(Deferred.succeed(quiet, undefined)), - ), - ], - { concurrency: "unbounded" }, + Effect.all([Effect.sleep("10 seconds"), served], { concurrency: "unbounded" }).pipe( + Effect.timeoutOption("30 seconds"), ), ), Effect.forkScoped, ); let nextStartAt = 0; return { - awaitFirstClient: Deferred.await(firstClient), - awaitTurn: Deferred.await(quiet).pipe( + awaitFirstClient: Fiber.join(firstClient).pipe(Effect.asVoid), + awaitTurn: Fiber.join(quiet).pipe( Effect.andThen(Clock.currentTimeMillis), Effect.flatMap((now) => { const startAt = Math.max(now, nextStartAt); - nextStartAt = startAt + TURN_STAGGER_MILLIS; - return startAt > now ? Effect.sleep(Duration.millis(startAt - now)) : Effect.void; + nextStartAt = startAt + 2_000; + return Effect.sleep(Duration.millis(startAt - now)); }), ), markClientServed: Deferred.succeed(clientServed, undefined).pipe(Effect.asVoid), } satisfies BackgroundStart; }); -/** See `BackgroundStart.awaitTurn`. */ -export const awaitBackgroundTurn: Effect.Effect = Effect.gen(function* () { +export const awaitBackgroundTurn = Effect.gen(function* () { const gate = yield* ServerBackgroundStart; if (gate !== undefined) yield* gate.awaitTurn; }); -/** See `BackgroundStart.markClientServed`. */ -export const markClientServed: Effect.Effect = Effect.gen(function* () { +export const markClientServed = Effect.gen(function* () { const gate = yield* ServerBackgroundStart; if (gate !== undefined) yield* gate.markClientServed; }); -/** `forkParked` for deferrable startup work: it also waits for its background turn. */ -export const forkBackground = ( - effect: Effect.Effect, -): Effect.Effect => +export const forkBackground = (effect: Effect.Effect) => forkParked(Effect.andThen(awaitBackgroundTurn, effect)); From 5dc056173dc9bc70cd6c163a5776955354fbdf0a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:57:20 +0000 Subject: [PATCH 120/143] refactor(tracing): drop query normalization and span suppression --- .../server/src/project/ProjectEnrichmentService.ts | 14 +++++++++----- packages/shared/src/observability.test.ts | 12 ------------ packages/shared/src/observability.ts | 13 +++---------- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index e77107c3f7d59..1477733d66bd3 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -239,9 +239,9 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( } }); - // peek, request and getAvailable are cache reads called per project and per - // thread on every sweep. Spans live on the lanes that do the actual work. - const peek = Effect.fnUntraced(function* (workspaceRoot: string) { + const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fn( + "ProjectEnrichmentService.peek", + )(function* (workspaceRoot) { const [repositoryIdentity, faviconPath] = yield* Effect.all( [ Cache.getSuccess(repositoryIdentityCache, workspaceRoot), @@ -256,7 +256,9 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( }; }); - const request = Effect.fnUntraced(function* (workspaceRoot: string) { + const request: ProjectEnrichmentService["Service"]["request"] = Effect.fn( + "ProjectEnrichmentService.request", + )(function* (workspaceRoot) { const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], { concurrency: "unbounded" }, @@ -272,7 +274,9 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( ); }); - const getAvailable = Effect.fnUntraced(function* (workspaceRoot: string) { + const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fn( + "ProjectEnrichmentService.getAvailable", + )(function* (workspaceRoot) { const available = yield* peek(workspaceRoot); yield* request(workspaceRoot); return available; diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index aa2a363a9781d..ccc2e04d6d2dd 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -143,18 +143,6 @@ describe("truncateTraceAttributes", () => { const attributes = { short: "ok", nested: { fine: "also ok" } }; assert.equal(truncateTraceAttributes(attributes), attributes); }); - - it("collapses whitespace in query text before clamping it", () => { - const query = `SELECT payload_json\n FROM orchestration_v2_projection_runs\n WHERE thread_id = ?`; - assert.equal( - truncateTraceAttributes({ "db.query.text": query })["db.query.text"], - "SELECT payload_json FROM orchestration_v2_projection_runs WHERE thread_id = ?", - ); - const long = `SELECT ${"column,\n ".repeat(100)}`; - const clamped = truncateTraceAttributes({ "db.query.text": long })["db.query.text"] as string; - assert.isTrue(clamped.startsWith("SELECT column, column,")); - assert.equal(clamped.length, 200 + "…[truncated]".length); - }); }); describe("decodeOtlpTraceRecords", () => { diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 58b99829816b8..9e632ca524382 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -319,17 +319,10 @@ export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttri let truncated: Record | undefined; for (const [key, value] of Object.entries(attributes)) { if (typeof value === "string" && ALWAYS_TRUNCATED_TRACE_ATTRIBUTES.has(key)) { - // Query text is indented source; collapsing whitespace keeps more of the - // statement in fewer bytes on one of the most frequent spans. - const head = value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH * 8); - const compact = head.replace(/\s+/g, " ").trim(); - const fits = - head.length === value.length && compact.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH; - if (fits && compact === value) continue; + if (value.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH) continue; truncated ??= { ...attributes }; - truncated[key] = fits - ? compact - : `${compact.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + truncated[key] = + `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; continue; } const next = truncateNestedValue(value); From 28620d9023b083084e15facc2e7ecfd62079e27b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:57:33 +0000 Subject: [PATCH 121/143] test(sync): trim duplicate compact event fixtures --- .../orchestration-v2/ProjectionStore.test.ts | 15 --- .../src/orchestration-v2/runtimeLayer.test.ts | 30 ----- .../state/orchestrationV2Projection.test.ts | 107 +++++++----------- 3 files changed, 40 insertions(+), 112 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index d0e35baa71b5d..e5ba69ca79e4a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5744,21 +5744,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.equal(yield* phase, "running"); assert.equal((yield* store.getThreadsWithPullRequests()).length, 1); - yield* store.apply({ - id: EventId.make("event:watched-pull-request:missing-link-synced"), - type: "thread.pull-request-link-synced", - threadId, - occurredAt: DateTime.add(at, { seconds: 2 }), - payload: { - host: link.host, - repository: link.repository, - number: 999, - snapshot, - stack: null, - }, - }); - assert.deepEqual((yield* store.getThread(threadId)).pullRequests, synced.pullRequests); - // An older build sharing the database writes only the payload; the sweep still follows it. const sql = yield* SqlClient.SqlClient; const [stored] = yield* sql<{ readonly payload_json: string }>` diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 1ba38daa94dfd..7e175c5793434 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2095,36 +2095,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { number === 3 ? [1, 2, 3] : [1, 2], ); } - const beforeSync = (yield* orchestrator.getThreadRecords(threadId, [])).thread; - const syncedSnapshot = { - state: "open" as const, - title: "Updated host title", - headBranch: "feature/pr-links", - baseBranch: "main", - isDraft: false, - updatedAt: "2026-07-24T00:00:00.000Z", - syncedAt: "2026-07-24T00:00:00.000Z", - }; - yield* orchestrator.dispatch({ - type: "thread.pull-request-link.sync", - commandId: CommandId.make("branch-pr-sync"), - threadId, - host: "github.com", - repository: "pingdotgg/t3code", - number: 2, - snapshot: syncedSnapshot, - stack: null, - }); - const afterSync = (yield* orchestrator.getThreadRecords(threadId, [])).thread; - assert.deepEqual( - afterSync.pullRequests?.map((link) => link.number), - [1, 2, 3], - ); - assert.deepEqual( - afterSync.pullRequests?.find((link) => link.number === 2)?.snapshot, - syncedSnapshot, - ); - assert.deepEqual(afterSync.updatedAt, beforeSync.updatedAt); yield* orchestrator.dispatch({ type: "thread.pull-request.unlink", commandId: CommandId.make("branch-pr-unlink"), diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index 4bcf5b7183f9b..29d660da5a019 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -122,73 +122,46 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.turnItems).toBe(emptyProjection.turnItems); }); - it("refreshes one linked pull request and keeps other links", () => { - const link = { - host: "github.com", - repository: "owner/repo", - number: 1, - url: "https://github.com/owner/repo/pull/1", - source: "manual" as const, - linkedAt: DateTime.formatIso(now), - snapshot: null, - stack: null, - }; - const other = { ...link, number: 2, url: "https://github.com/owner/repo/pull/2" }; - const projection = { - ...emptyProjection, - thread: { ...emptyProjection.thread, pullRequests: [link, other] }, - }; - const snapshot = { - state: "open" as const, - title: "Host title", - headBranch: "feature", - baseBranch: "main", - isDraft: false, - updatedAt: DateTime.formatIso(now), - syncedAt: DateTime.formatIso(now), - }; - const event = { - id: "event-pr-link-synced", - type: "thread.pull-request-link-synced", - threadId, - occurredAt: DateTime.add(now, { hours: 1 }), - payload: { host: link.host, repository: link.repository, number: 1, snapshot, stack: null }, - } as OrchestrationV2DomainEvent; - const next = applyOrchestrationV2ProjectionEvent(projection, event); - expect(next?.thread.pullRequests?.[0]?.snapshot).toEqual(snapshot); - expect(next?.thread.pullRequests?.[1]).toBe(other); - expect(next?.thread.updatedAt).toEqual(now); - expect(next?.updatedAt).toEqual(now); - expect(next?.turnItems).toBe(projection.turnItems); - }); - - it("applies a compact PR sync without replacing other links or thread metadata", () => { - const link = (number: number): ThreadPullRequestLink => ({ - host: "github.com", - repository: "pingdotgg/t3code", - number, - url: `https://github.com/pingdotgg/t3code/pull/${number}`, - source: "manual", - linkedAt: DateTime.formatIso(now), - snapshot: null, - stack: null, - }); - const links = [link(1), link(2)]; - const projection = { - ...emptyProjection, - thread: { ...emptyProjection.thread, pullRequests: links }, - }; - const updated = { ...links[1]!, source: "agent" as const }; - const next = applyOrchestrationV2ProjectionEvent(projection, { - id: "compact-sync" as OrchestrationV2DomainEvent["id"], - type: "thread.pull-request-link-updated", - threadId, - occurredAt: now, - payload: updated, - }); - expect(next?.thread).toEqual({ ...projection.thread, pullRequests: [links[0], updated] }); - expect(next?.thread.pullRequests?.[0]).toBe(links[0]); - }); + it.each(["thread.pull-request-link-updated", "thread.pull-request-link-synced"] as const)( + "applies %s without replacing other links or activity", + (type) => { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "manual", + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, + }); + const links = [link(1), link(2)]; + const projection = { + ...emptyProjection, + thread: { ...emptyProjection.thread, pullRequests: links }, + }; + const updated = { ...links[1]!, source: "agent" as const }; + const next = applyOrchestrationV2ProjectionEvent(projection, { + id: "compact-sync" as OrchestrationV2DomainEvent["id"], + type, + threadId, + occurredAt: DateTime.add(now, { hours: 1 }), + payload: + type === "thread.pull-request-link-updated" + ? updated + : { ...updated, snapshot: null, stack: null }, + } as OrchestrationV2DomainEvent); + expect(next?.thread).toEqual({ + ...projection.thread, + pullRequests: [links[0], type === "thread.pull-request-link-updated" ? updated : links[1]], + }); + expect(next?.updatedAt).toEqual( + type === "thread.pull-request-link-synced" ? now : DateTime.add(now, { hours: 1 }), + ); + expect(next?.turnItems).toBe(projection.turnItems); + expect(next?.thread.pullRequests?.[0]).toBe(links[0]); + }, + ); it("keeps live token usage when the terminal provider turn omits it", () => { const providerTurnId = ProviderTurnId.make("provider-turn-reducer"); From 50b477617e11831949381f45056d0a80cda9f496 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 01:59:13 +0000 Subject: [PATCH 122/143] test(perf): correct and simplify trimmed regression cases --- .../DesktopLocalEnvironmentAuth.test.ts | 9 +- .../Migrations/055_OrchestrationV2.test.ts | 4 +- .../state/orchestrationV2Projection.test.ts | 88 ++++++++++--------- packages/shared/src/markdownPipeline.test.ts | 4 +- 4 files changed, 58 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index aebf45e796069..2362d8633c3d3 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -262,7 +262,7 @@ describe("DesktopLocalEnvironmentAuth", () => { ); const result = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( Effect.flatMap((auth) => auth.getBearerToken), - Effect.either, + Effect.match({ onSuccess: (token) => token, onFailure: (error) => error._tag }), Effect.forkChild, Effect.provide( DesktopLocalEnvironmentAuth.layer.pipe(Layer.provide(Layer.merge(pool, client))), @@ -271,9 +271,10 @@ describe("DesktopLocalEnvironmentAuth", () => { assert.equal(requests, 0); yield* TestClock.adjust("41 seconds"); const token = yield* Fiber.join(result); - assert.equal(token._tag, ready ? "Right" : "Left"); - if (token._tag === "Right") assert.equal(token.right, "desktop-bearer-token"); - else assert.equal(token.left._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError"); + assert.equal( + token, + ready ? "desktop-bearer-token" : "DesktopLocalEnvironmentAuthSessionBootstrapError", + ); assert.equal(requests, ready ? 1 : 0); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index f584584ffc0d1..d07e3202314fd 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -144,8 +144,8 @@ layer("055_OrchestrationV2", (it) => { it.effect.each([false, true])("repairs sweep indexes with legacy id 62: %s", (legacy) => { const warnings: Array = []; const logger = Logger.make(({ fiber, logLevel }) => { - if (logLevel === "Warn") - warnings.push(fiber.getRef(References.CurrentLogAnnotations).skipped); + const skipped = fiber.getRef(References.CurrentLogAnnotations).skipped; + if (logLevel === "Warn" && skipped !== undefined) warnings.push(skipped); }); return Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index 29d660da5a019..f913481de2b7a 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -122,46 +122,54 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.turnItems).toBe(emptyProjection.turnItems); }); - it.each(["thread.pull-request-link-updated", "thread.pull-request-link-synced"] as const)( - "applies %s without replacing other links or activity", - (type) => { - const link = (number: number): ThreadPullRequestLink => ({ - host: "github.com", - repository: "pingdotgg/t3code", - number, - url: `https://github.com/pingdotgg/t3code/pull/${number}`, - source: "manual", - linkedAt: DateTime.formatIso(now), - snapshot: null, - stack: null, - }); - const links = [link(1), link(2)]; - const projection = { - ...emptyProjection, - thread: { ...emptyProjection.thread, pullRequests: links }, - }; - const updated = { ...links[1]!, source: "agent" as const }; - const next = applyOrchestrationV2ProjectionEvent(projection, { - id: "compact-sync" as OrchestrationV2DomainEvent["id"], - type, - threadId, - occurredAt: DateTime.add(now, { hours: 1 }), - payload: - type === "thread.pull-request-link-updated" - ? updated - : { ...updated, snapshot: null, stack: null }, - } as OrchestrationV2DomainEvent); - expect(next?.thread).toEqual({ - ...projection.thread, - pullRequests: [links[0], type === "thread.pull-request-link-updated" ? updated : links[1]], - }); - expect(next?.updatedAt).toEqual( - type === "thread.pull-request-link-synced" ? now : DateTime.add(now, { hours: 1 }), - ); - expect(next?.turnItems).toBe(projection.turnItems); - expect(next?.thread.pullRequests?.[0]).toBe(links[0]); - }, - ); + it("applies a compact PR sync without replacing other links or thread metadata", () => { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "manual", + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, + }); + const links = [link(1), link(2)]; + const projection = { + ...emptyProjection, + thread: { ...emptyProjection.thread, pullRequests: links }, + }; + const updated = { ...links[1]!, source: "agent" as const }; + const next = applyOrchestrationV2ProjectionEvent(projection, { + id: "compact-sync" as OrchestrationV2DomainEvent["id"], + type: "thread.pull-request-link-updated", + threadId, + occurredAt: now, + payload: updated, + }); + expect(next?.thread).toEqual({ ...projection.thread, pullRequests: [links[0], updated] }); + expect(next?.thread.pullRequests?.[0]).toBe(links[0]); + const snapshot = { + state: "open", + title: "Host title", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(now), + syncedAt: DateTime.formatIso(now), + }; + const synced = applyOrchestrationV2ProjectionEvent(projection, { + id: "host-sync", + type: "thread.pull-request-link-synced", + threadId, + occurredAt: DateTime.add(now, { hours: 1 }), + payload: { ...links[1], snapshot, stack: null }, + } as OrchestrationV2DomainEvent); + expect(synced?.thread.pullRequests?.[1]?.snapshot).toEqual(snapshot); + expect(synced?.thread.pullRequests?.[0]).toBe(links[0]); + expect(synced?.thread.updatedAt).toEqual(now); + expect(synced?.updatedAt).toEqual(now); + expect(synced?.turnItems).toBe(projection.turnItems); + }); it("keeps live token usage when the terminal provider turn omits it", () => { const providerTurnId = ProviderTurnId.make("provider-turn-reducer"); diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 916ce3b98786f..b88f787e62c43 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -46,7 +46,9 @@ describe("raw HTML detection", () => { it("renders tag-free markdown without loading raw HTML", () => { const markdown = "# Title\n\n1 < 2 and **bold** with \`code\`"; expect(markdownMayContainRawHtml(markdown)).toBe(false); - expect(chatMarkdownTree(markdown, false).children).toMatchObject([ + expect( + chatMarkdownTree(markdown, false).children.filter((node) => node.type === "element"), + ).toMatchObject([ { tagName: "h1", children: [{ value: "Title" }] }, { tagName: "p", From c1f27a027feba69e9814f624a878a112b967ce4d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:00:00 +0000 Subject: [PATCH 123/143] refactor(sync): reuse thread mutation for compact PR events --- .../src/orchestration-v2/Orchestrator.ts | 79 +++++++------------ 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 40f4297e028a9..eaa595b5f16a4 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2346,9 +2346,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const movesForward = thread.lastVisitedAt === null || DateTime.toEpochMillis(visitedAt.value) > DateTime.toEpochMillis(thread.lastVisitedAt); - // Viewing a thread changes read state only. Loading its transcript, bumping - // updatedAt, or recording the whole thread makes a routine read receipt - // scale with its history. + // Viewing a thread changes read state only. Loading its transcript (or + // bumping updatedAt) makes a routine read receipt scale with its history. yield* emit( events, command, @@ -2361,44 +2360,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - // The sync reactor writes one of these per linked thread whenever a pull request changes on - // its host, so the event records only that link's snapshot, not the thread. - const dispatchPullRequestLinkSync = Effect.fn("orchestrationV2.dispatch.pullRequestLinkSync")( - function* ( - command: Extract, - events: Ref.Ref>, - ) { - const thread = yield* projectionStore - .getThread(command.threadId) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - if (thread.deletedAt !== null) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: `Thread ${command.threadId} is deleted.`, - }); - } - yield* emit( - events, - command, - )({ - type: "thread.pull-request-link-synced", - threadId: command.threadId, - providerInstanceId: thread.providerInstanceId, - occurredAt: yield* DateTime.now, - payload: { - ...normalizeThreadPullRequestKey(command), - snapshot: command.snapshot, - stack: command.stack, - }, - }); - }, - ); - // Checked under the thread lock: the watch or the thread can change while the host is read. // The watch is recorded first so the wake's own thread events carry it. const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( @@ -2476,6 +2437,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.metadata.update" | "thread.pull-request.link" | "thread.pull-request.unlink" + | "thread.pull-request-link.sync" | "thread.pull-request.watch" | "thread.pull-request-watch.sync" | "thread.pull-request.sync" @@ -3095,7 +3057,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } case "thread.pull-request.link": - case "thread.pull-request.unlink": { + case "thread.pull-request.unlink": + case "thread.pull-request-link.sync": { const key = normalizeThreadPullRequestKey(command); const links = threadPullRequestsOf(thread); const existing = links.find((link) => threadPullRequestKeysEqual(link, key)); @@ -3139,7 +3102,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...links.filter((entry) => !threadPullRequestKeysEqual(entry, key)), link, ]; - } else { + } else if (command.type === "thread.pull-request.unlink") { if (!existing) return thread; const belongsToStack = existing.source === "stack" || @@ -3160,6 +3123,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : link, ) : links.filter((link) => link !== existing); + } else { + if (!existing) return thread; + pullRequests = links.map((link) => + link === existing + ? { ...link, snapshot: command.snapshot, stack: command.stack } + : link, + ); } return { ...thread, @@ -3176,7 +3146,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) ? thread.linkedPullRequest : null, - updatedAt: now, + updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now, }; } case "thread.pull-request.watch": @@ -3291,7 +3261,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } })(); - if (command.type === "thread.pull-request-watch.sync") { + if ( + command.type === "thread.pull-request-link.sync" || + command.type === "thread.pull-request-watch.sync" + ) { if (updatedThread === thread) return; const link = threadPullRequestsOf(updatedThread).find((candidate) => threadPullRequestKeysEqual(candidate, normalizeThreadPullRequestKey(command)), @@ -3301,11 +3274,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, command, )({ - type: "thread.pull-request-link-updated", + ...(command.type === "thread.pull-request-link.sync" + ? { + type: "thread.pull-request-link-synced" as const, + payload: { + ...normalizeThreadPullRequestKey(command), + snapshot: command.snapshot, + stack: command.stack, + }, + } + : { type: "thread.pull-request-link-updated" as const, payload: link }), threadId: command.threadId, providerInstanceId: updatedThread.providerInstanceId, occurredAt: now, - payload: link, }); return; } @@ -10858,6 +10839,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.metadata.update": case "thread.pull-request.link": case "thread.pull-request.unlink": + case "thread.pull-request-link.sync": case "thread.pull-request.watch": case "thread.pull-request.sync": case "thread.title.regeneration.complete": @@ -10867,9 +10849,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider.switch": yield* dispatchThreadMutation(command, events, effects); break; - case "thread.pull-request-link.sync": - yield* dispatchPullRequestLinkSync(command, events); - break; case "thread.pull-request-watch.sync": yield* dispatchPullRequestWatchSync(command, events, effects); break; From c78979862d575c46489731e24b15568b31cff345 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:00:34 +0000 Subject: [PATCH 124/143] test(checkpoint): reuse workspace refresh retry fixture --- .../RunFinalizationService.test.ts | 60 +++++-------------- 1 file changed, 14 insertions(+), 46 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index b1a9f9f8cfccf..e69df0c49a793 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -25,12 +25,17 @@ it.effect("refreshes workspace after checkpoint capture without reading history" const refreshStarted = Deferred.makeUnsafe(); const releaseRefresh = Deferred.makeUnsafe(); const refreshFinished = Deferred.makeUnsafe(); + let shouldFail = false; const refresh = vi.fn(() => - Deferred.succeed(refreshStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseRefresh)), - Effect.andThen(Deferred.succeed(refreshFinished, undefined)), - Effect.asVoid, - ), + shouldFail + ? Effect.fail( + new RunFinalization.RunFinalizationRefreshError({ cwd: "/repo", cause: "index.lock" }), + ) + : Deferred.succeed(refreshStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRefresh)), + Effect.andThen(Deferred.succeed(refreshFinished, undefined)), + Effect.asVoid, + ), ); const checkpointContext = { runs: [], @@ -64,49 +69,12 @@ it.effect("refreshes workspace after checkpoint capture without reading history" assert.isFalse(yield* Deferred.isDone(refreshFinished)); yield* Deferred.succeed(releaseRefresh, undefined); yield* Deferred.await(refreshFinished); - }).pipe(Effect.provide(layer)); -}); - -it.effect("retries a failed workspace refresh up to five attempts", () => { - const threadId = ThreadId.make("thread_finalize_retry"); - const runId = RunId.make("run_finalize_retry"); - const scopeId = CheckpointScopeId.make("scope_finalize_retry"); - let attempts = 0; - const refresh = () => - Effect.suspend(() => { - attempts += 1; - return Effect.fail( - new RunFinalization.RunFinalizationRefreshError({ cwd: "/repo", cause: "index.lock" }), - ); - }); - const layer = RunFinalization.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(CheckpointCapture.CheckpointCaptureServiceV2)({ execute: () => Effect.void }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getCheckpointContext: () => - Effect.succeed({ - runs: [], - checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], - checkpoints: [], - }), - }), - Layer.succeed(RunFinalization.RunFinalizationObserver, { - refresh, - refreshAfterTurn: () => Effect.void, - }), - ), - ), - ); - return Effect.gen(function* () { - const service = yield* RunFinalization.RunFinalizationService; + shouldFail = true; + refresh.mockClear(); yield* service.finalize({ threadId, runId, scopeId }); - // Steps through the 1.5s of backoff, then shows the retries stop. - for (let step = 0; step < 20; step += 1) { - yield* TestClock.adjust("100 millis"); - } + for (let step = 0; step < 20; step++) yield* TestClock.adjust("100 millis"); yield* TestClock.adjust("1 minute"); - assert.equal(attempts, 5); + assert.equal(refresh.mock.calls.length, 5); }).pipe(Effect.provide(layer)); }); From aa10df7289db4ba8d2ad8291849db4f86ccc3f07 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:02:06 +0000 Subject: [PATCH 125/143] test(startup): trim parking setup and retain unavailable backend checks --- .../DesktopLocalEnvironmentAuth.test.ts | 12 ++++--- .../server/src/httpResponseErrorGuard.test.ts | 35 ++++++------------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 2362d8633c3d3..25fc042e17440 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -246,7 +246,8 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Local"), - waitForReady: () => Effect.sleep("40 seconds").pipe(Effect.as(ready)), + waitForReady: () => + Effect.sleep(ready ? "40 seconds" : "2 minutes").pipe(Effect.as(ready)), currentConfig: Effect.succeedSome(config), }, ]), @@ -256,7 +257,9 @@ describe("DesktopLocalEnvironmentAuth", () => { HttpClient.make((request) => Effect.sync(() => { requests++; - return tokenResponse(request); + return ready + ? tokenResponse(request) + : HttpClientResponse.fromWeb(request, new Response("", { status: 503 })); }), ), ); @@ -269,13 +272,14 @@ describe("DesktopLocalEnvironmentAuth", () => { ), ); assert.equal(requests, 0); - yield* TestClock.adjust("41 seconds"); + yield* TestClock.adjust("3 minutes"); const token = yield* Fiber.join(result); assert.equal( token, ready ? "desktop-bearer-token" : "DesktopLocalEnvironmentAuthSessionBootstrapError", ); - assert.equal(requests, ready ? 1 : 0); + if (ready) assert.equal(requests, 1); + else assert.isAbove(requests, 0); }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index 3008b995211c4..9f51ba45b8671 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -107,50 +107,37 @@ describe("guardHttpResponseWriteErrors", () => { }); describe("holdRequestsUntilServing", () => { - it("answers requests and upgrades accepted before the handlers attach", async () => { + it("answers requests and upgrades accepted before handlers attach", async () => { const server = holdRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())); const port = await listen(server); - const requestsParked = Promise.withResolvers(); - let parsedRequests = 0; + const parked = Promise.withResolvers(); + let parsed = 0; server.on("connection", (socket) => socket.once("data", () => { - if (++parsedRequests === 2) requestsParked.resolve(); + if (++parsed === 2) parked.resolve(); }), ); - const response = fetchStatus(port, "/early"); - const upgradeResponse = new Promise((resolve, reject) => { + const upgrade = new Promise((resolve, reject) => { const client = NodeNet.connect(port, "127.0.0.1", () => { client.write( - [ - "GET /ws HTTP/1.1", - "Host: 127.0.0.1", - "Connection: Upgrade", - "Upgrade: websocket", - "", - "", - ].join("\r\n"), + "GET /ws HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", ); }); client.once("data", (data) => { - resolve(data.toString().split("\r\n")[0] ?? ""); client.destroy(); + resolve(data.toString().split("\r\n")[0]!); }); client.on("error", reject); }); - // Node's HTTP parser handles data before this observer. - await requestsParked.promise; - - server.on("request", (_request, response) => { + await parked.promise; + server.on("request", (_, response) => { response.writeHead(204); response.end(); }); - server.on("upgrade", (_request, socket) => { - socket.end("HTTP/1.1 101 Switching Protocols\r\n\r\n"); - }); - + server.on("upgrade", (_, socket) => socket.end("HTTP/1.1 101 Switching Protocols\r\n\r\n")); await expect(response).resolves.toBe(204); - await expect(upgradeResponse).resolves.toBe("HTTP/1.1 101 Switching Protocols"); + await expect(upgrade).resolves.toBe("HTTP/1.1 101 Switching Protocols"); }); it("hands parked requests to the handler before later ones", async () => { From a15ba43085811204814afe776026fbb0404dd507 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:03:08 +0000 Subject: [PATCH 126/143] refactor(auth): drop prepared connection equality cache --- packages/client-runtime/src/state/session.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index bc16632164dc8..d185d3b7ee916 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,7 +1,6 @@ import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; -import * as Equivalence from "effect/Equivalence"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -120,18 +119,12 @@ function makeEnvironmentSessionAtoms( ), ); - // A connect attempt reports the same prepared connection at each stage, each - // in a fresh Option. Comparing the connection itself keeps the session fetch - // below to one per attempt. const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => Option.none(), ), - ).pipe( - Atom.withEquality(Option.makeEquivalence(Equivalence.strictEqual())), - Atom.withLabel(`environment-prepared-connection:${environmentId}`), - ), + ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); // Keyed on the prepared connection's identity: a reconnect (new credential, From b7e7969518f1fff80fba8b9acef6451e8e989789 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:03:17 +0000 Subject: [PATCH 127/143] refactor(telemetry): drop startup SQL count shortcut --- apps/server/src/serverRuntimeStartup.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 26062fdb45a06..5c6737dc5c4c2 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -24,7 +24,6 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "./config.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; @@ -147,18 +146,15 @@ export const makeCommandGate = Effect.gen(function* () { const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; const projects = yield* ProjectService.ProjectService; - const sql = yield* SqlClient.SqlClient; + const threads = yield* ThreadManagement.ThreadManagementService; const { threadCount, projectCount } = yield* Effect.all({ projects: projects.snapshot, - // The threads the shell snapshot lists, counted without building it. - threads: sql<{ readonly count: number }>` - SELECT COUNT(*) AS count FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL - `, + threads: threads.getShellSnapshot(), }).pipe( - Effect.map(({ projects: projectSnapshot, threads: rows }) => ({ + Effect.map(({ projects: projectSnapshot, threads: shellSnapshot }) => ({ projectCount: projectSnapshot.projects.length, - threadCount: rows[0]?.count ?? 0, + threadCount: shellSnapshot.threads.length + shellSnapshot.archivedThreads.length, })), Effect.catch((cause) => Effect.logWarning("failed to gather V2 startup counts for telemetry", { From 249ac7b06ba210e9b9a38b922703e65a9fcd1704 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:09 +0000 Subject: [PATCH 128/143] refactor(startup): drop maintenance staggering --- apps/server/src/serverActivation.test.ts | 10 +--------- apps/server/src/serverActivation.ts | 14 ++------------ 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts index be2d23d0a3580..0caaa3a0cff1a 100644 --- a/apps/server/src/serverActivation.test.ts +++ b/apps/server/src/serverActivation.test.ts @@ -24,30 +24,22 @@ it.effect("proves a root is parked before returning and releases it with one gat ), ); -it.effect("defers maintenance until the client loads and staggers its startup work", () => +it.effect("defers maintenance until the client loads and startup is quiet", () => Effect.scoped( Effect.gen(function* () { const activation = yield* Deferred.make(); const firstRan = yield* Deferred.make(); - const secondRan = yield* Deferred.make(); const background = yield* ServerActivation.makeBackgroundStart(Deferred.await(activation)); yield* ServerActivation.forkBackground(Deferred.succeed(firstRan, undefined)).pipe( Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), Effect.provideService(ServerActivation.ServerBackgroundStart, background), ); - yield* ServerActivation.forkBackground(Deferred.succeed(secondRan, undefined)).pipe( - Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), - Effect.provideService(ServerActivation.ServerBackgroundStart, background), - ); yield* Deferred.succeed(activation, undefined); yield* TestClock.adjust("10 seconds"); expect(yield* Deferred.isDone(firstRan)).toBe(false); - expect(yield* Deferred.isDone(secondRan)).toBe(false); yield* background.markClientServed; yield* Deferred.await(firstRan); expect(yield* Deferred.isDone(secondRan)).toBe(false); - yield* TestClock.adjust("2 seconds"); - yield* Deferred.await(secondRan); }), ), ); diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts index 77bd85e63a4b8..d1fef371e138d 100644 --- a/apps/server/src/serverActivation.ts +++ b/apps/server/src/serverActivation.ts @@ -1,7 +1,5 @@ -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import type * as Scope from "effect/Scope"; @@ -45,7 +43,7 @@ export class ServerBackgroundStart extends Context.Reference) => Effect.gen(function* () { const clientServed = yield* Deferred.make(); @@ -62,17 +60,9 @@ export const makeBackgroundStart = (activation: Effect.Effect) => ), Effect.forkScoped, ); - let nextStartAt = 0; return { awaitFirstClient: Fiber.join(firstClient).pipe(Effect.asVoid), - awaitTurn: Fiber.join(quiet).pipe( - Effect.andThen(Clock.currentTimeMillis), - Effect.flatMap((now) => { - const startAt = Math.max(now, nextStartAt); - nextStartAt = startAt + 2_000; - return Effect.sleep(Duration.millis(startAt - now)); - }), - ), + awaitTurn: Fiber.join(quiet).pipe(Effect.asVoid), markClientServed: Deferred.succeed(clientServed, undefined).pipe(Effect.asVoid), } satisfies BackgroundStart; }); From 19fd619a3bb7d326a64c47f8271da0d364e23841 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:04:48 +0000 Subject: [PATCH 129/143] refactor(contracts): simplify private compact event schemas --- packages/contracts/src/orchestrationV2.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f2d42d15e79b6..8db937200daf8 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1722,24 +1722,17 @@ export const OrchestrationV2RawProviderEvent = Schema.Struct({ }); export type OrchestrationV2RawProviderEvent = typeof OrchestrationV2RawProviderEvent.Type; -/** - * One linked pull request's fresh host snapshot. Every thread linked to a pull request takes - * one of these when the host reports a change, so it names the link instead of carrying the - * thread: a full-thread event repeated the thread's every other link per change. - */ -export const OrchestrationV2ThreadPullRequestLinkSynced = Schema.Struct({ +/** A host refresh carries one link snapshot. */ +const OrchestrationV2ThreadPullRequestLinkSynced = Schema.Struct({ ...ThreadPullRequestKey.fields, snapshot: ThreadPullRequestSnapshot, stack: Schema.NullOr(ThreadPullRequestStack), }); -export type OrchestrationV2ThreadPullRequestLinkSynced = - typeof OrchestrationV2ThreadPullRequestLinkSynced.Type; /** The thread's read watermark after a visit, the only field a visit changes. */ -export const OrchestrationV2ThreadVisitRecorded = Schema.Struct({ +const OrchestrationV2ThreadVisitRecorded = Schema.Struct({ lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc), }); -export type OrchestrationV2ThreadVisitRecorded = typeof OrchestrationV2ThreadVisitRecorded.Type; const OrchestrationV2EventBase = Schema.Struct({ id: EventId, From 6c6925b3684eb564b8c951832a6ca0bbc46e0297 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:06:19 +0000 Subject: [PATCH 130/143] test(orchestration): fold baseline ordering into failure coverage --- .../RunExecutionService.test.ts | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 1815da4060ed9..64a8c6196775d 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1030,7 +1030,7 @@ it.effect("refreshes MCP credential liveness before calling the provider", () => }).pipe(Effect.provide(layerRunExecutionTest)), ); -it.effect.each([false, true])("starts after baseline capture, including failure: %s", (fails) => +it.effect("starts the provider only after checkpoint baseline capture fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-baseline-failure"); const runId = RunId.make("run:run-execution-baseline-failure"); @@ -1045,7 +1045,7 @@ it.effect.each([false, true])("starts after baseline capture, including failure: id: CheckpointScopeId.make("checkpoint-scope:run-execution-baseline-failure"), } as OrchestrationV2CheckpointScope; const providerStarts = yield* Ref.make(0); - const order: string[] = []; + let baselineFinished = false; const writes = yield* Ref.make< ReadonlyArray<{ events: ReadonlyArray; @@ -1058,26 +1058,21 @@ it.effect.each([false, true])("starts after baseline capture, including failure: McpAppModelContext.layerEmpty, Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => - Effect.sync(() => { - order.push("baseline:start"); - }).pipe( - Effect.andThen(Effect.yieldNow), + Effect.yieldNow.pipe( Effect.andThen( + Effect.fail( + new CheckpointService.CheckpointBaselineCaptureError({ + scopeId: checkpointScope.id, + ordinalWithinScope: 0, + cause: new Error("VCS process timed out"), + }), + ), + ), + Effect.ensuring( Effect.sync(() => { - order.push("baseline:end"); + baselineFinished = true; }), ), - Effect.andThen( - fails - ? Effect.fail( - new CheckpointService.CheckpointBaselineCaptureError({ - scopeId: checkpointScope.id, - ordinalWithinScope: 0, - cause: new Error("VCS process timed out"), - }), - ) - : Effect.void, - ), ), }), Layer.mock(EventSink.EventSinkV2)({ @@ -1109,7 +1104,7 @@ it.effect.each([false, true])("starts after baseline capture, including failure: }, { get(target, key, receiver) { - order.push("session:" + String(key)); + assert.isTrue(baselineFinished); return Reflect.get(target, key, receiver); }, }, @@ -1154,8 +1149,6 @@ it.effect.each([false, true])("starts after baseline capture, including failure: }); }).pipe(Effect.provide(layerTest)); - assert.deepEqual(order.slice(0, 2), ["baseline:start", "baseline:end"]); - assert.include(order, "session:startTurn"); assert.equal(yield* Ref.get(providerStarts), 1); const events = (yield* Ref.get(writes)).flatMap((write) => [...write.events]); From e72e51738d2c3277b5373707aaa133300b5e32b9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:07:02 +0000 Subject: [PATCH 131/143] test(startup): correct reduced gate and retry assertions --- .../orchestration-v2/RunFinalizationService.test.ts | 12 ++++++++---- apps/server/src/serverActivation.test.ts | 1 - 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index e69df0c49a793..bdc6ec29712e6 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -26,11 +26,15 @@ it.effect("refreshes workspace after checkpoint capture without reading history" const releaseRefresh = Deferred.makeUnsafe(); const refreshFinished = Deferred.makeUnsafe(); let shouldFail = false; + let failures = 0; const refresh = vi.fn(() => shouldFail - ? Effect.fail( - new RunFinalization.RunFinalizationRefreshError({ cwd: "/repo", cause: "index.lock" }), - ) + ? Effect.suspend(() => { + failures++; + return Effect.fail( + new RunFinalization.RunFinalizationRefreshError({ cwd: "/repo", cause: "index.lock" }), + ); + }) : Deferred.succeed(refreshStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseRefresh)), Effect.andThen(Deferred.succeed(refreshFinished, undefined)), @@ -74,7 +78,7 @@ it.effect("refreshes workspace after checkpoint capture without reading history" yield* service.finalize({ threadId, runId, scopeId }); for (let step = 0; step < 20; step++) yield* TestClock.adjust("100 millis"); yield* TestClock.adjust("1 minute"); - assert.equal(refresh.mock.calls.length, 5); + assert.equal(failures, 5); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts index 0caaa3a0cff1a..6f77ef4795658 100644 --- a/apps/server/src/serverActivation.test.ts +++ b/apps/server/src/serverActivation.test.ts @@ -39,7 +39,6 @@ it.effect("defers maintenance until the client loads and startup is quiet", () = expect(yield* Deferred.isDone(firstRan)).toBe(false); yield* background.markClientServed; yield* Deferred.await(firstRan); - expect(yield* Deferred.isDone(secondRan)).toBe(false); }), ), ); From 04b6ed1f6848ba55b87d7829d1c264b0aa5c7137 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:13:41 +0000 Subject: [PATCH 132/143] refactor(server): drop unmeasured parallel editor discovery --- apps/server/src/process/externalLauncher.ts | 32 ++++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 3fbadcc806cb5..8ab130b01e38e 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -429,20 +429,23 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { - // Each check is a few PATH lookups over shared directory listings, plus one - // `xdg-mime` spawn for the Linux file manager, so they run together. The - // result keeps EDITORS order. - const found = yield* Effect.forEach( - EDITORS, - (editor) => - editor.commands === null - ? resolveUsableFileManagerCommand(platform, env).pipe( - Effect.map((command) => command !== undefined), - ) - : resolveEditorCommand(editor, env).pipe(Effect.map(Option.isSome)), - { concurrency: "unbounded" }, - ); - return EDITORS.flatMap((editor, index) => (found[index] ? [editor.id] : [])); + const available: EditorId[] = []; + + for (const editor of EDITORS) { + if (editor.commands === null) { + if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { + available.push(editor.id); + } + continue; + } + + const command = yield* resolveEditorCommand(editor, env); + if (Option.isSome(command)) { + available.push(editor.id); + } + } + + return available; }); const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( @@ -825,6 +828,7 @@ export const make = Effect.gen(function* () { return scan; }).pipe(Effect.uninterruptible); const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, resolveFileManagerRevealKind: () => From b88df2f9450f4516b7e306fdf760fd3d98b9fc62 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:14:55 +0000 Subject: [PATCH 133/143] refactor(orchestration): drop unmeasured background workspace refresh --- .../RunFinalizationService.test.ts | 34 +------------------ .../RunFinalizationService.ts | 30 +++++----------- 2 files changed, 9 insertions(+), 55 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index bdc6ec29712e6..1898d4e0acc76 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -5,10 +5,8 @@ import { ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { TestClock } from "effect/testing"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; @@ -22,25 +20,7 @@ it.effect("refreshes workspace after checkpoint capture without reading history" const runId = RunId.make("run_finalize"); const scopeId = CheckpointScopeId.make("scope_finalize"); const capture = vi.fn(() => Effect.void); - const refreshStarted = Deferred.makeUnsafe(); - const releaseRefresh = Deferred.makeUnsafe(); - const refreshFinished = Deferred.makeUnsafe(); - let shouldFail = false; - let failures = 0; - const refresh = vi.fn(() => - shouldFail - ? Effect.suspend(() => { - failures++; - return Effect.fail( - new RunFinalization.RunFinalizationRefreshError({ cwd: "/repo", cause: "index.lock" }), - ); - }) - : Deferred.succeed(refreshStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseRefresh)), - Effect.andThen(Deferred.succeed(refreshFinished, undefined)), - Effect.asVoid, - ), - ); + const refresh = vi.fn(() => Effect.sync(() => assert.equal(capture.mock.calls.length, 1))); const checkpointContext = { runs: [], checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], @@ -64,21 +44,9 @@ it.effect("refreshes workspace after checkpoint capture without reading history" ); return Effect.gen(function* () { const service = yield* RunFinalization.RunFinalizationService; - // Finalize returns while the refresh is still blocked: the thread's effect - // lane is free for the next turn as soon as the checkpoint commits. yield* service.finalize({ threadId, runId, scopeId }); assert.equal(capture.mock.calls.length, 1); - yield* Deferred.await(refreshStarted); assert.deepEqual(refresh.mock.calls[0], [{ cwd: "/repo", threadId, runId }]); - assert.isFalse(yield* Deferred.isDone(refreshFinished)); - yield* Deferred.succeed(releaseRefresh, undefined); - yield* Deferred.await(refreshFinished); - shouldFail = true; - refresh.mockClear(); - yield* service.finalize({ threadId, runId, scopeId }); - for (let step = 0; step < 20; step++) yield* TestClock.adjust("100 millis"); - yield* TestClock.adjust("1 minute"); - assert.equal(failures, 5); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index d582c1829c5b4..d56ae20dc9ef2 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -1,9 +1,7 @@ import { CheckpointScopeId, ProjectId, RunId, ThreadId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; @@ -54,7 +52,6 @@ const make = Effect.gen(function* () { const checkpointCapture = yield* CheckpointCapture.CheckpointCaptureServiceV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const observer = yield* RunFinalizationObserver; - const refreshScope = yield* Effect.scope; const finalize: RunFinalizationService["Service"]["finalize"] = Effect.fn( "RunFinalizationService.finalize", @@ -75,25 +72,14 @@ const make = Effect.gen(function* () { ); const cwd = projection.checkpointScopes.find((scope) => scope.id === input.scopeId)?.cwd; if (cwd !== undefined) { - // The run is already completed and its checkpoint committed. The VCS and - // file-list refresh only updates client caches, so it runs beside the - // thread's effect lane instead of holding the next turn back for seconds. - yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }).pipe( - // Retries as the effect worker did when this ran on the lane: five attempts, from 100ms. - Effect.retry({ schedule: Schedule.exponential("100 millis"), times: 4 }), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : Effect.logWarning("failed to refresh workspace after run completion", cause).pipe( - Effect.annotateLogs({ - threadId: input.threadId, - runId: input.runId, - cwd, - }), - ), - ), - Effect.forkIn(refreshScope), - ); + yield* observer + .refresh({ cwd, threadId: input.threadId, runId: input.runId }) + .pipe( + Effect.mapError( + (cause) => + new RunFinalizationError({ ...input, operation: "refresh-workspace", cause }), + ), + ); } }); return RunFinalizationService.of({ finalize }); From 29c8898e4510de9e6926b84dc44721f93ac7083e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:15:35 +0000 Subject: [PATCH 134/143] test(startup): trim duplicate rendering and sync assertions --- apps/desktop/src/window/DesktopWindow.test.ts | 5 +-- .../PullRequestSyncReactor.test.ts | 36 ++++++------------- .../boundedSnapshotTransport.test.ts | 6 ++-- packages/shared/src/markdownPipeline.test.ts | 17 ++------- 4 files changed, 16 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8d5a1f0d2fc58..d92847f9f0084 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -685,10 +685,7 @@ describe("DesktopWindow", () => { yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; - const closeMain = Effect.suspend(() => { - const closed = fakeWindow.windowListeners.get("closed"); - return closed ? Effect.sync(() => closed()) : Effect.die("closed listener missing"); - }); + const closeMain = Effect.sync(() => fakeWindow.windowListeners.get("closed")!()); yield* desktopWindow.ensureMain; yield* closeMain; diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 4db7e00655d16..1898ebf5cf0a9 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1170,27 +1170,23 @@ describe("PullRequestSyncReactor", () => { Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); - const hostDown = yield* Ref.make(true); + let hostDown = true; + const link = makeLink(7, { state: "open" }); const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("one", { pullRequests: [makeLink(7, { state: "open" }), makeLink(8)] }), - ]), + snapshot: makeSnapshot([makeThread("one", { pullRequests: [link, makeLink(8)] })]), summary: (input) => - Ref.get(hostDown).pipe( - Effect.flatMap((down) => - down && input.number === 8 - ? Effect.fail( - new PullRequestOperationError({ operation: "summary", detail: "host down" }), - ) - : Effect.succeed(makeSummary(input)), - ), + Effect.suspend(() => + hostDown && input.number === 8 + ? Effect.fail( + new PullRequestOperationError({ operation: "summary", detail: "host down" }), + ) + : Effect.succeed(makeSummary(input)), ), }); yield* Effect.gen(function* () { const reactor = yield* startAndSweep(fixture); - yield* Ref.set(hostDown, false); - yield* Ref.set(fixture.summaryCalls, []); + hostDown = false; yield* Ref.set(fixture.syncCommands, []); yield* Queue.offer(fixture.domainEvents, { @@ -1198,23 +1194,13 @@ describe("PullRequestSyncReactor", () => { id: EventId.make("event:one:link-synced"), threadId: ThreadId.make("one"), occurredAt: DateTime.makeUnsafe(NOW), - payload: { - host: "github.com", - repository: "owner/repository", - number: 7, - snapshot: makeLink(7, { state: "open" }).snapshot!, - stack: null, - }, + payload: { ...link, snapshot: link.snapshot! }, }); // The thread's lookup, then the requested sweep. yield* Queue.take(fixture.snapshotReads); yield* Queue.take(fixture.snapshotReads); yield* reactor.drain; - assert.deepStrictEqual( - (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), - [8], - ); assert.deepStrictEqual( (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), [8], diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts index b6474d5cb8c8e..fa2ff994dc6df 100644 --- a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -308,11 +308,9 @@ it.layer(TestLayer)("compact bounded snapshot transport", (it) => { Effect.provide(liveManagement), ); const live = items[1]; - assert.equal(live?.kind, "event"); - if (live?.kind !== "event") return; + if (live?.kind !== "event") return assert.fail("missing live event"); const decoded = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(live)))); - assert.equal(decoded.kind, "event"); - if (decoded.kind !== "event") return; + if (decoded.kind !== "event") return assert.fail("missing decoded live event"); assert.equal( decoded.event.type, acceptThreadFieldEvents ? "thread.visit-recorded" : "thread.visited", diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index b88f787e62c43..d7bf942ccc697 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -44,22 +44,9 @@ describe("raw HTML detection", () => { }); it("renders tag-free markdown without loading raw HTML", () => { - const markdown = "# Title\n\n1 < 2 and **bold** with \`code\`"; + const markdown = "# Title\n\n1 < 2 and **bold** with `code`"; expect(markdownMayContainRawHtml(markdown)).toBe(false); - expect( - chatMarkdownTree(markdown, false).children.filter((node) => node.type === "element"), - ).toMatchObject([ - { tagName: "h1", children: [{ value: "Title" }] }, - { - tagName: "p", - children: [ - { value: "1 < 2 and " }, - { tagName: "strong" }, - { value: " with " }, - { tagName: "code" }, - ], - }, - ]); + expect(chatMarkdownTree(markdown, false)).toEqual(chatMarkdownTree(markdown, true)); }); }); From ff2ed61dadd3c4dca13c95099243248d61fee4c3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:17:39 +0000 Subject: [PATCH 135/143] test(startup): trim redundant effect scopes --- .../PullRequestSyncReactor.test.ts | 78 +++++++++---------- apps/server/src/serverActivation.test.ts | 52 ++++++------- .../src/server/managedProvider.test.ts | 74 +++++++++--------- 3 files changed, 98 insertions(+), 106 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 1898ebf5cf0a9..7158c8c417d7a 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1167,46 +1167,44 @@ describe("PullRequestSyncReactor", () => { ); it.effect("retries a link whose first sync failed when a sibling link syncs", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - let hostDown = true; - const link = makeLink(7, { state: "open" }); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("one", { pullRequests: [link, makeLink(8)] })]), - summary: (input) => - Effect.suspend(() => - hostDown && input.number === 8 - ? Effect.fail( - new PullRequestOperationError({ operation: "summary", detail: "host down" }), - ) - : Effect.succeed(makeSummary(input)), - ), + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + let hostDown = true; + const link = makeLink(7, { state: "open" }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [link, makeLink(8)] })]), + summary: (input) => + Effect.suspend(() => + hostDown && input.number === 8 + ? Effect.fail( + new PullRequestOperationError({ operation: "summary", detail: "host down" }), + ) + : Effect.succeed(makeSummary(input)), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + hostDown = false; + yield* Ref.set(fixture.syncCommands, []); + + yield* Queue.offer(fixture.domainEvents, { + type: "thread.pull-request-link-synced", + id: EventId.make("event:one:link-synced"), + threadId: ThreadId.make("one"), + occurredAt: DateTime.makeUnsafe(NOW), + payload: { ...link, snapshot: link.snapshot! }, }); - - yield* Effect.gen(function* () { - const reactor = yield* startAndSweep(fixture); - hostDown = false; - yield* Ref.set(fixture.syncCommands, []); - - yield* Queue.offer(fixture.domainEvents, { - type: "thread.pull-request-link-synced", - id: EventId.make("event:one:link-synced"), - threadId: ThreadId.make("one"), - occurredAt: DateTime.makeUnsafe(NOW), - payload: { ...link, snapshot: link.snapshot! }, - }); - // The thread's lookup, then the requested sweep. - yield* Queue.take(fixture.snapshotReads); - yield* Queue.take(fixture.snapshotReads); - yield* reactor.drain; - - assert.deepStrictEqual( - (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), - [8], - ); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + // The thread's lookup, then the requested sweep. + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), + [8], + ); + }).pipe(Effect.provide(fixture.layer)); + }), ); }); diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts index 6f77ef4795658..df76eb5aac553 100644 --- a/apps/server/src/serverActivation.test.ts +++ b/apps/server/src/serverActivation.test.ts @@ -25,35 +25,31 @@ it.effect("proves a root is parked before returning and releases it with one gat ); it.effect("defers maintenance until the client loads and startup is quiet", () => - Effect.scoped( - Effect.gen(function* () { - const activation = yield* Deferred.make(); - const firstRan = yield* Deferred.make(); - const background = yield* ServerActivation.makeBackgroundStart(Deferred.await(activation)); - yield* ServerActivation.forkBackground(Deferred.succeed(firstRan, undefined)).pipe( - Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), - Effect.provideService(ServerActivation.ServerBackgroundStart, background), - ); - yield* Deferred.succeed(activation, undefined); - yield* TestClock.adjust("10 seconds"); - expect(yield* Deferred.isDone(firstRan)).toBe(false); - yield* background.markClientServed; - yield* Deferred.await(firstRan); - }), - ), + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const firstRan = yield* Deferred.make(); + const background = yield* ServerActivation.makeBackgroundStart(Deferred.await(activation)); + yield* ServerActivation.forkBackground(Deferred.succeed(firstRan, undefined)).pipe( + Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), + Effect.provideService(ServerActivation.ServerBackgroundStart, background), + ); + yield* Deferred.succeed(activation, undefined); + yield* TestClock.adjust("10 seconds"); + expect(yield* Deferred.isDone(firstRan)).toBe(false); + yield* background.markClientServed; + yield* Deferred.await(firstRan); + }), ); it.effect("eventually starts probes and maintenance even when no client connects", () => - Effect.scoped( - Effect.gen(function* () { - const background = yield* ServerActivation.makeBackgroundStart(Effect.void); - const firstClient = yield* background.awaitFirstClient.pipe(Effect.forkScoped); - const maintenance = yield* background.awaitTurn.pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(firstClient); - expect(maintenance.pollUnsafe()).toBeUndefined(); - yield* TestClock.adjust("20 seconds"); - yield* Fiber.join(maintenance); - }), - ), + Effect.gen(function* () { + const background = yield* ServerActivation.makeBackgroundStart(Effect.void); + const firstClient = yield* background.awaitFirstClient.pipe(Effect.forkScoped); + const maintenance = yield* background.awaitTurn.pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(firstClient); + expect(maintenance.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(maintenance); + }), ); diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 521238dd15515..2721b7a9dbb0b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -142,44 +142,42 @@ const enrichedSnapshotSecond: ServerProvider = { describe("makeManagedServerProvider", () => { it.effect("parks the initial probe and reads settings again when startup releases it", () => - Effect.scoped( - Effect.gen(function* () { - const host = yield* ProviderHost.ProviderHost; - const startup = yield* Deferred.make(); - const settings = yield* Ref.make({ enabled: true }); - const settingsChanges = yield* PubSub.unbounded(); - const checkCalls = yield* Ref.make(0); - const enrichedWith = yield* Deferred.make(); - yield* makeManagedServerProvider({ - resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), - getSettings: Ref.get(settings), - streamSettings: Stream.fromPubSub(settingsChanges), - haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, - initialSnapshot: () => Effect.succeed(initialSnapshot), - checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( - Effect.as(refreshedSnapshot), - ), - enrichSnapshot: ({ settings }) => - Deferred.succeed(enrichedWith, settings).pipe(Effect.asVoid), - refreshInterval: "1 hour", - }).pipe( - Effect.provideService(ProviderHost.ProviderHost, { - ...host, - awaitStartupProbe: Deferred.await(startup), - }), - ); - yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(checkCalls), 0); - yield* Ref.set(settings, { enabled: false }); - // A settings change while parked does not probe early. - yield* PubSub.publish(settingsChanges, { enabled: false }); - yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(checkCalls), 0); - yield* Deferred.succeed(startup, undefined); - assert.deepStrictEqual(yield* Deferred.await(enrichedWith), { enabled: false }); - assert.strictEqual(yield* Ref.get(checkCalls), 1); - }), - ).pipe(Effect.provide(layerAlwaysRunTest)), + Effect.gen(function* () { + const host = yield* ProviderHost.ProviderHost; + const startup = yield* Deferred.make(); + const settings = yield* Ref.make({ enabled: true }); + const settingsChanges = yield* PubSub.unbounded(); + const checkCalls = yield* Ref.make(0); + const enrichedWith = yield* Deferred.make(); + yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Ref.get(settings), + streamSettings: Stream.fromPubSub(settingsChanges), + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.as(refreshedSnapshot), + ), + enrichSnapshot: ({ settings }) => + Deferred.succeed(enrichedWith, settings).pipe(Effect.asVoid), + refreshInterval: "1 hour", + }).pipe( + Effect.provideService(ProviderHost.ProviderHost, { + ...host, + awaitStartupProbe: Deferred.await(startup), + }), + ); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 0); + yield* Ref.set(settings, { enabled: false }); + // A settings change while parked does not probe early. + yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 0); + yield* Deferred.succeed(startup, undefined); + assert.deepStrictEqual(yield* Deferred.await(enrichedWith), { enabled: false }); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }).pipe(Effect.provide(layerAlwaysRunTest)), ); it.effect( From e66d78518d8a34c07e24c8d1427810f537a31656 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 02:18:19 +0000 Subject: [PATCH 136/143] test(markdown): compare rendered nodes without parser metadata --- packages/shared/src/markdownPipeline.test.ts | 50 ++++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index d7bf942ccc697..1450f9526ccca 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; @@ -36,33 +36,31 @@ it("preserves in-app thread links through the shared Markdown sanitizer", () => expect(JSON.stringify(tree)).not.toContain("javascript:"); }); -describe("raw HTML detection", () => { - it("flags every way markdown can start raw HTML", () => { - for (const markdown of ["
", "a", "", "", ""]) { - expect(markdownMayContainRawHtml(markdown)).toBe(true); - } - }); +it("flags every way markdown can start raw HTML", () => { + for (const markdown of ["
", "a", "", "", ""]) { + expect(markdownMayContainRawHtml(markdown)).toBe(true); + } +}); - it("renders tag-free markdown without loading raw HTML", () => { - const markdown = "# Title\n\n1 < 2 and **bold** with `code`"; - expect(markdownMayContainRawHtml(markdown)).toBe(false); - expect(chatMarkdownTree(markdown, false)).toEqual(chatMarkdownTree(markdown, true)); - }); +it("renders tag-free markdown without loading raw HTML", () => { + const markdown = "# Title\n\n1 < 2 and **bold** with `code`"; + expect(markdownMayContainRawHtml(markdown)).toBe(false); + expect(chatMarkdownTree(markdown, false).children).toEqual( + chatMarkdownTree(markdown, true).children, + ); }); -describe("loadRehypeRaw", () => { - it("keeps a failed load so every render waits on the same settled promise", async () => { - vi.resetModules(); - vi.doMock("rehype-raw", () => { - throw new Error("chunk failed"); - }); - try { - const { loadRehypeRaw } = await import("./markdownPipeline.ts"); - const failed = loadRehypeRaw(); - await expect(failed).rejects.toThrow(); - expect(loadRehypeRaw()).toBe(failed); - } finally { - vi.doUnmock("rehype-raw"); - } +it("keeps a failed load so every render waits on the same settled promise", async () => { + vi.resetModules(); + vi.doMock("rehype-raw", () => { + throw new Error("chunk failed"); }); + try { + const { loadRehypeRaw } = await import("./markdownPipeline.ts"); + const failed = loadRehypeRaw(); + await expect(failed).rejects.toThrow(); + expect(loadRehypeRaw()).toBe(failed); + } finally { + vi.doUnmock("rehype-raw"); + } }); From 04f35fc038465c20e8b89cd6e4943766355f5b71 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 06:04:53 +0000 Subject: [PATCH 137/143] perf(http): retain measured precompressed asset serving --- apps/server/src/http.test.ts | 69 ++++++++++++++++++++++++++++++++++++ apps/server/src/http.ts | 33 +++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9d3686a46692b..39f0fea22ef56 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; +import * as NodeZlib from "node:zlib"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -214,6 +215,74 @@ it.layer( Layer.provideMerge(NodeServices.layer), ), )("static HTTP responses", (it) => { + it.effect("serves precompressed manifest assets and revalidates other files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-compressed-" }); + yield* fs.makeDirectory(path.join(staticDir, "assets")); + const body = 'export const build = "precompressed";\n'.repeat(100); + const asset = "assets/app-1234abcd.js"; + const br = NodeZlib.brotliCompressSync(body); + const gz = NodeZlib.gzipSync(body); + yield* fs.writeFileString(path.join(staticDir, asset), body); + yield* fs.writeFile(path.join(staticDir, `${asset}.br`), br); + yield* fs.writeFile(path.join(staticDir, `${asset}.gz`), gz); + yield* fs.writeFileString( + path.join(staticDir, "vite-manifest.json"), + JSON.stringify({ "src/app.ts": { file: asset, isEntry: true } }), + ); + // A hash-like filename and a compressed copy alone cannot make a custom file immutable. + const custom = "assets/custom-1234abcd.js"; + yield* fs.writeFileString(path.join(staticDir, custom), body); + yield* fs.writeFile(path.join(staticDir, `${custom}.br`), br); + const request = yield* makeStaticRequest(staticDir); + + for (const [acceptEncoding, encoding, length] of [ + ["br, gzip", "br", br.byteLength], + ["br;q=0, gzip", "gzip", gz.byteLength], + ["identity", undefined, Buffer.byteLength(body)], + ] as const) { + const response = yield* request(`/${asset}`, { + headers: { "accept-encoding": acceptEncoding }, + }); + expect(response.status).toBe(200); + expect(response.headers["content-encoding"]).toBe(encoding); + expect(response.headers["content-length"]).toBe(String(length)); + expect(response.headers["content-type"]).toContain("javascript"); + expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); + expect(response.headers["vary"]).toBe("Accept-Encoding"); + expect(yield* response.text).toBe(body); + const head = yield* request(`/${asset}`, { + method: "HEAD", + headers: { "accept-encoding": acceptEncoding }, + }); + expect(head.headers["content-encoding"]).toBe(encoding); + expect(head.headers["content-length"]).toBe(String(length)); + expect(yield* head.text).toBe(""); + const unchanged = yield* request(`/${asset}`, { + headers: { + "accept-encoding": acceptEncoding, + "if-none-match": response.headers["etag"]!, + }, + }); + expect(unchanged.status).toBe(304); + expect(unchanged.headers["vary"]).toBe("Accept-Encoding"); + expect(yield* unchanged.text).toBe(""); + } + const unlisted = yield* request(`/${custom}`, { headers: { "accept-encoding": "br" } }); + expect(unlisted.headers["cache-control"]).toBe("no-cache"); + expect(unlisted.headers["content-length"]).toBeUndefined(); + expect(yield* unlisted.text).toBe(body); + + yield* fs.remove(path.join(staticDir, `${asset}.br`)); + const fallback = yield* request(`/${asset}`, { headers: { "accept-encoding": "br, gzip" } }); + expect(fallback.headers["content-encoding"]).toBe("gzip"); + expect(fallback.headers["content-length"]).toBe(String(gz.byteLength)); + expect(yield* fallback.text).toBe(body); + }), + ); + it.effect("revalidates non-HTML files and returns changed contents", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 50f608a41af8c..1b552cd058459 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -571,6 +571,26 @@ const streamStaticFile = (file: FileSystem.File, size: bigint) => }), ); +/** Match the compression middleware's preference and reject malformed quality values. */ +function acceptedStaticEncodings(header: string | undefined): ReadonlyArray<"br" | "gzip"> { + if (!header?.trim()) return []; + const accepted = new Map(); + for (const part of header.split(",")) { + const member = part + .trim() + .toLowerCase() + .replace(/[ \t]*;[ \t]*/g, ";"); + const match = /^([a-z0-9!#$%&'*+.^_`|~-]+)(?:;q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/.exec( + member, + ); + if (!match) return []; + accepted.set(match[1]!, match[2] === undefined ? 1 : Number(match[2])); + } + return (["br", "gzip"] as const).filter( + (encoding) => (accepted.get(encoding) ?? accepted.get("*") ?? 0) > 0, + ); +} + const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; @@ -687,6 +707,19 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; + // Only manifest-backed hashed assets can trust an adjacent compressed copy. + // Custom static files may be overwritten without updating their sidecars. + if (immutable) { + for (const encoding of acceptedStaticEncodings(request.headers["accept-encoding"])) { + const compressed = yield* openStaticFile(`${filePath}.${encoding === "br" ? "br" : "gz"}`); + if (!compressed) continue; + return HttpServerResponse.stream(streamStaticFile(compressed.file, compressed.info.size), { + headers: { ...headers, "Content-Encoding": encoding, Vary: "Accept-Encoding" }, + contentType, + contentLength: Number(compressed.info.size), + }); + } + } // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. // HEAD still passes through compression, which selects headers without reading the stream. return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { From b18cfcea339cc676ba1e048a3b3b6f87e56fd498 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 15 Oct 2026 10:41:42 +0000 Subject: [PATCH 138/143] perf(server): restore concurrent editor discovery probes --- apps/server/src/process/externalLauncher.ts | 29 +++++++++------------ 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ab130b01e38e..667db5c4a2ff2 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -429,23 +429,18 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { - const available: EditorId[] = []; - - for (const editor of EDITORS) { - if (editor.commands === null) { - if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { - available.push(editor.id); - } - continue; - } - - const command = yield* resolveEditorCommand(editor, env); - if (Option.isSome(command)) { - available.push(editor.id); - } - } - - return available; + // Independent probes share PATH listings and retain EDITORS order. + const found = yield* Effect.forEach( + EDITORS, + (editor) => + editor.commands === null + ? resolveUsableFileManagerCommand(platform, env).pipe( + Effect.map((command) => command !== undefined), + ) + : resolveEditorCommand(editor, env).pipe(Effect.map(Option.isSome)), + { concurrency: "unbounded" }, + ); + return EDITORS.flatMap((editor, index) => (found[index] ? [editor.id] : [])); }); const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( From e63af0a375131098c3cce78f52f6879f7836dc93 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 16 Oct 2026 05:43:36 +0000 Subject: [PATCH 139/143] fix(web): drop removed mobile beta notice import --- apps/web/src/routes/__root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3f5b115332ba2..2a103add57ea2 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -54,7 +54,6 @@ import { import { isElectron } from "../env"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; -import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; From c75f25cf0fb6374b32d317741c403405a0801b6e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 16 Oct 2026 06:20:38 +0000 Subject: [PATCH 140/143] fix(server): compact single-link sync and visit events --- .../src/orchestration-v2/ProjectionMaintenance.ts | 15 ++++++++++----- .../src/orchestration-v2/runtimeLayer.test.ts | 14 +++++++++++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index f8acaf065cb93..3cbbe183cce3f 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -217,11 +217,14 @@ export const layer: Layer.Layer< "thread.model-selection-updated", "thread.provider-switched", ]; - // Read-state events carry the whole thread too, but client reducers apply - // them without bumping updatedAt. They supersede only each other: a newer + // Read-state events do not bump updatedAt. They supersede only each other: a newer // visit must not delete the rename that carries the activity timestamp, // while any newer activity-bearing event supersedes both groups. - const READ_STATE_THREAD_EVENT_TYPES = ["thread.visited", "thread.marked-unread"]; + const READ_STATE_THREAD_EVENT_TYPES = [ + "thread.visited", + "thread.marked-unread", + "thread.visit-recorded", + ]; const supersedableThreadEventTypes = new Set(SUPERSEDABLE_THREAD_EVENT_TYPES); const readStateThreadEventTypes = new Set(READ_STATE_THREAD_EVENT_TYPES); @@ -280,7 +283,7 @@ export const layer: Layer.Layer< AND event.event_type IN ('message.updated', 'node.updated') THEN json_extract(event.payload_json, '$.id') WHEN event.application_event_version = 2 - AND event.event_type = 'thread.pull-request-link-updated' + AND event.event_type IN ('thread.pull-request-link-updated', 'thread.pull-request-link-synced') THEN json_array(json_extract(event.payload_json, '$.host'), json_extract(event.payload_json, '$.repository'), json_extract(event.payload_json, '$.number')) @@ -322,13 +325,15 @@ export const layer: Layer.Layer< obsolete.push(row.sequence); } else retainedReadStateThreadIds.add(row.stream_id); } else if ( - row.event_type === "thread.pull-request-link-updated" && + (row.event_type === "thread.pull-request-link-updated" || + row.event_type === "thread.pull-request-link-synced") && retainedThreadIds.has(row.stream_id) ) { // A newer whole-thread payload already carries this link's state. obsolete.push(row.sequence); } else if ( row.event_type === "thread.pull-request-link-updated" || + row.event_type === "thread.pull-request-link-synced" || row.event_type === "message.updated" || row.event_type === "node.updated" ) { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 0eef24f64581c..5022fd525bb12 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4885,6 +4885,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", assert.deepEqual(after?.pullRequests?.[0], before?.pullRequests?.[0]); assert.deepEqual(after?.pullRequests?.[2], before?.pullRequests?.[2]); assert.equal(after?.pullRequests?.[1]?.stack?.id, "updated"); + assert.deepEqual(after?.updatedAt, before?.updatedAt); assert.isTrue((yield* maintenance.rebuild).valid); assert.deepEqual( (yield* orchestrator.getThreadShell(threadId))?.pullRequests, @@ -4918,7 +4919,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", yield* maintenance.compactEventStore; const linkEvents = yield* sql<{ command_id: string }>` SELECT command_id FROM orchestration_events - WHERE stream_id = ${threadId} AND event_type = 'thread.pull-request-link-updated' + WHERE stream_id = ${threadId} AND event_type = 'thread.pull-request-link-synced' ORDER BY sequence `; assert.deepEqual( @@ -4937,7 +4938,7 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", const threadEvents = () => sql<{ event_type: string }>` SELECT event_type FROM orchestration_events WHERE stream_id = ${threadId} - AND event_type IN ('thread.metadata-updated', 'thread.visited') + AND event_type IN ('thread.metadata-updated', 'thread.visit-recorded') ORDER BY sequence `; yield* orchestrator.dispatch({ @@ -4960,8 +4961,12 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", yield* maintenance.compactEventStore; assert.deepEqual( (yield* threadEvents()).map((row) => row.event_type), - ["thread.metadata-updated", "thread.visited"], + ["thread.metadata-updated", "thread.visit-recorded"], ); + const visited = yield* orchestrator.getThreadShell(threadId); + assert.equal(DateTime.formatIso(visited!.lastVisitedAt!), "2026-10-13T15:00:00.000Z"); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual(yield* orchestrator.getThreadShell(threadId), visited); yield* orchestrator.dispatch({ type: "thread.metadata.update", commandId: CommandId.make("compact-pr-rename-again"), @@ -4973,6 +4978,9 @@ it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("compact PR events", (yield* threadEvents()).map((row) => row.event_type), ["thread.metadata-updated"], ); + const renamed = yield* orchestrator.getThreadShell(threadId); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual(yield* orchestrator.getThreadShell(threadId), renamed); }), ); }); From 09d098cc0de6831074f3d90aae760200286313a2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 02:17:21 +0000 Subject: [PATCH 141/143] fix(server): move perf migrations after main's 063 and 064 --- .../orchestration-v2/ProjectionStore.test.ts | 2 +- .../src/orchestration-v2/ProjectionStore.ts | 6 +- apps/server/src/persistence/Migrations.ts | 16 +- .../Migrations/055_OrchestrationV2.test.ts | 16 +- ...CanonicalizeThreadPullRequestLinks.test.ts | 169 ++++++++++-------- ...ts => 065_ProjectionThreadSweepIndexes.ts} | 12 +- .../reconcileV2PreviewMigration.test.ts | 2 + apps/web/src/components/ChatView.tsx | 5 +- 8 files changed, 128 insertions(+), 100 deletions(-) rename apps/server/src/persistence/Migrations/{063_ProjectionThreadSweepIndexes.ts => 065_ProjectionThreadSweepIndexes.ts} (86%) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index eb071df2b040c..97573037d646e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -33,7 +33,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as Statement from "effect/sql/Statement"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; -import ProjectionThreadSweepIndexes from "../persistence/Migrations/063_ProjectionThreadSweepIndexes.ts"; +import ProjectionThreadSweepIndexes from "../persistence/Migrations/065_ProjectionThreadSweepIndexes.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index bca9bd974ceb2..67669ea65f355 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5318,7 +5318,7 @@ export const layer: Layer.Layer = ), ); - // The settlement filters and fork source spell the sweep index's expressions (migration 061) + // The settlement filters and fork source spell the sweep index's expressions (migration 065) // exactly, so a scan of active threads reads them from the index instead of each payload. const selectShellThreadRows = ( threadId?: ThreadId, @@ -5681,7 +5681,7 @@ export const layer: Layer.Layer = LIMIT 1 ) WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`} - -- These match the sweep index (migration 061), which holds the values. + -- These match the sweep index (migration 065), which holds the values. AND t.archived_at IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NULL AND json_extract(t.payload_json, '$.pinnedAt') IS NULL @@ -5769,7 +5769,7 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} AND archived_at IS NULL - -- Read from the sweep index (migration 061) rather than each payload. + -- Read from the sweep index (migration 065) rather than each payload. AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC `; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index dd7341089db92..17d0114a648bc 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -68,6 +68,8 @@ import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts"; import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts"; import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts"; +import Migration0065 from "./Migrations/065_ProjectionThreadSweepIndexes.ts"; + export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], [2, "OrchestrationCommandReceipts", Migration0002], @@ -134,6 +136,7 @@ export const migrationEntries = [ [62, "ThreadFailureLookupIndex", Migration0062], [63, "DropUnusedV1Projections", Migration0063], [64, "CanonicalizeThreadPullRequestLinks", Migration0064], + [65, "ProjectionThreadSweepIndexes", Migration0065], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -178,8 +181,17 @@ const assertV2MigrationsCanRun = Effect.fn("assertV2MigrationsCanRun")(function* return []; const recordedName = recordedNames.get(id); if (recordedName === name) return []; - // Migration 63 repairs the index skipped by older sweep builds at id 62. - if (id === 62 && recordedName === "ProjectionThreadSweepIndexes") return []; + // Migration 65 repairs main's effects skipped by published perf migration ids. + if ( + (id === 62 && + (recordedName === "ProjectionThreadSweepIndexes" || + recordedName === "ProjectionThreadReadPayload")) || + (id === 63 && + (recordedName === "ProjectionThreadSweepIndexes" || + recordedName === "ProjectionThreadReadPayload")) || + (id === 64 && recordedName === "ProjectionThreadReadPayload") + ) + return []; return [`${id}_${name} (recorded: ${recordedName ?? "nothing"})`]; }); if (skipped.length === 0) return; diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 4326d8912cfc3..8503bf8f5e429 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -6,7 +6,7 @@ import * as References from "effect/References"; import * as SqlClient from "effect/sql/SqlClient"; import { migrationEntries, runMigrations } from "../Migrations.ts"; -import ProjectionThreadSweepIndexes from "./063_ProjectionThreadSweepIndexes.ts"; +import ProjectionThreadSweepIndexes from "./065_ProjectionThreadSweepIndexes.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); @@ -16,7 +16,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 64 }, (_, index) => index + 1), + Array.from({ length: 65 }, (_, index) => index + 1), ); }), ); @@ -39,6 +39,7 @@ layer("055_OrchestrationV2", (it) => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -69,6 +70,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 62, name: "ThreadFailureLookupIndex" }, { migration_id: 63, name: "DropUnusedV1Projections" }, { migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" }, + { migration_id: 65, name: "ProjectionThreadSweepIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` @@ -165,7 +167,10 @@ layer("055_OrchestrationV2", (it) => { runtime_mode, interaction_mode, created_at, updated_at, payload_json ) VALUES ('thread', 'project', 'Title', 'codex', 'codex', 'full-access', 'default', '2026-07-24', '2026-07-24', '{"settledOverride":"settled","pullRequests":[{"number":1}]}')`; - assert.deepStrictEqual(yield* runMigrations(), [[63, "ProjectionThreadSweepIndexes"]]); + assert.deepStrictEqual( + yield* runMigrations(), + migrationEntries.filter(([id]) => id > 62).map(([id, name]) => [id, name]), + ); assert.deepStrictEqual(warnings, legacy ? [["62_ThreadFailureLookupIndex"]] : []); yield* ProjectionThreadSweepIndexes; assert.deepStrictEqual(yield* runMigrations(), []); @@ -181,11 +186,6 @@ layer("055_OrchestrationV2", (it) => { yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = json_set(payload_json, '$.pullRequests', json('[]'))`; assert.deepStrictEqual(yield* sweep, []); - assert.deepStrictEqual( - yield* sql`SELECT run_id FROM orchestration_v2_projection_runs - INDEXED BY orchestration_v2_projection_runs_failed_idx WHERE status = 'failed'`, - [], - ); assert.deepStrictEqual( yield* sql`SELECT turn_item_id FROM orchestration_v2_projection_turn_items INDEXED BY orchestration_v2_projection_turn_items_failed_error_idx diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts index 706cc02760eac..409fbf78b870c 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts @@ -11,7 +11,7 @@ import * as EventStore from "../../orchestration-v2/EventStore.ts"; import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ProjectionMaintenance from "../../orchestration-v2/ProjectionMaintenance.ts"; import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; -import { runMigrations } from "../Migrations.ts"; +import { migrationManifest, runMigrations } from "../Migrations.ts"; const decodeThread = Schema.decodeSync(Schema.fromJsonString(OrchestrationV2AppThreadJson)); const updatedAt = "2026-09-01T00:00:00.000Z"; @@ -63,39 +63,47 @@ const layer = it.layer( ); layer("064_CanonicalizeThreadPullRequestLinks", (it) => { - it.effect("migrates single links once, preserves arrays, and keeps links after a rebuild", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 63 }); - const fixtures = [ - { ...thread("github"), linkedPullRequest: linked }, - { - ...thread("forgejo"), - linkedPullRequest: { ...linked, url: "http://forge.example:3000/acme/web/pulls/42" }, - }, - { - ...thread("azure"), - linkedPullRequest: { - ...linked, - repository: "project/_git/web", - url: "https://dev.azure.com/org/project/_git/web/pullrequest/42", + it.effect.each([false, true])( + "migrates links and preserves rebuilds with published perf ids: %s", + (published) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 62 }); + if (published) { + yield* sql`INSERT INTO effect_sql_migrations (migration_id, name) VALUES + (63, 'ProjectionThreadSweepIndexes'), (64, 'ProjectionThreadReadPayload')`; + } else { + yield* runMigrations({ toMigrationInclusive: 63 }); + } + const fixtures = [ + { ...thread("github"), linkedPullRequest: linked }, + { + ...thread("forgejo"), + linkedPullRequest: { ...linked, url: "http://forge.example:3000/acme/web/pulls/42" }, }, - }, - { ...thread("empty"), linkedPullRequest: linked, pullRequests: [] }, - { ...thread("current"), linkedPullRequest: linked, pullRequests: [currentLink] }, - { ...thread("unlinked"), linkedPullRequest: null }, - ...Array.from({ length: 501 }, (_, index) => ({ - ...thread(`page-${String(index).padStart(3, "0")}`), - linkedPullRequest: { - ...linked, - number: 9000, - url: "https://github.com/acme/web/pull/9000", + { + ...thread("azure"), + linkedPullRequest: { + ...linked, + repository: "project/_git/web", + url: "https://dev.azure.com/org/project/_git/web/pullrequest/42", + }, }, - })), - ]; - for (const fixture of fixtures) { - const payload = JSON.stringify(fixture); - yield* sql` + { ...thread("empty"), linkedPullRequest: linked, pullRequests: [] }, + { ...thread("current"), linkedPullRequest: linked, pullRequests: [currentLink] }, + { ...thread("unlinked"), linkedPullRequest: null }, + ...Array.from({ length: 501 }, (_, index) => ({ + ...thread(`page-${String(index).padStart(3, "0")}`), + linkedPullRequest: { + ...linked, + number: 9000, + url: "https://github.com/acme/web/pull/9000", + }, + })), + ]; + for (const fixture of fixtures) { + const payload = JSON.stringify(fixture); + yield* sql` INSERT INTO orchestration_v2_projection_threads ( thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json @@ -104,7 +112,7 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { ${updatedAt}, ${updatedAt}, ${payload} ) `; - yield* sql` + yield* sql` INSERT INTO orchestration_events ( event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version @@ -113,9 +121,9 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { 'user', ${payload}, '{}', 2 ) `; - } - const v1Payload = JSON.stringify({ linkedPullRequest: linked }); - yield* sql` + } + const v1Payload = JSON.stringify({ linkedPullRequest: linked }); + yield* sql` INSERT INTO orchestration_events ( event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version @@ -123,51 +131,62 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { 'user', ${v1Payload}, '{}', 1) `; - assert.deepEqual(yield* runMigrations(), [[64, "CanonicalizeThreadPullRequestLinks"]]); - assert.deepEqual(yield* runMigrations(), []); - const rows = yield* sql<{ readonly thread_id: string; readonly payload_json: string }>` + assert.deepEqual( + yield* runMigrations(), + migrationManifest.filter(([id]) => id > (published ? 64 : 63)), + ); + assert.deepEqual( + yield* sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('projection_thread_activities', 'projection_turns', 'projection_thread_proposed_plans', 'projection_pending_approvals', 'projection_state', 'checkpoint_diff_blobs')`, + [], + ); + assert.deepEqual(yield* runMigrations(), []); + const rows = yield* sql<{ readonly thread_id: string; readonly payload_json: string }>` SELECT thread_id, payload_json FROM orchestration_v2_projection_threads `; - const migrated = new Map(rows.map((row) => [row.thread_id, decodeThread(row.payload_json)])); - assert.deepEqual(migrated.get("github")?.pullRequests, [ - { ...currentLink, number: 42, url: linked.url, source: "manual" }, - ]); - assert.equal(migrated.get("forgejo")?.pullRequests?.[0]?.host, "forge.example:3000"); - assert.equal(migrated.get("azure")?.pullRequests?.[0]?.host, "dev.azure.com"); - assert.equal(migrated.get("azure")?.pullRequests?.[0]?.repository, "org/project/_git/web"); - assert.deepEqual(migrated.get("empty")?.pullRequests, []); - assert.deepEqual(migrated.get("current")?.pullRequests, [currentLink]); - assert.isUndefined(migrated.get("unlinked")?.pullRequests); - assert.equal(migrated.get("page-500")?.pullRequests?.[0]?.number, 9000); - assert.isTrue( - [...migrated.values()].every((value) => DateTime.formatIso(value.updatedAt) === updatedAt), - ); - const events = yield* sql<{ readonly stream_id: string; readonly payload_json: string }>` + const migrated = new Map( + rows.map((row) => [row.thread_id, decodeThread(row.payload_json)]), + ); + assert.deepEqual(migrated.get("github")?.pullRequests, [ + { ...currentLink, number: 42, url: linked.url, source: "manual" }, + ]); + assert.equal(migrated.get("forgejo")?.pullRequests?.[0]?.host, "forge.example:3000"); + assert.equal(migrated.get("azure")?.pullRequests?.[0]?.host, "dev.azure.com"); + assert.equal(migrated.get("azure")?.pullRequests?.[0]?.repository, "org/project/_git/web"); + assert.deepEqual(migrated.get("empty")?.pullRequests, []); + assert.deepEqual(migrated.get("current")?.pullRequests, [currentLink]); + assert.isUndefined(migrated.get("unlinked")?.pullRequests); + assert.equal(migrated.get("page-500")?.pullRequests?.[0]?.number, 9000); + assert.isTrue( + [...migrated.values()].every( + (value) => DateTime.formatIso(value.updatedAt) === updatedAt, + ), + ); + const events = yield* sql<{ readonly stream_id: string; readonly payload_json: string }>` SELECT stream_id, payload_json FROM orchestration_events WHERE application_event_version = 2 `; - assert.deepEqual( - new Map(events.map((event) => [event.stream_id, decodeThread(event.payload_json)])), - migrated, - ); - const [v1] = yield* sql<{ readonly payload_json: string }>` + assert.deepEqual( + new Map(events.map((event) => [event.stream_id, decodeThread(event.payload_json)])), + migrated, + ); + const [v1] = yield* sql<{ readonly payload_json: string }>` SELECT payload_json FROM orchestration_events WHERE event_id = 'event:v1' `; - assert.equal(v1?.payload_json, v1Payload); + assert.equal(v1?.payload_json, v1Payload); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - assert.isTrue((yield* maintenance.rebuild).valid); - for (const [host, repository, id] of [ - ["github.com", "acme/web", "github"], - ["forge.example:3000", "acme/web", "forgejo"], - ["dev.azure.com", "org/project/_git/web", "azure"], - ] as const) { - assert.deepEqual( - (yield* listLinkedPullRequestThreads({ host, repository, number: 42 })).threads.map( - (value) => value.id, - ), - [ThreadId.make(id)], - ); - } - }), + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + assert.isTrue((yield* maintenance.rebuild).valid); + for (const [host, repository, id] of [ + ["github.com", "acme/web", "github"], + ["forge.example:3000", "acme/web", "forgejo"], + ["dev.azure.com", "org/project/_git/web", "azure"], + ] as const) { + assert.deepEqual( + (yield* listLinkedPullRequestThreads({ host, repository, number: 42 })).threads.map( + (value) => value.id, + ), + [ThreadId.make(id)], + ); + } + }), ); }); diff --git a/apps/server/src/persistence/Migrations/063_ProjectionThreadSweepIndexes.ts b/apps/server/src/persistence/Migrations/065_ProjectionThreadSweepIndexes.ts similarity index 86% rename from apps/server/src/persistence/Migrations/063_ProjectionThreadSweepIndexes.ts rename to apps/server/src/persistence/Migrations/065_ProjectionThreadSweepIndexes.ts index 3397291062d59..4ab462d73d994 100644 --- a/apps/server/src/persistence/Migrations/063_ProjectionThreadSweepIndexes.ts +++ b/apps/server/src/persistence/Migrations/065_ProjectionThreadSweepIndexes.ts @@ -1,5 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; +import DropUnusedV1Projections from "./063_DropUnusedV1Projections.ts"; +import CanonicalizeThreadPullRequestLinks from "./064_CanonicalizeThreadPullRequestLinks.ts"; /** * Background sweeps (settlement, pull request sync, the shell reads behind pull request @@ -12,6 +14,9 @@ import * as SqlClient from "effect/sql/SqlClient"; * spell each expression exactly as it is here, or SQLite parses the payload again. */ export default Effect.gen(function* () { + // Published perf builds used ids 63/64, so the migrator skips main's effects there. + yield* DropUnusedV1Projections; + yield* CanonicalizeThreadPullRequestLinks; const sql = yield* SqlClient.SqlClient; // Development builds of this change created an index of the same name over copied columns. // Replace it so every database gets this definition. @@ -35,13 +40,6 @@ export default Effect.gen(function* () { ) WHERE deleted_at IS NULL AND archived_at IS NULL `; - // Usage-limit recovery only concerns threads whose latest run failed; this finds the few - // threads with any failed run without reading the rest. - yield* sql` - CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_failed_idx - ON orchestration_v2_projection_runs(thread_id) - WHERE status = 'failed' - `; // Older sweep builds recorded id 62, so they skip main's failed-error index migration. yield* sql` CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_failed_error_idx diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 802c5110490ac..bcd1cc2493535 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -44,6 +44,7 @@ describe("V2 preview upgrade", () => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ProjectionThreadSweepIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -131,6 +132,7 @@ describe("V2 preview upgrade", () => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ProjectionThreadSweepIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5249cc1eaf60b..b015e09d27c45 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4009,10 +4009,7 @@ export default function ChatView(props: ChatViewProps) { // re-pins on its own (independent of the refs), so the timeline needs a // render-visible flag to switch it off once the user scrolls away. const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); - const isTimelineFollowingEnd = useCallback( - () => timelineScrollModeRef.current === "following-end", - [], - ); + const isTimelineFollowingEnd = () => timelineScrollModeRef.current === "following-end"; const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); From fa395d08d09b02ff9bff30236feae22917a99d5c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 02:21:03 +0000 Subject: [PATCH 142/143] test(server): isolate published perf migration upgrade cases --- .../064_CanonicalizeThreadPullRequestLinks.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts index 409fbf78b870c..44c8b0f2a1485 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts @@ -52,15 +52,14 @@ const thread = (id: string) => ({ deletedAt: null, }); -const layer = it.layer( - ProjectionMaintenance.layer.pipe( - Layer.provideMerge( - Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( - Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), - ), +const fixtureLayer = ProjectionMaintenance.layer.pipe( + Layer.provideMerge( + Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), ), ), ); +const layer = it.layer(fixtureLayer); layer("064_CanonicalizeThreadPullRequestLinks", (it) => { it.effect.each([false, true])( @@ -187,6 +186,6 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { [ThreadId.make(id)], ); } - }), + }).pipe(Effect.provide(Layer.fresh(fixtureLayer))), ); }); From 2cb9f14fb257d9f46763b9e5f5df54b809d2f4c9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 19 Oct 2026 02:24:06 +0000 Subject: [PATCH 143/143] test(web): await lazy markdown in browser rendering checks --- apps/web/src/components/ChatMarkdown.browser.test.tsx | 7 +++++-- .../src/components/chat/MessagesTimeline.browser.test.tsx | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index eac41c7eb0be1..65d242eb5a1e3 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,9 +1,10 @@ +import { loadRehypeRaw } from "@t3tools/shared/markdownPipeline"; import { EnvironmentId } from "@t3tools/contracts"; import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import { act, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { MarkdownFindContext } from "./chat/markdownFindContext"; @@ -46,6 +47,8 @@ import ChatMarkdown from "./ChatMarkdown"; const roots: Root[] = []; +beforeAll(() => loadRehypeRaw()); + beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); }); @@ -769,7 +772,7 @@ describe("ChatMarkdown thread find", () => { await show("Needle"); await frame(); - expect(diagrams()).toBe(2); + await vi.waitFor(() => expect(diagrams()).toBe(2)); await show("Alpha"); await frame(); await frame(); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 5c6e62d114fcc..bf83b40a0d34d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,3 +1,4 @@ +import { loadRehypeRaw } from "@t3tools/shared/markdownPipeline"; import { RegistryProvider } from "@effect/atom-react"; import { ApprovalRequestId, @@ -10,7 +11,7 @@ import { import type { LegendListRef } from "@legendapp/list/react"; import { act, createRef, Profiler, useState, type ComponentProps, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -147,6 +148,8 @@ function longText(tail = "deep hidden detail only after expand") { const roots: Root[] = []; let container: HTMLDivElement; +beforeAll(() => loadRehypeRaw()); + beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); });