diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4c41ce2bf150..c620dc84a9f9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -18,6 +18,7 @@ import { type SidebarProjectGroupingMode, type SidebarThreadSortOrder, } from "@t3tools/contracts"; +import { exhaustedUntil } from "@t3tools/shared/usageLimits"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useFocusEffect } from "@react-navigation/native"; @@ -792,6 +793,13 @@ export function HomeScreen(props: HomeScreenProps) { ); } const thread = item.item.thread; + const provider = serverConfigs + .get(thread.environmentId) + ?.providers.find( + (candidate) => + candidate.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + ); return ( - provider.instanceId === - (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), - )?.driver ?? null - } + providerDriver={provider?.driver ?? null} + limitsResetAt={exhaustedUntil(provider?.usageLimits, Date.now())} environmentLabel={ Object.keys(props.savedConnectionsById).length > 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 07357a1b7524..32a200b72774 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -11,6 +11,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; +import { exhaustedUntil } from "@t3tools/shared/usageLimits"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; @@ -854,6 +855,13 @@ function ThreadNavigationSidebarPane( case "v2-thread": { const thread = item.item.thread; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + const provider = serverConfigs + .get(thread.environmentId) + ?.providers.find( + (candidate) => + candidate.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + ); return ( - provider.instanceId === - (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), - )?.driver ?? null - } + providerDriver={provider?.driver ?? null} + limitsResetAt={exhaustedUntil(provider?.usageLimits, Date.now())} environmentLabel={ Object.keys(savedConnectionsById).length > 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 97c13de56aab..0bd0665e3375 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -332,6 +332,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly providerDriver: string | null; + /** Account-wide usage-limit reset for the thread's provider instance, or + null when it isn't exhausted. Feeds the "Until limits reset" snooze + preset so it leads the menu while the limit is in force. */ + readonly limitsResetAt: string | null; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors the web sidebar's remote-environment cloud icon, but as text since @@ -473,8 +477,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozed: snoozedRow, }); const snoozePresets = useMemo( - () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), - [props.snoozePresetMinute, swipeActions.secondary], + () => + swipeActions.secondary === "snooze" + ? resolveSnoozePresets(new Date(), { limitsResetAt: props.limitsResetAt }) + : ([] as const), + [props.snoozePresetMinute, props.limitsResetAt, swipeActions.secondary], ); const snoozePresetActions = useMemo( () => @@ -587,6 +594,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { event: nativeEvent.event, displayedPresets: snoozePresets, now: new Date(), + limitsResetAt: props.limitsResetAt, }); if (snoozeSelection._tag === "selected") { handleSnooze(snoozeSelection.preset.snoozedUntil); @@ -607,6 +615,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleUnsettle, handleUnsnooze, snoozePresets, + props.limitsResetAt, ], ); const primaryAction = useMemo(() => { diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 33ae27cc0638..3060fc9360a6 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -106,6 +106,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { ); } }); + + it("resolves a snooze:limits-reset event when the option is set", () => { + const selectedAt = new Date(2026, 4, 8, 10); + const resetsAt = new Date(2026, 4, 8, 14).toISOString(); + const displayedPresets = resolveSnoozePresets(selectedAt, { limitsResetAt: resetsAt }); + + const selection = resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:limits-reset", + displayedPresets, + now: selectedAt, + limitsResetAt: resetsAt, + }); + + expect(selection).toEqual({ + _tag: "selected", + preset: displayedPresets.find((preset) => preset.id === "limits-reset"), + }); + }); }); describe("resolveThreadListV2Enabled", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2b44851f9309..5174f2238f6b 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -34,15 +34,16 @@ export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; readonly now: Date; + readonly limitsResetAt?: string | null; }): | { readonly _tag: "selected"; readonly preset: SnoozePreset } | { readonly _tag: "expired" } | { readonly _tag: "not-snooze" } { if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; - const currentPreset = resolveSnoozePresets(input.now).find( - (candidate) => input.event === `snooze:${candidate.id}`, - ); + const currentPreset = resolveSnoozePresets(input.now, { + limitsResetAt: input.limitsResetAt, + }).find((candidate) => input.event === `snooze:${candidate.id}`); if (currentPreset) return { _tag: "selected", preset: currentPreset }; const displayedPreset = input.displayedPresets.find( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 11226371172f..e2480aa1c5b1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,7 +28,12 @@ import { import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; -import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + effectiveSnoozed, + threadWokeAt, + usageLimitSnoozePreset, +} from "@t3tools/client-runtime/state/thread-settled"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -49,6 +54,7 @@ import { import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { exhaustedUntil } from "@t3tools/shared/usageLimits"; import { getTerminalLabel, nextTerminalId, @@ -199,6 +205,7 @@ import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; +import { snoozeWakeDescription } from "./Sidebar.snooze"; import { buildProjectScript, commandForProjectScript, @@ -1371,7 +1378,7 @@ export default function ChatView(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); - const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); + const { settleThread, pinThread, confirmAndUnpinThread, snoozeThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -5150,6 +5157,10 @@ export default function ChatView(props: ChatViewProps) { const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; const nowMinute = useNowMinute(); + // One quantized clock for the usage-limit UI, so its visibility rule, its + // label and its snooze target can never disagree within a minute. + const nowMinuteIso = `${nowMinute}:00.000Z`; + const nowMinuteDate = useMemo(() => new Date(nowMinuteIso), [nowMinuteIso]); const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = activeThreadShell !== null && @@ -5265,6 +5276,37 @@ export default function ChatView(props: ChatViewProps) { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsnoozeThreadMutation]); + // Read off the provider snapshot the Limits tab already draws from: the + // latest reset among the instance's exhausted windows, or null while it serves. + const usageLimitResetsAt = useMemo( + () => exhaustedUntil(conversationProviderStatus?.usageLimits, nowMinuteDate.getTime()), + [conversationProviderStatus?.usageLimits, nowMinuteDate], + ); + // The same preset the snooze menus lead with, on the shared minute tick so + // the notice expires with it instead of needing a timer of its own. + const usageLimitPreset = useMemo( + () => + usageLimitResetsAt === null || !supportsSnooze || activeThreadSnoozed + ? null + : usageLimitSnoozePreset(usageLimitResetsAt, nowMinuteDate), + [activeThreadSnoozed, nowMinuteDate, supportsSnooze, usageLimitResetsAt], + ); + const handleSnoozeUntilUsageLimitReset = useCallback(async () => { + if (activeThreadRef === null || usageLimitPreset === null) return; + // No success toast: the parked-thread banner that replaces this notice + // already offers Wake now. + const result = await snoozeThread(activeThreadRef, usageLimitPreset.snoozedUntil); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, [activeThreadRef, snoozeThread, usageLimitPreset]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -5496,6 +5538,65 @@ export default function ChatView(props: ChatViewProps) { isUnsnoozing, isUnsettling, ]); + // Session-scoped dismissals keyed per (thread, reset), so dismissing one + // limit does not hide the next one the provider reports. + const [dismissedUsageLimitKeys, setDismissedUsageLimitKeys] = useState>( + new Set(), + ); + const usageLimitKey = + activeThread && usageLimitResetsAt !== null ? `${activeThread.id}:${usageLimitResetsAt}` : null; + // Nothing auto-resumes on the reset — the offer just parks the thread out of + // the inbox until the provider is serving again. The reset time is worth + // showing even while snoozing is unavailable, so only the button is gated. + const usageLimitBannerItem = useMemo(() => { + if ( + usageLimitPreset === null || + usageLimitResetsAt === null || + usageLimitKey === null || + activeThreadShell === null || + dismissedUsageLimitKeys.has(usageLimitKey) + ) { + return null; + } + const snoozable = canSnooze(activeThreadShell, { now: nowMinuteIso }); + const snoozeAction = ( + + ); + return { + id: `usage-limit:${usageLimitKey}`, + variant: "warning", + icon: , + title: "Usage limit reached", + description: `Limits reset ${snoozeWakeDescription(usageLimitResetsAt, nowMinuteDate, timestampFormat)}`, + actions: snoozable ? ( + snoozeAction + ) : ( + + {snoozeAction}} /> + Snoozing is unavailable while work is pending + + ), + dismissLabel: "Dismiss usage limit notice", + onDismiss: () => setDismissedUsageLimitKeys((keys) => new Set(keys).add(usageLimitKey)), + }; + }, [ + activeThreadShell, + dismissedUsageLimitKeys, + handleSnoozeUntilUsageLimitReset, + nowMinuteDate, + nowMinuteIso, + timestampFormat, + usageLimitKey, + usageLimitPreset, + usageLimitResetsAt, + ]); // Session-scoped dismissals, one key per (thread, snapshot). A set rather // than a single slot so dismissing the banner on one thread does not // resurface it on another thread dismissed earlier. @@ -5615,10 +5716,12 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + const usageLimitItems = usageLimitBannerItem === null ? [] : [usageLimitBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...systemComposerBannerItems, ...backgroundLivenessItems, + ...usageLimitItems, ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, @@ -5627,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) { return [ ...systemComposerBannerItems, ...backgroundLivenessItems, + ...usageLimitItems, ...resumeCompactionItems, ...wokeThreadItems, { @@ -5679,6 +5783,7 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, + usageLimitBannerItem, wokeThreadBannerItem, ]); useEffect(() => { diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..0e62958a78f0 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -68,6 +68,35 @@ describe("resolveSnoozePresets", () => { expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i); expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00"); }); + + it("prepends the limits-reset preset, time-only today and weekday-qualified otherwise", () => { + const now = localDate(2026, 4, 8, 10); + const sameDay = resolveSnoozePresets(now, "24-hour", { + limitsResetAt: localDate(2026, 4, 8, 18).toISOString(), + }); + expect(sameDay[0]?.id).toBe("limits-reset"); + expect(sameDay[0]?.whenLabel).toBe("18:01"); + + const laterWeek = resolveSnoozePresets(now, "24-hour", { + limitsResetAt: localDate(2026, 4, 13, 9).toISOString(), + }); + expect(laterWeek[0]?.whenLabel).toMatch(/Mon/); + }); + + it("omits the limits-reset preset with no option or a past/malformed reset", () => { + const now = localDate(2026, 4, 8, 10); + expect(resolveSnoozePresets(now, "24-hour").some((p) => p.id === "limits-reset")).toBe(false); + expect( + resolveSnoozePresets(now, "24-hour", { limitsResetAt: null }).some( + (p) => p.id === "limits-reset", + ), + ).toBe(false); + expect( + resolveSnoozePresets(now, "24-hour", { + limitsResetAt: localDate(2026, 4, 8, 9).toISOString(), + }).some((p) => p.id === "limits-reset"), + ).toBe(false); + }); }); describe("snoozeWakeDescription", () => { diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..6f9159c3f88b 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -18,11 +18,18 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, + options?: { readonly limitsResetAt?: string | null }, ): ReadonlyArray { - return resolveSharedSnoozePresets(now).map((preset) => { + return resolveSharedSnoozePresets(now, options).map((preset) => { const wake = parseTimestampDate(preset.snoozedUntil); if (wake === null) return preset; const time = timeOfDayLabel(wake, timestampFormat); + if (preset.id === "limits-reset") { + return { + ...preset, + whenLabel: snoozeWakeDescription(preset.snoozedUntil, now, timestampFormat), + }; + } return { ...preset, whenLabel: @@ -45,9 +52,13 @@ export function snoozeWakeDescription( const wake = parseTimestampDate(snoozedUntil); if (wake === null) return ""; const time = timeOfDayLabel(wake, timestampFormat); + // Midnight to midnight, rounded: a DST day is 23 or 25 hours long, so a + // fixed 24-hour bucket would file a wake just past midnight on the wrong day. const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); - const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); + const startOfWakeDay = new Date(wake); + startOfWakeDay.setHours(0, 0, 0, 0); + const dayDelta = Math.round((startOfWakeDay.getTime() - startOfToday.getTime()) / DAY_MS); if (dayDelta === 0) return time; if (dayDelta === 1) return `tomorrow ${time}`; const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 77b7b0abf0a6..ea22560ffdff 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { exhaustedUntil } from "@t3tools/shared/usageLimits"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -34,6 +35,7 @@ import { type EnvironmentMachineKind, type ProjectIconOverride, type ScopedThreadRef, + type ServerProviderUsageLimits, type ThreadId, } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; @@ -420,13 +422,19 @@ function SnoozePopoverButton(props: { onOpenChange: (open: boolean) => void; onSnooze: (preset: SnoozePreset) => void; timestampFormat: TimestampFormat; + usageLimits: ServerProviderUsageLimits | undefined; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; + const { open, onOpenChange, onSnooze, timestampFormat, usageLimits } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( - () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), - [open, timestampFormat], + () => + open + ? resolveSnoozePresets(new Date(), timestampFormat, { + limitsResetAt: exhaustedUntil(usageLimits, Date.now()), + }) + : [], + [open, timestampFormat, usageLimits], ); return ( @@ -1632,6 +1640,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} timestampFormat={props.timestampFormat} + usageLimits={providerEntry?.snapshot.usageLimits} /> ) : null} {props.settlementSupported ? ( @@ -3293,7 +3302,18 @@ export default function Sidebar() { const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const menuProviderInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const menuProviderEntry = providerEntriesByEnvironment + .get(thread.environmentId) + ?.get(menuProviderInstanceId); + const menuLimitsResetAt = exhaustedUntil( + menuProviderEntry?.snapshot.usageLimits, + Date.now(), + ); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, { + limitsResetAt: menuLimitsResetAt, + }); const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index a66ea21b9891..ed7d314cf1b6 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -6,6 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { exhaustedUntil } from "@t3tools/shared/usageLimits"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; @@ -23,6 +24,7 @@ import { readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentSupportsTitleRegeneration, + readThreadProviderSnapshot, readThreadShell, useProjects, } from "../state/entities"; @@ -136,7 +138,10 @@ export function useThreadActionMenu(input: { titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; - const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const providerSnapshot = readThreadProviderSnapshot(threadRef); + const snoozePresets = resolveSnoozePresets(now, timestampFormat, { + limitsResetAt: exhaustedUntil(providerSnapshot?.usageLimits, now.getTime()), + }); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c44c5b437b63..17aa83be186f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -8,7 +8,12 @@ import { type EnvironmentThreadStatus, mergeEnvironmentThread, } from "@t3tools/client-runtime/state/threads"; -import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; +import type { + ScopedProjectRef, + ScopedThreadRef, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; @@ -183,6 +188,19 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } +/** The provider snapshot backing a thread's current model, or null when the + environment's config hasn't loaded or the instance isn't in it. Used to + read account-wide usage limits (e.g. for the limits-reset snooze offer) + without threading the whole provider list through every caller. */ +export function readThreadProviderSnapshot(threadRef: ScopedThreadRef): ServerProvider | null { + const thread = readThreadShell(threadRef); + if (thread === null) return null; + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providers = + appAtomRegistry.get(environmentServerConfigsAtom).get(threadRef.environmentId)?.providers ?? []; + return providers.find((provider) => provider.instanceId === instanceId) ?? null; +} + /** Whether the environment's server understands thread.settle/unsettle. False for pre-settlement servers (capability defaults false on decode), so clients under version skew fall back instead of erroring. */ diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..fc59e03903ca 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -36,6 +36,16 @@ On web and desktop, use Settings → Providers → **Models** to add an unlisted name and options. Only options supported by the provider integration affect turns. Antigravity uses its account catalog and does not support custom models. +## Usage limit snooze + +On web and desktop, when Claude or Codex reports that you have hit a usage limit, a notice shows +when your limits reset and offers to snooze the thread until a minute after that. Snoozing only +hides the thread from your active list until then — nothing resumes on its own, and you can wake +the thread at any time. Snoozing is unavailable while the thread is waiting on you or has a +message no turn has picked up yet, but the reset time still shows. Dismiss the notice to hide it +until the next limit, or let it disappear on its own once the reset time passes. The same option +also appears first in the thread's snooze menu while the limit is in force. + ## Model defaults T3 Code remembers your provider, model, and model options for new threads. A diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index f5209a09e499..9e7dccd6db04 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -171,7 +171,13 @@ const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; -export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; +export type SnoozePresetId = + | "limits-reset" + | "hour" + | "three-hours" + | "evening" + | "tomorrow" + | "next-week"; export interface SnoozePreset { readonly id: SnoozePresetId; @@ -202,14 +208,53 @@ function addSnoozeDays(base: Date, days: number): Date { return next; } +/** + * Waking exactly at the provider's reset instant races the limit still being + * in force, so the offered snooze clears it by a minute. + */ +const USAGE_LIMIT_SNOOZE_GRACE_MS = 60_000; + +/** + * The "Until limits reset" preset, shared by the composer banner offer and + * every thread snooze menu so the two entry points can never disagree on the + * wake time. Null when there is nothing to offer: no reset reported, a reset + * already in the past, or a reset at the edge of the representable Date + * range with no valid wake time once the grace is added. + */ +export function usageLimitSnoozePreset(resetsAt: string, now: Date): SnoozePreset | null { + const resetsAtMs = Date.parse(resetsAt); + if (Number.isNaN(resetsAtMs) || resetsAtMs <= now.getTime()) return null; + const wake = new Date(resetsAtMs + USAGE_LIMIT_SNOOZE_GRACE_MS); + if (Number.isNaN(wake.getTime())) return null; + // Day-aware like "Next week": a weekly reset days out must not read as + // a time today. + const time = snoozeTimeOfDayLabel(wake); + return { + id: "limits-reset", + label: "Until limits reset", + whenLabel: + wake.toDateString() === now.toDateString() + ? time + : `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}`, + snoozedUntil: wake.toISOString(), + }; +} + /** * Shared "snooze until" choices for every client. "This evening" only * appears while it is meaningfully before evening; after that the calendar * choices start at "Tomorrow". Calendar presets that land on the same * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday * morning, so only "Tomorrow" is offered. + * + * When `limitsResetAt` resolves to a preset, it is prepended — the same + * "snooze until the account serves again" offer the composer banner makes, + * surfaced everywhere a thread can be snoozed while the limit is in force. */ -export function resolveSnoozePresets(now: Date): ReadonlyArray { +export function resolveSnoozePresets( + now: Date, + options?: { readonly limitsResetAt?: string | null }, +): ReadonlyArray { const inAnHour = new Date(now.getTime() + HOUR_MS); const inThreeHours = new Date(now.getTime() + 3 * HOUR_MS); const presets: SnoozePreset[] = [ @@ -256,7 +301,9 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { }); } - return presets; + const limitsResetAt = options?.limitsResetAt; + const limitsPreset = limitsResetAt != null ? usageLimitSnoozePreset(limitsResetAt, now) : null; + return limitsPreset != null ? [limitsPreset, ...presets] : presets; } /** diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 8a62103950bf..c7b6cef6256f 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -11,6 +11,7 @@ import { snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, + usageLimitSnoozePreset, type ThreadSnoozeShell, } from "./threadSettled.ts"; import type { OrchestrationThreadShell } from "@t3tools/contracts"; @@ -371,3 +372,76 @@ describe("resolveSnoozePresets", () => { expect(tomorrow.getDay()).toBe(1); }); }); + +describe("usageLimitSnoozePreset", () => { + const RESETS_AT = "2026-04-10T14:00:00.000Z"; + + it("builds a preset a minute past the reset", () => { + expect(usageLimitSnoozePreset(RESETS_AT, new Date(NOW))).toEqual({ + id: "limits-reset", + label: "Until limits reset", + whenLabel: expect.any(String), + snoozedUntil: "2026-04-10T14:01:00.000Z", + }); + }); + + it("qualifies the time with a weekday when the reset is on another day", () => { + const now = new Date(2026, 3, 10, 12); + const preset = usageLimitSnoozePreset(new Date(2026, 3, 13, 9).toISOString(), now); + expect(preset?.whenLabel).toMatch(/^Mon /); + expect( + usageLimitSnoozePreset(new Date(2026, 3, 10, 18).toISOString(), now)?.whenLabel, + ).not.toMatch(/^[A-Z][a-z]{2} /); + }); + + it("is null once the reset has passed", () => { + expect(usageLimitSnoozePreset("2026-04-10T11:00:00.000Z", new Date(NOW))).toBeNull(); + }); + + it("is null on malformed reset data", () => { + expect(usageLimitSnoozePreset("not-a-date", new Date(NOW))).toBeNull(); + }); +}); + +describe("resolveSnoozePresets with limitsResetAt", () => { + const RESETS_AT = "2026-04-10T14:00:00.000Z"; + + it("prepends the limits-reset preset when the option resolves to one", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), { + limitsResetAt: RESETS_AT, + }); + expect(presets[0]?.id).toBe("limits-reset"); + expect(presets.map((preset) => preset.id)).toEqual([ + "limits-reset", + "hour", + "three-hours", + "evening", + "tomorrow", + "next-week", + ]); + }); + + it("omits the preset when no option is given", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10)); + expect(presets.some((preset) => preset.id === "limits-reset")).toBe(false); + }); + + it("omits the preset when limitsResetAt is null, past, or malformed", () => { + const now = localDate(2026, 4, 8, 10); + expect( + resolveSnoozePresets(now, { limitsResetAt: null }).some( + (preset) => preset.id === "limits-reset", + ), + ).toBe(false); + expect( + resolveSnoozePresets(now, { + limitsResetAt: new Date(now.getTime() - 1_000).toISOString(), + }).some((preset) => preset.id === "limits-reset"), + ).toBe(false); + expect( + resolveSnoozePresets(now, { limitsResetAt: "not-a-date" }).some( + (preset) => preset.id === "limits-reset", + ), + ).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 83ac6906c61e..f56743e1ba78 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -12,6 +12,7 @@ import { collectLimitSources, collectLimitsGroups, elapsedShare, + exhaustedUntil, formatResetsIn, limitsNotice, paceOf, @@ -71,6 +72,53 @@ describe("pace", () => { }); }); +describe("exhaustedUntil", () => { + it("reports the reset of an exhausted window", () => { + expect( + exhaustedUntil( + { checkedAt: now.toString(), windows: [{ ...window, usedPercent: 100 }] }, + now, + ), + ).toBe(window.resetsAt); + }); + + it("picks the later reset when more than one window is exhausted", () => { + const later = { + ...window, + id: "seven_day", + usedPercent: 100, + resetsAt: "2026-09-06T15:30:00.000Z", + }; + expect( + exhaustedUntil( + { checkedAt: now.toString(), windows: [{ ...window, usedPercent: 100 }, later] }, + now, + ), + ).toBe(later.resetsAt); + }); + + it("ignores a model-scoped bucket, which limits one model and not the account", () => { + const scoped = { ...window, id: "seven_day_fable", usedPercent: 100 }; + expect(exhaustedUntil({ checkedAt: now.toString(), windows: [scoped] }, now)).toBeNull(); + }); + + it("is null when nothing is exhausted or the reset has already passed", () => { + expect( + exhaustedUntil({ checkedAt: now.toString(), windows: [{ ...window, usedPercent: 99 }] }, now), + ).toBeNull(); + expect( + exhaustedUntil( + { + checkedAt: now.toString(), + windows: [{ ...window, usedPercent: 100, resetsAt: "2026-09-03T11:00:00.000Z" }], + }, + now, + ), + ).toBeNull(); + expect(exhaustedUntil(undefined, now)).toBeNull(); + }); +}); + describe("limitsNotice", () => { it("explains empty bars and passes provider messages through", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 8341796e39c3..b2b710484267 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -209,3 +209,36 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): if (resetsAt === null) return null; return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; } + +/** + * Windows that gate every turn on the account: Claude's session and weekly + * allowances and Codex's two positions. Claude's model-scoped buckets + * (`seven_day_`) only limit that one model, so they never count as + * the account being exhausted. + */ +const ACCOUNT_WIDE_WINDOW_IDS: ReadonlySet = new Set([ + "five_hour", + "seven_day", + "primary", + "secondary", +]); + +/** + * The latest reset among exhausted account-wide windows still ahead of `now`, + * or null when the account is serving. Every exhausted window has to clear + * before the account does, so the composer's "reset at" claim names the last. + */ +export function exhaustedUntil( + limits: ServerProviderUsageLimits | undefined, + now: number, +): string | null { + let latest: { readonly at: number; readonly resetsAt: string } | null = null; + for (const window of limits?.windows ?? []) { + if (!ACCOUNT_WIDE_WINDOW_IDS.has(window.id)) continue; + if (window.usedPercent < 100 || window.resetsAt === undefined) continue; + const at = resetMillis(window); + if (at === null || at <= now) continue; + if (latest === null || at > latest.at) latest = { at, resetsAt: window.resetsAt }; + } + return latest?.resetsAt ?? null; +}