From ae7b27de824e890f2cdfc85018fc9301e7d45022 Mon Sep 17 00:00:00 2001 From: Gabe Fletcher Date: Fri, 7 Aug 2026 00:14:50 -0400 Subject: [PATCH 01/10] fix: prevent reconnect loops during server stalls (#5561) Co-authored-by: t3-turbo-simulation Co-authored-by: Claude Fable 5 Co-authored-by: Theo Browne --- .../src/process/externalLauncher.test.ts | 61 +++++ apps/server/src/process/externalLauncher.ts | 13 +- apps/web/src/components/ChatView.tsx | 11 +- .../src/connection/supervisor.test.ts | 112 ++++++-- .../src/connection/supervisor.ts | 22 +- .../client-runtime/src/rpc/session.test.ts | 27 ++ .../src/state/shell-sync.test.ts | 125 +++++---- packages/client-runtime/src/state/shell.ts | 77 ++++-- packages/shared/src/observability.test.ts | 26 ++ packages/shared/src/observability.ts | 63 ++++- packages/shared/src/shell.ts | 65 +++++ patches/effect@4.0.0-beta.103.patch | 23 +- pnpm-lock.yaml | 247 +++++++++--------- 13 files changed, 637 insertions(+), 235 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 43ca40e9c7c..36ef8264328 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -155,6 +156,66 @@ it.effect("discovers editors through the service API", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("memoizes editor discovery and refreshes after the cache window", () => { + let statCalls = 0; + const fileInfo = { type: "File" } as FileSystem.File.Info; + const launcherLayer = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Effect.sync(() => { + statCalls += 1; + return fileInfo; + }), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const first = yield* launcher.resolveAvailableEditors(); + assert.equal(first.includes("vscode"), true); + const statCallsAfterFirstScan = statCalls; + assert.isAbove(statCallsAfterFirstScan, 0); + + // Past the shared command-resolution cache TTL (30s) but within the + // discovery cache window: the memoized set is reused without any scan. + yield* TestClock.adjust("31 seconds"); + const second = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...second], [...first]); + assert.equal(statCalls, statCallsAfterFirstScan); + + // Past the discovery cache window the next call rescans. + yield* TestClock.adjust("30 seconds"); + yield* launcher.resolveAvailableEditors(); + assert.isAbove(statCalls, statCallsAfterFirstScan); + }).pipe( + Effect.provide( + Layer.mergeAll( + launcherLayer, + Layer.succeed(HostProcessPlatform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + TestClock.layer(), + ), + ), + ); +}); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 9c2f0e417d3..2cac42f0fec 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -298,6 +298,12 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit return yield* buildAvailableEditors(platform, env); }); +// Editor discovery walks PATH for every known editor and runs for every +// client connect (the server config embeds the available editors). Memoize +// the discovered set for a bounded window so repeat connects skip even the +// per-command cache lookups in @t3tools/shared/shell. +const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; + /** * ExternalLauncher - Service tag for browser/editor launch operations. */ @@ -443,8 +449,13 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const cachedAvailableEditors = yield* Effect.cachedWithTTL( + provideCommandResolutionServices(resolveAvailableEditors()), + EDITOR_DISCOVERY_CACHE_TTL, + ); + return ExternalLauncher.of({ - resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), + resolveAvailableEditors: () => cachedAvailableEditors, launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f17e7021c44..708a97be545 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4732,12 +4732,21 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || - activeEnvironmentUnavailable || sendInFlightRef.current ) { notifyDirectAnnotationAttached(); return; } + if (activeEnvironmentUnavailable) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Not connected: message not sent", + description: "Reconnecting to the environment. Try again once it is connected.", + }), + ); + return; + } if (activePendingProgress) { if (directAnnotation) { notifyDirectAnnotationAttached(); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index a925859049f..5e50c44d961 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -248,7 +248,7 @@ describe("EnvironmentSupervisor", () => { const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); expect(firstAttempt).toBeDefined(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); @@ -358,7 +358,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) { yield* TestClock.adjust(delay); yield* eventuallyState( supervisor.state, @@ -384,7 +384,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); const retrying = yield* awaitState( supervisor.state, @@ -489,7 +489,7 @@ describe("EnvironmentSupervisor", () => { }, }); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); expect(yield* Ref.get(harness.prepareCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), @@ -526,7 +526,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* eventuallyState( supervisor.state, (state) => state.phase === "backoff" && state.attempt === 2, @@ -539,7 +539,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(3); - yield* TestClock.adjust("999 millis"); + yield* TestClock.adjust("2999 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(3); yield* TestClock.adjust("1 milli"); yield* eventuallyState( @@ -588,7 +588,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "blocked" && state.attempt === 2, @@ -703,7 +703,7 @@ describe("EnvironmentSupervisor", () => { ); expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -728,7 +728,7 @@ describe("EnvironmentSupervisor", () => { (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -741,7 +741,7 @@ describe("EnvironmentSupervisor", () => { expect(secondFailure.retryAt).not.toBeNull(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); expect(yield* Ref.get(harness.sessionCount)).toBe(2); yield* TestClock.adjust("1 second"); @@ -766,7 +766,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -805,7 +805,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2, @@ -834,7 +834,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connecting" && state.attempt === 2, @@ -925,9 +925,14 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("reconnects when the foreground liveness probe fails", () => + it.effect("reconnects immediately when the foreground liveness probe fails", () => Effect.gen(function* () { + const allowReconnect = yield* Deferred.make(); const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 2 + ? Deferred.await(allowReconnect).pipe(Effect.as(PREPARED_CONNECTION)) + : Effect.succeed(PREPARED_CONNECTION), probe: (attempt) => attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, }); @@ -937,15 +942,77 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); - yield* TestClock.adjust("1 second"); + const reconnecting = yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting", + ); + expect(reconnecting.attempt).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + // No TestClock advance: a failed wake probe skips the first backoff rung. + yield* Deferred.succeed(allowReconnect, undefined); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps normal backoff when a reconnect after a failed wake probe also fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 2 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + probe: (attempt) => + attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + // The immediate follow-up attempt fails: only the first attempt after + // the wake probe skips the ladder, so this failure backs off normally. + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + yield* TestClock.adjust("1 milli"); yield* eventuallyState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("uses the full tolerance window for a stalled desktop foreground probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("14999 millis"); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + yield* TestClock.adjust("1 milli"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); - expect(yield* Ref.get(harness.releaseCount)).toBe(1); }).pipe(Effect.provide(TestClock.layer())), ); @@ -961,15 +1028,14 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active-probe"); yield* TestClock.adjust("3 seconds"); + // The timed-out wake probe reconnects immediately without a backoff + // sleep: no further clock advance is needed. yield* awaitState( supervisor.state, - (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", - ); - yield* TestClock.adjust("1 second"); - yield* eventuallyState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 2a9c7519072..85fda10ef1a 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -29,7 +29,7 @@ import * as RpcSession from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; +const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; @@ -232,6 +232,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); + // Set when a foreground wake probe fails or times out: the user is actively + // returning to the app on a dead transport, so the follow-up reconnect skips + // the first backoff rung instead of sleeping. + const wakeProbeFailed = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired ? availableState(initialIntent, 0) @@ -441,6 +445,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ), ); if (probeEvent._tag === "ProbeCompleted") { + if (Exit.isFailure(probeEvent.exit)) { + yield* Ref.set(wakeProbeFailed, true); + } yield* probeEvent.exit; break; } @@ -673,6 +680,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const outcome: AttemptOutcome = yield* Effect.scoped( runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), ); + // Consumed on every iteration so a stale marker can never leak into a + // later, unrelated failure. + const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); if (outcome.established) { generation = nextGeneration; if (outcome.stable) { @@ -709,6 +719,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( continue; } + if (failedWakeProbe) { + // The wake probe found a dead transport while the user is returning to + // the app, so reconnect immediately instead of sleeping the first + // backoff rung. Only this first attempt skips the ladder; if it fails + // too, normal backoff resumes. + resetRetryLadder(); + yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error)); + continue; + } + failureCount += 1; const delayMs = retryDelayMs(failureCount - 1); pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index f7868834b57..0af5850bf6c 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -287,6 +287,33 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("tolerates two missed pong windows before closing the session", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const closedFiber = yield* Effect.forkChild(Effect.flip(session.closed)); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + yield* TestClock.adjust("15 seconds"); + expect(closedFiber.pollUnsafe()).toBeUndefined(); + expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + { _tag: "Ping" }, + { _tag: "Ping" }, + { _tag: "Ping" }, + ]); + + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(closedFiber); + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ reason: "transport" }); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect("reaches ready when a newer server sends unknown config members", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index e006fc3cd76..40e9bd80dc5 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -150,34 +150,34 @@ describe("environment shell synchronization", () => { }), ); - it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => + it.effect("requests a full socket snapshot when the HTTP refresh fails", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, projects: [], - threads: [{ id: "stale-thread" } as never], + threads: [{ id: "cached-thread" } as never], updatedAt: "2026-06-06T00:00:00.000Z", }; - const httpSnapshot: OrchestrationShellSnapshot = { + const resetSnapshot: OrchestrationShellSnapshot = { ...cachedSnapshot, - snapshotSequence: 9, + snapshotSequence: 9_999, threads: [], updatedAt: "2026-06-07T00:00:00.000Z", }; const events = yield* Queue.unbounded(); - const capturedAfterSequence = yield* SubscriptionRef.make(undefined); - const capturedCompletionMarker = yield* Ref.make(undefined); - const loaderCalls = yield* SubscriptionRef.make(0); + const wakeups = yield* Queue.unbounded(); + const subscribeInputs = yield* Queue.unbounded<{ + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }>(); + const loaderCalls = yield* Ref.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; }) => Stream.unwrap( - Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe( - Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)), - Effect.as(Stream.fromQueue(events)), - ), + Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); @@ -208,57 +208,66 @@ describe("environment shell synchronization", () => { clear: () => Effect.void, }); const snapshotLoader = ShellSnapshotLoader.of({ - load: () => - SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( - Effect.as(Option.some(httpSnapshot)), - ), + load: () => Ref.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), }); const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ShellSnapshotLoader, snapshotLoader), + Effect.provideService( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), + ), ); - // Wait until the subscription is established from the warm cache. - yield* SubscriptionRef.changes(capturedAfterSequence).pipe( - Stream.filter((value) => value !== undefined), - Stream.runHead, - ); - - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); - expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + const subscribeInput = yield* Queue.take(subscribeInputs); + expect(subscribeInput.afterSequence).toBeUndefined(); + expect(subscribeInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); - expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot); + yield* Queue.offer(events, { kind: "snapshot", snapshot: resetSnapshot }); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); + + const live = yield* SubscriptionRef.get(shellState); + expect(Option.getOrThrow(live.snapshot)).toEqual(resetSnapshot); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + yield* Queue.offer(wakeups, "application-active"); + const resumedInput = yield* Queue.take(subscribeInputs); + expect(resumedInput.afterSequence).toBe(resetSnapshot.snapshotSequence); + expect(resumedInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); }), ); - it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => + it.effect("resubscribes from the in-memory shell cursor when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); const wakeups = yield* Queue.unbounded(); const loaderCalls = yield* Ref.make(0); - const subscriptionCount = yield* Ref.make(0); + const capturedAfterSequences = yield* Ref.make>([]); const client = { - [ORCHESTRATION_WS_METHODS.subscribeShell]: () => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => Stream.unwrap( - Ref.update(subscriptionCount, (count) => count + 1).pipe( - Effect.as(Stream.fromQueue(events)), - ), + Ref.update(capturedAfterSequences, (captured) => [ + ...captured, + input.afterSequence, + ]).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make(Option.some(session(client))); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, - session: yield* SubscriptionRef.make(Option.some(session(client))), + session: activeSession, prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), connect: Effect.void, disconnect: Effect.void, @@ -296,54 +305,60 @@ describe("environment shell synchronization", () => { ), ); - yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 10, - ), - Stream.runHead, - ); + // A new session starts from an authoritative HTTP snapshot. + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 1) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10]); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); - yield* Queue.offer(wakeups, "application-active"); + // A newer snapshot arrives on the stream and advances the cursor. + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 40 }, + }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 20, + (value) => Option.isSome(value.snapshot) && value.snapshot.value.snapshotSequence === 40, ), Stream.runHead, ); + yield* Queue.offer(wakeups, "application-active"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 2) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 2) break; yield* Effect.yieldNow; } - - expect(yield* Ref.get(loaderCalls)).toBe(2); - expect(yield* Ref.get(subscriptionCount)).toBe(2); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40]); + yield* Queue.offer(events, { kind: "synchronized" }); yield* Queue.offer(wakeups, "application-active-probe"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 3) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 3) break; yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40]); yield* Queue.offer(wakeups, "application-active-reconnect"); for (let attempt = 0; attempt < 10; attempt += 1) { yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect((yield* Ref.get(capturedAfterSequences)).length).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + // Replacing the session performs another authoritative refresh. + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 4) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40, 20]); + expect(yield* Ref.get(loaderCalls)).toBe(2); }), ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a266af5f5f4..c150bbb75b8 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -21,6 +21,7 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -71,6 +72,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") error: Option.none(), }); const awaitingCompletion = yield* Ref.make(false); + const lastAuthoritativeSession = yield* Ref.make(null); + const activeSubscriptionSession = yield* Ref.make(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -166,6 +169,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: waiting ? "synchronizing" : "live", error: Option.none(), }); + if (item.kind === "snapshot") { + const session = yield* Ref.get(activeSubscriptionSession); + if (session !== null) { + yield* Ref.set(lastAuthoritativeSession, session); + } + } yield* Queue.offer(persistence, nextSnapshot); }); @@ -180,6 +189,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { + yield* Ref.set(activeSubscriptionSession, session); const supportsCompletionMarker = yield* session.initialConfig.pipe( Effect.map((config) => config.shellResumeCompletionMarker === true), Effect.orElseSucceed(() => false), @@ -187,30 +197,53 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; - const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((value) => value.value), - Stream.runHead, - Effect.map(Option.getOrThrow), - ), - }), - ), - ); - const httpSnapshot = yield* snapshotLoader.load(prepared); - if (Option.isSome(httpSnapshot)) { - yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); - return { - afterSequence: httpSnapshot.value.snapshotSequence, - ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), - }; + // Foreground resubscriptions on the same live session can resume from + // the in-memory cursor. A new session reloads the authoritative HTTP + // snapshot so a valid cursor cannot preserve incomplete cached data. + const hasAuthoritativeSnapshot = (yield* Ref.get(lastAuthoritativeSession)) === session; + let canResume = hasAuthoritativeSnapshot; + let current = yield* SubscriptionRef.get(state); + if (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) { + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); + const httpSnapshot = yield* snapshotLoader.load(prepared); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + canResume = true; + current = yield* SubscriptionRef.get(state); + } } - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + // If the authoritative refresh failed, omit the cached cursor so the + // socket fallback sends a complete snapshot for this new session. + if (!canResume || Option.isNone(current.snapshot)) { + return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + } + if (!supportsCompletionMarker) { + // Without a completion marker there is no synchronized signal for a + // resumed subscription, so report live immediately, like threads. + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + status: "live" as const, + error: Option.none(), + })); + } + return { + afterSequence: current.snapshot.value.snapshotSequence, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; }), { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 4bd1070bf1f..c58395393d3 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -21,6 +21,7 @@ import { makeTraceSink, type TraceRecord, type TraceSinkFlushStats, + truncateTraceAttributes, } from "./observability.ts"; describe("errorTag", () => { @@ -111,6 +112,31 @@ const makeTestLayer = (tracePath: string) => const nodeServicesIt = it.layer(NodeServices.layer); +describe("truncateTraceAttributes", () => { + it("clamps oversized strings at any depth without mutating the input", () => { + const stack = "s".repeat(2_000); + const attributes = { + "db.query.text": "q".repeat(2_000), + short: "ok", + error: { name: "Error", stack, nested: ["a".repeat(2_000)] }, + }; + const truncated = truncateTraceAttributes(attributes); + + assert.equal((truncated["db.query.text"] as string).length, 200 + "…[truncated]".length); + assert.equal(truncated["short"], "ok"); + const error = truncated["error"] as { stack: string; nested: Array }; + assert.equal(error.stack.length, 500 + "…[truncated]".length); + assert.equal(error.nested[0]?.length, 500 + "…[truncated]".length); + // Input is untouched: the live span's attributes are shared. + assert.equal(attributes.error.stack, stack); + }); + + it("returns the same reference when nothing exceeds the limits", () => { + const attributes = { short: "ok", nested: { fine: "also ok" } }; + assert.equal(truncateTraceAttributes(attributes), attributes); + }); +}); + describe("observability", () => { it("normalizes circular arrays, maps, and sets without recursing forever", () => { const array: Array = ["alpha"]; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index e0a7595865d..67057c54880 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -248,6 +248,61 @@ function formatTraceExit(exit: Exit.Exit): EffectTraceRecord[" }; } +const TRACE_ATTRIBUTE_MAX_LENGTH = 500; +const TRACE_ATTRIBUTE_TRUNCATED_LENGTH = 200; +const TRACE_ATTRIBUTE_TRUNCATION_SUFFIX = "…[truncated]"; +const ALWAYS_TRUNCATED_TRACE_ATTRIBUTES: ReadonlySet = new Set(["db.query.text"]); + +// Clamps strings nested inside already-normalized attribute values (arrays and +// plain objects from normalizeJsonValue, e.g. an Error's `stack`). Returns the +// input reference when nothing was clamped. +function truncateNestedValue(value: unknown): unknown { + if (typeof value === "string") { + return value.length <= TRACE_ATTRIBUTE_MAX_LENGTH + ? value + : `${value.slice(0, TRACE_ATTRIBUTE_MAX_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + } + if (Array.isArray(value)) { + const truncated = value.map(truncateNestedValue); + return truncated.some((entry, index) => entry !== value[index]) ? truncated : value; + } + if (isPlainObject(value)) { + let truncated: Record | undefined; + for (const [key, entry] of Object.entries(value)) { + const next = truncateNestedValue(entry); + if (next === entry) continue; + truncated ??= { ...value }; + truncated[key] = next; + } + return truncated ?? value; + } + return value; +} + +/** + * Clamps oversized attribute values on the serialized trace record so the file + * sink stays small, including strings nested inside arrays and objects (e.g. + * error stacks). Returns a new record when anything was clamped; never + * mutates the input (the live span's attributes are shared with other tracers). + */ +export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttributes { + 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; + truncated ??= { ...attributes }; + truncated[key] = + `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + continue; + } + const next = truncateNestedValue(value); + if (next === value) continue; + truncated ??= { ...attributes }; + truncated[key] = next; + } + return truncated ?? attributes; +} + export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { const status = span.status as Extract; const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; @@ -263,16 +318,18 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { startTimeUnixNano: String(status.startTime), endTimeUnixNano: String(status.endTime), durationMs: Number(status.endTime - status.startTime) / 1_000_000, - attributes: compactTraceAttributes(Object.fromEntries(span.attributes)), + attributes: truncateTraceAttributes( + compactTraceAttributes(Object.fromEntries(span.attributes)), + ), events: span.events.map(([name, startTime, attributes]) => ({ name, timeUnixNano: String(startTime), - attributes: compactTraceAttributes(attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(attributes)), })), links: span.links.map((link) => ({ traceId: link.span.traceId, spanId: link.span.spanId, - attributes: compactTraceAttributes(link.attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(link.attributes)), })), exit: formatTraceExit(status.exit), }; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cf2f2417ff4..efdd05683ab 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -491,6 +492,54 @@ function resolveCommandCandidates( return Array.from(new Set(candidates)); } +// Session bootstrap resolves the same commands over and over, each PATH scan +// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of +// thousands per connect). Memoize the scan outcome per +// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the +// cache while any change to the search environment invalidates immediately. +// Explicit-path resolution is never cached - callers probe paths they have +// just written (e.g. managed binary installs). A "not-found" outcome is also +// cached for the TTL, so a just-installed binary can stay invisible for up to +// 30s unless resolved by explicit path. +// TTL expiry uses the monotonic clock (Clock.currentTimeNanos) so backward +// wall-clock adjustments cannot keep expired entries alive. +const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; +const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; +const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); + +interface CommandResolutionCacheEntry { + readonly resolvedPath: string | null; + readonly expiresAtNanos: bigint; +} + +// The cache lives in the Effect environment (like HostProcessPlatform above) +// so tests and embedders can provide an isolated instance; the default is a +// single process-wide map shared by all consumers. +export const CommandResolutionCache = Context.Reference>( + "@t3tools/shared/shell/CommandResolutionCache", + { + defaultValue: () => new Map(), + }, +); + +function cacheCommandResolution( + cache: Map, + cacheKey: string, + resolvedPath: string | null, + nowNanos: bigint, +): void { + if (cache.size >= COMMAND_RESOLUTION_CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(cacheKey, { + resolvedPath, + expiresAtNanos: nowNanos + COMMAND_RESOLUTION_CACHE_TTL_NANOS, + }); +} + const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( filePath: string, platform: NodeJS.Platform, @@ -538,6 +587,20 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat if (pathValue.length === 0) { return yield* new CommandResolutionError({ command, reason: "not-found" }); } + + const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( + COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR, + ); + const cache = yield* CommandResolutionCache; + const nowNanos = yield* Clock.currentTimeNanos; + const cached = cache.get(cacheKey); + if (cached !== undefined && cached.expiresAtNanos > nowNanos) { + if (cached.resolvedPath === null) { + return yield* new CommandResolutionError({ command, reason: "not-found" }); + } + return cached.resolvedPath; + } + const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); @@ -550,10 +613,12 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat for (const candidate of commandCandidates) { const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos); return candidatePath; } } } + cacheCommandResolution(cache, cacheKey, null, nowNanos); return yield* new CommandResolutionError({ command, reason: "not-found" }); }); diff --git a/patches/effect@4.0.0-beta.103.patch b/patches/effect@4.0.0-beta.103.patch index 561db6f5263..a46ccf9c976 100644 --- a/patches/effect@4.0.0-beta.103.patch +++ b/patches/effect@4.0.0-beta.103.patch @@ -278,32 +278,43 @@ index b536d0a..12ffac0 100644 }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 -@@ -687,20 +716,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun +@@ -687,20 +716,28 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun }; })); const defaultRetryPolicy = /*#__PURE__*/Schedule.min([/*#__PURE__*/Schedule.exponential(500, 1.5), /*#__PURE__*/Schedule.spaced(5000)]); -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { let recievedPong = true; ++ let missedPongs = 0; const latch = Latch.makeUnsafe(); const reset = () => { recievedPong = true; ++ missedPongs = 0; latch.closeUnsafe(); }; - const onPong = () => { -+ const onPong = Effect.sync(() => { - recievedPong = true; +- recievedPong = true; - }; ++ const onPong = Effect.sync(() => { ++ recievedPong = true; ++ missedPongs = 0; + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); yield* Effect.suspend(() => { - if (!recievedPong) return latch.open; - recievedPong = false; +- if (!recievedPong) return latch.open; +- recievedPong = false; - return writePing; ++ if (!recievedPong) { ++ missedPongs += 1; ++ if (missedPongs >= 3) return latch.open; ++ return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); ++ } ++ recievedPong = false; ++ missedPongs = 0; + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); return { timeout: latch.await, -@@ -843,6 +872,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun +@@ -843,6 +880,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun * @since 4.0.0 */ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7c6f4cc1f5..0be461aacbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 - effect@4.0.0-beta.103: a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9 + effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 @@ -116,7 +116,7 @@ importers: version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -134,7 +134,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) electron: specifier: 41.5.0 version: 41.5.0 @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -199,7 +199,7 @@ importers: version: 4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -274,7 +274,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo: specifier: ~56.0.12 version: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -422,7 +422,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -446,16 +446,16 @@ importers: version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) @@ -467,7 +467,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -477,7 +477,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -531,7 +531,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0) '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -567,7 +567,7 @@ importers: version: 0.7.1 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) jose: specifier: 'catalog:' version: 6.2.2 @@ -607,10 +607,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) @@ -658,7 +658,7 @@ importers: version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -676,23 +676,23 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(a455401069e1fee89f31a277c51247f6) + version: 2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee) drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -710,17 +710,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -735,11 +735,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -748,11 +748,11 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -761,17 +761,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -783,17 +783,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -814,7 +814,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) jose: specifier: 'catalog:' version: 6.2.2 @@ -824,10 +824,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -845,14 +845,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -864,17 +864,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -886,7 +886,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -898,7 +898,7 @@ importers: version: link:../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pngjs: specifier: 7.0.0 version: 7.0.0 @@ -908,7 +908,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -5947,6 +5947,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -11640,24 +11641,24 @@ snapshots: '@cloudflare/workers-types@5.20260726.1': {} - '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': dependencies: @@ -11670,48 +11671,48 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@alchemy.run/node-utils': 0.0.5 - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(8bf551e378e9cbc11f8bf1003fbc14a8)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(f97c3167f1a1990dddb83bff73e575e5)': dependencies: - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -11747,47 +11748,47 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) swagger2openapi: 7.0.8 transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ioredis: 5.11.0 mime: 4.1.0 undici: 8.9.0 @@ -11795,14 +11796,14 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@cloudflare/workers-types': 5.20260726.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pg: 8.22.0 pg-connection-string: 2.14.0 pg-cursor: 2.21.0(pg@8.22.0) @@ -11811,9 +11812,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -11846,9 +11847,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@egjs/hammerjs@2.0.17': dependencies: @@ -15350,22 +15351,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(a455401069e1fee89f31a277c51247f6): + alchemy@2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(8bf551e378e9cbc11f8bf1003fbc14a8) - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(f97c3167f1a1990dddb83bff73e575e5) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -15375,7 +15376,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -15391,11 +15392,11 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -16418,15 +16419,15 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 @@ -16446,7 +16447,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9): + effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 From 6fa457607886caf096e7871b67e447f76d3772f6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 00:15:59 -0400 Subject: [PATCH 02/10] fix(server): settle stopped Claude subagents (#5568) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 19 ++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 40 ++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index afa65ea39d6..d3d768b5384 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1511,7 +1511,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("interruptTurn stops every live task before interrupting the turn", () => { + it.effect("interruptTurn settles every acknowledged live task before interrupting", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1568,11 +1568,28 @@ describe("ClaudeAdapterLive", () => { yield* Fiber.join(taskEventsFiber); + const stoppedTaskEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.interruptTurn(session.threadId); // Only the still-live task is stopped; interrupt always fires after. assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); assert.equal(harness.query.interruptCalls.length, 1); + + const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); + assert.equal(stoppedTaskEvents.length, 1); + const stoppedTaskEvent = stoppedTaskEvents[0]; + assert.equal(stoppedTaskEvent?.type, "task.completed"); + if (stoppedTaskEvent?.type === "task.completed") { + assert.equal(String(stoppedTaskEvent.payload.taskId), "task-live"); + assert.equal(stoppedTaskEvent.payload.status, "stopped"); + assert.equal(stoppedTaskEvent.payload.taskType, "local_agent"); + assert.equal(stoppedTaskEvent.payload.title, "Agent A"); + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f6f1c14420d..92445522cc4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -65,6 +65,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -4419,11 +4420,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Effect.forEach( liveIds, (taskId) => - Effect.tryPromise({ - // Invoke through the query object: SDK methods rely on `this`. - try: () => context.query.stopTask!(taskId), - catch: () => undefined, - }).pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), + Effect.gen(function* () { + const stopAcknowledged = yield* Effect.tryPromise({ + // Invoke through the query object: SDK methods rely on `this`. + try: () => context.query.stopTask!(taskId), + catch: () => undefined, + }).pipe( + Effect.timeoutOption("3 seconds"), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { + return; + } + + // stopTask only acknowledges the control request. Its separate + // task_notification can lose the race with interrupt(), so make + // the acknowledged stop authoritative for the durable UI state. + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + }).pipe(Effect.ignore), { concurrency: 8, discard: true }, ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); } From 1c7d059f550a53dd94d5b9802640ecd11b759d1a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 00:32:01 -0400 Subject: [PATCH 03/10] fix: scrolling up during a running thread no longer snaps back to the bottom (#5566) Co-authored-by: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 71 ++++++++- apps/web/src/components/ChatView.tsx | 143 +++++++++++++++--- .../components/chat/MessagesTimeline.logic.ts | 32 +++- .../components/chat/MessagesTimeline.test.tsx | 33 +++- .../src/components/chat/MessagesTimeline.tsx | 14 +- 5 files changed, 258 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fd8ffb270cb..28df94b529b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); + // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed + // whenever the viewport drifts back inside its geometric threshold, which + // yanked users off history they were reading every time a stream chunk grew + // a row. Follow breaks when the user scrolls up and away, and re-arms only + // when the list actually returns to the end (or on send / thread switch). + const [endFollowEnabled, setEndFollowEnabled] = useState(true); + const endFollowEnabledRef = useRef(true); + // A "user scroll session" spans from drag start through the end of its + // momentum; only motion inside a session can break follow, so MVCP + // compensations and programmatic scrolls never strand a follower. + const userScrollSessionRef = useRef(false); + const setEndFollow = useCallback((enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + }, []); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearListEnd.value = contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; + + // Latch bookkeeping. LegendList recomputes its inset-aware end distance + // before invoking this handler, so getState() is current. Returning to + // the end re-arms follow no matter who scrolled (the user, or our own + // scroll-to-end); moving away breaks it only during a user-initiated + // scroll session, so MVCP compensations and programmatic repositioning + // can never strand a follower. + const listState = props.listRef.current?.getState(); + if (listState) { + if (listState.isWithinMaintainScrollAtEndThreshold) { + setEndFollow(true); + } else if (userScrollSessionRef.current) { + setEndFollow(false); + } + } }, - [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], + [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow], ); + const handleScrollBeginDrag = useCallback(() => { + userScrollSessionRef.current = true; + }, []); + // The session must survive past finger-lift so momentum that carries the + // user away from the end still breaks follow; a drag released with no + // momentum ends its session at the release itself, otherwise at momentum + // end. Leaving a session open would let a later animated maintain-scroll + // read as user motion and break follow spuriously. + const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => { + const velocity = event.nativeEvent.velocity?.y ?? 0; + if (Math.abs(velocity) < 0.05) { + userScrollSessionRef.current = false; + } + }, []); + const handleMomentumScrollEnd = useCallback(() => { + userScrollSessionRef.current = false; + }, []); // Gated variant of the 180ms feed layout slide. Instant while browsing // history: maintainVisibleContentPosition compensates the scroll offset in @@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); + // A thread switch opens pinned to the end; a send explicitly returns to the + // live edge (ThreadDetailScreen scrolls the new message into place). Both + // re-arm follow regardless of where the user had scrolled before. + useEffect(() => { + userScrollSessionRef.current = false; + setEndFollow(true); + }, [props.threadId, setEndFollow]); + useEffect(() => { + if (props.anchorMessageId !== null) { + userScrollSessionRef.current = false; + setEndFollow(true); + } + }, [props.anchorMessageId, setEndFollow]); + const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { @@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling + disclosureToggleSettling || !endFollowEnabled ? false : { animated: true, @@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} ListHeaderComponent={ <> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 708a97be545..3c416b8f88a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd + // 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 pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; + setTimelineLiveFollowEnabled(false); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) { }, []); useEffect(() => { let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { - passive: true, + let frame: number | null = null; + const attach = (remainingAttempts: number) => { + frame = requestAnimationFrame(() => { + frame = null; + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + // The list may not have mounted on the first frame after a thread + // switch — without a retry the opt-out listeners never attach and + // live-follow becomes impossible to escape for the whole thread. + if (remainingAttempts > 0) { + attach(remainingAttempts - 1); + } + return; + } + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge. Follow now gates + // LegendList's maintainScrollAtEnd, so a spurious break while pinned + // at the end produces no scroll event, never re-arms, and streaming + // silently stops following. Underflowing content can't scroll at all, + // so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === + false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + if (contentScrollsUp()) { + handleManualNavigation(); + } + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + if (contentScrollsUp()) { + handleManualNavigation(); + } + break; + default: + break; + } + }; + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + scrollNode.addEventListener("keydown", handleKeyDown); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("keydown", handleKeyDown); + }; }); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); - }; - }); + }; + attach(12); return () => { - cancelAnimationFrame(frame); + if (frame !== null) { + cancelAnimationFrame(frame); + } removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + // Anchored-end space can be remeasured when the turn completes. Once the + // user has scrolled away (or returned to ordinary end-following), that + // remeasurement must not restart the send-time anchor positioning. + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; } @@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index e5ecdbd2004..c204499273a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; export interface TimelineEndState { readonly isAtEnd?: boolean; - readonly isNearEnd?: boolean; + readonly contentLength?: number; + readonly scroll?: number; + readonly scrollLength?: number; } -export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { - return state?.isNearEnd ?? state?.isAtEnd; +/** + * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's + * isNearEnd fires within half a viewport, which re-armed live-follow while the + * user was reading history and yanked them back down on the next stream chunk. + * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming + * reliable while streaming content is still growing under the viewport. + */ +export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; + +export function resolveTimelineIsAtEnd( + state: TimelineEndState | undefined, + endInset = 0, +): boolean | undefined { + if (!state) { + return undefined; + } + if (state.isAtEnd) { + return true; + } + const { contentLength, scroll, scrollLength } = state; + if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { + return state.isAtEnd; + } + // contentLength includes the end inset (composer overlay), so subtract it to + // measure the distance to the real content bottom. + return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } export function resolveTimelineMinimapHeightStyle(itemCount: number): string { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..cf055f05b74 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -194,6 +194,7 @@ function buildProps() { onAnchorReady: () => {}, onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, + liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -296,7 +297,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("1 changed file"); }); - it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { + it("treats only the strict list end as the live edge", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -307,10 +308,36 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); - expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); - expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); + // Within the pixel band above the content bottom counts as the end... + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 1170, + scrollLength: 800, + }), + ).toBe(true); + // ...but half a viewport up (LegendList's isNearEnd territory) does not. + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 900, + scrollLength: 800, + }), + ).toBe(false); + // The composer inset is part of contentLength and must not count as + // distance-to-end. + expect( + resolveTimelineIsAtEnd( + { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 }, + 100, + ), + ).toBe(true); + // Geometry missing (older state shape): fall back to the strict flag. + expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8e27b7b6962..a5fb0360204 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -219,6 +219,13 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + /** + * Whether the timeline should keep pinning to the live edge as content + * grows. Off while the user is reading history; LegendList's own + * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's + * scroll-mode refs whenever the user drifts near the bottom. + */ + liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; @@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + liveFollowEnabled, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, @@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); + const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } @@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || !liveFollowEnabled ? false : { animated: false, From 9547cf24634ccafe5d381964449b722d33f812b8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:15:40 -0400 Subject: [PATCH 04/10] fix(server): one disconnecting client no longer blocks every reconnect (#5572) Co-authored-by: Claude --- .../src/process/externalLauncher.test.ts | 65 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 41 ++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 36ef8264328..1ab6166e92a 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -216,6 +217,70 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = ); }); +// A client that disconnects mid-scan interrupts the shared discovery effect on +// the connection fiber. The cache must not retain that interrupt: doing so +// replayed it to every later connect for the whole TTL, so `server.getConfig` +// failed and no client could reconnect until the server restarted. +it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => { + const fileInfo = { type: "File" } as FileSystem.File.Info; + let blockFirstScan = true; + let scans = 0; + const launcherLayer = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + // The first scan parks inside `stat` so the interrupt lands while + // discovery is in flight, which is what a client disconnecting + // mid-connect does to the shared effect. + stat: () => + Effect.gen(function* () { + scans += 1; + if (blockFirstScan) { + return yield* Effect.never; + } + return fileInfo; + }), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + + // The next connect must still get a real answer well inside the TTL. + blockFirstScan = false; + scans = 0; + const editors = yield* launcher.resolveAvailableEditors(); + assert.equal(editors.includes("vscode"), true); + assert.isAbove(scans, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + launcherLayer, + Layer.succeed(HostProcessPlatform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-interrupt-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + ), + ), + ); +}); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 2cac42f0fec..8ec928f26fc 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -302,7 +304,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the // per-command cache lookups in @t3tools/shared/shell. -const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; +// +// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the +// first caller's Exit whatever it is, including an interrupt. Callers run this +// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), +// so one client disconnecting mid-scan would cache the interrupt and replay it +// to every later connect for the whole TTL, breaking `server.getConfig` +// permanently. Storing only on success means an interrupted scan leaves the +// cache untouched and the next connect simply rescans. +// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the +// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock +// adjustment cannot keep an expired entry alive. +const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; + +interface EditorDiscoveryCacheEntry { + readonly editors: ReadonlyArray; + readonly expiresAtNanos: bigint; +} /** * ExternalLauncher - Service tag for browser/editor launch operations. @@ -449,10 +467,25 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - const cachedAvailableEditors = yield* Effect.cachedWithTTL( - provideCommandResolutionServices(resolveAvailableEditors()), - EDITOR_DISCOVERY_CACHE_TTL, + const editorDiscoveryCache = yield* Ref.make>( + Option.none(), ); + const cachedAvailableEditors = Effect.gen(function* () { + const nowNanos = yield* Clock.currentTimeNanos; + const entry = yield* Ref.get(editorDiscoveryCache); + if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { + return entry.value.editors; + } + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + yield* Ref.set( + editorDiscoveryCache, + Option.some({ + editors, + expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, + }), + ); + return editors; + }); return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, From ddfe45c66eccd93c0adf61db92a16cbbbcde23e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:17:49 -0400 Subject: [PATCH 05/10] test(server): catch client transfer regressions in CI (#5350) --- .github/scripts/thread-transfer-report.cjs | 429 ++++++++++++++++++ .../scripts/thread-transfer-report.test.cjs | 292 ++++++++++++ .github/workflows/ci.yml | 21 + .github/workflows/thread-transfer-report.yml | 75 +++ .../NetworkTransferMeasurement.integration.ts | 177 ++++++++ .../OrchestrationEngineHarness.integration.ts | 13 + .../TestProviderAdapter.integration.ts | 23 +- .../TransferBudgetReport.integration.ts | 212 +++++++++ .../TransferBudgetScenario.integration.ts | 128 ++++++ .../integration/fixtures/transferBudget.ts | 372 +++++++++++++++ apps/server/src/server.test.ts | 271 ++++++++++- apps/server/src/ws.ts | 2 +- 12 files changed, 1973 insertions(+), 42 deletions(-) create mode 100644 .github/scripts/thread-transfer-report.cjs create mode 100644 .github/scripts/thread-transfer-report.test.cjs create mode 100644 .github/workflows/thread-transfer-report.yml create mode 100644 apps/server/integration/NetworkTransferMeasurement.integration.ts create mode 100644 apps/server/integration/TransferBudgetReport.integration.ts create mode 100644 apps/server/integration/TransferBudgetScenario.integration.ts create mode 100644 apps/server/integration/fixtures/transferBudget.ts diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs new file mode 100644 index 00000000000..94a02b7806d --- /dev/null +++ b/.github/scripts/thread-transfer-report.cjs @@ -0,0 +1,429 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const ARTIFACT_NAME = "thread-transfer-results"; +const RESULT_FILE = "thread-transfer-result.json"; +const COMMENT_MARKER = ""; +const PROVIDERS = ["codex", "claudeAgent"]; +const OBSERVED_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "threadSnapshotDecodedBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const CEILING_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const SCENARIO_KEYS = [ + "id", + "historyTurns", + "historyCommandToolsPerTurn", + "historyMcpResultBytes", + "measuredCommandTools", + "measuredMcpResultBytes", +]; + +function resultShaMarker(sha) { + return ``; +} + +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertExactKeys(value, expected, label) { + assertObject(value, label); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected fields`); + } +} + +function assertMetric(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) { + throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`); + } +} + +function validateResult(value) { + assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result"); + if (value.schemaVersion !== 1) { + throw new Error("result.schemaVersion must be 1"); + } + + assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); + if (value.scenario.id !== "thread-transfer-v1") { + throw new Error("result.scenario.id is not supported"); + } + for (const key of SCENARIO_KEYS.slice(1)) { + assertMetric(value.scenario[key], `result.scenario.${key}`); + } + + assertExactKeys(value.providers, PROVIDERS, "result.providers"); + for (const provider of PROVIDERS) { + const entry = value.providers[provider]; + assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`); + assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`); + assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`); + for (const key of OBSERVED_KEYS) { + assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`); + } + for (const key of CEILING_KEYS) { + assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`); + } + } + + return value; +} + +function readResult(directory) { + if (!directory) return undefined; + const file = path.join(directory, RESULT_FILE); + if (!fs.existsSync(file)) return undefined; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 64 * 1_024) { + throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + } + return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function formatBytes(bytes) { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`; + return `${(bytes / 1_024).toFixed(1)} KiB`; +} + +function formatValue(value, kind) { + return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); +} + +function formatImpact(current, baseline, kind) { + if (baseline === undefined) return "—"; + const delta = current - baseline; + const prefix = delta > 0 ? "+" : delta < 0 ? "−" : ""; + const magnitude = formatValue(Math.abs(delta), kind); + const percent = + baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`; + return `${prefix}${magnitude}${percent}`; +} + +function sameScenario(left, right) { + return SCENARIO_KEYS.every((key) => left[key] === right[key]); +} + +const METRICS = [ + { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" }, + { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" }, + { + key: "measuredTurnWebSocketWireBytes", + label: "Live turn WebSocket wire", + kind: "bytes", + }, + { + key: "measuredTurnWebSocketDecodedBytes", + label: "Live turn WebSocket decoded", + kind: "bytes", + }, + { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, +]; + +function renderComment(input) { + const current = input.current; + const baseline = input.baseline; + const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario); + const rows = []; + const ceilingChanges = []; + let failed = false; + + for (const provider of PROVIDERS) { + for (const metric of METRICS) { + const observed = current.providers[provider].observed[metric.key]; + const ceiling = current.providers[provider].ceiling[metric.key]; + const baselineObserved = comparable + ? baseline.providers[provider].observed[metric.key] + : undefined; + const pass = observed <= ceiling; + failed ||= !pass; + rows.push( + `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + ); + + if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + ceilingChanges.push( + `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + ); + } + } + } + + const baselineLink = input.baselineRun + ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` + : "unavailable"; + const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`; + const notices = []; + if (!baseline) { + notices.push( + "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.", + ); + } else if (!comparable) { + notices.push( + "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.", + ); + } else if (!input.baselineRun.matchesBase) { + notices.push( + "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.", + ); + } + if (ceilingChanges.length > 0) { + notices.push( + `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`, + ); + } + + return [ + COMMENT_MARKER, + resultShaMarker(input.currentRun.sha), + "## Thread transfer impact", + "", + failed + ? "❌ One or more thread transfer ceilings were exceeded." + : "✅ Thread transfer remains within every enforced ceiling.", + ...(notices.length > 0 ? ["", ...notices] : []), + "", + "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ...rows, + "", + `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, + "", + "
", + "Scenario and decoded snapshot size", + "", + `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`, + "", + ...PROVIDERS.map( + (provider) => + `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + ), + "", + "
", + "", + "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._", + ].join("\n"); +} + +async function artifactsForRun(github, owner, repo, runId) { + return github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: runId, + per_page: 100, + }); +} + +function findResultArtifact(artifacts) { + return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired); +} + +async function resolve({ github, context, core }) { + const source = context.payload.workflow_run; + const { owner, repo } = context.repo; + if (source.event !== "pull_request") { + core.setOutput("publish", "false"); + return; + } + + let pullNumber = source.pull_requests?.[0]?.number; + if (!pullNumber) { + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: source.head_sha, per_page: 100 }, + ); + const matchingPulls = associated.filter( + (pull) => + pull.state === "open" && + pull.head.sha === source.head_sha && + pull.head.ref === source.head_branch, + ); + if (matchingPulls.length !== 1) { + core.info( + `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`, + ); + core.setOutput("publish", "false"); + return; + } + pullNumber = matchingPulls[0].number; + } + if (!pullNumber) { + core.info("No open pull request is associated with the completed CI run."); + core.setOutput("publish", "false"); + return; + } + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (pull.head.sha !== source.head_sha) { + core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`); + core.setOutput("publish", "false"); + return; + } + + const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); + const sourceArtifact = findResultArtifact(sourceArtifacts); + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 100, + }); + const orderedRuns = [ + ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), + ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ].slice(0, 20); + + let baselineRun; + for (const run of orderedRuns) { + const artifacts = await artifactsForRun(github, owner, repo, run.id); + if (findResultArtifact(artifacts)) { + baselineRun = run; + break; + } + } + + core.setOutput("publish", "true"); + core.setOutput("pull_number", String(pullNumber)); + core.setOutput("pr_artifact", sourceArtifact ? "true" : "false"); + core.setOutput("pr_run_id", String(source.id)); + core.setOutput("pr_sha", source.head_sha); + core.setOutput("pr_conclusion", source.conclusion ?? "unknown"); + core.setOutput("baseline_artifact", baselineRun ? "true" : "false"); + core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : ""); + core.setOutput("baseline_sha", baselineRun?.head_sha ?? ""); + core.setOutput( + "baseline_matches_base", + baselineRun?.head_sha === pull.base.sha ? "true" : "false", + ); +} + +async function upsertComment(github, context, pullNumber, body, options = {}) { + const { owner, repo } = context.repo; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER), + ); + if ( + options.preserveResultSha && + existing?.body?.includes(resultShaMarker(options.preserveResultSha)) + ) { + return; + } + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body }); + } +} + +async function upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + expectedSha, + body, + options, +) { + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (pull.head.sha !== expectedSha) { + core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`); + return false; + } + + await upsertComment(github, context, pullNumber, body, options); + return true; +} + +async function publish({ github, context, core }) { + const pullNumber = Number(process.env.PR_NUMBER); + if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { + throw new Error("PR_NUMBER is invalid"); + } + + const current = readResult(process.env.PR_RESULT_DIR); + const currentRun = { + sha: process.env.PR_SHA, + conclusion: process.env.PR_CONCLUSION, + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`, + }; + if (!current) { + await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + [ + COMMENT_MARKER, + "## Thread transfer impact", + "", + `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`, + "", + "_This comment will update automatically after the next completed run._", + ].join("\n"), + { preserveResultSha: currentRun.sha }, + ); + return; + } + + const baseline = readResult(process.env.BASELINE_RESULT_DIR); + const baselineRun = baseline + ? { + sha: process.env.BASELINE_SHA, + matchesBase: process.env.BASELINE_MATCHES_BASE === "true", + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, + } + : undefined; + const body = renderComment({ current, baseline, currentRun, baselineRun }); + const published = await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + body, + ); + if (published) { + core.info(`Updated thread transfer report on PR #${pullNumber}.`); + } +} + +module.exports = { + publish, + readResult, + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +}; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs new file mode 100644 index 00000000000..4935864e46f --- /dev/null +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -0,0 +1,292 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +} = require("./thread-transfer-report.cjs"); + +function result(overrides = {}) { + const observed = { + totalWireBytes: 2_200_000, + threadSnapshotWireBytes: 1_950_000, + threadSnapshotDecodedBytes: 9_100_000, + measuredTurnWebSocketWireBytes: 250_000, + measuredTurnWebSocketDecodedBytes: 1_150_000, + measuredTurnWebSocketMessages: 15, + }; + const ceiling = { + totalWireBytes: 2_900_000, + threadSnapshotWireBytes: 2_600_000, + measuredTurnWebSocketWireBytes: 320_000, + measuredTurnWebSocketDecodedBytes: 1_550_000, + measuredTurnWebSocketMessages: 20, + }; + return { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: 10, + historyCommandToolsPerTurn: 5, + historyMcpResultBytes: 900_000, + measuredCommandTools: 20, + measuredMcpResultBytes: 1_100_000, + }, + providers: { + codex: { observed: { ...observed, ...overrides }, ceiling }, + claudeAgent: { observed, ceiling }, + }, + }; +} + +test("validates the fixed artifact schema", () => { + assert.equal(validateResult(result()).schemaVersion, 1); + assert.throws( + () => validateResult({ ...result(), injectedMarkdown: "@everyone" }), + /unexpected fields/, + ); + assert.throws( + () => validateResult(result({ totalWireBytes: "lots" })), + /non-negative safe integer/, + ); +}); + +test("renders baseline, impact, ceiling, and ceiling changes", () => { + const baseline = result(); + const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); + current.providers.codex.ceiling = { + ...current.providers.codex.ceiling, + measuredTurnWebSocketWireBytes: 330_000, + }; + const comment = renderComment({ + current, + baseline, + currentRun: { + sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + conclusion: "success", + url: "https://github.com/pingdotgg/t3code/actions/runs/2", + }, + baselineRun: { + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + matchesBase: true, + url: "https://github.com/pingdotgg/t3code/actions/runs/1", + }, + }); + + assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); + assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); + assert.match(comment, /This PR changes transfer ceilings/); + assert.match(comment, /312\.5 KiB → 322\.3 KiB/); + assert.match(comment, //); + assert.match( + comment, + //, + ); +}); + +test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { + const outputs = {}; + const listWorkflowRunArtifacts = () => {}; + const listWorkflowRuns = () => {}; + const listPullRequestsAssociatedWithCommit = () => {}; + const github = { + paginate: async (method, input) => { + if (method === listPullRequestsAssociatedWithCommit) { + return [ + { + number: 5350, + state: "open", + head: { sha: "head-sha", ref: "feature-branch", repo: null }, + }, + ]; + } + if (method === listWorkflowRunArtifacts) { + return [ + { + name: "thread-transfer-results", + expired: false, + runId: input.run_id, + }, + ]; + } + if (method === listWorkflowRuns) { + return [{ id: 1, head_sha: "base-sha" }]; + } + throw new Error("unexpected pagination call"); + }, + rest: { + actions: { listWorkflowRunArtifacts, listWorkflowRuns }, + pulls: { + get: async () => ({ + data: { + head: { sha: "head-sha" }, + base: { sha: "base-sha", ref: "main" }, + }, + }), + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }; + await resolve({ + github, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "true"); + assert.equal(outputs.pull_number, "5350"); + assert.equal(outputs.pr_artifact, "true"); + assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_matches_base, "true"); +}); + +test("does not guess when a fallback commit belongs to multiple PRs", async () => { + const outputs = {}; + const listPullRequestsAssociatedWithCommit = () => {}; + let fetchedPull = false; + await resolve({ + github: { + paginate: async (method) => { + assert.equal(method, listPullRequestsAssociatedWithCommit); + return [5350, 5351].map((number) => ({ + number, + state: "open", + head: { + sha: "head-sha", + ref: "feature-branch", + repo: { full_name: "pingdotgg/t3code" }, + }, + })); + }, + rest: { + actions: {}, + pulls: { + get: async () => { + fetchedPull = true; + }, + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "false"); + assert.equal(fetchedPull, false); +}); + +test("does not publish a stale result after the PR head advances", async () => { + let listedComments = false; + const info = []; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => { + listedComments = true; + return []; + }, + rest: { + issues: { + listComments: () => {}, + createComment: () => { + throw new Error("must not create a stale comment"); + }, + updateComment: () => { + throw new Error("must not update a stale comment"); + }, + }, + pulls: { + get: async () => ({ data: { head: { sha: "new-head-sha" } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: (message) => info.push(message) }, + 5350, + "old-head-sha", + "stale body", + ); + + assert.equal(published, false); + assert.equal(listedComments, false); + assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]); +}); + +test("preserves a successful result when a same-SHA rerun has no artifact", async () => { + let updatedComment = false; + const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => [ + { + id: 1, + user: { login: "github-actions[bot]" }, + body: `\n`, + }, + ], + rest: { + issues: { + listComments: () => {}, + createComment: () => { + updatedComment = true; + }, + updateComment: () => { + updatedComment = true; + }, + }, + pulls: { + get: async () => ({ data: { head: { sha } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: () => {} }, + 5350, + sha, + "missing artifact warning", + { preserveResultSha: sha }, + ); + + assert.equal(published, true); + assert.equal(updatedComment, false); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e51867cbe7..052a8c20cf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,8 +84,29 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Test + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json run: vp run test + - name: Publish transfer budget report + if: always() + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + else + echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload thread transfer result + if: always() + uses: actions/upload-artifact@v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer-result.json + if-no-files-found: ignore + retention-days: 30 + - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml new file mode 100644 index 00000000000..23eec72923b --- /dev/null +++ b/.github/workflows/thread-transfer-report.yml @@ -0,0 +1,75 @@ +name: Thread Transfer Report + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + publish: + name: Publish PR comment + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04 + concurrency: + group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: true + steps: + # workflow_run has a write-capable token even for fork PRs. Only load the + # publisher from the trusted default branch and never execute PR code. + - name: Checkout trusted publisher + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Test trusted publisher + run: node --test .github/scripts/thread-transfer-report.test.cjs + + - id: resolve + name: Resolve PR and baseline artifacts + uses: actions/github-script@v8 + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.resolve({ github, context, core }); + + - name: Download PR result + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/pr + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.pr_run_id }} + + - name: Download main baseline + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/main + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.baseline_run_id }} + + - name: Update thread transfer comment + if: steps.resolve.outputs.publish == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} + PR_SHA: ${{ steps.resolve.outputs.pr_sha }} + PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} + PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} + PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr + BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} + BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} + BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} + BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.publish({ github, context, core }); diff --git a/apps/server/integration/NetworkTransferMeasurement.integration.ts b/apps/server/integration/NetworkTransferMeasurement.integration.ts new file mode 100644 index 00000000000..75714d1519e --- /dev/null +++ b/apps/server/integration/NetworkTransferMeasurement.integration.ts @@ -0,0 +1,177 @@ +// @effect-diagnostics nodeBuiltinImport:off - Measures the real Node HTTP and WebSocket transports. +import * as NodeHttp from "node:http"; +import * as NodeZlib from "node:zlib"; + +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { WsRpcGroup } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +export class TransferHttpRequestError extends Schema.TaggedErrorClass()( + "TransferHttpRequestError", + { + url: Schema.String, + cause: Schema.Defect(), + }, +) {} + +export interface HttpTransferMeasurement { + readonly status: number; + readonly contentEncoding: string | null; + readonly encodedBody: Uint8Array; + readonly encodedBodyBytes: number; + readonly decodedBody: Uint8Array; + readonly decodedBodyBytes: number; + /** HTTP response bytes read from the socket, including status line and headers. */ + readonly wireBytes: number; +} + +export const measureHttpGet = Effect.fn("TransferBudget.measureHttpGet")(function* (input: { + readonly url: string; + readonly headers?: Readonly>; +}) { + return yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + let socketBytesBeforeResponse = 0; + const request = NodeHttp.get( + input.url, + { + agent: false, + headers: { + "accept-encoding": "gzip", + connection: "close", + ...input.headers, + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.once("error", reject); + response.once("end", () => { + try { + const encodedBody = Buffer.concat(chunks); + const header = response.headers["content-encoding"]; + const contentEncoding = Array.isArray(header) + ? (header[0] ?? null) + : (header ?? null); + const decodedBody = + contentEncoding === "gzip" ? NodeZlib.gunzipSync(encodedBody) : encodedBody; + resolve({ + status: response.statusCode ?? 0, + contentEncoding, + encodedBody, + encodedBodyBytes: encodedBody.byteLength, + decodedBody, + decodedBodyBytes: decodedBody.byteLength, + wireBytes: Math.max(0, response.socket.bytesRead - socketBytesBeforeResponse), + }); + } catch (cause) { + reject(cause); + } + }); + }, + ); + request.once("socket", (socket) => { + socketBytesBeforeResponse = socket.bytesRead; + }); + request.once("error", reject); + request.setTimeout(10_000, () => { + request.destroy(new Error(`Timed out reading ${input.url}`)); + }); + }), + catch: (cause) => new TransferHttpRequestError({ url: input.url, cause }), + }); +}); + +export interface WebSocketTransferTotals { + readonly wireBytes: number; + readonly decodedBytes: number; + readonly messages: number; +} + +export interface WebSocketTransferRecorder { + readonly connect: ( + url: string, + protocols: string | string[] | undefined, + cookie: string, + ) => globalThis.WebSocket; + readonly totals: () => WebSocketTransferTotals; + readonly negotiatedExtensions: () => string; +} + +interface NodeWebSocketWithTransport extends NodeSocket.NodeWS.WebSocket { + readonly _socket?: { + readonly bytesRead: number; + }; +} + +function rawDataBytes(data: NodeSocket.NodeWS.RawData): number { + if (Array.isArray(data)) { + return data.reduce((total, chunk) => total + chunk.byteLength, 0); + } + return data.byteLength; +} + +export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { + let socket: NodeWebSocketWithTransport | null = null; + let decodedBytes = 0; + let messages = 0; + + return { + connect: (url, protocols, cookie) => { + const nextSocket = new NodeSocket.NodeWS.WebSocket(url, protocols, { + headers: { cookie }, + perMessageDeflate: true, + }) as NodeWebSocketWithTransport; + socket = nextSocket; + nextSocket.on("message", (data) => { + const bytes = rawDataBytes(data); + decodedBytes += bytes; + messages += 1; + }); + return nextSocket as unknown as globalThis.WebSocket; + }, + totals: () => ({ + wireBytes: socket?._socket?.bytesRead ?? 0, + decodedBytes, + messages, + }), + negotiatedExtensions: () => socket?.extensions ?? "", + }; +} + +export function transferDelta( + start: WebSocketTransferTotals, + end: WebSocketTransferTotals, +): WebSocketTransferTotals { + return { + wireBytes: Math.max(0, end.wireBytes - start.wireBytes), + decodedBytes: Math.max(0, end.decodedBytes - start.decodedBytes), + messages: Math.max(0, end.messages - start.messages), + }; +} + +export function countingWsRpcProtocolLayer(input: { + readonly url: string; + readonly cookie: string; + readonly recorder: WebSocketTransferRecorder; +}) { + const webSocketConstructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url, protocols) => + input.recorder.connect(url, protocols, input.cookie), + ); + return RpcClient.layerProtocolSocket().pipe( + Layer.provide( + Socket.layerWebSocket(input.url, { openTimeout: "10 seconds" }).pipe( + Layer.provide(webSocketConstructorLayer), + ), + ), + Layer.provide(RpcSerialization.layerJson), + ); +} + +export const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); +export type CountingWsRpcClient = Effect.Success; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c3f77d677b1..d192cbeac8e 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -55,6 +55,8 @@ import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceip import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; +import { CheckpointReactor } from "../src/orchestration/Services/CheckpointReactor.ts"; +import { ProviderRuntimeIngestionService } from "../src/orchestration/Services/ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -218,6 +220,8 @@ export interface OrchestrationIntegrationHarness { timeoutMs?: number, ): Effect.Effect; }; + readonly drainProviderRuntime: Effect.Effect; + readonly drainCheckpointReactor: Effect.Effect; readonly dispose: Effect.Effect; } @@ -392,6 +396,13 @@ export const makeOrchestrationIntegrationHarness = ( const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => runtime.runPromise(Effect.service(OrchestrationReactor)), ).pipe(Effect.orDie); + const providerRuntimeIngestion = yield* tryRuntimePromise( + "load ProviderRuntimeIngestion service", + () => runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)), + ).pipe(Effect.orDie); + const checkpointReactor = yield* tryRuntimePromise("load CheckpointReactor service", () => + runtime.runPromise(Effect.service(CheckpointReactor)), + ).pipe(Effect.orDie); const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), ).pipe(Effect.orDie); @@ -556,6 +567,8 @@ export const makeOrchestrationIntegrationHarness = ( waitForDomainEvent, waitForPendingApproval, waitForReceipt, + drainProviderRuntime: providerRuntimeIngestion.drain, + drainCheckpointReactor: checkpointReactor.drain, dispose, } satisfies OrchestrationIntegrationHarness; }); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 0e64699de97..095cca4e5e7 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -11,7 +11,6 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -226,9 +225,9 @@ function missingSessionEffect( export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => Effect.gen(function* () { const provider = options?.provider ?? ProviderDriverKind.make("codex"); - const crypto = yield* Crypto.Crypto; const runtimeEvents = yield* Queue.unbounded(); let sessionCount = 0; + let eventCount = 0; const sessions = new Map(); const queuedResponsesForNextSession: TestTurnResponse[] = []; const interruptCallsBySession = new Map>(); @@ -242,18 +241,10 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter >(); const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); - const randomUUIDv4 = (threadId: ThreadId) => - crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterValidationError({ - provider, - operation: "crypto/randomUUIDv4", - issue: `Failed to generate test runtime identifier for thread '${threadId}'.`, - cause, - }), - ), - ); + const nextEventId = (threadId: ThreadId) => { + eventCount += 1; + return EventId.make(`test-provider:${provider}:${threadId}:${eventCount}`); + }; const startSession: ProviderAdapterShape["startSession"] = (input) => Effect.gen(function* () { @@ -322,7 +313,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter for (const fixtureEvent of response.events) { const rawEvent: Record = { ...(fixtureEvent as Record), - eventId: yield* randomUUIDv4(input.threadId), + eventId: nextEventId(input.threadId), provider, sessionId: RuntimeSessionId.make(String(input.threadId)), }; @@ -379,7 +370,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter if (deferredTurnCompletedEvents.length === 0) { yield* emit({ type: "turn.completed", - eventId: EventId.make(yield* randomUUIDv4(input.threadId)), + eventId: nextEventId(input.threadId), provider, createdAt: nowIso(), threadId: state.snapshot.threadId, diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts new file mode 100644 index 00000000000..f773b5b8b84 --- /dev/null +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -0,0 +1,212 @@ +import type { ProviderDriverKind } from "@t3tools/contracts"; + +import type { + HttpTransferMeasurement, + WebSocketTransferTotals, +} from "./NetworkTransferMeasurement.integration.ts"; +import { + TRANSFER_HISTORY_MCP_RESULT_BYTES, + TRANSFER_HISTORY_TOOLS_PER_TURN, + TRANSFER_HISTORY_TURN_COUNT, + TRANSFER_MEASURED_MCP_RESULT_BYTES, + TRANSFER_MEASURED_TOOLS, +} from "./fixtures/transferBudget.ts"; + +export interface TransferBudgetRun { + readonly provider: ProviderDriverKind; + readonly threadSnapshot: HttpTransferMeasurement; + readonly measuredTurnWebSocket: WebSocketTransferTotals; +} + +interface ProviderTransferBudget { + readonly totalWireBytes: number; + readonly threadSnapshotWireBytes: number; + readonly measuredTurnWebSocketWireBytes: number; + readonly measuredTurnWebSocketDecodedBytes: number; + readonly measuredTurnWebSocketMessages: number; +} + +// These caps leave roughly 30% headroom above the client projection of the +// deterministic 9 MB retained-result fixture. Full MCP results stay in +// persistence, so accidentally shipping them again exceeds these caps by +// orders of magnitude. The CI report preserves exact values for review. +const TRANSFER_BUDGET = { + totalWireBytes: 15_500, + threadSnapshotWireBytes: 7_500, + measuredTurnWebSocketWireBytes: 8_000, + measuredTurnWebSocketDecodedBytes: 68_000, + measuredTurnWebSocketMessages: 21, +} satisfies ProviderTransferBudget; + +export const TRANSFER_BUDGETS: Readonly> = { + codex: TRANSFER_BUDGET, + claudeAgent: TRANSFER_BUDGET, +}; + +function totalWireBytes(run: TransferBudgetRun): number { + return run.threadSnapshot.wireBytes + run.measuredTurnWebSocket.wireBytes; +} + +function observedTransfer(run: TransferBudgetRun) { + return { + totalWireBytes: totalWireBytes(run), + threadSnapshotWireBytes: run.threadSnapshot.wireBytes, + threadSnapshotDecodedBytes: run.threadSnapshot.decodedBodyBytes, + measuredTurnWebSocketWireBytes: run.measuredTurnWebSocket.wireBytes, + measuredTurnWebSocketDecodedBytes: run.measuredTurnWebSocket.decodedBytes, + measuredTurnWebSocketMessages: run.measuredTurnWebSocket.messages, + }; +} + +/** Machine-readable input for the trusted PR comment publisher. */ +export function formatTransferBudgetResult(runs: ReadonlyArray): string { + const providers = Object.fromEntries( + runs.flatMap((run) => { + const ceiling = TRANSFER_BUDGETS[run.provider]; + return ceiling ? [[run.provider, { observed: observedTransfer(run), ceiling }]] : []; + }), + ); + + return `${JSON.stringify( + { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: TRANSFER_HISTORY_TURN_COUNT, + historyCommandToolsPerTurn: TRANSFER_HISTORY_TOOLS_PER_TURN, + historyMcpResultBytes: TRANSFER_HISTORY_MCP_RESULT_BYTES, + measuredCommandTools: TRANSFER_MEASURED_TOOLS, + measuredMcpResultBytes: TRANSFER_MEASURED_MCP_RESULT_BYTES, + }, + providers, + }, + null, + 2, + )}\n`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) { + return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB (${bytes.toLocaleString("en-US")} B)`; + } + return `${(bytes / 1_024).toFixed(1)} KiB (${bytes.toLocaleString("en-US")} B)`; +} + +function row( + provider: ProviderDriverKind, + phase: string, + metric: string, + observed: number, + maximum: number, + format: (value: number) => string = formatBytes, +): string { + const status = observed <= maximum ? "PASS" : "FAIL"; + return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | ${format(maximum)} | ${status} |`; +} + +export function transferBudgetViolations(runs: ReadonlyArray): string[] { + const violations: string[] = []; + for (const run of runs) { + const budget = TRANSFER_BUDGETS[run.provider]; + if (!budget) { + violations.push(`${run.provider}: no transfer budget is configured`); + continue; + } + const checks = [ + ["total thread wire bytes", totalWireBytes(run), budget.totalWireBytes], + ["thread snapshot wire bytes", run.threadSnapshot.wireBytes, budget.threadSnapshotWireBytes], + [ + "measured-turn WebSocket wire bytes", + run.measuredTurnWebSocket.wireBytes, + budget.measuredTurnWebSocketWireBytes, + ], + [ + "measured-turn WebSocket decoded bytes", + run.measuredTurnWebSocket.decodedBytes, + budget.measuredTurnWebSocketDecodedBytes, + ], + [ + "measured-turn WebSocket messages", + run.measuredTurnWebSocket.messages, + budget.measuredTurnWebSocketMessages, + ], + ] as const; + for (const [metric, observed, maximum] of checks) { + if (observed > maximum) { + violations.push(`${run.provider}: ${metric} was ${observed}, maximum ${maximum}`); + } + } + } + return violations; +} + +export function formatTransferBudgetReport(runs: ReadonlyArray): string { + const lines = [ + "# T3 Code thread transfer budget", + "", + "Wire values are thread data bytes read from local HTTP and WebSocket sockets. HTTP includes response headers; WebSocket measurement starts after the resumed thread subscription synchronizes. TCP/IP, TLS framing, and the WebSocket upgrade are excluded. WebSocket permessage-deflate is negotiated.", + `Scenario: ${TRANSFER_HISTORY_TURN_COUNT} historical turns with ${TRANSFER_HISTORY_TOOLS_PER_TURN} command tools and one retained ${formatBytes(TRANSFER_HISTORY_MCP_RESULT_BYTES)} MCP result each, followed by one measured turn with ${TRANSFER_MEASURED_TOOLS} command tools and a retained ${formatBytes(TRANSFER_MEASURED_MCP_RESULT_BYTES)} MCP result. Payload sizes are calibrated from heavy local Codex and Claude histories and contain no user data.`, + "", + "| Provider | Total thread wire | Budget | Result |", + "| --- | ---: | ---: | --- |", + ...runs.flatMap((run) => { + const budget = TRANSFER_BUDGETS[run.provider]; + if (!budget) return []; + const observed = observedTransfer(run).totalWireBytes; + return [ + `| ${run.provider} | ${formatBytes(observed)} | ${formatBytes(budget.totalWireBytes)} | ${observed <= budget.totalWireBytes ? "PASS" : "FAIL"} |`, + ]; + }), + "", + "## Detailed measurements", + "", + "| Provider | Phase | Metric | Observed | Budget | Result |", + "| --- | --- | --- | ---: | ---: | --- |", + ]; + + for (const run of runs) { + const budget = TRANSFER_BUDGETS[run.provider]; + if (!budget) continue; + lines.push( + row( + run.provider, + "thread snapshot", + "HTTP wire", + run.threadSnapshot.wireBytes, + budget.threadSnapshotWireBytes, + ), + row( + run.provider, + "measured turn", + "WebSocket wire", + run.measuredTurnWebSocket.wireBytes, + budget.measuredTurnWebSocketWireBytes, + ), + row( + run.provider, + "measured turn", + "WebSocket decoded", + run.measuredTurnWebSocket.decodedBytes, + budget.measuredTurnWebSocketDecodedBytes, + ), + row( + run.provider, + "measured turn", + "WebSocket messages", + run.measuredTurnWebSocket.messages, + budget.measuredTurnWebSocketMessages, + String, + ), + ); + } + + lines.push("", "## Compression diagnostics", ""); + for (const run of runs) { + lines.push( + `- ${run.provider}: thread snapshot ${formatBytes(run.threadSnapshot.decodedBodyBytes)} decoded to ${formatBytes(run.threadSnapshot.encodedBodyBytes)} gzip.`, + ); + } + + return `${lines.join("\n")}\n`; +} diff --git a/apps/server/integration/TransferBudgetScenario.integration.ts b/apps/server/integration/TransferBudgetScenario.integration.ts new file mode 100644 index 00000000000..77dfbc1dd7f --- /dev/null +++ b/apps/server/integration/TransferBudgetScenario.integration.ts @@ -0,0 +1,128 @@ +import { + CommandId, + defaultInstanceIdForDriver, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { TurnProcessingQuiescedReceipt } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; +import type { OrchestrationIntegrationHarness } from "./OrchestrationEngineHarness.integration.ts"; +import { + expectedRecordedAssistantText, + makeRecordedTransferTurn, + TRANSFER_HISTORY_TURN_COUNT, +} from "./fixtures/transferBudget.ts"; + +export const TRANSFER_PROJECT_ID = ProjectId.make("transfer-budget-project"); +export const TRANSFER_THREAD_ID = ThreadId.make("transfer-budget-thread"); +export const TRANSFER_MEASURED_TURN_INDEX = TRANSFER_HISTORY_TURN_COUNT; + +export function transferModelSelection(provider: ProviderDriverKind) { + return { + instanceId: defaultInstanceIdForDriver(provider), + model: DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL, + }; +} + +function turnTimestamp(turnIndex: number): string { + return `2026-06-01T00:${String(turnIndex).padStart(2, "0")}:00.000Z`; +} + +export const TRANSFER_MEASURED_TURN_CREATED_AT = turnTimestamp(TRANSFER_MEASURED_TURN_INDEX); + +const waitForTurnQuiesced = Effect.fn("TransferBudget.waitForTurnQuiesced")(function* ( + harness: OrchestrationIntegrationHarness, + checkpointTurnCount: number, +) { + const receipt = yield* harness.waitForReceipt( + (receipt): receipt is TurnProcessingQuiescedReceipt => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === TRANSFER_THREAD_ID && + receipt.checkpointTurnCount === checkpointTurnCount, + ); + yield* harness.drainProviderRuntime; + yield* harness.drainCheckpointReactor; + return receipt; +}); + +export const seedTransferBudgetHistory = Effect.fn("TransferBudget.seedHistory")(function* ( + harness: OrchestrationIntegrationHarness, + provider: ProviderDriverKind, +) { + if (!harness.adapterHarness) { + return yield* Effect.die(new Error("Transfer budget history requires the replay adapter.")); + } + + const modelSelection = transferModelSelection(provider); + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`transfer:${provider}:project-create`), + projectId: TRANSFER_PROJECT_ID, + title: "Transfer Budget Project", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: modelSelection, + createdAt: turnTimestamp(0), + }); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`transfer:${provider}:thread-create`), + threadId: TRANSFER_THREAD_ID, + projectId: TRANSFER_PROJECT_ID, + title: `${provider} transfer history`, + modelSelection, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "main", + worktreePath: harness.workspaceDir, + createdAt: turnTimestamp(0), + }); + + for (let turnIndex = 0; turnIndex < TRANSFER_HISTORY_TURN_COUNT; turnIndex += 1) { + const response = makeRecordedTransferTurn(provider, turnIndex); + if (turnIndex === 0) { + yield* harness.adapterHarness.queueTurnResponseForNextSession(response); + } else { + yield* harness.adapterHarness.queueTurnResponse(TRANSFER_THREAD_ID, response); + } + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`transfer:${provider}:turn:${turnIndex + 1}`), + threadId: TRANSFER_THREAD_ID, + message: { + messageId: MessageId.make(`transfer-user-${turnIndex + 1}`), + role: "user", + text: `Inspect transfer behavior for historical turn ${turnIndex + 1}.`, + attachments: [], + }, + modelSelection, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: turnTimestamp(turnIndex), + }); + yield* waitForTurnQuiesced(harness, turnIndex + 1); + } +}); + +export const queueMeasuredTransferTurn = Effect.fn("TransferBudget.queueMeasuredTurn")(function* ( + harness: OrchestrationIntegrationHarness, + provider: ProviderDriverKind, +) { + if (!harness.adapterHarness) { + return yield* Effect.die(new Error("Transfer budget measurement requires the replay adapter.")); + } + const response = makeRecordedTransferTurn(provider, TRANSFER_MEASURED_TURN_INDEX); + yield* harness.adapterHarness.queueTurnResponse(TRANSFER_THREAD_ID, response); +}); + +export function expectedMeasuredAssistantText(provider: ProviderDriverKind): string { + return expectedRecordedAssistantText(provider, TRANSFER_MEASURED_TURN_INDEX); +} + +export { TRANSFER_HISTORY_TURN_COUNT, waitForTurnQuiesced }; diff --git a/apps/server/integration/fixtures/transferBudget.ts b/apps/server/integration/fixtures/transferBudget.ts new file mode 100644 index 00000000000..d3567d386b9 --- /dev/null +++ b/apps/server/integration/fixtures/transferBudget.ts @@ -0,0 +1,372 @@ +import { EventId, ProviderDriverKind } from "@t3tools/contracts"; + +import type { + FixtureProviderRuntimeEvent, + TestTurnResponse, +} from "../TestProviderAdapter.integration.ts"; + +const FIXTURE_THREAD_ID = "transfer-budget-thread"; +const FIXTURE_TURN_ID = "transfer-budget-turn"; + +export const TRANSFER_HISTORY_TURN_COUNT = 10; +export const TRANSFER_HISTORY_TOOLS_PER_TURN = 5; +export const TRANSFER_MEASURED_TOOLS = 20; +export const TRANSFER_HISTORY_MCP_RESULT_BYTES = 900_000; +export const TRANSFER_MEASURED_MCP_RESULT_BYTES = 1_100_000; + +const sourceModules = [ + "connection/session.ts", + "connection/supervisor.ts", + "rpc/client.ts", + "rpc/protocol.ts", + "state/threads.ts", + "state/threadReducer.ts", + "state/threadSnapshotHttp.ts", + "orchestration/http.ts", + "orchestration/Normalizer.ts", + "orchestration/ActivityPayloadProjection.ts", + "provider/ProviderService.ts", + "provider/ProviderRuntimeIngestion.ts", + "persistence/ProjectionSnapshotQuery.ts", + "persistence/OrchestrationEventStore.ts", + "checkpointing/CheckpointStore.ts", + "checkpointing/CheckpointDiffQuery.ts", + "server.ts", +] as const; + +function fixtureTimestamp(turnIndex: number, eventIndex: number): string { + const minute = String(turnIndex).padStart(2, "0"); + const second = String(Math.floor(eventIndex / 1_000)).padStart(2, "0"); + const millisecond = String(eventIndex % 1_000).padStart(3, "0"); + return `2026-06-01T00:${minute}:${second}.${millisecond}Z`; +} + +function mix(value: number): number { + let mixed = value | 0; + mixed ^= mixed >>> 16; + mixed = Math.imul(mixed, 0x7feb352d); + mixed ^= mixed >>> 15; + mixed = Math.imul(mixed, 0x846ca68b); + mixed ^= mixed >>> 16; + return mixed >>> 0; +} + +function digest(seed: number): string { + return [0, 1, 2, 3] + .map((offset) => + mix(seed + offset * 0x9e3779b9) + .toString(16) + .padStart(8, "0"), + ) + .join(""); +} + +/** Produces safe, deterministic output with enough entropy to exercise gzip. */ +function diagnosticOutput(input: { + readonly provider: ProviderDriverKind; + readonly turnIndex: number; + readonly toolIndex: number; + readonly targetBytes: number; +}): string { + const chunks: string[] = []; + const providerSeed = input.provider === "codex" ? 0x43_4f_44_45 : 0x43_4c_41_55; + let length = 0; + let lineIndex = 0; + + while (length < input.targetBytes) { + const modulePath = sourceModules[(input.toolIndex + lineIndex) % sourceModules.length]; + const seed = + providerSeed + input.turnIndex * 100_003 + input.toolIndex * 10_007 + lineIndex * 101; + const line = + `${String(lineIndex + 1).padStart(6, "0")} ${modulePath} ` + + `operation=project-transfer-${input.turnIndex + 1}-${input.toolIndex + 1} ` + + `cursor=${mix(seed)} digest=${digest(seed)} status=completed\n`; + chunks.push(line); + length += line.length; + lineIndex += 1; + } + + return chunks.join("").slice(0, input.targetBytes); +} + +function assistantChunks(provider: ProviderDriverKind, turnIndex: number): ReadonlyArray { + const providerName = provider === "codex" ? "Codex" : "Claude"; + const paragraphs: string[] = [ + `I traced the ${providerName} request through the environment connection and orchestration layers. `, + ]; + let paragraphIndex = 0; + while (paragraphs.join("").length < 4_096) { + const modulePath = sourceModules[paragraphIndex % sourceModules.length]; + paragraphs.push( + `Pass ${paragraphIndex + 1} reviewed ${modulePath} for turn ${turnIndex + 1}. ` + + "The shell cursor stayed monotonic, the thread snapshot remained resumable, and the client received only incremental events. ", + ); + paragraphIndex += 1; + } + const text = paragraphs.join("").slice(0, 4_096); + return Array.from({ length: Math.ceil(text.length / 256) }, (_, index) => + text.slice(index * 256, (index + 1) * 256), + ); +} + +export function expectedRecordedAssistantText( + provider: ProviderDriverKind, + turnIndex: number, +): string { + return assistantChunks(provider, turnIndex).join(""); +} + +function unifiedDiff(provider: ProviderDriverKind, turnIndex: number): string { + const lines = sourceModules + .slice(0, 8) + .flatMap((modulePath, index) => [ + `diff --git a/${modulePath} b/${modulePath}`, + `--- a/${modulePath}`, + `+++ b/${modulePath}`, + `@@ -${index + 1},2 +${index + 1},3 @@`, + ` const provider = "${provider}";`, + `+const transferTurn = ${turnIndex + 1};`, + `+const transferSample = ${1_500 + index * 97};`, + ]); + return lines.join("\n"); +} + +function baseEvent( + provider: ProviderDriverKind, + turnIndex: number, + eventIndex: number, +): Pick { + return { + eventId: EventId.make(`recorded:${provider}:${turnIndex}:${eventIndex}`), + provider, + createdAt: fixtureTimestamp(turnIndex, eventIndex), + threadId: FIXTURE_THREAD_ID, + }; +} + +/** + * Synthetic canonical events calibrated from heavy local Codex and Claude + * threads. Ten historical turns produce 9 MB of retained MCP results without + * committing user content. Command output is intentionally modest because the + * client projection strips it. + */ +export function makeRecordedTransferTurn( + provider: ProviderDriverKind, + turnIndex: number, +): TestTurnResponse { + const measuredTurn = turnIndex >= TRANSFER_HISTORY_TURN_COUNT; + const toolCount = measuredTurn ? TRANSFER_MEASURED_TOOLS : TRANSFER_HISTORY_TOOLS_PER_TURN; + const turnId = `${FIXTURE_TURN_ID}-${turnIndex + 1}`; + const events: FixtureProviderRuntimeEvent[] = []; + let eventIndex = 0; + + events.push({ + type: "turn.started", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + payload: { + model: provider === "codex" ? "gpt-5.4" : "claude-opus-4-1", + effort: provider === "codex" ? "high" : "default", + }, + }); + + for (let toolIndex = 0; toolIndex < toolCount; toolIndex += 1) { + const itemId = `tool-${turnIndex + 1}-${toolIndex + 1}`; + const command = + provider === "codex" + ? `vp test transfer-budget-${toolIndex + 1}` + : `review transfer budget ${toolIndex + 1}`; + events.push( + { + type: "item.started", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId, + payload: { + itemType: "command_execution", + status: "inProgress", + title: `Inspect transfer path ${toolIndex + 1}`, + detail: "Inspecting the HTTP snapshot and WebSocket projection boundaries.", + data: { + threadId: FIXTURE_THREAD_ID, + turnId, + startedAtMs: turnIndex * 60_000 + eventIndex, + item: { + id: itemId, + type: "commandExecution", + command, + cwd: "/workspace/transfer-budget", + processId: String(toolIndex + 1), + status: "inProgress", + commandActions: [], + aggregatedOutput: "", + }, + }, + }, + }, + { + type: "item.completed", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId, + payload: { + itemType: "command_execution", + status: "completed", + title: `Inspected transfer path ${toolIndex + 1}`, + detail: "Collected a deterministic multi-module transfer diagnostic.", + data: { + threadId: FIXTURE_THREAD_ID, + turnId, + completedAtMs: turnIndex * 60_000 + eventIndex, + item: { + id: itemId, + type: "commandExecution", + command, + cwd: "/workspace/transfer-budget", + processId: String(toolIndex + 1), + status: "completed", + commandActions: [], + aggregatedOutput: diagnosticOutput({ + provider, + turnIndex, + toolIndex, + targetBytes: 1_000, + }), + exitCode: 0, + durationMs: 500 + toolIndex, + }, + }, + }, + }, + ); + } + + const mcpItemId = `mcp-${turnIndex + 1}`; + const mcpResultBytes = measuredTurn + ? TRANSFER_MEASURED_MCP_RESULT_BYTES + : TRANSFER_HISTORY_MCP_RESULT_BYTES; + events.push( + { + type: "item.started", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId: mcpItemId, + payload: { + itemType: "mcp_tool_call", + status: "inProgress", + title: "fixture-history · inspect_transfer_log", + detail: "Reading a retained diagnostic result from the provider history.", + data: { + startedAtMs: turnIndex * 60_000 + eventIndex, + threadId: FIXTURE_THREAD_ID, + turnId, + item: { + type: "mcpToolCall", + id: mcpItemId, + server: "fixture-history", + tool: "inspect_transfer_log", + arguments: { turn: turnIndex + 1 }, + status: "inProgress", + }, + }, + }, + }, + { + type: "item.completed", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId: mcpItemId, + payload: { + itemType: "mcp_tool_call", + status: "completed", + title: "fixture-history · inspect_transfer_log", + detail: "Retained a deterministic diagnostic result in the thread history.", + data: { + completedAtMs: turnIndex * 60_000 + eventIndex, + threadId: FIXTURE_THREAD_ID, + turnId, + item: { + type: "mcpToolCall", + id: mcpItemId, + server: "fixture-history", + tool: "inspect_transfer_log", + arguments: { turn: turnIndex + 1 }, + durationMs: 1_000 + turnIndex, + error: null, + result: { + content: [ + { + type: "text", + text: diagnosticOutput({ + provider, + turnIndex, + toolIndex: toolCount, + targetBytes: mcpResultBytes, + }), + }, + ], + }, + status: "completed", + }, + }, + }, + }, + ); + + const chunks = assistantChunks(provider, turnIndex); + for (const [contentIndex, delta] of chunks.entries()) { + events.push({ + type: "content.delta", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId: `assistant-${turnIndex + 1}`, + payload: { + streamKind: "assistant_text", + delta, + contentIndex, + }, + }); + } + + events.push( + { + type: "thread.token-usage.updated", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + payload: { + usage: { + usedTokens: 18_000 + turnIndex * 1_900, + maxTokens: 200_000, + inputTokens: 15_000 + turnIndex * 1_700, + cachedInputTokens: 9_000 + turnIndex * 1_100, + outputTokens: 3_000 + turnIndex * 200, + toolUses: toolCount, + durationMs: 4_000 + turnIndex * 250, + }, + }, + }, + { + type: "turn.diff.updated", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + payload: { + unifiedDiff: unifiedDiff(provider, turnIndex), + }, + }, + { + type: "turn.completed", + ...baseEvent(provider, turnIndex, eventIndex), + turnId, + payload: { + state: "completed", + stopReason: "end_turn", + usage: { + inputTokens: 15_000 + turnIndex * 1_700, + outputTokens: 3_000 + turnIndex * 200, + }, + }, + }, + ); + + return { events }; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a403e228b06..4ddb01e09dd 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,7 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, @@ -16,6 +16,8 @@ import { KeybindingRule, MessageId, ExternalLauncherCommandNotFoundError, + OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -41,6 +43,7 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -52,6 +55,8 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -70,11 +75,34 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationThreadDetailSnapshot), +); + +const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function* ( + queue: Queue.Queue, + predicate: (value: A) => boolean, + waitDescription: string, +) { + return yield* Effect.gen(function* () { + const values: A[] = []; + while (true) { + const value = yield* Queue.take(queue); + values.push(value); + if (predicate(value)) return values; + } + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)), + }), + ); +}); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { resolveAvailableEditorsForConfig } from "./ws.ts"; +import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -123,6 +151,32 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as Data from "effect/Data"; +import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; +import { + countingWsRpcProtocolLayer, + makeCountingWsRpcClient, + makeWebSocketTransferRecorder, + measureHttpGet, + transferDelta, +} from "../integration/NetworkTransferMeasurement.integration.ts"; +import { + expectedMeasuredAssistantText, + queueMeasuredTransferTurn, + seedTransferBudgetHistory, + TRANSFER_HISTORY_TURN_COUNT, + TRANSFER_MEASURED_TURN_CREATED_AT, + TRANSFER_MEASURED_TURN_INDEX, + TRANSFER_THREAD_ID, + transferModelSelection, + waitForTurnQuiesced, +} from "../integration/TransferBudgetScenario.integration.ts"; +import { + formatTransferBudgetReport, + formatTransferBudgetResult, + type TransferBudgetRun, + transferBudgetViolations, +} from "../integration/TransferBudgetReport.integration.ts"; + const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; @@ -549,9 +603,12 @@ const buildAppUnderTest = (options?: { ), ), ); + const serviceLauncherClientLayer = ServiceLauncherClient.layer.pipe( + Layer.provide(Layer.succeed(HostProcessEnvironment, {})), + ); const servedRoutesLayer = HttpRouter.serve( - makeRoutesLayer.pipe(Layer.provide(ServiceLauncherClient.layer)), + makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), { disableListenLog: true, disableLogger: true, @@ -1319,6 +1376,28 @@ const getWsServerUrl = ( ); }); +// Mirrors NodeHttpServer.layerTest, which does not expose server options, +// with the production `websocket: { perMessageDeflate: true }` setting. +const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( + Layer.provide( + Layer.fresh(FetchHttpClient.layer).pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), + ), + ), + Layer.provideMerge( + Layer.unwrap( + Effect.map( + Effect.promise(() => import("node:http")), + (NodeHttp) => + NodeHttpServer.layer(NodeHttp.createServer, { + port: 0, + websocket: { perMessageDeflate: true }, + }), + ), + ), + ), +); + it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("parks HTTP ingress until command readiness", () => Effect.gen(function* () { @@ -3219,28 +3298,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - // Mirrors NodeHttpServer.layerTest, which does not expose server options, - // with the production `websocket: { perMessageDeflate: true }` setting. - const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( - Layer.provide( - Layer.fresh(FetchHttpClient.layer).pipe( - Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), - ), - ), - Layer.provideMerge( - Layer.unwrap( - Effect.map( - Effect.promise(() => import("node:http")), - (NodeHttp) => - NodeHttpServer.layer(NodeHttp.createServer, { - port: 0, - websocket: { perMessageDeflate: true }, - }), - ), - ), - ), - ); - it.effect("negotiates permessage-deflate with clients that offer it", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -7839,3 +7896,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); }); + +it.live( + "reports thread HTTP and WebSocket transfer budgets", + () => + Effect.gen(function* () { + const providers = [ + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), + ] as const; + + const runs = yield* Effect.forEach( + providers, + (provider) => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ provider }), + (harness) => + Effect.gen(function* () { + yield* seedTransferBudgetHistory(harness, provider); + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: harness.engine, + projectionSnapshotQuery: harness.snapshotQuery, + }, + }); + + const baseUrl = yield* getHttpServerUrl(); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + + const recorder = makeWebSocketTransferRecorder(); + const wsUrl = baseUrl.replace(/^http:/, "ws:") + "/ws"; + const protocolLayer = countingWsRpcProtocolLayer({ + url: wsUrl, + cookie, + recorder, + }); + + return yield* Effect.scoped( + Effect.gen(function* () { + const client = yield* makeCountingWsRpcClient; + + const threadSnapshot = yield* measureHttpGet({ + url: `${baseUrl}/api/orchestration/threads/${TRANSFER_THREAD_ID}`, + headers: { cookie }, + }); + assert.equal(threadSnapshot.status, 200); + assert.equal(threadSnapshot.contentEncoding, "gzip"); + const decodedThread = yield* decodeTransferThreadSnapshot( + Buffer.from(threadSnapshot.decodedBody).toString("utf8"), + ); + assert.equal( + decodedThread.thread.messages.length, + TRANSFER_HISTORY_TURN_COUNT * 2, + ); + + const threadItems = yield* Queue.unbounded(); + yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: TRANSFER_THREAD_ID, + afterSequence: decodedThread.snapshotSequence, + requestCompletionMarker: true, + }).pipe( + Stream.runForEach((item) => + Queue.offer(threadItems, item).pipe(Effect.asVoid), + ), + Effect.forkScoped, + ); + const initialThreadItems = yield* collectQueueUntil( + threadItems, + (item) => item.kind === "synchronized", + `${provider} thread subscription to synchronize`, + ); + assert.isFalse(initialThreadItems.some((item) => item.kind === "snapshot")); + assert.include(recorder.negotiatedExtensions(), "permessage-deflate"); + + yield* queueMeasuredTransferTurn(harness, provider); + const turnStartTotals = recorder.totals(); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`transfer:${provider}:measured-turn`), + threadId: TRANSFER_THREAD_ID, + message: { + messageId: MessageId.make("transfer-user-measured"), + role: "user", + text: "Measure the client-bound transfer for this turn.", + attachments: [], + }, + modelSelection: transferModelSelection(provider), + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: TRANSFER_MEASURED_TURN_CREATED_AT, + }); + yield* waitForTurnQuiesced(harness, TRANSFER_MEASURED_TURN_INDEX + 1); + const finalThreadSequence = yield* harness.engine + .readEvents(decodedThread.snapshotSequence, 10_000) + .pipe( + Stream.runFold( + () => decodedThread.snapshotSequence, + (sequence, event) => + event.aggregateId === TRANSFER_THREAD_ID && isThreadDetailEvent(event) + ? Math.max(sequence, event.sequence) + : sequence, + ), + ); + assert.isAbove(finalThreadSequence, decodedThread.snapshotSequence); + + yield* collectQueueUntil( + threadItems, + (item) => + item.kind === "event" && item.event.sequence === finalThreadSequence, + `${provider} thread stream to reach sequence ${finalThreadSequence}`, + ); + const measuredTurnWebSocket = transferDelta(turnStartTotals, recorder.totals()); + + const finalThreadSnapshot = yield* harness.snapshotQuery + .getThreadDetailSnapshot(TRANSFER_THREAD_ID) + .pipe(Effect.map(Option.getOrThrow)); + const expectedAssistantText = expectedMeasuredAssistantText(provider); + const measuredAssistant = finalThreadSnapshot.thread.messages.find( + (message) => + message.role === "assistant" && message.text === expectedAssistantText, + ); + assert.isDefined(measuredAssistant); + assert.isTrue( + finalThreadSnapshot.thread.messages.length >= TRANSFER_HISTORY_TURN_COUNT * 2, + ); + assert.equal(measuredAssistant?.streaming, false); + assert.equal(finalThreadSnapshot.thread.session?.status, "ready"); + assert.equal( + finalThreadSnapshot.thread.checkpoints.length, + TRANSFER_HISTORY_TURN_COUNT + 1, + ); + + return { + provider, + threadSnapshot, + measuredTurnWebSocket, + } satisfies TransferBudgetRun; + }).pipe(Effect.provide(protocolLayer)), + ); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeHttpServerTestWithWsDeflate)), + { concurrency: 1 }, + ); + + const report = formatTransferBudgetReport(runs); + yield* Effect.logInfo(`\n${report}`); + const reportPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_REPORT_PATH").pipe( + Config.option, + ); + if (Option.isSome(reportPath)) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(reportPath.value, report); + } + const resultPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_RESULT_PATH").pipe( + Config.option, + ); + if (Option.isSome(resultPath)) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(resultPath.value, formatTransferBudgetResult(runs)); + } + assert.deepEqual(transferBudgetViolations(runs), []); + }).pipe(Effect.provide(NodeServices.layer)), + 120_000, +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6bafb9ec3ba..a6b155c296f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -268,7 +268,7 @@ function projectSetupScriptCompatibilityDetail( } } -function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< +export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { type: From 2288d416aa3bcb5f2eeb4004228872593296971e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:18:34 -0400 Subject: [PATCH 06/10] fix(web): stop the "requests are slow" warning from firing on every provider update (#5570) Co-authored-by: Claude Opus 5 (1M context) --- .../SlowRpcRequestToastCoordinator.tsx | 5 ++- apps/web/src/connection/platform.ts | 2 +- apps/web/src/rpc/requestLatencyState.test.ts | 27 ++++++++++++++ apps/web/src/rpc/requestLatencyState.ts | 37 +++++++++++++++---- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx index 07711ca84b7..f5391c13aba 100644 --- a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx +++ b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx @@ -5,7 +5,10 @@ import { toastManager } from "./ui/toast"; function describeSlowRequests(requests: ReadonlyArray): string { const count = requests.length; - const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); + // Thresholds vary per method, so report the smallest one the batch has passed. + const thresholdSeconds = Math.round( + Math.min(...requests.map((request) => request.thresholdMs)) / 1000, + ); return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142e..c7652136f54 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -590,7 +590,7 @@ const rpcRequestObserverLayer = Layer.succeed( Effect.sync(() => { nextObservedRpcRequestId += 1; const requestId = `${environmentId}:${nextObservedRpcRequestId}`; - trackRpcRequestSent(requestId, `${method} · ${environmentId}`); + trackRpcRequestSent(requestId, method, `${method} · ${environmentId}`); return Effect.sync(() => { acknowledgeRpcRequest(requestId); }); diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 504c93e1f78..e5b3144d252 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -6,6 +6,7 @@ import { getSlowRpcAckRequests, resetRequestLatencyStateForTests, trackRpcRequestSent, + LONG_RUNNING_RPC_ACK_THRESHOLD_MS, SLOW_RPC_ACK_THRESHOLD_MS, MAX_TRACKED_RPC_ACK_REQUESTS, } from "./requestLatencyState"; @@ -58,6 +59,32 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it("keeps ignoring untracked methods when a display tag is supplied", () => { + trackRpcRequestSent( + "1", + WS_METHODS.previewAutomationConnect, + `${WS_METHODS.previewAutomationConnect} · env-1`, + ); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }); + + it("gives provider updates a longer threshold before warning", () => { + trackRpcRequestSent("1", WS_METHODS.serverUpdateProvider, "server.updateProvider · env-1"); + vi.advanceTimersByTime(LONG_RUNNING_RPC_ACK_THRESHOLD_MS - 1); + expect(getSlowRpcAckRequests()).toEqual([]); + + vi.advanceTimersByTime(1); + expect(getSlowRpcAckRequests()).toMatchObject([ + { + requestId: "1", + tag: "server.updateProvider · env-1", + thresholdMs: LONG_RUNNING_RPC_ACK_THRESHOLD_MS, + }, + ]); + }); + it("evicts the oldest pending requests once the tracker reaches capacity", () => { for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { trackRpcRequestSent(String(index), "server.getConfig"); diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index c30ffc88279..4736d3783c3 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -5,6 +5,12 @@ import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; export const SLOW_RPC_ACK_THRESHOLD_MS = 15_000; +/** + * Some requests are slow by design — they shell out to a package manager on the + * server and only respond once the install finishes. Warning about those after + * 15s is noise, so they get a much longer leash. + */ +export const LONG_RUNNING_RPC_ACK_THRESHOLD_MS = 120_000; export const MAX_TRACKED_RPC_ACK_REQUESTS = 256; let slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS; @@ -22,7 +28,12 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map(); -const untrackedRpcAckTags = new Set([WS_METHODS.previewAutomationConnect]); +const untrackedRpcAckMethods = new Set([WS_METHODS.previewAutomationConnect]); +const longRunningRpcAckMethods = new Set([ + WS_METHODS.serverUpdateProvider, + WS_METHODS.serverRefreshProviders, + WS_METHODS.serverUpdateServer, +]); const slowRpcAckRequestsAtom = Atom.make>([]).pipe( Atom.keepAlive, @@ -37,16 +48,27 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { return appAtomRegistry.get(slowRpcAckRequestsAtom); } -function shouldTrackRpcAck(tag: string): boolean { - return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); +function shouldTrackRpcAck(method: string): boolean { + return !method.includes("subscribe") && !untrackedRpcAckMethods.has(method); +} + +function rpcAckThresholdMs(method: string): number { + return longRunningRpcAckMethods.has(method) + ? Math.max(slowRpcAckThresholdMs, LONG_RUNNING_RPC_ACK_THRESHOLD_MS) + : slowRpcAckThresholdMs; } export function getSlowRpcAckRequests(): ReadonlyArray { return getSlowRpcAckRequestsValue(); } -export function trackRpcRequestSent(requestId: string, tag: string): void { - if (!shouldTrackRpcAck(tag)) { +/** + * Starts the slow-request timer for one in-flight unary RPC. `method` is the + * bare WS method (used to decide whether and how long to wait); `tag` is the + * human-readable label shown in the toast, which defaults to the method. + */ +export function trackRpcRequestSent(requestId: string, method: string, tag = method): void { + if (!shouldTrackRpcAck(method)) { return; } @@ -54,17 +76,18 @@ export function trackRpcRequestSent(requestId: string, tag: string): void { evictOldestPendingRpcRequestIfNeeded(); const startedAtMs = Date.now(); + const thresholdMs = rpcAckThresholdMs(method); const request: SlowRpcAckRequest = { requestId, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, tag, - thresholdMs: slowRpcAckThresholdMs, + thresholdMs, }; const timeoutId = setTimeout(() => { pendingRpcAckRequests.delete(requestId); appendSlowRpcAckRequest(request); - }, slowRpcAckThresholdMs); + }, thresholdMs); pendingRpcAckRequests.set(requestId, { request, From cf5c9948c895e120965165b7b2b643ca275c5315 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:19:20 -0400 Subject: [PATCH 07/10] fix(web): keep agent panel rows stable (#5569) --- .../ProviderRuntimeIngestion.activity.test.ts | 84 +++++++ .../Layers/ProviderRuntimeIngestion.ts | 105 +++++--- apps/web/src/components/AgentsPanel.tsx | 229 +++++++++--------- .../src/state/subagentRuntime.test.ts | 71 ++++++ .../src/state/subagentRuntime.ts | 24 +- 5 files changed, 369 insertions(+), 144 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts new file mode 100644 index 00000000000..93604103864 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts @@ -0,0 +1,84 @@ +import { + EventId, + ProviderDriverKind, + RuntimeTaskId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts"; + +const base = { + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-08-06T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), +}; + +describe("runtimeEventToActivities task progress", () => { + it("persists usage independently from replaceable activity", () => { + const taskId = RuntimeTaskId.make("agent-1"); + const usageOnly = { + ...base, + type: "task.progress", + eventId: EventId.make("evt-usage"), + payload: { + taskId, + description: "Agent one", + typedUsage: { totalTokens: 73_700_000 }, + }, + } satisfies ProviderRuntimeEvent; + const command = { + ...base, + type: "task.progress", + eventId: EventId.make("evt-command"), + payload: { + taskId, + description: "Agent one", + summary: "Running tests", + lastToolName: "exec_command", + }, + } satisfies ProviderRuntimeEvent; + + const usageActivities = runtimeEventToActivities(usageOnly); + const commandActivities = runtimeEventToActivities(command); + + expect(usageActivities.map((activity) => activity.id)).toEqual(["task-usage:thread-1:agent-1"]); + expect(commandActivities.map((activity) => activity.id)).toEqual([ + "task-progress:thread-1:agent-1", + ]); + const usagePayload = usageActivities[0]?.payload as Record | undefined; + expect(usagePayload?.typedUsage).toEqual({ totalTokens: 73_700_000 }); + expect(usagePayload?.usageSnapshot).toBe(true); + }); + + it("splits combined progress and usage into their independent snapshots", () => { + const event = { + ...base, + type: "task.progress", + eventId: EventId.make("evt-combined"), + payload: { + taskId: RuntimeTaskId.make("agent-2"), + description: "Agent two", + summary: "Inspecting the panel", + typedUsage: { totalTokens: 4_200, toolUses: 7 }, + status: "running", + }, + } satisfies ProviderRuntimeEvent; + + const activities = runtimeEventToActivities(event); + const progressPayload = activities[0]?.payload as Record; + const usagePayload = activities[1]?.payload as Record; + + expect(activities.map((activity) => activity.id)).toEqual([ + "task-progress:thread-1:agent-2", + "task-usage:thread-1:agent-2", + ]); + expect(progressPayload.summary).toBe("Inspecting the panel"); + expect(progressPayload.status).toBe("running"); + expect(progressPayload).not.toHaveProperty("typedUsage"); + expect(usagePayload.typedUsage).toEqual({ totalTokens: 4_200, toolUses: 7 }); + expect(usagePayload.usageSnapshot).toBe(true); + expect(usagePayload).not.toHaveProperty("status"); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0420420939e..189dd696106 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -563,39 +563,80 @@ export function runtimeEventToActivities( } case "task.progress": { + const linkage = taskLinkageActivityFields(event.payload as Record); + // Usage and activity are independent latest-state streams. Keeping them + // under separate stable ids prevents a command/reasoning update from + // replacing the last known token count (and prevents a usage-only tick + // from blanking the last meaningful activity). + const identityLinkage = { ...linkage }; + delete identityLinkage.typedUsage; + delete identityLinkage.status; + delete identityLinkage.error; + const title = + event.payload.description.trim().length > 0 + ? { title: truncateDetail(event.payload.description, 120) } + : {}; + const hasProgressState = + event.payload.typedUsage === undefined || + event.payload.summary !== undefined || + event.payload.lastToolName !== undefined || + event.payload.status !== undefined || + event.payload.error !== undefined; return [ - { - // Stable per-task id: progress is "latest state", not history, so - // each tick REPLACES the last via the activity upsert (PK + the - // replace-by-id apply in projector and client reducer). Keeps one - // progress row per task instead of thousands, so a large fleet's - // ticks can no longer evict its own start/terminal rows out of - // the 500-row retention window. Thread-scoped: activity_id is a - // GLOBAL primary key and Claude task ids are session-local, so a - // bare taskId could collide across threads and steal another - // thread's row (review finding). - id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`), - createdAt: event.createdAt, - tone: "info", - kind: "task.progress", - summary: - event.payload.description.trim().length > 0 - ? truncateDetail(event.payload.description, 120) - : "Reasoning update", - payload: { - taskId: event.payload.taskId, - ...(event.payload.description.trim().length > 0 - ? { title: truncateDetail(event.payload.description, 120) } - : {}), - detail: truncateDetail(event.payload.summary ?? event.payload.description), - ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), - ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), - ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), - ...taskLinkageActivityFields(event.payload as Record), - }, - turnId: toTurnId(event.turnId) ?? null, - ...maybeSequence, - }, + ...(hasProgressState + ? [ + { + // Stable per-task id: activity is "latest state", not + // history, so each meaningful tick replaces the last. This + // bounds a large fleet to one activity row per task. + id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`), + createdAt: event.createdAt, + tone: "info" as const, + kind: "task.progress" as const, + summary: + event.payload.description.trim().length > 0 + ? truncateDetail(event.payload.description, 120) + : "Reasoning update", + payload: { + taskId: event.payload.taskId, + ...title, + detail: truncateDetail(event.payload.summary ?? event.payload.description), + ...(event.payload.summary + ? { summary: truncateDetail(event.payload.summary) } + : {}), + ...(event.payload.lastToolName + ? { lastToolName: event.payload.lastToolName } + : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), + ...(event.payload.error ? { error: event.payload.error } : {}), + ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...identityLinkage, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ] + : []), + ...(event.payload.typedUsage !== undefined + ? [ + { + id: EventId.make(`task-usage:${event.threadId}:${event.payload.taskId}`), + createdAt: event.createdAt, + tone: "info" as const, + kind: "task.progress" as const, + summary: "Task usage updated", + payload: { + taskId: event.payload.taskId, + ...title, + ...identityLinkage, + usageSnapshot: true, + typedUsage: event.payload.typedUsage, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ] + : []), ]; } diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 169c662e585..4eeff67ce5f 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -4,13 +4,11 @@ * spawn batch). * * Visualization rules (from live-test feedback): - * - Live work first: running workflows and direct spawns sort above settled. - * - Rows are flat status lines — no expansion, no per-agent tool feeds. The - * row answers "who / what phase / how much"; anything deeper is a future - * drill-in, not an unfold. - * - A settled workflow run collapses to a single summary line; click it to - * show its member list inline (the one allowed toggle — run granularity, - * not agent granularity). + * - Spawn order is stable. Activity and completion update rows in place. + * - Agent rows reserve three fixed lines for identity, activity, and metrics; + * changing data must never change their height. + * - Workflow expansion is presentation state. A live run stays expanded when + * it settles; older collapsed runs can still be opened at run granularity. * - Static status dots, DOM-write elapsed timers, plain token counters. */ import { useAtomValue } from "@effect/atom-react"; @@ -143,54 +141,50 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { const visuals = STATUS_VISUALS[agent.status]; const activity = agentActivityText(agent); const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); + const role = + agent.role?.trim().toLocaleLowerCase() === agent.title.trim().toLocaleLowerCase() + ? null + : agent.role; + const metadata = [ + modelLabel, + agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : "— tok", + agent.usage?.toolUses !== undefined ? `${agent.usage.toolUses} tools` : null, + agent.activationCount > 1 ? `run ${agent.activationCount}` : null, + ].filter((value): value is string => value !== null); return ( -
-
- - - - - - {agent.title} - {agent.role ? ( - - {agent.role} - - ) : null} - - - {agent.status === "completed" ? ( - - ) : null} - +
+ + + + + {agent.title} + {role ? ( + + {role} - {activity ? ( - - {activity} - + ) : null} + + + + + {agent.status === "completed" ? ( + ) : null} - - {modelLabel ? {modelLabel} : null} - {agent.usage ? ( - - {modelLabel ? "· " : ""} - {formatSubagentTokenCount(agent.usage.totalTokens)} tok - - ) : null} - {agent.usage?.toolUses !== undefined ? ( - · {agent.usage.toolUses} tools - ) : null} - {agent.activationCount > 1 ? · run {agent.activationCount} : null} - {visuals.label} - -
+
+ + {activity ?? visuals.label} + + + {metadata.join(" · ")} + + {visuals.label}
); } @@ -314,18 +308,32 @@ function WorkflowScriptView({ } /** - * Collapsible phase section (Claude Code Background-tasks pattern): live - * phases open by default, done phases collapsed to header + member dot row. - * User toggles override the default and stick for the phase's lifetime. + * Collapsible phase section. A phase opens when it becomes active, then keeps + * that shape as it settles so completion never yanks rows out from under the + * user. Manual toggles stick until a later activation begins. */ -function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { - const [userOpen, setUserOpen] = useState(null); - const open = userOpen ?? phase.state === "running"; +function PhaseSection({ + phase, + defaultOpen = false, +}: { + phase: AgentPanelWorkflowGroup["phases"][number]; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen || phase.state === "running"); + const previousState = useRef(phase.state); + + useEffect(() => { + if (previousState.current !== "running" && phase.state === "running") { + setOpen(true); + } + previousState.current = phase.state; + }, [phase.state]); + return (
{scriptOpen && canShowScript ? ( @@ -416,7 +436,7 @@ function LiveWorkflowSection({ /> ) : null} {group.phases.map((phase) => ( - + ))} {group.unphasedMembers.map((member) => ( @@ -429,11 +449,16 @@ function LiveWorkflowSection({ } /** - * Settled workflow: one summary line. Click toggles the member list — the - * only expansion in the panel, at run granularity. + * Collapsed workflow: one summary line. The parent owns expansion so a live + * workflow keeps its shape when it settles. */ -function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { - const [open, setOpen] = useState(false); +function CollapsedWorkflowSection({ + group, + onExpand, +}: { + group: AgentPanelWorkflowGroup; + onExpand: () => void; +}) { const members = workflowMembers(group); const failed = members.filter((member) => member.status === "failed").length; // Coordinator usage may already aggregate members (panel-footer rule): @@ -450,9 +475,9 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
- {open ? ( -
- {members.map((member) => ( - - ))} -
- ) : null}
); } +/** A workflow's open state is presentation state, not a status derivative. */ +function WorkflowSection({ + group, + environmentId, + threadId, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const [open, setOpen] = useState(() => workflowIsLive(group)); + return open ? ( + setOpen(false)} + /> + ) : ( + setOpen(true)} /> + ); +} + export function AgentsPanel({ model, environmentId = null, @@ -503,48 +540,24 @@ export function AgentsPanel({ ); } - const liveWorkflows = model.workflows.filter(workflowIsLive); - const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group)); - const liveDirect = model.directAgents.filter( - (agent) => - agent.status === "running" || agent.status === "pending" || agent.status === "waiting", - ); - const settledDirect = model.directAgents.filter( - (agent) => - agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting", - ); - return (
- {liveWorkflows.map((group) => ( - ( + ))} - {liveDirect.length > 0 ? ( + {model.directAgents.length > 0 ? (
Direct spawns
- {liveDirect.map((agent) => ( - - ))} -
- ) : null} - {settledWorkflows.length > 0 || settledDirect.length > 0 ? ( -
-
- Earlier -
- {settledWorkflows.map((group) => ( - - ))} - {settledDirect.map((agent) => ( + {model.directAgents.map((agent) => ( ))}
diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index c6c758511a3..ceb40517550 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -186,6 +186,34 @@ describe("foldSubagentActivities", () => { expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 }); }); + it("usage snapshots enrich an existing agent without changing its status", () => { + const [agent] = fold([ + activity("task.started", { taskId: "usage-waiting", taskType: "local_agent" }), + activity("task.progress", { taskId: "usage-waiting", status: "waiting" }), + activity("task.progress", { + taskId: "usage-waiting", + usageSnapshot: true, + typedUsage: { totalTokens: 1_200 }, + }), + ]); + + expect(agent?.status).toBe("waiting"); + expect(agent?.usage?.totalTokens).toBe(1_200); + }); + + it("a retained usage snapshot can still reconstruct a running agent", () => { + const [agent] = fold([ + activity("task.progress", { + taskId: "usage-only", + usageSnapshot: true, + typedUsage: { totalTokens: 800 }, + }), + ]); + + expect(agent?.status).toBe("running"); + expect(agent?.usage?.totalTokens).toBe(800); + }); + it("partial terminal usage preserves known breakdown fields", () => { const agents = fold([ activity("task.started", { taskId: "task-6", taskType: "local_agent" }), @@ -362,6 +390,49 @@ describe("deriveAgentPanelModel", () => { ); }); + it("keeps direct spawns in first-seen order as their activity changes", () => { + const directRoster = fold([ + activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), + activity("task.started", { taskId: "direct-b", title: "Second" }, "2026-08-01T11:00:01.000Z"), + activity( + "task.progress", + { taskId: "direct-a", summary: "Newest activity" }, + "2026-08-01T11:00:02.000Z", + ), + ]); + + expect( + deriveAgentPanelModel({ agents: directRoster }).directAgents.map((agent) => agent.id), + ).toEqual(["direct-a", "direct-b"]); + }); + + it("keeps first-seen order after the roster retention ranking runs", () => { + const starts = Array.from({ length: 101 }, (_, index) => + activity( + "task.started", + { taskId: `capped-${index}`, title: `Agent ${index}` }, + `2026-08-01T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String( + index % 60, + ).padStart(2, "0")}.000Z`, + ), + ); + const cappedRoster = fold([ + ...starts, + activity( + "task.progress", + { taskId: "capped-0", summary: "Newest activity" }, + "2026-08-01T12:02:00.000Z", + ), + ]); + + const ids = deriveAgentPanelModel({ agents: cappedRoster }).directAgents.map( + (agent) => agent.id, + ); + expect(ids).toHaveLength(100); + expect(ids.slice(0, 3)).toEqual(["capped-0", "capped-2", "capped-3"]); + expect(ids.at(-1)).toBe("capped-100"); + }); + it("a phase with only pending members never reads as running", () => { const pendingRoster = fold([ activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index c81dd634140..e5f2b586b8c 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -80,6 +80,8 @@ export interface RuntimeSubagent { readonly phases: ReadonlyArray; readonly runHandles: SubagentRunHandles | null; readonly recentActivity: ReadonlyArray; + /** First retained observation, used as the roster's stable display order. */ + readonly firstSeenAt: string; readonly startedAt: string | null; readonly completedAt: string | null; readonly updatedAt: string; @@ -247,6 +249,7 @@ interface MutableAgent { phases: ReadonlyArray; runHandles: SubagentRunHandles | null; recentActivity: ReadonlyArray; + firstSeenAt: string; startedAt: string | null; completedAt: string | null; updatedAt: string; @@ -300,6 +303,7 @@ function getOrCreate( phases: [], runHandles: null, recentActivity: [], + firstSeenAt: at, startedAt: null, completedAt: null, updatedAt: at, @@ -500,14 +504,19 @@ export function foldSubagentActivities( // Membership is sticky per taskId: rows after the first (terminal // rows often carry only taskId+status, no marker fields) inherit the // first row's classification instead of being re-judged. - if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break; + const existed = agents.has(taskId); + if (!existed && isBackgroundTaskActivity(payload)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); if (agent.activationCount === 0) agent.activationCount = 1; const explicitStatus = asRuntimeStatus(payload.status); if (explicitStatus) { applyStatus(agent, explicitStatus, at); - } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") { + } else if ( + (payload.usageSnapshot !== true || !existed) && + !isTerminalSubagentStatus(agent.status) && + agent.status !== "idle" + ) { applyStatus(agent, "running", at); } const summary = asString(payload.summary); @@ -726,7 +735,10 @@ export function deriveAgentPanelModel({ return EMPTY_PANEL_MODEL; } - const workflows = source.filter((agent) => agent.kind === "workflow"); + const workflows = source + .filter((agent) => agent.kind === "workflow") + .slice() + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)); const workflowIds = new Set(workflows.map((workflow) => workflow.id)); const members = new Map(); const direct: RuntimeSubagent[] = []; @@ -827,7 +839,11 @@ export function deriveAgentPanelModel({ return { workflows: workflowGroups, - directAgents: direct.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), + // Updates and the >100-agent retention ranking must never reshuffle rows + // that remain visible. + directAgents: direct + .slice() + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)), runningCount, waitingCount, idleCount, From 4a07c1ca9d9033ef35161a1ffbea44d0418ca2bf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:53:19 -0400 Subject: [PATCH 08/10] docs: ship production T3 Connect public config in .env.example (#5573) Co-authored-by: Claude Fable 5 --- .env.example | 21 ++++++++++++--------- docs/internals/t3-connect.md | 12 ++++++++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 61cdd66d246..fc67dcef947 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ # Optional: T3 Connect source builds -# Leave these unset to disable optional T3 Connect features in local source builds. -# Release builds inject their public values at build time. Do not add server-side -# secrets to this file. +# `cp .env.example .env` enables T3 Connect against the production deployment. +# These are the same public identifiers baked into official release builds, not +# secrets. Remove or comment them out to build with cloud features disabled. +# Do not add server-side secrets to this file. -# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. -# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... -# T3CODE_CLERK_JWT_TEMPLATE=t3-relay -# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Production Clerk instance. To use your own, get these from the Clerk Dashboard +# under API keys, JWT templates, and OAuth applications. +T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk +T3CODE_CLERK_JWT_TEMPLATE=t3-relay +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API # hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk @@ -15,8 +17,9 @@ # T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile # T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -# Get this from your relay deployment. `infra/relay` deploys update it automatically. -# T3CODE_RELAY_URL=https://relay.example.com +# Production relay. For a self-hosted relay, `infra/relay` deploys update it +# automatically. +T3CODE_RELAY_URL=https://relay.t3.codes # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index c8a0217919f..c734f0f9dd7 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -14,8 +14,16 @@ For the wider system diagram, see ## Application Keys -T3 Connect is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env` -or `.env.local` file: +T3 Connect is disabled in a fresh clone. To enable it for source builds against the production +deployment, copy the repository-root example file: + +```sh +cp .env.example .env +``` + +`.env.example` carries the production public identifiers (the same values baked into official +release builds). To target a different Clerk application or relay, set the values yourself in a +repository-root `.env` or `.env.local` file: ```dotenv T3CODE_CLERK_PUBLISHABLE_KEY= From a8cd2ad2ebb32ad789e8e0ecd2fc713c2edc38f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:16:14 -0400 Subject: [PATCH 09/10] fix(web): plans stop hijacking the UI, fold into chat instead (#5558) Co-authored-by: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 - .../OrchestrationEngineHarness.integration.ts | 6 +- .../Layers/CheckpointReactor.test.ts | 3 + .../Layers/OrchestrationEngine.test.ts | 5 + .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 5 + .../Layers/ProviderCommandReactor.test.ts | 3 + .../Layers/ProviderRuntimeIngestion.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.ts | 17 ++ .../orchestration/ThreadPlanProgress.test.ts | 44 +++ .../src/orchestration/ThreadPlanProgress.ts | 76 +++++ apps/server/src/orchestration/runtimeLayer.ts | 13 +- apps/web/src/components/ChatView.tsx | 207 +++---------- apps/web/src/components/PlanSidebar.tsx | 284 ------------------ apps/web/src/components/RightPanelTabs.tsx | 6 +- apps/web/src/components/SidebarV2.tsx | 13 +- apps/web/src/components/chat/ChatComposer.tsx | 58 ---- .../chat/CompactComposerControlsMenu.tsx | 18 +- .../components/chat/MessagesTimeline.logic.ts | 24 ++ .../src/components/chat/MessagesTimeline.tsx | 119 +++++++- .../components/settings/SettingsPanels.tsx | 31 -- .../src/components/settings/settingsSearch.ts | 5 - apps/web/src/planSidebarDismissal.ts | 21 -- apps/web/src/rightPanelStore.test.ts | 59 +++- apps/web/src/rightPanelStore.ts | 26 +- apps/web/src/session-logic.test.ts | 188 ++++++------ apps/web/src/session-logic.ts | 131 +++++--- packages/contracts/src/orchestration.ts | 14 + packages/contracts/src/settings.ts | 2 - 30 files changed, 619 insertions(+), 768 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadPlanProgress.test.ts create mode 100644 apps/server/src/orchestration/ThreadPlanProgress.ts delete mode 100644 apps/web/src/components/PlanSidebar.tsx delete mode 100644 apps/web/src/planSidebarDismissal.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f2191..26c3127de5f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d192cbeac8e..71ef59a0910 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; @@ -310,7 +311,10 @@ export const makeOrchestrationIntegrationHarness = ( checkpointStoreLayer, providerLayer, RuntimeReceiptBusTest, - ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); + ).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), + ); const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ddb525cd547..08ea1437bb2 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -296,6 +297,7 @@ describe("CheckpointReactor", () => { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -304,6 +306,7 @@ describe("CheckpointReactor", () => { ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 857f5b887fc..19290d6ec40 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -32,6 +32,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline, @@ -57,6 +58,7 @@ async function createOrchestrationSystem() { OrchestrationProjectionSnapshotQueryLive, ).pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), @@ -820,6 +822,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -926,6 +929,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -1070,6 +1074,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 09d7573f5d8..8e65295b1ba 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -32,6 +32,7 @@ import { } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; @@ -2673,6 +2674,7 @@ const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 92c87ebdc04..d5dda7aa86b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; @@ -30,6 +31,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -447,6 +449,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { hasPendingUserInput: false, hasActionableProposedPlan: false, backgroundLiveness: null, + planProgress: null, }, ]); @@ -1830,6 +1833,7 @@ it.effect( const resolveCalls: string[] = []; const layer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (cwd: string) => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f036198fe49..9633f162d2b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -44,6 +44,7 @@ import { } from "../../persistence/Errors.ts"; import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; +import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; @@ -344,6 +345,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; + const threadPlanProgress = yield* ThreadPlanProgressService; const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; @@ -1908,6 +1910,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2051,6 +2054,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", @@ -2326,6 +2330,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( threadRow.value.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), } satisfies OrchestrationThreadShell); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f355bfc45ae..2b4d3771605 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { providerErrorLabel, providerErrorLabelFromInstanceHint, @@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => { ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 31e30c4d1ad..dfc47320768 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,6 +45,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => { // Single shared liveness instance across ingestion (writer), the // engine, and the snapshot query (reader). Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 189dd696106..a86adea5232 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -35,6 +35,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; +import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionService, @@ -867,6 +868,7 @@ export function runtimeEventToActivities( const make = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; + const threadPlanProgress = yield* ThreadPlanProgressService; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -1935,6 +1937,21 @@ const make = Effect.gen(function* () { yield* rememberTaskDescription(thread.id, event.payload.taskId, description); } } + // Working-indicator plan progress: current step while the turn runs, + // cleared on settle so a finished plan never lingers as stale UI. + // Events carrying a turn id that conflicts with the active turn are + // stale (superseded turn) and must neither overwrite nor clear the + // active turn's progress; session.exited always clears. + if (event.type === "session.exited") { + threadPlanProgress.clearThreadPlanProgress(thread.id); + } else if (!conflictsWithActiveTurn) { + if (event.type === "turn.plan.updated") { + threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan); + } else if (event.type === "turn.completed" || event.type === "turn.aborted") { + threadPlanProgress.clearThreadPlanProgress(thread.id); + } + } + // Sidebar background liveness: fed from the same lifecycle stream, // read by the shell query at mapping time (no persistence). switch (event.type) { diff --git a/apps/server/src/orchestration/ThreadPlanProgress.test.ts b/apps/server/src/orchestration/ThreadPlanProgress.test.ts new file mode 100644 index 00000000000..99545327672 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPlanProgress.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; + +describe("ThreadPlanProgress", () => { + it("tracks the in-progress step and clears when the plan completes", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-1"; + progress.recordPlanProgress(threadId, [ + { step: "Audit failure paths", status: "completed" }, + { step: "Implement the fix", status: "inProgress" }, + { step: "Run targeted tests", status: "pending" }, + ]); + expect(progress.getThreadPlanProgress(threadId)).toEqual({ + step: "Implement the fix", + completedSteps: 1, + totalSteps: 3, + }); + + progress.recordPlanProgress(threadId, [ + { step: "Audit failure paths", status: "completed" }, + { step: "Implement the fix", status: "completed" }, + { step: "Run targeted tests", status: "completed" }, + ]); + expect(progress.getThreadPlanProgress(threadId)).toBeNull(); + }); + + it("falls back to the first non-completed step when nothing is in progress", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-2"; + progress.recordPlanProgress(threadId, [ + { step: "First", status: "pending" }, + { step: "Second", status: "pending" }, + ]); + expect(progress.getThreadPlanProgress(threadId)?.step).toBe("First"); + }); + + it("clearThreadPlanProgress removes the entry (turn settled / session died)", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-3"; + progress.recordPlanProgress(threadId, [{ step: "Only step", status: "inProgress" }]); + progress.clearThreadPlanProgress(threadId); + expect(progress.getThreadPlanProgress(threadId)).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/ThreadPlanProgress.ts b/apps/server/src/orchestration/ThreadPlanProgress.ts new file mode 100644 index 00000000000..1c638bf89a6 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPlanProgress.ts @@ -0,0 +1,76 @@ +/** + * ThreadPlanProgressService - in-memory per-thread plan progress for the + * Working indicators (sidebar rows, in-chat working line). + * + * Plans are a progress annotation, not a surface of their own: the useful + * kernel of a turn.plan.updated event is "which step is the agent on right + * now". Ingestion records the current step here and the shell query reads it + * at mapping time — no persistence, no migration (same pattern as + * ThreadBackgroundLivenessService). Cleared when the turn settles or the + * session dies, so a finished plan never lingers as stale UI. + * + * @module ThreadPlanProgressService + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export interface ThreadPlanProgress { + readonly step: string; + readonly completedSteps: number; + readonly totalSteps: number; +} + +interface PlanStepInput { + readonly step: string; + readonly status: string; +} + +export class ThreadPlanProgressService extends Context.Service< + ThreadPlanProgressService, + { + /** + * Feed one turn.plan.updated payload. An all-completed plan clears the + * entry (the turn is wrapping up; nothing is "in progress" anymore). + */ + readonly recordPlanProgress: (threadId: string, plan: ReadonlyArray) => void; + + /** Turn settled or session died: the working indicator reverts to plain. */ + readonly clearThreadPlanProgress: (threadId: string) => void; + + readonly getThreadPlanProgress: (threadId: string) => ThreadPlanProgress | null; + } +>()("t3/orchestration/ThreadPlanProgress/ThreadPlanProgressService") {} + +export function make(): ThreadPlanProgressService["Service"] { + const progressByThreadId = new Map(); + + return { + recordPlanProgress: (threadId, plan) => { + const totalSteps = plan.length; + const completedSteps = plan.filter((step) => step.status === "completed").length; + // Current step: the in-progress one, else the first pending one (a + // plan that was just written has no in-progress step yet). + const current = + plan.find((step) => step.status === "inProgress") ?? + plan.find((step) => step.status !== "completed"); + if (totalSteps === 0 || completedSteps === totalSteps || current === undefined) { + progressByThreadId.delete(threadId); + return; + } + progressByThreadId.set(threadId, { + step: current.step, + completedSteps, + totalSteps, + }); + }, + + clearThreadPlanProgress: (threadId) => { + progressByThreadId.delete(threadId); + }, + + getThreadPlanProgress: (threadId) => progressByThreadId.get(threadId) ?? null, + }; +} + +export const layer = Layer.effect(ThreadPlanProgressService, Effect.sync(make)); diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index 0bc624ec365..779042e2f68 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -6,6 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, @@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, - // Shared background-liveness registry: written by runtime ingestion, - // read by the snapshot query. provideMerge feeds the same instance to - // the snapshot query here and re-exports it for runtime ingestion. -).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); + // Shared background-liveness and plan-progress registries: written by + // runtime ingestion, read by the snapshot query. provideMerge feeds the + // same instance to the snapshot query here and re-exports it for runtime + // ingestion. +).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), +); export const OrchestrationLayerLive = Layer.mergeAll( OrchestrationInfrastructureLayerLive, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c416b8f88a..a4c9bcc7c54 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,7 +85,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - findSidebarProposedPlan, + deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { - clearPlanSidebarDismissal, - dismissPlanSidebarForTurn, - isPlanSidebarDismissedForTurn, -} from "../planSidebarDismissal"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanel, @@ -157,7 +152,6 @@ import { import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -234,7 +228,6 @@ import { useProject, useProjects, useThread, - useThreadProposedPlans, useThreadRefs, useThreadShell, } from "../state/entities"; @@ -451,8 +444,6 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; -type ThreadPlanCatalogEntry = Pick; - function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { @@ -1271,7 +1262,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -1341,10 +1331,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); + const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1577,10 +1564,10 @@ function ChatViewContent(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1607,36 +1594,11 @@ function ChatViewContent(props: ChatViewProps) { previewPanelOpen, ]); - const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const sourcePlanThreadRef = useMemo(() => { - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { - return null; - } - return scopeThreadRef(activeThread.environmentId, sourceThreadId); - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); - const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); - const threadPlanCatalog = useMemo(() => { - if (!activeThread) { - return []; - } - const entries: ThreadPlanCatalogEntry[] = [ - { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, - ]; - if (sourcePlanThreadRef) { - entries.push({ - id: sourcePlanThreadRef.threadId, - proposedPlans: sourceThreadProposedPlans, - }); - } - return entries; - }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -2086,6 +2048,7 @@ function ChatViewContent(props: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). @@ -2148,21 +2111,25 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const sidebarProposedPlan = useMemo( - () => - findSidebarProposedPlan({ - threads: threadPlanCatalog, - latestTurn: activeLatestTurn, - latestTurnSettled, - threadId: activeThread?.id ?? null, - }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], - ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; + // Current step for the in-chat working row: only for the running turn's own + // plan (deriveActivePlanState falls back to older turns' plans, which must + // not label fresh work). Falls back to the first pending step so an + // all-pending freshly written plan labels the row, matching the chip and + // the server's planProgress. + const workingStepLabel = useMemo(() => { + if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { + return null; + } + return ( + activePlan.steps.find((step) => step.status === "inProgress")?.step ?? + activePlan.steps.find((step) => step.status === "pending")?.step ?? + null + ); + }, [activeLatestTurn?.turnId, activePlan]); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -2431,8 +2398,13 @@ function ChatViewContent(props: ChatViewProps) { }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + turnPlans, + ), + [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -3142,47 +3114,15 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const dismissPlanSidebarForCurrentTurn = useCallback(() => { - if (!activeThreadKey) return; - dismissPlanSidebarForTurn( - activeThreadKey, - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__", - ); - }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]); - const togglePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } else if (activeThreadKey) { - clearPlanSidebarDismissal(activeThreadKey); - } - useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); - const closePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); - dismissPlanSidebarForCurrentTurn(); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { if (!activeThreadRef) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [ - activeThreadRef, - dismissPlanSidebarForCurrentTurn, - isGitRepo, - isServerThread, - onDiffPanelOpen, - planSidebarOpen, - ]); + }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -3318,11 +3258,6 @@ function ChatViewContent(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - if (surface.kind === "plan") { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - } else if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -3334,20 +3269,16 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], + [activeThreadRef, diffOpen, onDiffPanelOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { - if (planSidebarOpen) { - closePlanSidebar(); - } else { - closePreviewPanel(); - } + closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); + }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -3357,10 +3288,6 @@ function ChatViewContent(props: ChatViewProps) { const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - if (surfaces.some((surface) => surface.kind === "plan")) { - dismissPlanSidebarForCurrentTurn(); - } - for (const surface of surfaces) { if (surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ @@ -3386,7 +3313,6 @@ function ChatViewContent(props: ChatViewProps) { activePreviewState.sessions, closePreview, closeTerminalMutation, - dismissPlanSidebarForCurrentTurn, storeCloseTerminal, ], ); @@ -3976,37 +3902,9 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - if (planSidebarOpenOnNextThreadRef.current) { - planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); - // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. - // Don't auto-open for plans carried over from a previous turn (the user can open manually). - useEffect(() => { - if (!autoOpenPlanSidebar) return; - if (!activePlan) return; - if (planSidebarOpen) return; - const latestTurnId = activeLatestTurn?.turnId ?? null; - if (latestTurnId && activePlan.turnId !== latestTurnId) return; - const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - if (!activeThreadRef) return; - if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return; - useRightPanelStore.getState().open(activeThreadRef, "plan"); - }, [ - activePlan, - activeLatestTurn?.turnId, - activeThreadRef, - autoOpenPlanSidebar, - planSidebarOpen, - sidebarProposedPlan?.turnId, - ]); - useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -5556,15 +5454,6 @@ function ChatViewContent(props: ChatViewProps) { if (failure === null) { acknowledgeActiveThreadWoke(); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default" && autoOpenPlanSidebar) { - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } sendInFlightRef.current = false; return; } @@ -5597,7 +5486,6 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ], @@ -5700,8 +5588,6 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { - // Signal that the plan sidebar should open on the new thread when enabled. - planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", @@ -5758,7 +5644,6 @@ function ChatViewContent(props: ChatViewProps) { resetLocalDispatch, runtimeMode, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ]); @@ -5950,12 +5835,12 @@ function ChatViewContent(props: ChatViewProps) {
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( + {rightPanelOpen && !shouldUseRightPanelSheet ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( - ) : activeRightPanelSurface?.kind === "agents" ? ( - {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
- {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - label?: string; - environmentId: EnvironmentId; - threadRef?: ScopedThreadRef | undefined; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar" | "embedded"; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - label = "Plan", - environmentId, - threadRef, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { - reportFailure: false, - }); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - if (!workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void (async () => { - const result = await writeProjectFile({ - environmentId, - input: { - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }, - }); - setIsSavingToWorkspace(false); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.value.relativePath, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); - - return ( -
- {/* Header */} -
-
- - {label} - - {activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null} -
-
- {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} -
-
- - {/* Content */} - -
- {/* Explanation */} - {activePlan?.explanation ? ( -

- {activePlan.explanation} -

- ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} -

-
- ))} -
- ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
- - {proposedPlanExpanded ? ( -
- -
- ) : null} -
- ) : null} - - {/* Empty state */} - {!activePlan && !planMarkdown ? ( -
-

No active plan yet.

-

- Plans will appear here when generated. -

-
- ) : null} -
-
-
- ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b0f18f6e126..b9345ab8c3c 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,6 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -213,8 +213,6 @@ function surfaceTitle( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); - case "plan": - return "Plan"; case "agents": return "Agents"; case "preview": { @@ -276,8 +274,6 @@ function SurfaceIcon({ ); case "terminal": return ; - case "plan": - return ; case "agents": return ; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 003bec64d0f..f918480e1a5 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1098,7 +1098,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
- {thread.branch ? ( + {/* While working, the current plan step outranks the branch: + it's the one line that says what the thread is doing. */} + {status === "working" && thread.planProgress ? ( + + {thread.planProgress.step} + {/* Completed count, matching the transcript chip's n/m. */} + + {" "} + {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} + + + ) : thread.branch ? ( {thread.branch} ) : ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f6d34315dac..6f3a6ec22cd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -10,7 +10,6 @@ import type { ScopedThreadRef, ServerProvider, ThreadId, - TurnId, } from "@t3tools/contracts"; import { ProviderDriverKind, @@ -195,7 +194,6 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, - ListTodoIcon, PencilRulerIcon, type LucideIcon, LockIcon, @@ -300,12 +298,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; - showPlanToggle: boolean; - planSidebarLabel: string; - planSidebarOpen: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; - onTogglePlanSidebar: () => void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -313,9 +307,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode"; - const planSidebarTooltip = props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; const interactionModeToggle = props.showInteractionModeToggle ? ( <> @@ -391,36 +382,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {interactionModeToggle} - - {props.showPlanToggle ? ( - <> - - - - } - > - - {props.planSidebarLabel} - - {planSidebarTooltip} - - - ) : null} ); }); @@ -576,10 +537,6 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; - activePlan: { turnId?: TurnId } | null; - sidebarProposedPlan: { turnId?: TurnId } | null; - planSidebarLabel: string; - planSidebarOpen: boolean; // Mode runtimeMode: RuntimeMode; @@ -632,7 +589,6 @@ export interface ChatComposerProps { toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; - togglePlanSidebar: () => void; focusComposer: () => void; scheduleComposerFocus: () => void; @@ -675,10 +631,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) respondingRequestIds, showPlanFollowUpPrompt, activeProposedPlan, - activePlan, - sidebarProposedPlan, - planSidebarLabel, - planSidebarOpen, runtimeMode, interactionMode, lockedProvider, @@ -709,7 +661,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode, handleRuntimeModeChange, handleInteractionModeChange, - togglePlanSidebar, focusComposer, scheduleComposerFocus, setThreadError, @@ -1180,7 +1131,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; @@ -3187,15 +3137,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {isComposerFooterCompact ? ( ) : ( @@ -3210,12 +3156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} interactionMode={interactionMode} runtimeMode={runtimeMode} - showPlanToggle={showPlanSidebarToggle} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} - onTogglePlanSidebar={togglePlanSidebar} /> )} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index b808f562920..20b57dea8c3 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,10 +1,9 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { EllipsisIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, - MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -13,15 +12,11 @@ import { } from "../ui/menu"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { - activePlan: boolean; interactionMode: ProviderInteractionMode; - planSidebarLabel: string; - planSidebarOpen: boolean; runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { return ( @@ -74,17 +69,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Auto Full access - {props.activePlan ? ( - <> - - - - {props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} - - - ) : null} ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c204499273a..6bc0a2a6203 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -4,6 +4,7 @@ import { workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, + type TurnPlanEntry, type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; @@ -201,6 +202,12 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } + | { + kind: "turn-plan"; + id: string; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { @@ -576,6 +583,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.kind === "turn-plan") { + nextRows.push({ + kind: "turn-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + turnPlan: timelineEntry.turnPlan, + }); + continue; + } + const assistantTurnStillInProgress = timelineEntry.message.role === "assistant" && unsettledTurnId !== null && @@ -659,6 +676,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; + case "turn-plan": { + const bp = b as typeof a; + // Plans rewrite in place: compare the snapshot's identity fields so an + // unchanged plan keeps its row reference (virtualization stability). + return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; + } + case "work": return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a5fb0360204..9e10a8b39cf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -152,6 +152,8 @@ interface TimelineRowActivityState { isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; latestTurnId: TurnId | null; + /** Current plan step label for the working row, when the turn has a plan. */ + workingStepLabel: string | null; } const TimelineRowCtx = createContext(null!); @@ -196,6 +198,7 @@ interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; + workingStepLabel?: string | null; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; @@ -240,6 +243,7 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, + workingStepLabel = null, activeTurnInProgress, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, @@ -508,8 +512,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isRevertingCheckpoint, activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, + workingStepLabel, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], + [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], ); // Stable renderItem — no closure deps. Row components read shared state @@ -911,7 +916,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time // they sit closer to the work that follows them. (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "turn-plan" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -929,6 +935,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} + {row.kind === "turn-plan" ? : null} {row.kind === "working" ? : null}
); @@ -1159,16 +1166,117 @@ function ProposedPlanTimelineRow({ ); } +/** + * Inline folded plan chip: one row per turn that produced plan/todo steps. + * Collapsed by default — a segment bar plus the in-progress step label — + * and expands in place to the full step list. Replaces the old plan sidebar. + */ +const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ + row, +}: { + row: Extract; +}) { + const [expanded, setExpanded] = useState(false); + const { steps } = row.turnPlan.plan; + const completedCount = steps.filter((step) => step.status === "completed").length; + const allDone = completedCount === steps.length; + // Label priority: the in-progress step, else the next pending step (plan + // just created), else the last step (plan finished, rendered muted). + const label = + steps.find((step) => step.status === "inProgress")?.step ?? + steps.find((step) => step.status === "pending")?.step ?? + steps.at(-1)?.step ?? + "Plan"; + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + + return ( +
+ + {expanded ? ( +
+ {steps.map((step) => ( +
+ + {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} + + + {step.step} + +
+ ))} +
+ ) : null} +
+ ); +}); + function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return (
-
+
- + {row.createdAt ? ( <> Working for @@ -1177,6 +1285,9 @@ function WorkingTimelineRow({ row }: { row: Extract + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe319568..b1c50e8717a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -623,9 +623,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), - ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar - ? ["Auto-open task panel"] - : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -655,7 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) { [ isTextGenerationModelDirty, isBackgroundActivityDirty, - settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -701,7 +697,6 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1897,32 +1892,6 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, - }) - } - /> - ) : null - } - control={ - - updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) - } - aria-label="Open the task panel automatically" - /> - } - /> - (); - -export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void { - dismissedTurnByThreadKey.set(threadKey, turnKey); -} - -export function clearPlanSidebarDismissal(threadKey: string): void { - dismissedTurnByThreadKey.delete(threadKey); -} - -export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean { - return dismissedTurnByThreadKey.get(threadKey) === turnKey; -} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c7457cfd304..69831242f2f 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -102,6 +102,41 @@ describe("rightPanelStore", () => { }); }); + it("drops persisted plan surfaces and does not reopen an empty panel", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [ + { id: "plan", kind: "plan" }, + { id: "diff", kind: "diff" }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: false, + activeSurfaceId: null, + surfaces: [], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }, + }, + }); + }); + it("open sets the active panel for a thread", () => { useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); @@ -109,7 +144,7 @@ describe("rightPanelStore", () => { }); it("opening a different kind keeps both surfaces and activates the new one", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); expect( @@ -119,7 +154,7 @@ describe("rightPanelStore", () => { it("reopening an inactive singleton activates its existing surface", () => { useRightPanelStore.getState().open(refA, "diff"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "diff"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ @@ -127,7 +162,7 @@ describe("rightPanelStore", () => { activeSurfaceId: "diff", surfaces: [ { id: "diff", kind: "diff" }, - { id: "plan", kind: "plan" }, + { id: "agents", kind: "agents" }, ], }); }); @@ -207,15 +242,15 @@ describe("rightPanelStore", () => { it("removes persisted file surfaces when their workspace no longer exists", () => { useRightPanelStore.getState().openFile(refA, "src/index.ts"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().openFile(refA, "README.md"); useRightPanelStore.getState().reconcileFileSurfaces(refA, false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); useRightPanelStore.getState().openFile(refB, "conductor.json"); @@ -228,13 +263,13 @@ describe("rightPanelStore", () => { }); it("close hides the panel without clearing its selected surface", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().close(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: false, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); }); @@ -264,12 +299,12 @@ describe("rightPanelStore", () => { it("toggle to a different kind switches active", () => { useRightPanelStore.getState().toggle(refA, "preview"); - useRightPanelStore.getState().toggle(refA, "plan"); - expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); + useRightPanelStore.getState().toggle(refA, "agents"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents"); }); it("removeThread clears persisted state", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().removeThread(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index cccb7238ca8..2e72c7b4e10 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -5,7 +5,7 @@ * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at - * workspace paths, and diff/plan/files remain singleton surfaces. + * workspace paths, and diff/files remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; export const RIGHT_PANEL_KINDS = [ - "plan", "diff", "files", "file", @@ -45,11 +44,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 8; +// v9 removed the "plan" surface kind (plans render inline in the transcript). +const RIGHT_PANEL_STORAGE_VERSION = 9; export interface ThreadRightPanelState { isOpen: boolean; @@ -99,8 +98,6 @@ const singletonSurface = ( return { id: "diff", kind }; case "files": return { id: "files", kind }; - case "plan": - return { id: "plan", kind }; case "agents": return { id: "agents", kind }; } @@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap((surface) => { + // Dropped surface kind: plans now render inline in the + // transcript (v9). + if ((surface as { kind?: string }).kind === "plan") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && @@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): { ]; }) : []; - const activeSurfaceId = surfaces.some( + const persistedActiveSurfaceId = surfaces.some( (surface) => surface.id === validThreadState?.activeSurfaceId, ) ? (validThreadState?.activeSurfaceId ?? null) : null; + // A migration that dropped every surface (e.g. plan-only panels + // in v9) must not reopen an empty panel. const isOpen = - typeof validThreadState?.isOpen === "boolean" + surfaces.length > 0 && + (typeof validThreadState?.isOpen === "boolean" ? validThreadState.isOpen - : activeSurfaceId !== null; + : persistedActiveSurfaceId !== null); + // An open panel needs an active surface: if migration dropped + // the persisted one (e.g. plan was active), fall back to the + // first survivor instead of rendering an open empty panel. + const activeSurfaceId = + persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); return [threadKey, { isOpen, surfaces, activeSurfaceId }]; }, ), diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3732afbd338..f5effff6602 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -11,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveActiveWorkStartedAt, deriveActivePlanState, + deriveTurnPlans, derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, deriveWorkLogEntries, findLatestProposedPlan, - findSidebarProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, workEntryIndicatesToolFailure, @@ -410,6 +410,95 @@ describe("deriveActivePlanState", () => { }); }); +describe("deriveTurnPlans", () => { + it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-1a", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "inProgress" }], + }, + }), + makeActivity({ + id: "plan-1b", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "completed" }], + }, + }), + makeActivity({ + id: "plan-2a", + createdAt: "2026-02-23T00:01:00.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-2", + payload: { + plan: [{ step: "Ship it", status: "pending" }], + }, + }), + ]; + + const turnPlans = deriveTurnPlans(activities); + expect(turnPlans).toHaveLength(2); + expect(turnPlans[0]).toMatchObject({ + id: "turn-plan:turn-1", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + }); + expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]); + expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]); + }); + + it("skips activities without parseable steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-bad", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); + + it("drops a turn's chip when a later snapshot clears the plan", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-set", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [{ step: "Inspect code", status: "inProgress" }] }, + }), + makeActivity({ + id: "plan-clear", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); +}); + describe("findLatestProposedPlan", () => { it("prefers the latest proposed plan for the active turn", () => { expect( @@ -515,103 +604,6 @@ describe("hasActionableProposedPlan", () => { }); }); -describe("findSidebarProposedPlan", () => { - it("prefers the running turn source proposed plan when available on the same thread", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: ThreadId.make("thread-2"), - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - ], - }, - { - id: ThreadId.make("thread-2"), - proposedPlans: [ - { - id: "plan-2", - turnId: TurnId.make("turn-other"), - planMarkdown: "# Latest elsewhere", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:04.000Z", - updatedAt: "2026-02-23T00:00:05.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: false, - threadId: ThreadId.make("thread-1"), - }), - ).toEqual({ - id: "plan-1", - turnId: "turn-plan", - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: "thread-2", - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }); - }); - - it("falls back to the latest proposed plan once the turn is settled", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Older", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - { - id: "plan-2", - turnId: TurnId.make("turn-latest"), - planMarkdown: "# Latest", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:03.000Z", - updatedAt: "2026-02-23T00:00:04.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: true, - threadId: ThreadId.make("thread-1"), - })?.planMarkdown, - ).toBe("# Latest"); - }); -}); - describe("workEntryIndicatesToolFailure", () => { const base = { id: "w1", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a1ff70bc043..4d0a76cf133 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -151,6 +151,12 @@ export type TimelineEntry = createdAt: string; proposedPlan: ProposedPlan; } + | { + id: string; + kind: "turn-plan"; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { id: string; kind: "work"; @@ -532,26 +538,10 @@ export function derivePendingUserInputs( ); } -export function deriveActivePlanState( - activities: ReadonlyArray, - latestTurnId: TurnId | undefined, -): ActivePlanState | null { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); - // Prefer plan from the current turn; fall back to the most recent plan from any turn - // so that TodoWrite tasks persist across follow-up messages. - const latest = Option.firstSomeOf([ - ...(latestTurnId - ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) - : Option.none()), - Arr.last(allPlanActivities), - ]).pipe(Option.getOrNull); - if (!latest) { - return null; - } +function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null { const payload = - latest.payload && typeof latest.payload === "object" - ? (latest.payload as Record) + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) : null; const rawPlan = payload?.plan; if (!Array.isArray(rawPlan)) { @@ -580,8 +570,8 @@ export function deriveActivePlanState( return null; } return { - createdAt: latest.createdAt, - turnId: latest.turnId, + createdAt: activity.createdAt, + turnId: activity.turnId, ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}), @@ -589,6 +579,72 @@ export function deriveActivePlanState( }; } +export function deriveActivePlanState( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): ActivePlanState | null { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); + // Prefer plan from the current turn; fall back to the most recent plan from any turn + // so that TodoWrite tasks persist across follow-up messages. + const latest = Option.firstSomeOf([ + ...(latestTurnId + ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) + : Option.none()), + Arr.last(allPlanActivities), + ]).pipe(Option.getOrNull); + if (!latest) { + return null; + } + return planStateFromActivity(latest); +} + +export interface TurnPlanEntry { + /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */ + id: string; + /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */ + createdAt: string; + turnId: TurnId | null; + plan: ActivePlanState; +} + +/** + * One inline plan chip per turn that produced plan/todo steps: the latest + * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less + * plan activities collapse into a single chip keyed by thread order. + */ +export function deriveTurnPlans( + activities: ReadonlyArray, +): TurnPlanEntry[] { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const byTurn = new Map(); + for (const activity of ordered) { + if (activity.kind !== "turn.plan.updated") { + continue; + } + const plan = planStateFromActivity(activity); + const key = activity.turnId ?? "no-turn"; + if (!plan) { + // A later snapshot with no steps clears the turn's plan; keeping the + // stale entry would freeze the chip on a withdrawn plan. + byTurn.delete(key); + continue; + } + const existing = byTurn.get(key); + if (existing) { + existing.plan = plan; + } else { + byTurn.set(key, { + id: `turn-plan:${key}`, + createdAt: activity.createdAt, + turnId: activity.turnId, + plan, + }); + } + } + return [...byTurn.values()]; +} + export function findLatestProposedPlan( proposedPlans: ReadonlyArray, latestTurnId: TurnId | string | null | undefined, @@ -619,30 +675,6 @@ export function findLatestProposedPlan( return toLatestProposedPlanState(latestPlan); } -export function findSidebarProposedPlan(input: { - threads: ReadonlyArray>; - latestTurn: Pick | null; - latestTurnSettled: boolean; - threadId: ThreadId | string | null | undefined; -}): LatestProposedPlanState | null { - const activeThreadPlans = - input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; - - if (!input.latestTurnSettled) { - const sourceProposedPlan = input.latestTurn?.sourceProposedPlan; - if (sourceProposedPlan) { - const sourcePlan = input.threads - .find((thread) => thread.id === sourceProposedPlan.threadId) - ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId); - if (sourcePlan) { - return toLatestProposedPlanState(sourcePlan); - } - } - } - - return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null); -} - export function hasActionableProposedPlan( proposedPlan: LatestProposedPlanState | Pick | null, ): boolean { @@ -1542,6 +1574,7 @@ export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, workEntries: ReadonlyArray, + turnPlans: ReadonlyArray = [], ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1555,13 +1588,19 @@ export function deriveTimelineEntries( createdAt: proposedPlan.createdAt, proposedPlan, })); + const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({ + id: turnPlan.id, + kind: "turn-plan", + createdAt: turnPlan.createdAt, + turnPlan, + })); const workRows: TimelineEntry[] = workEntries.map((entry) => ({ id: entry.id, kind: "work", createdAt: entry.createdAt, entry, })); - return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => + return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt), ); } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7ccb3dc7cac..26204961923 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -446,6 +446,20 @@ export const OrchestrationThreadShell = Schema.Struct({ * live work. Optional so old servers/clients interop; absent = none. */ backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))), + /** + * Current plan step while a turn runs, for the Working indicators + * (sidebar row, in-chat working line). Cleared when the turn settles — + * never persists as stale UI. Optional so old servers/clients interop. + */ + planProgress: Schema.optional( + Schema.NullOr( + Schema.Struct({ + step: TrimmedNonEmptyString, + completedSteps: NonNegativeInt, + totalSteps: NonNegativeInt, + }), + ), + ), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb..7679ab6e492 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ - autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -747,7 +746,6 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ - autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From 48aa875c0e6f8f2ee83d6972dd4357a4a083fe30 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:42:50 -0400 Subject: [PATCH 10/10] feat(web): remove Build/Plan toggle from the composer (#5551) Co-authored-by: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 12 ++++- apps/web/src/components/chat/ChatComposer.tsx | 53 ++++++++++++------- .../components/settings/BetaSettingsPanel.tsx | 12 +++++ .../src/components/settings/settingsSearch.ts | 5 ++ packages/contracts/src/settings.ts | 5 ++ 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 26c3127de5f..c1cb8588b5e 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -29,6 +29,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a4c9bcc7c54..f0e25e64475 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1481,8 +1481,13 @@ function ChatViewContent(props: ChatViewProps) { ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + // Plan mode is legacy (Settings → Beta). With the flag off the effective + // mode is forced to "default" — even for threads with a stored plan mode — + // so nobody is trapped in plan mode while its toggle is hidden. The next + // send persists "default" back to the thread. + const interactionMode = settings.planModeEnabled + ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) + : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; @@ -4811,7 +4816,10 @@ function ChatViewContent(props: ChatViewProps) { }); return; } + // Legacy plan mode: /plan and /default only act when the beta flag is on; + // otherwise they send as plain text like any other message. const standaloneSlashCommand = + settings.planModeEnabled && composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6f3a6ec22cd..795cb910227 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -875,14 +875,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + // Plan mode is a legacy feature behind Settings → Beta. With the flag off, + // ChatView forces the effective mode to "default", so hiding the toggle + // can't trap anyone in plan mode. + const planModeUiEnabled = settings.planModeEnabled; const composerProviderControls = useMemo( () => ({ - showInteractionModeToggle: getProviderInteractionModeToggle( - providerStatuses, - selectedProvider, - ), + showInteractionModeToggle: + planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider), }), - [providerStatuses, selectedProvider], + [planModeUiEnabled, providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -1043,20 +1045,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, + ...(planModeUiEnabled + ? ([ + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] as const) + : []), ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( (command) => ({ @@ -1091,7 +1097,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + planModeUiEnabled, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1857,6 +1869,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { + if (!planModeUiEnabled) return false; toggleInteractionMode(); return true; } diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 740d3048f0e..4b96fb15398 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -60,6 +60,7 @@ export function BetaSettingsPanel() { const sidebarAutoSettleAfterDays = useClientSettings( (settings) => settings.sidebarAutoSettleAfterDays, ); + const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateSettings = useUpdateClientSettings(); return ( @@ -114,6 +115,17 @@ export function BetaSettingsPanel() { ) : null} ) : null} + updateSettings({ planModeEnabled: Boolean(checked) })} + aria-label="Restore plan mode (legacy)" + /> + } + /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 69d16147ac7..3b3a8c220ac 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -177,6 +177,11 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/beta", targetId: "sidebar-v2", }, + { + id: "restore-plan-mode", + title: "Restore plan mode (legacy)", + to: "/settings/beta", + }, { id: "archive", title: "Archived threads", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7679ab6e492..4b477227f26 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -167,6 +167,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Legacy plan mode. The composer's Build/Plan toggle was removed from the + // default UI; this beta flag restores it (plus the /plan and /default slash + // commands) for users who still rely on the old workflow. + planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -781,6 +785,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + planModeEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey(