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 {