From 89ee69e4430b21ee14565abf5c34dae43f38c1d8 Mon Sep 17 00:00:00 2001 From: Shubh Date: Sat, 5 Sep 2026 07:04:11 +0530 Subject: [PATCH 01/89] fix(server): advertise truecolor in the integrated terminal (#7680) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/terminal/Manager.test.ts | 25 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c99b5a000438..deea39631788 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1687,6 +1687,31 @@ it.layer( }), ); + it.effect.each(["linux", "darwin", "win32"] as const)( + "advertises truecolor before the PTY backend on %s without replacing explicit values", + (platform) => + Effect.gen(function* () { + for (const [parentColor, runtimeColor, expected] of [ + [undefined, undefined, "truecolor"], + ["", undefined, "truecolor"], + ["24bit", undefined, "24bit"], + ["24bit", "", "truecolor"], + ["24bit", "custom", "custom"], + ] as const) { + const env = Object.freeze({ COLORTERM: parentColor }); + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/sh", + env, + }).pipe(Effect.provide(withHostPlatform(platform))); + yield* manager.open( + openInput({ env: runtimeColor === undefined ? {} : { COLORTERM: runtimeColor } }), + ); + expect(ptyAdapter.spawnInputs[0]?.env.COLORTERM).toBe(expected); + expect(env.COLORTERM).toBe(parentColor); + } + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index fcfdc2bb26cc..f04e3c2d897b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1270,6 +1270,10 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } + // Both PTY backends feed truecolor-capable terminal clients. + if (spawnEnv.COLORTERM === undefined || spawnEnv.COLORTERM === "") { + spawnEnv.COLORTERM = "truecolor"; + } return stripAppImageRuntimeEnv(spawnEnv); } From 940e8233c227a186044078e99e45e1933eb525e4 Mon Sep 17 00:00:00 2001 From: Vitaly Iegorov Date: Sat, 5 Sep 2026 03:42:17 +0200 Subject: [PATCH 02/89] fix(claude): surface usage-limit pauses in the thread (#7165) Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/provider/Layers/ClaudeAdapter.test.ts | 524 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 103 +++- docs/user/providers-claude.md | 8 + 3 files changed, 628 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index afea9a605084..84cc03b75a6d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -23,6 +23,7 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -3662,6 +3663,529 @@ describe("ClaudeAdapterLive", () => { ); }); + const observeUsageLimitEvents = (adapter: ClaudeAdapterShape, query: FakeClaudeQuery) => + Effect.gen(function* () { + const runtimeEvents: Array = []; + let receipt: Deferred.Deferred | undefined; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if ( + receipt && + event.type === "session.state.changed" && + event.payload.reason === "api_retry:1/1" + ) { + yield* Deferred.succeed(receipt, undefined); + } + }), + ).pipe(Effect.forkChild); + const drainSdkMessages = Effect.gen(function* () { + receipt = yield* Deferred.make(); + // The heartbeat follows queued SDK messages without adding a warning. + query.emit({ + type: "system", + subtype: "api_retry", + attempt: 1, + max_retries: 1, + retry_delay_ms: 0, + error_status: 429, + error: { type: "rate_limit_error" }, + session_id: "sdk-session-limit", + uuid: "usage-limit-drain", + } as unknown as SDKMessage); + yield* Deferred.await(receipt); + }); + return { runtimeEvents, runtimeEventsFiber, drainSdkMessages }; + }); + + it.effect("surfaces a rejected Claude usage limit once per turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + // resetsAt is epoch seconds, so the window reopens 4h 1m30s out. + const nowMs = yield* Clock.currentTimeMillis; + const rateLimitInfo = { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 4 * 60 * 60 + 90, + }; + const rejected = { + type: "rate_limit_event", + rate_limit_info: rateLimitInfo, + session_id: "sdk-session-limit", + uuid: "rate-limit-rejected", + }; + // Sibling fields drift while the window is parked, so the same rendered + // line can arrive more than once inside one turn. + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + // The repeat lands minutes later, so the remaining wait has visibly + // shrunk. Deduping on the rendered row would let that drift through. + yield* TestClock.adjust("5 minutes"); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + const usageLimitRows = () => + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")); + assert.equal(usageLimitRows().length, 1); + // A wait, not a wall clock: the server renders this row but clients read + // it from other timezones. Reading resetsAt as milliseconds would put the + // window minutes out instead of hours, so the hour also pins the scale. + assert.match( + usageLimitRows()[0] ?? "", + /^Claude usage limit reached\. This turn is paused until the 5-hour limit resets in 4h( \d{1,2}m)?\.$/, + ); + // The exact instant still rides along for clients that want to render it. + assert.deepEqual( + runtimeEvents.find((event) => event.type === "runtime.warning")?.payload.detail, + rateLimitInfo, + ); + // The raw telemetry event still flows for every copy. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + // Same window, drifting siblings: still the one pause. + harness.query.emit({ + ...rejected, + rate_limit_info: { ...rateLimitInfo, utilization: 0.99 }, + uuid: "rate-limit-rejected-drift", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.equal(usageLimitRows().length, 1); + + // Retrying inside the same window renders the identical line. Staying + // quiet there would put the new turn right back to a silent spin. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-limit", + uuid: "result-limit", + } as unknown as SDKMessage); + yield* drainSdkMessages; + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "retry", attachments: [] }); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(usageLimitRows().length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps allowed and malformed Claude rate-limit events out of the work log", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + // A turn is in flight, so silence here is the status filter doing its job + // rather than the between-turns guard. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitInfo of [ + { status: "allowed", rateLimitType: "five_hour", utilization: 0.4 }, + { status: "allowed_warning", rateLimitType: "five_hour", utilization: 0.9 }, + // Undeclared shape from an older/newer CLI must not take the session down. + undefined, + ]) { + harness.query.emit({ + type: "rate_limit_event", + ...(rateLimitInfo ? { rate_limit_info: rateLimitInfo } : {}), + session_id: "sdk-session-limit-ok", + uuid: `rate-limit-${rateLimitInfo?.status ?? "malformed"}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stays quiet when no turn is parked by the Claude limit", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The stream stays live between turns, so a reject can land with nothing + // to pause; claiming "this turn is paused" there would be a lie. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-idle", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + // Provisioned overage carries the request even though the base window + // rejected it, so the turn keeps running and needs no row. + for (const overage of [ + { overageStatus: "allowed" }, + { overageStatus: "allowed_warning" }, + { isUsingOverage: true }, + { overageInUse: true }, + ]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + utilization: 1, + ...overage, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-overage", + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + // Idle and overage-covered events still reach the account telemetry stream. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 5, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("still surfaces the pause when overage is exhausted too", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The overage-exhausted / out-of-credits shape: the base window and the + // overage it would have spent both reject, with neither isUsingOverage + // nor overageInUse set to say anything is still covered. Nothing is + // carrying the turn here, so staying quiet would be the silent spin + // this row exists to prevent. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + overageStatus: "rejected", + }, + session_id: "sdk-session-dual-reject", + uuid: "rate-limit-dual-reject", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 1); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps one row per window when two Claude limits interleave", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const nowSeconds = Math.floor(nowMs / 1000); + const rejection = (rateLimitType: string, resetsAt: number, uuid: string) => ({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-interleaved", + uuid, + }); + + // One turn can park on more than one window; each deserves its own row, + // and a later repeat of an earlier window deserves none. + for (const message of [ + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour"), + rejection("seven_day", nowSeconds + 48 * 60 * 60, "limit-seven-day"), + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour-repeat"), + ]) { + harness.query.emit(message as unknown as SDKMessage); + yield* drainSdkMessages; + } + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")) + .map((message) => message.replace(/ in \d+h( \d{1,2}m)?/, "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("re-announces a Claude limit for a synthetic turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const rejected = { + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt: Math.floor(nowMs / 1000) + 2 * 60 * 60, + }, + session_id: "sdk-session-synthetic", + uuid: "rate-limit-synthetic", + }; + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-synthetic", + uuid: "result-synthetic", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + // A background agent answering between prompts auto-starts a synthetic + // turn, which parks on the same window and needs its own row. + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-synthetic", + uuid: "assistant-synthetic", + parent_tool_use_id: null, + message: { + id: "assistant-message-synthetic", + content: [{ type: "text", text: "Following up" }], + }, + } as unknown as SDKMessage); + yield* drainSdkMessages; + harness.query.emit({ ...rejected, uuid: "rate-limit-synthetic-2" } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("drops an unusable Claude reset time, not the row or the session", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const [rateLimitType, resetsAt] of [ + ["five_hour", undefined], + // Implausibly far out once scaled to milliseconds: no credible wait. + ["seven_day", 1e20], + ] as const) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-limit-unusable", + uuid: `rate-limit-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + // A throw inside the telemetry handler would tear the session down. + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "session.exited" || event.type === "runtime.error") + .map((event) => event.type), + [], + ); + // Still live enough to take the next turn. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "still here", attachments: [] }); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("warns for unmapped Claude limits and names the probed model bucket", () => { + const scopedLimitNames = Ref.makeUnsafe({ overageIncluded: undefined }); + const harness = makeHarness({ scopedLimitNames }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitType of ["seven_day_overage_included", "future_window"]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType }, + session_id: "sdk-session-unmapped-limit", + uuid: `rejected-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated"), + [], + ); + + yield* Ref.set(scopedLimitNames, { overageIncluded: "Model A" }); + const nowMs = yield* Clock.currentTimeMillis; + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "seven_day_overage_included", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 3600, + }, + session_id: "sdk-session-unmapped-limit", + uuid: "rejected-probed-bucket", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => event.payload.message), + [ + "Claude usage limit reached. This turn is paused until the 7-day model limit resets.", + "Claude usage limit reached. This turn is paused until the limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day Model A limit resets in 1h.", + ], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 1, + ); + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("consumes Claude command lifecycle notifications silently", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d295821d8dfa..a005f583066f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -14,6 +14,7 @@ import { type PermissionResult, type PermissionUpdate, type SDKMessage, + type SDKRateLimitInfo, type SDKResultMessage, type SettingSource, type SDKUserMessage, @@ -327,6 +328,8 @@ interface ClaudeSessionContext { lastKnownTotalProcessedTokens: number | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + /** Limits already announced for the running turn, keyed `window:resetsAt`. */ + announcedUsageLimits: { turnId: string; keys: Set } | undefined; stopped: boolean; } @@ -503,6 +506,55 @@ function isInterruptedResult(result: SDKResultMessage): boolean { ); } +const CLAUDE_USAGE_LIMIT_WINDOWS = { + five_hour: "5-hour", + seven_day: "7-day", + seven_day_opus: "7-day Opus", + seven_day_sonnet: "7-day Sonnet", + seven_day_overage_included: "7-day model", + overage: "overage", +} satisfies Record, string>; + +/** Beyond this the reset time is not credible, so the row ships without a wait. */ +const CLAUDE_USAGE_LIMIT_MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * `resetsAt` is epoch seconds. The row states the remaining wait rather than a + * wall-clock time: this renders on the server, while the row is read on clients + * that may sit in another timezone and locale, and that carry their own + * timestamp preference. A wait reads the same everywhere. + */ +function describeClaudeUsageLimit( + info: SDKRateLimitInfo, + nowMs: number, + names: ClaudeScopedLimitNames, +): string { + const label = + info.rateLimitType === "seven_day_overage_included" && names.overageIncluded + ? `7-day ${names.overageIncluded}` + : info.rateLimitType + ? CLAUDE_USAGE_LIMIT_WINDOWS[info.rateLimitType] + : undefined; + const resetsAtMs = info.resetsAt === undefined ? undefined : info.resetsAt * 1000; + const waitMs = + resetsAtMs === undefined || !Number.isFinite(nowMs) ? undefined : resetsAtMs - nowMs; + const wait = + waitMs !== undefined && waitMs > 0 && waitMs <= CLAUDE_USAGE_LIMIT_MAX_WAIT_MS + ? formatClaudeUsageLimitWait(waitMs) + : undefined; + return `Claude usage limit reached. This turn is paused until the ${ + label ? `${label} ` : "" + }limit resets${wait ? ` in ${wait}` : ""}.`; +} + +function formatClaudeUsageLimitWait(waitMs: number): string { + const totalMinutes = Math.ceil(waitMs / 60_000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${totalMinutes}m`; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; +} + function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.make(value); } @@ -3802,16 +3854,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (message.type === "rate_limit_event") { + const rateLimitInfo = message.rate_limit_info; + if (!rateLimitInfo) return; const names = options?.scopedLimitNames ? yield* Ref.get(options.scopedLimitNames) : { overageIncluded: undefined }; - const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names); - if (!limits) return; - yield* offerRuntimeEvent({ - ...base, - type: "account.rate-limits.updated", - payload: { limits }, - }); + const limits = claudeRateLimitEventToUpdate(rateLimitInfo, names); + if (limits) { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { limits }, + }); + } + // A rejected window parks the turn inside the SDK: no further messages + // arrive and no result lands, so without a row the thread just spins. + // Warnings (allowed_warning) still have headroom and stay quiet, an + // account spending provisioned overage keeps running despite the reject, + // and between turns there is no turn to report as paused. + if ( + rateLimitInfo.status === "rejected" && + rateLimitInfo.overageStatus !== "allowed" && + rateLimitInfo.overageStatus !== "allowed_warning" && + rateLimitInfo.isUsingOverage !== true && + rateLimitInfo.overageInUse !== true && + context.turnState !== undefined + ) { + // Tracked per turn as a set of limit identities, not as the rendered + // row: a parked window re-fires while the remaining wait shrinks, and a + // turn can park on more than one window, so a single slot would let an + // interleaved repeat through. A new turn — including a synthetic one — + // starts a fresh set and announces its pause again. + const turnId = context.turnState.turnId; + if (context.announcedUsageLimits?.turnId !== turnId) { + context.announcedUsageLimits = { turnId, keys: new Set() }; + } + const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`; + if (!context.announcedUsageLimits.keys.has(limitKey)) { + context.announcedUsageLimits.keys.add(limitKey); + const notice = describeClaudeUsageLimit( + rateLimitInfo, + Date.parse(stamp.createdAt), + names, + ); + yield* emitRuntimeWarning(context, notice, rateLimitInfo); + } + } return; } }); @@ -4695,6 +4783,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTotalProcessedTokens: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + announcedUsageLimits: undefined, stopped: false, }; yield* Ref.set(contextRef, context); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 707cb5493225..b43b58e52627 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -49,6 +49,14 @@ You can also send `/compact` in an existing conversation. Web and desktop offer a large older thread. See [commands and skills](./composer.md#commands-and-skills) for using composer commands. +## Usage limits + +If your Claude subscription runs out of usage mid-turn, the thread shows which +limit was reached and the remaining wait when Claude provides a reset time. +Claude Code holds the turn until that window reopens, so it can keep showing as +working. Wait for the reset, or stop the turn and continue later. The warning's +timestamp shows when the displayed wait started. + ## Skills Claude skills come from the config directory's `skills` folder and the project's From ce4712d5b04fb998f79fe132245289191147e5d5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 19:06:21 -0700 Subject: [PATCH 03/89] fix: restore UX after performance improvements (#9799) Restore status feedback, code-view worker reuse, streaming highlighting recovery, safe outbox recovery, current HTML, and projected live-event budgets. Verified with browser checks, iOS outbox recovery, 281 focused tests, and full CI. CI uses Shivam's HTTPS mirror fix. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .github/workflows/ci.yml | 8 +- apps/marketing/src/lib/homeMotion.test.ts | 226 ++++++++++++++++++ apps/marketing/src/lib/homeMotion.ts | 176 ++++++++++++++ apps/marketing/src/pages/index.astro | 47 +++- .../features/threads/use-project-actions.ts | 7 +- apps/mobile/src/lib/attachmentUpload.ts | 7 +- .../state/pending-task-editor-writes.test.ts | 2 +- .../mobile/src/state/thread-outbox-manager.ts | 44 +++- .../src/state/thread-outbox-removal.test.ts | 2 +- .../mobile/src/state/thread-outbox-storage.ts | 33 ++- apps/mobile/src/state/thread-outbox.test.ts | 193 ++++++++++++--- apps/mobile/src/state/thread-outbox.ts | 4 - .../src/state/use-composer-drafts.test.ts | 87 ++++++- .../src/state/use-thread-outbox-drain.test.ts | 12 +- .../src/state/use-thread-outbox-drain.ts | 31 +-- apps/server/src/http.ts | 16 +- .../src/orchestration/LiveStreamBudget.ts | 2 +- .../orchestration/ThreadLiveEventCoalescer.ts | 5 +- apps/server/src/server.test.ts | 168 +++++++++++-- apps/server/src/ws.ts | 38 ++- apps/web/src/components/ChatMarkdown.test.tsx | 44 ++++ apps/web/src/components/ChatMarkdown.tsx | 5 +- .../src/components/DiffWorkerPoolProvider.tsx | 108 ++++++--- .../src/components/RenderErrorBoundary.tsx | 31 ++- .../chat/ComposerActivityStatus.tsx | 5 +- .../chat/ComposerServerUpdateStatus.tsx | 9 +- .../src/components/chat/MessagesTimeline.tsx | 53 +++- .../diffs/StyledDiffCodeView.test.tsx | 51 +++- apps/web/src/index.css | 83 +++++++ apps/web/src/lib/visibleAnimation.test.ts | 154 ++++++++++++ apps/web/src/lib/visibleAnimation.ts | 64 +++++ 31 files changed, 1511 insertions(+), 204 deletions(-) create mode 100644 apps/marketing/src/lib/homeMotion.test.ts create mode 100644 apps/marketing/src/lib/homeMotion.ts create mode 100644 apps/web/src/lib/visibleAnimation.test.ts create mode 100644 apps/web/src/lib/visibleAnimation.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee5f57a83c..fba55f53aaf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,9 @@ jobs: run: vpr typecheck - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Build desktop pipeline run: vp run build:desktop @@ -89,7 +91,9 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/apps/marketing/src/lib/homeMotion.test.ts b/apps/marketing/src/lib/homeMotion.test.ts new file mode 100644 index 000000000000..42f7775a0157 --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { startHomeMotion } from "./homeMotion"; + +class ElementStub extends EventTarget { + properties = new Map(); + style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; + children: ElementStub[] = []; + scrollLeft = 0; + scrollWidth = 1_200; + clientWidth = 400; + matches = () => false; + contains = (target: EventTarget | null) => + target === this || (target instanceof ElementStub && this.children.includes(target)); + querySelectorAll = () => this.children; + getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); + scrollTo = vi.fn((options: ScrollToOptions) => { + this.scrollLeft = options.left ?? this.scrollLeft; + }); +} + +let observers: ObserverStub[] = []; +class ObserverStub { + constructor(private readonly callback: IntersectionObserverCallback) { + observers.push(this); + } + observe = vi.fn(); + disconnect = vi.fn(); + report(target: ElementStub, isIntersecting: boolean) { + this.callback( + [{ target, isIntersecting } as unknown as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); +let viewport = new EventTarget(); +let reduced = Object.assign(new EventTarget(), { matches: false }); +let fine = Object.assign(new EventTarget(), { matches: true }); +let frames = new Map(); +let dispose: (() => void) | undefined; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + observers = []; + frames = new Map(); + page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); + viewport = new EventTarget(); + reduced = Object.assign(new EventTarget(), { matches: false }); + fine = Object.assign(new EventTarget(), { matches: true }); + vi.stubGlobal("document", page); + vi.stubGlobal( + "window", + Object.assign(viewport, { + matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), + }), + ); + vi.stubGlobal("Node", ElementStub); + vi.stubGlobal("IntersectionObserver", ObserverStub); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); +}); + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function fixture() { + const hero = new ElementStub(); + const field = new ElementStub(); + const mark = new ElementStub(); + const otherMark = new ElementStub(); + field.children = [mark, otherMark]; + const endorsements = new ElementStub(); + const caret = new ElementStub(); + dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< + typeof startHomeMotion + >[0]); + return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; +} + +function movePointer(hero: ElementStub, x = 400, y = 600) { + hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); +} + +describe("homepage motion", () => { + it("gates each mark and caret and batches pointer input into one frame", () => { + const { hero, field, mark, otherMark, caret, observer } = fixture(); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + observer.report(mark, true); + observer.report(caret, true); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("running"); + + movePointer(hero, 100, 100); + movePointer(hero); + expect(frames.size).toBe(1); + expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); + const [id, callback] = [...frames][0]!; + frames.delete(id); + callback(0); + expect(field.properties.get("--px")).toBe("18.0px"); + expect(field.properties.get("--py")).toBe("14.0px"); + + movePointer(hero); + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + expect(frames.size).toBe(0); + expect(field.properties.get("--px")).toBe("0px"); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("paused"); + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + reduced.matches = true; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + reduced.matches = false; + fine.matches = false; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + }); + + it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => { + const { endorsements, observer } = fixture(); + expect(vi.getTimerCount()).toBe(0); + observer.report(endorsements, true); + vi.advanceTimersByTime(7_999); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(16_001); + expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([ + 400, 800, 400, + ]); + expect( + endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"), + ).toBe(true); + + endorsements.clientWidth = endorsements.scrollWidth; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + endorsements.clientWidth = 400; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(1); + }); + + it("pauses paging for hover, focus, hidden content, and reduced motion", () => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + const changeVisibility = (visible: boolean) => { + page.visibilityState = visible ? "visible" : "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + }; + const changeMotion = (matches: boolean) => { + reduced.matches = matches; + reduced.dispatchEvent(new Event("change")); + }; + const pauses = [ + [ + () => endorsements.dispatchEvent(new Event("pointerenter")), + () => endorsements.dispatchEvent(new Event("pointerleave")), + ], + [ + () => endorsements.dispatchEvent(new Event("focusin")), + () => + endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })), + ], + [() => changeVisibility(false), () => changeVisibility(true)], + [() => observer.report(endorsements, false), () => observer.report(endorsements, true)], + [() => changeMotion(true), () => changeMotion(false)], + ] as const; + for (const [pause, resume] of pauses) { + pause(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(16_000); + resume(); + expect(vi.getTimerCount()).toBe(1); + } + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(8_000); + expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" }); + endorsements.dispatchEvent(new Event("pointerenter")); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event(event)); + observer.report(endorsements, false); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event("pointerleave")); + viewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(60_000); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + }); + + it("cancels pending work and ignores events after cleanup", () => { + const { hero, mark, endorsements, observer } = fixture(); + observer.report(mark, true); + observer.report(endorsements, true); + movePointer(hero); + dispose?.(); + observer.report(mark, true); + movePointer(hero); + reduced.dispatchEvent(new Event("change")); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + expect(frames.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + }); +}); diff --git a/apps/marketing/src/lib/homeMotion.ts b/apps/marketing/src/lib/homeMotion.ts new file mode 100644 index 000000000000..5322eae4406d --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.ts @@ -0,0 +1,176 @@ +/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ +export function startHomeMotion({ + hero, + field, + endorsements, + caret, +}: { + hero: HTMLElement; + field: HTMLElement; + endorsements: HTMLElement; + caret: HTMLElement; +}) { + if (typeof IntersectionObserver === "undefined") return () => {}; + + const marks = Array.from(field.querySelectorAll(".hero-float-mark")); + const visible = new Set(); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const finePointer = window.matchMedia("(pointer: fine)"); + const events = new AbortController(); + const eventOptions = { signal: events.signal }; + let disposed = false; + let hovered = endorsements.matches(":hover"); + let focused = endorsements.contains(document.activeElement); + let userControlled = false; + let direction = 1; + let automaticScroll = false; + let pageTimer: ReturnType | undefined; + let pointerFrame: number | undefined; + let pointer: { x: number; y: number } | null = null; + + const canMove = (element: Element) => + !disposed && + visible.has(element) && + document.visibilityState === "visible" && + !reducedMotion.matches; + const canParallax = () => finePointer.matches && marks.some(canMove); + const canPage = () => + canMove(endorsements) && + !hovered && + !focused && + !userControlled && + endorsements.scrollWidth > endorsements.clientWidth; + + function resetPointer() { + if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pointer = null; + field.style.setProperty("--px", "0px"); + field.style.setProperty("--py", "0px"); + } + + function updatePaging() { + if (canPage()) { + pageTimer ??= setTimeout(advancePage, 8_000); + return; + } + if (pageTimer !== undefined) clearTimeout(pageTimer); + pageTimer = undefined; + if (automaticScroll) { + automaticScroll = false; + endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" }); + } + } + + function advancePage() { + pageTimer = undefined; + if (!canPage()) return; + const end = endorsements.scrollWidth - endorsements.clientWidth; + const current = endorsements.scrollLeft; + if (current >= end - 1) direction = -1; + else if (current <= 1) direction = 1; + automaticScroll = true; + endorsements.scrollTo({ + left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)), + behavior: "smooth", + }); + updatePaging(); + } + + function update() { + for (const mark of marks) { + mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); + } + caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused"); + const parallax = canParallax(); + field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); + if (!parallax) resetPointer(); + updatePaging(); + } + + const observer = new IntersectionObserver((entries) => { + if (disposed) return; + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target); + else visible.delete(entry.target); + } + update(); + }); + for (const element of [...marks, endorsements, caret]) observer.observe(element); + + hero.addEventListener( + "pointermove", + (event) => { + if (!canParallax()) return; + pointer = { x: event.clientX, y: event.clientY }; + pointerFrame ??= requestAnimationFrame(() => { + pointerFrame = undefined; + if (!pointer || !canParallax()) return; + const bounds = hero.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) return; + field.style.setProperty( + "--px", + `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`, + ); + field.style.setProperty( + "--py", + `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`, + ); + }); + }, + eventOptions, + ); + hero.addEventListener("pointerleave", resetPointer, eventOptions); + endorsements.addEventListener( + "pointerenter", + () => { + hovered = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "pointerleave", + () => { + hovered = false; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusin", + () => { + focused = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusout", + (event) => { + focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget); + updatePaging(); + }, + eventOptions, + ); + const takeControl = () => { + userControlled = true; + updatePaging(); + }; + endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true }); + endorsements.addEventListener("pointerdown", takeControl, eventOptions); + endorsements.addEventListener("keydown", takeControl, eventOptions); + document.addEventListener("visibilitychange", update, eventOptions); + window.addEventListener("resize", update, eventOptions); + reducedMotion.addEventListener("change", update, eventOptions); + finePointer.addEventListener("change", update, eventOptions); + update(); + + return () => { + if (disposed) return; + disposed = true; + events.abort(); + observer.disconnect(); + update(); + }; +} diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd28b446ccf1..a4fdc966b9d8 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -355,6 +355,7 @@ const screenshot = await getImage({