From 6a8d99fd7a2fdea70316c70ce3dd90df5d8beb61 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:08:17 +0000 Subject: [PATCH 1/3] fix(client): a usage-limit pause shows one notice instead of five The timeline error, Claude's own limit message and pause notice, and the snooze row's Wake now step aside while the usage-limit recovery notice covers the pause. The notice says when the thread resumes, and its actions are compact inline links on web and mobile. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 27 +++- .../threads/UsageLimitRecoveryCard.tsx | 145 +++++++++++------- apps/web/src/components/ChatView.tsx | 31 ++-- .../chat/UsageLimitRecoveryBanner.test.tsx | 1 + .../chat/UsageLimitRecoveryBanner.tsx | 42 ++--- .../chat/useThreadLifecycle.browser.test.tsx | 1 + .../components/chat/useThreadLifecycle.tsx | 21 ++- apps/web/src/components/ui/button.tsx | 6 +- .../client-runtime/src/state/threadDetail.ts | 36 ++++- .../src/state/threadExecution.ts | 96 ++++++++++++ 10 files changed, 306 insertions(+), 100 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4841c5f76dc7e..f8d2e3b5c7698 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -62,6 +62,7 @@ import { BACKGROUND_WORK_STOP_RETRY_AFTER_MS, type BackgroundWorkStopRequest, isBackgroundWorkStopPending, + limitRecoveryOwnsSnooze, presentPendingBackgroundWork, presentProviderGoal, } from "@t3tools/client-runtime/state/thread-execution"; @@ -642,6 +643,19 @@ function ThreadDetailBody( return () => clearTimeout(timer); // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Re-arm if timer clamping fires before the wake time. }, [snoozed, snoozedUntil, lifecycleNow]); + // The usage-limit recovery card shows this snooze and its Wake now. + const lifecycleSnoozed = + snoozed && + !limitRecoveryOwnsSnooze({ + runId: + props.selectedThread.runtime?.status === "failed" && + props.selectedThread.runtime.lastErrorClass === "usage_limit" + ? props.selectedThread.latestRun?.runId + : null, + resetAt: props.selectedThread.runtime?.usageLimitResetAt, + recovery: props.selectedThread.limitRecovery, + snoozedUntil, + }); const settled = configLoaded && props.selectedThread.settledOverride === "settled"; const wokeAt = configLoaded ? threadWokeAt(props.selectedThread, { now: lifecycleNow }) : null; const woke = isThreadWokeUnseen({ @@ -809,22 +823,22 @@ function ThreadDetailBody( }, }; } - if (contentPresentationKind === "ready" && (snoozed || settled || woke)) { - const state = snoozed ? "snoozed" : settled ? "settled" : "woke"; + if (contentPresentationKind === "ready" && (lifecycleSnoozed || settled || woke)) { + const state = lifecycleSnoozed ? "snoozed" : settled ? "settled" : "woke"; const label = - snoozed && snoozedUntil !== null + lifecycleSnoozed && snoozedUntil !== null ? `Snoozed, wakes in ${snoozeWakeLabel(snoozedUntil, { now: lifecycleNow })}` : settled ? "Settled" : "Woke from snooze"; - const actionLabel = snoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss"; + const actionLabel = lifecycleSnoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss"; return { kind: "lifecycle", state, label, action: { label: statusPending - ? snoozed + ? lifecycleSnoozed ? "Waking..." : settled ? "Un-settling..." @@ -832,7 +846,8 @@ function ThreadDetailBody( : actionLabel, accessibilityLabel: actionLabel, disabled: !props.canOperateThread || statusPending, - onPress: () => void runStatusAction(snoozed ? "wake" : settled ? "unsettle" : "dismiss"), + onPress: () => + void runStatusAction(lifecycleSnoozed ? "wake" : settled ? "unsettle" : "dismiss"), }, }; } diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 27fece0272be6..201fc78234f5a 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -1,7 +1,7 @@ -import { Button } from "../../components/ui/Button"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { limitRecoveryOwnsSnooze } from "@t3tools/client-runtime/state/thread-execution"; import type { EnvironmentId } from "@t3tools/contracts"; import { CHATGPT_USAGE_LIMIT_CODE, @@ -9,7 +9,6 @@ import { CHATGPT_WORKSPACE_ADMIN_REQUEST, CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, } from "@t3tools/shared/usageLimits"; -import * as DateTime from "effect/DateTime"; import * as Clipboard from "expo-clipboard"; import { useState } from "react"; import { Linking, Pressable, View } from "react-native"; @@ -31,6 +30,7 @@ export function UsageLimitRecoveryCard({ const [error, setError] = useState(null); const resetAt = thread.runtime?.usageLimitResetAt ?? null; const canSchedule = + thread.settledOverride !== "settled" && resetAt !== null && Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt); const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt)); @@ -38,6 +38,14 @@ export function UsageLimitRecoveryCard({ const recovery = thread.limitRecovery; const scheduled = recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; + const displayAt = + canSchedule && + scheduled && + resetAt && + thread.snoozedUntil && + Date.parse(thread.snoozedUntil) > Date.parse(resetAt) + ? thread.snoozedUntil + : resetAt; const [copied, setCopied] = useState(false); const [actionPending, setActionPending] = useState(false); const code = thread.runtime?.lastErrorCode; @@ -51,16 +59,19 @@ export function UsageLimitRecoveryCard({ !runId ) return null; - const resetText = resetAt - ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` + const resetText = displayAt + ? `${canSchedule && scheduled ? "Resumes" : "Resets"} ${new Date(displayAt).toLocaleString( + undefined, + { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }, + )}.` : null; const snoozed = - recovery?.snooze === true && - recovery.runId === runId && - recovery.resetAt === resetAt && - resetAt !== null && - thread.snoozedUntil !== null && - Date.parse(thread.snoozedUntil) === Date.parse(resetAt); + limitRecoveryOwnsSnooze({ + runId, + resetAt, + recovery, + snoozedUntil: thread.snoozedUntil, + }) && !resetPassed; async function toggle(action: "resume" | "snooze") { if (!resetAt || !runId || !canSchedule) return; if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) { @@ -123,50 +134,52 @@ export function UsageLimitRecoveryCard({ ) : null} - + {chatGptLimit ? ( - - - ChatGPT usage limit reached - - - {workspaceLimit - ? copied - ? "Request copied. Send it to your workspace admin." - : "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." - : (resetText ?? "Review your usage settings in ChatGPT.")} - - - {action.label} - - - ) : ( - - {resetText ?? - "The provider did not report a reset time. Retry manually when your limit is available."} + + ChatGPT usage limit reached - )} - {canSchedule ? ( - - - - + + ); } diff --git a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx index 76f9434eedf76..a60d7f24b18cc 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.browser.test.tsx @@ -111,6 +111,7 @@ function RestoreBranchHarness(props: { canOperateThread: true, clientSettingsHydrated: true, activeThreadShell: null, + limitRecoveryHoldsSnooze: false, activeLatestRun: null as never, activeProjectCwd: "/repo", serverConfig: null, diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index b9f8a42d289c3..50ed46ae671f9 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -98,6 +98,8 @@ interface ThreadLifecycleInput { readonly threadDetailRef: ScopedThreadRef | null; readonly activeThreadKey: string | null; readonly activeThreadShell: EnvironmentThreadShell | null; + /** The usage-limit recovery banner shows this snooze and its Wake now. */ + readonly limitRecoveryHoldsSnooze: boolean; readonly activeLatestRun: EnvironmentThreadShell["latestRun"]; readonly activeProjectCwd: string | null; readonly serverConfig: EnvironmentServerConfig | null; @@ -150,6 +152,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { activeThreadKey, activeThreadShell, activeLatestRun, + limitRecoveryHoldsSnooze, activeProjectCwd, serverConfig, runtimeMode, @@ -610,14 +613,16 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { ? snoozeWakeLabel(snoozedUntil, { now: `${nowMinute}:00.000Z` }) : null; const threadStatusLine = activeThreadSnoozed ? ( - } - label={snoozeWake && snoozeWake !== "now" ? `Snoozed, ${snoozeWake} left` : "Snoozed"} - clockMinute={nowMinute} - actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} - actionDisabled={!canOperateThread || isUnsnoozing} - onAction={() => void handleUnsnoozeActiveThreadStable()} - /> + limitRecoveryHoldsSnooze ? null : ( + } + label={snoozeWake && snoozeWake !== "now" ? `Snoozed, ${snoozeWake} left` : "Snoozed"} + clockMinute={nowMinute} + actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} + actionDisabled={!canOperateThread || isUnsnoozing} + onAction={() => void handleUnsnoozeActiveThreadStable()} + /> + ) ) : activeThreadSettled ? ( } diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 5b36a5ae5ebb1..0830513e5be45 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -105,6 +105,9 @@ const inlineButtonVariants = cva( { defaultVariants: { tone: "default" }, variants: { + touchTarget: { + padded: "pointer-coarse:min-h-11 pointer-coarse:px-1.5", + }, tone: { default: "text-foreground", muted: "text-muted-foreground hover:text-foreground", @@ -119,11 +122,12 @@ const inlineButtonVariants = cva( export function InlineButton({ className, tone, + touchTarget, render, ...props }: useRender.ComponentProps<"button"> & VariantProps) { const defaultProps = { - className: cn(inlineButtonVariants({ tone }), className), + className: cn(inlineButtonVariants({ tone, touchTarget }), className), "data-slot": "inline-button", type: render ? undefined : ("button" as const), }; diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 7a17e7326b802..9c6e497340b11 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -14,7 +14,13 @@ import { type ThreadQueueWorkflowState, } from "./threadWorkflows.ts"; import { deriveThreadTurnSubagents, type ThreadTurnSubagents } from "./threadSubagents.ts"; -import { presentRunStop, type RunStopPresentation } from "./threadExecution.ts"; +import { + deriveUsageLimitNoticeRows, + presentRunStop, + withoutUsageLimitNoticeRows, + type RunStopPresentation, + type UsageLimitNoticeRows, +} from "./threadExecution.ts"; import { deriveThreadCheckpointSummaries, type ThreadCheckpointSummary, @@ -298,11 +304,37 @@ export function createEnvironmentThreadDetailAtoms( }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-quiet-thread:${key}`)); }); + // Read from the structure so streamed text does not rescan runs and turn items. + const usageLimitNoticeRowsAtomFamily = Atom.family((key: string) => { + let previous: EnvironmentThreadStructure | null = null; + let value: UsageLimitNoticeRows | null = null; + return Atom.make((get) => { + const thread = get(quietThreadAtomFamily(key)); + if (thread !== previous) { + previous = thread; + value = thread === null ? null : deriveUsageLimitNoticeRows(thread.projection); + } + return value; + }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-usage-limit-notice:${key}`)); + }); + + // Presentation only: the projection keeps every row for other readers. const visibleTurnItemsAtomFamily = Atom.family((key: string) => { const projectQuestionHistory = createQuestionHistoryProjector(); + let previousRows: OrchestrationV2ThreadProjection["visibleTurnItems"] | null = null; + let previousNotice: UsageLimitNoticeRows | null = null; + let previous = EMPTY_VISIBLE_TURN_ITEMS; return Atom.make((get): OrchestrationV2ThreadProjection["visibleTurnItems"] => { const projection = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); - return projection === null ? EMPTY_VISIBLE_TURN_ITEMS : projectQuestionHistory(projection); + if (projection === null) return EMPTY_VISIBLE_TURN_ITEMS; + const rows = projectQuestionHistory(projection); + const notice = get(usageLimitNoticeRowsAtomFamily(key)); + if (rows !== previousRows || notice !== previousNotice) { + previousRows = rows; + previousNotice = notice; + previous = withoutUsageLimitNoticeRows(rows, notice); + } + return previous; }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-visible-turn-items:${key}`)); }); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index f4335256e98fb..6b260d7224c20 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -16,6 +16,7 @@ import { type ServerProvider, type ServerProviderModel, type OrchestrationV2ExecutionNode, + type OrchestrationV2LimitRecovery, type OrchestrationV2ThreadProjection, orchestrationV2RunWorkStartedAt, type RunId, @@ -41,6 +42,7 @@ import { type ThreadRuntimeSummary, type ThreadStructure, type ThreadStructureRuntime, + type ThreadStructureTurnItem, } from "./models.ts"; import { commandDisplayText } from "../work-log/commandLabel.ts"; import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; @@ -320,6 +322,100 @@ export function deriveThreadStructureRuntime( }; } +export interface UsageLimitNoticeRows { + readonly itemIds: ReadonlySet; + /** Hidden only when its text reads as the provider's limit message; see PROVIDER_LIMIT_NOTICE. */ + readonly providerNoticeItemId: ThreadStructureTurnItem["id"] | null; +} + +// Claude Code's own limit messages, such as "You've hit your 5-hour limit · resets 7:10am". +const PROVIDER_LIMIT_NOTICE = + /^(?:You[’']ve hit your (?:(?:5-hour|session|weekly|usage) )?limit|You[’']re out of extra usage|Claude AI usage limit reached)(?: · resets (?:[1-9]|1[0-2])(?::[0-5]\d)?(?:am|pm)(?: \([A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+)*\))?)?\.?$/i; + +/** + * The timeline rows that repeat a usage-limit pause while its recovery notice shows: the run's + * limit failure, Claude's pause notices, and the provider's own limit message. They return to + * history once another run executes. + */ +export function deriveUsageLimitNoticeRows( + projection: ThreadStructure, +): UsageLimitNoticeRows | null { + const runtime = deriveThreadStructureRuntime(projection); + const run = presentedLatestRun(projection); + if (runtime?.status !== "failed" || runtime.lastErrorClass !== "usage_limit" || run === null) { + return null; + } + const failure = latestRootProviderFailure(run, projection.turnItems); + const itemIds = new Set(); + let failedProviderTurnId: ThreadStructureTurnItem["providerTurnId"] = null; + for (const item of projection.turnItems) { + if (item.runId !== run.id) continue; + if ( + item.type === "error" && + item.status === "failed" && + item.nodeId === run.rootNodeId && + item.failure.class === "usage_limit" + ) { + itemIds.add(item.id); + if (item.failure === failure) failedProviderTurnId = item.providerTurnId; + } + } + let providerNotice: ThreadStructureTurnItem | null = null; + if (failedProviderTurnId !== null) { + for (const item of projection.turnItems) { + if (item.runId !== run.id || item.providerTurnId !== failedProviderTurnId) continue; + if ( + item.type === "system_notice" && + item.nodeId === run.rootNodeId && + item.parentItemId === null && + item.nativeItemRef?.driver === "claudeAgent" && + item.nativeItemRef.nativeId?.startsWith(`usage-limit:${failedProviderTurnId}:`) === true + ) { + itemIds.add(item.id); + } + if ( + item.type === "assistant_message" && + item.parentItemId === null && + (providerNotice === null || item.ordinal > providerNotice.ordinal) + ) { + providerNotice = item; + } + } + } + return { itemIds, providerNoticeItemId: providerNotice?.id ?? null }; +} + +export function withoutUsageLimitNoticeRows< + Row extends OrchestrationV2ThreadProjection["visibleTurnItems"][number], +>(rows: ReadonlyArray, notice: UsageLimitNoticeRows | null): ReadonlyArray { + if (notice === null) return rows; + const hidden = ({ item }: Row) => + notice.itemIds.has(item.id) || + (item.id === notice.providerNoticeItemId && + item.type === "assistant_message" && + PROVIDER_LIMIT_NOTICE.test(item.text.trim())); + return rows.some(hidden) ? rows.filter((row) => !hidden(row)) : rows; +} + +/** Whether a usage-limit recovery holds the thread's snooze, so its notice offers Wake now. */ +export function limitRecoveryOwnsSnooze(input: { + readonly runId: RunId | null | undefined; + readonly resetAt: string | null | undefined; + readonly recovery: OrchestrationV2LimitRecovery | null | undefined; + readonly snoozedUntil: string | null | undefined; +}): boolean { + const { runId, resetAt, recovery, snoozedUntil } = input; + return ( + recovery?.snooze === true && + runId != null && + recovery.runId === runId && + resetAt != null && + recovery.resetAt === resetAt && + snoozedUntil != null && + Date.parse(snoozedUntil) === Date.parse(resetAt) + ); +} + export function threadRuntimeHasInterruptibleRun( runtime: ThreadStructureRuntime | null | undefined, ): boolean { From d83412a020a3951c5734c701b93db974026192de Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:41:22 +0000 Subject: [PATCH 2/3] fix(web): keep the usage-limit notice beside an open question The timeline no longer shows the limit row, so the composer notice is the only place the limit shows on web. An open question hid it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index 45c6edfe14a8c..83dbbaf354db9 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -55,6 +55,7 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann return { id: `usage-limit-recovery:${runId}`, variant: "warning", + group: "environment", icon: , title: "Usage limit reached", description: resetDescription, @@ -65,6 +66,7 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann return { id: `usage-limit-recovery:${runId}`, variant: "warning", + group: "environment", icon: