diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e628eea08e31..68f71f32d879 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"; @@ -823,6 +824,13 @@ export function HomeScreen(props: HomeScreenProps) { const thread = item.item.thread; const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; const movedId = `${thread.environmentId}:${thread.id}`; + 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 d3cd65fe8a7b..fee0b346fa44 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; +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"; @@ -889,6 +890,13 @@ function ThreadNavigationSidebarPane( : threadMovePlanners.active; const movedId = `${thread.environmentId}:${thread.id}`; 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 c2e66ece53de..ff8cee8362b4 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -350,6 +350,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 @@ -485,8 +489,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( () => @@ -606,6 +613,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); @@ -628,6 +636,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 0ee78b2e3fa6..1440b052878a 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -114,6 +114,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 3629e63df462..4a72198822ea 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -40,15 +40,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 6d1906ff975c..73f685473ef3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -39,7 +39,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 { parseCodexFeedbackCommand, submitCodexFeedback, @@ -63,6 +68,7 @@ import { } 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, @@ -211,6 +217,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, @@ -1380,7 +1387,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], @@ -5222,6 +5229,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 && @@ -5337,6 +5348,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 @@ -5568,6 +5610,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. @@ -5704,12 +5805,14 @@ export default function ChatView(props: ChatViewProps) { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; + const usageLimitItems = usageLimitBannerItem === null ? [] : [usageLimitBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, + ...usageLimitItems, ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, @@ -5720,6 +5823,7 @@ export default function ChatView(props: ChatViewProps) { ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, + ...usageLimitItems, ...resumeCompactionItems, ...wokeThreadItems, { @@ -5774,6 +5878,7 @@ export default function ChatView(props: ChatViewProps) { showBranchMismatchBanner, systemComposerBannerItems, usageLimitsBanner, + 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..848916f2e2b7 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: @@ -33,6 +40,15 @@ export function resolveSnoozePresets( }); } +/** + * Menus resolve their presets when they open, so a `limits-reset` row left on + * screen past the reset would snooze into the past. Only that row can expire; + * the others are relative to the open time. + */ +export function snoozePresetExpired(preset: SnoozePreset, now = Date.now()): boolean { + return Date.parse(preset.snoozedUntil) <= now; +} + /** * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", * "17:30" (today). @@ -45,9 +61,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 5df4a9ea8b8b..bcd8e7cb43b0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -18,6 +18,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 { parseScopedThreadKey, @@ -30,6 +31,7 @@ import { type EnvironmentMachineKind, type ProjectIconOverride, type ScopedThreadRef, + type ServerProviderUsageLimits, type ThreadId, } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; @@ -189,6 +191,7 @@ import { } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, + snoozePresetExpired, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset, @@ -434,13 +437,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 ( @@ -472,6 +481,7 @@ function SnoozePopoverButton(props: { onClick={(event) => { event.stopPropagation(); onOpenChange(false); + if (snoozePresetExpired(preset)) return; onSnooze(preset); }} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground" @@ -1847,6 +1857,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} timestampFormat={props.timestampFormat} + usageLimits={providerEntry?.snapshot.usageLimits} /> ) : null} {props.settlementSupported ? ( @@ -3916,7 +3927,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({ @@ -3944,7 +3966,7 @@ export default function Sidebar() { const preset = snoozePresets.find( (candidate) => `snooze:${candidate.id}` === clicked.value, ); - if (preset) attemptSnooze(threadRef, preset); + if (preset && !snoozePresetExpired(preset)) attemptSnooze(threadRef, preset); return; } switch (clicked.value) { @@ -4120,6 +4142,7 @@ export default function Sidebar() { markThreadUnread, openProjectSettings, projectCwdByKey, + providerEntriesByEnvironment, serverConfigs, startThreadRename, updateThreadMetadata, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index a66ea21b9891..afcb5af6f078 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -6,11 +6,16 @@ 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"; -import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; +import { + resolveSnoozePresets, + snoozePresetExpired, + snoozeWakeDescription, +} from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, type ThreadActionMenuId, @@ -23,6 +28,7 @@ import { readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentSupportsTitleRegeneration, + readThreadProviderSnapshot, readThreadShell, useProjects, } from "../state/entities"; @@ -136,7 +142,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, @@ -153,7 +162,7 @@ export function useThreadActionMenu(input: { const action: ThreadActionMenuId = clicked.value; if (action.startsWith("snooze:")) { const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); - if (!preset) return; + if (!preset || snoozePresetExpired(preset)) return; const result = await snoozeThread(threadRef, preset.snoozedUntil); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index d9610e20717f..8a1dadfc748e 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 48eb87ce0c2d..58e9fa99795e 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -19,6 +19,7 @@ import { collectLimitSources, collectLimitsGroups, elapsedShare, + exhaustedUntil, formatResetsIn, limitsNotice, paceOf, @@ -79,6 +80,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 784b1ada3e31..f09402449099 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -727,3 +727,36 @@ export function collectProviderUsageLimits( } return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; } + +/** + * 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; +}