From f3c5a87aea8bb2bcb6e1402fff5e446bec8ed4c2 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sat, 5 Sep 2026 05:41:38 +0200 Subject: [PATCH 1/8] feat(web): snooze a thread until provider limits reset When the provider instance behind a thread reports an exhausted usage window, the composer shows the reset time and offers to snooze the thread until a minute past it. Derived on the client from the provider snapshot that #9507 already publishes, so no contract, server, or migration change. Built with Claude Fable 5.1 in Claude Code. --- apps/web/src/components/ChatView.tsx | 148 +++++++++++++++++- docs/user/composer.md | 9 ++ .../client-runtime/src/state/threadSettled.ts | 45 ++++++ .../src/state/threadSnoozed.test.ts | 82 ++++++++++ packages/shared/src/usageLimits.test.ts | 48 ++++++ packages/shared/src/usageLimits.ts | 33 ++++ 6 files changed, 363 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d1906ff975c..f0c9ac0c5acb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -39,7 +39,11 @@ 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 { + effectiveSnoozed, + threadWokeAt, + usageLimitSnoozeOffer, +} from "@t3tools/client-runtime/state/thread-settled"; import { parseCodexFeedbackCommand, submitCodexFeedback, @@ -63,6 +67,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 +216,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 +1386,8 @@ 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, unsnoozeThread } = + 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,79 @@ export default function ChatView(props: ChatViewProps) { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsnoozeThreadMutation]); + // Derived from the provider snapshot #9507 already publishes: the latest + // reset among the instance's exhausted windows, or null while it is serving. + const usageLimitResetsAt = useMemo( + () => exhaustedUntil(conversationProviderStatus?.usageLimits, nowMinuteDate.getTime()), + [conversationProviderStatus?.usageLimits, nowMinuteDate], + ); + // Minute-quantized like the settle rules, so the offer expires on the same + // shared tick instead of needing a timer of its own. + const usageLimitOffer = useMemo( + () => + activeThreadShell === null || !supportsSnooze + ? null + : usageLimitSnoozeOffer(activeThreadShell, { + resetsAt: usageLimitResetsAt, + now: nowMinuteIso, + }), + [activeThreadShell, nowMinuteIso, supportsSnooze, usageLimitResetsAt], + ); + const [snoozingUsageLimitKey, setSnoozingUsageLimitKey] = useState(null); + const isSnoozingUsageLimit = + snoozingUsageLimitKey !== null && snoozingUsageLimitKey === activeThreadKey; + const handleSnoozeUntilUsageLimitReset = useCallback(async () => { + if (activeThreadRef === null || usageLimitOffer === null) return; + const threadRef = activeThreadRef; + const threadKey = scopedThreadKey(threadRef); + setSnoozingUsageLimitKey(threadKey); + try { + const result = await snoozeThread(threadRef, usageLimitOffer.snoozedUntil); + if (result._tag === "Failure") { + if (!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.", + }), + ); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(usageLimitOffer.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + // Undo targets the thread that was snoozed, not whatever is open + // when it is clicked: this toast outlives navigation by 5s, and + // handleUnsnoozeActiveThread resolves the active thread on click. + onClick: () => { + void unsnoozeThread(threadRef).then((undone) => { + if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { + const undoError = squashAtomCommandFailure(undone); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to wake thread", + description: + undoError instanceof Error ? undoError.message : "An error occurred.", + }), + ); + } + }); + }, + }, + }), + ); + } finally { + setSnoozingUsageLimitKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, snoozeThread, timestampFormat, unsnoozeThread, usageLimitOffer]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -5568,6 +5652,62 @@ 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 && usageLimitOffer ? `${activeThread.id}:${usageLimitOffer.resetsAt}` : 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 ( + usageLimitOffer === null || + usageLimitKey === null || + dismissedUsageLimitKeys.has(usageLimitKey) + ) { + return null; + } + const snoozeAction = ( + + ); + return { + id: `usage-limit:${usageLimitKey}`, + variant: "warning", + icon: , + title: "Usage limit reached", + description: `Limits reset ${snoozeWakeDescription(usageLimitOffer.resetsAt, nowMinuteDate, timestampFormat)}`, + actions: usageLimitOffer.snoozable ? ( + snoozeAction + ) : ( + + {snoozeAction}} /> + Snoozing is unavailable while work is pending + + ), + dismissLabel: "Dismiss usage limit notice", + onDismiss: () => setDismissedUsageLimitKeys((keys) => new Set(keys).add(usageLimitKey)), + }; + }, [ + dismissedUsageLimitKeys, + handleSnoozeUntilUsageLimitReset, + isSnoozingUsageLimit, + nowMinuteDate, + timestampFormat, + usageLimitKey, + usageLimitOffer, + ]); // 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 +5844,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 +5862,7 @@ export default function ChatView(props: ChatViewProps) { ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, + ...usageLimitItems, ...resumeCompactionItems, ...wokeThreadItems, { @@ -5774,6 +5917,7 @@ export default function ChatView(props: ChatViewProps) { showBranchMismatchBanner, systemComposerBannerItems, usageLimitsBanner, + usageLimitBannerItem, wokeThreadBannerItem, ]); useEffect(() => { diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..7aacd80bc9b2 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -36,6 +36,15 @@ 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. + ## 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..6fb9b0371fd3 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -259,6 +259,51 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { return presets; } +/** + * 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; + +export interface UsageLimitSnoozeOffer { + /** The reset the provider reported, for the "limits reset at" label. */ + readonly resetsAt: string; + /** Wake time to snooze to, a minute past the reset. */ + readonly snoozedUntil: string; + /** + * Whether snoozing is available right now. Telling the user about the limit + * matters even when it is not — the canonical case is a message the provider + * rejected, which leaves a queued turn start that snooze refuses for its + * grace window, exactly while the user is staring at the stall. + */ + readonly snoozable: boolean; +} + +/** + * The usage-limit offer for a thread, or null when there is nothing to say. A + * reset in the past is stale provider state, and a thread the user already + * parked has answered the offer. + */ +export function usageLimitSnoozeOffer( + shell: ThreadSnoozeShell & Pick, + options: { readonly resetsAt: string | null; readonly now: string }, +): UsageLimitSnoozeOffer | null { + const { resetsAt } = options; + if (resetsAt == null) return null; + const resetsAtMs = Date.parse(resetsAt); + if (Number.isNaN(resetsAtMs) || resetsAtMs <= Date.parse(options.now)) return null; + if (effectiveSnoozed(shell, options)) return null; + // A reset at the edge of the representable Date range has no valid wake + // time once the grace is added; treat it as malformed rather than throw. + const snoozedUntil = new Date(resetsAtMs + USAGE_LIMIT_SNOOZE_GRACE_MS); + if (Number.isNaN(snoozedUntil.getTime())) return null; + return { + resetsAt, + snoozedUntil: snoozedUntil.toISOString(), + snoozable: canSnooze(shell, options), + }; +} + /** * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes * round up so a snooze never reads "0m" while still hidden. Shared by web diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 8a62103950bf..b06cd93ac4af 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, + usageLimitSnoozeOffer, type ThreadSnoozeShell, } from "./threadSettled.ts"; import type { OrchestrationThreadShell } from "@t3tools/contracts"; @@ -371,3 +372,84 @@ describe("resolveSnoozePresets", () => { expect(tomorrow.getDay()).toBe(1); }); }); + +describe("usageLimitSnoozeOffer", () => { + const RESETS_AT = "2026-04-10T14:00:00.000Z"; + + function makeLimitShell( + input: Parameters[0] & { readonly latestUserMessageAt?: string }, + ) { + return { ...makeShell(input), latestUserMessageAt: input.latestUserMessageAt ?? null }; + } + + it("offers a snooze one minute past the reported reset", () => { + expect(usageLimitSnoozeOffer(makeLimitShell({}), { resetsAt: RESETS_AT, now: NOW })).toEqual({ + resetsAt: RESETS_AT, + snoozedUntil: "2026-04-10T14:01:00.000Z", + snoozable: true, + }); + }); + + it("offers nothing when no limit was reported", () => { + expect( + usageLimitSnoozeOffer(makeLimitShell({ sessionStatus: "ready" }), { + resetsAt: null, + now: NOW, + }), + ).toBeNull(); + }); + + it("offers nothing once the reset has passed", () => { + expect( + usageLimitSnoozeOffer(makeLimitShell({}), { + resetsAt: "2026-04-10T11:00:00.000Z", + now: NOW, + }), + ).toBeNull(); + }); + + it("offers nothing on malformed reset data", () => { + expect( + usageLimitSnoozeOffer(makeLimitShell({}), { resetsAt: "not-a-date", now: NOW }), + ).toBeNull(); + }); + + it("stops offering once the user has already snoozed the thread", () => { + expect( + usageLimitSnoozeOffer(makeLimitShell({ snoozedUntil: FUTURE_WAKE }), { + resetsAt: RESETS_AT, + now: NOW, + }), + ).toBeNull(); + }); + + // Visibility and actionability are separate: the reset time is worth showing + // even in the states snooze refuses, which is exactly when a user is stuck. + it("still reports the limit while the agent is blocked on the user, with snooze off", () => { + const offer = usageLimitSnoozeOffer(makeLimitShell({ pending: "approval" }), { + resetsAt: RESETS_AT, + now: NOW, + }); + expect(offer?.resetsAt).toBe(RESETS_AT); + expect(offer?.snoozable).toBe(false); + }); + + // The canonical path: the user sends a message, the provider rejects it for + // the limit, and no turn adopts it — leaving a queued turn start. + it("still reports the limit during the queued-turn-start grace, with snooze off", () => { + const offer = usageLimitSnoozeOffer( + makeLimitShell({ latestUserMessageAt: "2026-04-10T11:59:30.000Z" }), + { resetsAt: RESETS_AT, now: NOW }, + ); + expect(offer?.resetsAt).toBe(RESETS_AT); + expect(offer?.snoozable).toBe(false); + }); + + it("re-enables snooze once the queued-turn-start grace expires", () => { + const offer = usageLimitSnoozeOffer( + makeLimitShell({ latestUserMessageAt: "2026-04-10T11:50:00.000Z" }), + { resetsAt: RESETS_AT, now: NOW }, + ); + expect(offer?.snoozable).toBe(true); + }); +}); 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; +} From 20d2bd8d80b5f13d252949c050efc56abc8026b0 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sat, 5 Sep 2026 06:35:17 +0200 Subject: [PATCH 2/8] feat(web,mobile): offer the limits-reset snooze from the snooze menus The banner's "snooze until limits reset" now also leads every thread snooze menu while the limit is in force, built from the same preset so the two entry points can never disagree on the wake time. --- apps/mobile/src/features/home/HomeScreen.tsx | 19 ++--- .../threads/ThreadNavigationSidebar.tsx | 19 ++--- .../features/threads/thread-list-v2-items.tsx | 13 +++- .../src/features/threads/threadListV2.test.ts | 18 +++++ .../src/features/threads/threadListV2.ts | 7 +- .../web/src/components/Sidebar.snooze.test.ts | 29 +++++++ apps/web/src/components/Sidebar.snooze.ts | 18 ++++- apps/web/src/components/Sidebar.tsx | 28 ++++++- apps/web/src/hooks/useThreadActionMenu.ts | 7 +- apps/web/src/state/entities.ts | 20 ++++- docs/user/composer.md | 3 +- .../client-runtime/src/state/threadSettled.ts | 63 +++++++++++---- .../src/state/threadSnoozed.test.ts | 78 +++++++++++++++++++ 13 files changed, 275 insertions(+), 47 deletions(-) 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/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..bdb93b4a09a1 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -18,11 +18,15 @@ 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: dayAwareWhenLabel(wake, now, time) }; + } return { ...preset, whenLabel: @@ -33,6 +37,18 @@ export function resolveSnoozePresets( }); } +/** Time only when `wake` falls on the same calendar day as `now`, otherwise + weekday + time — the same day-aware split `snoozeWakeDescription` uses, + without its "tomorrow"/date-beyond-a-week special cases. */ +function dayAwareWhenLabel(wake: Date, now: Date, time: string): string { + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); + if (dayDelta === 0) return time; + const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); + return `${weekday} ${time}`; +} + /** * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", * "17:30" (today). diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5df4a9ea8b8b..fd2a634b9705 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, @@ -434,13 +435,14 @@ function SnoozePopoverButton(props: { onOpenChange: (open: boolean) => void; onSnooze: (preset: SnoozePreset) => void; timestampFormat: TimestampFormat; + limitsResetAt: string | null; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; + const { open, onOpenChange, onSnooze, timestampFormat, limitsResetAt } = 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 }) : []), + [open, timestampFormat, limitsResetAt], ); return ( @@ -1337,6 +1339,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useEffect(() => { if (!showSnoozeButton) setSnoozeMenuOpen(false); }, [showSnoozeButton]); + // Only worth reading while the popover is actually open — same lazy + // pattern as the presets themselves resolving at open time. + const snoozeLimitsResetAt = useMemo( + () => (snoozeMenuOpen ? exhaustedUntil(providerEntry?.snapshot.usageLimits, Date.now()) : null), + [snoozeMenuOpen, providerEntry], + ); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (!pr?.url) return; @@ -1847,6 +1855,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} timestampFormat={props.timestampFormat} + limitsResetAt={snoozeLimitsResetAt} /> ) : null} {props.settlementSupported ? ( @@ -3916,7 +3925,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 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 7aacd80bc9b2..fc59e03903ca 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -43,7 +43,8 @@ when your limits reset and offers to snooze the thread until a minute after that 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. +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 diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 6fb9b0371fd3..5ee734e308e6 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,47 @@ 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; + return { + id: "limits-reset", + label: "Until limits reset", + whenLabel: snoozeTimeOfDayLabel(wake), + 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,15 +295,11 @@ 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; } -/** - * 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; - export interface UsageLimitSnoozeOffer { /** The reset the provider reported, for the "limits reset at" label. */ readonly resetsAt: string; @@ -290,16 +325,12 @@ export function usageLimitSnoozeOffer( ): UsageLimitSnoozeOffer | null { const { resetsAt } = options; if (resetsAt == null) return null; - const resetsAtMs = Date.parse(resetsAt); - if (Number.isNaN(resetsAtMs) || resetsAtMs <= Date.parse(options.now)) return null; if (effectiveSnoozed(shell, options)) return null; - // A reset at the edge of the representable Date range has no valid wake - // time once the grace is added; treat it as malformed rather than throw. - const snoozedUntil = new Date(resetsAtMs + USAGE_LIMIT_SNOOZE_GRACE_MS); - if (Number.isNaN(snoozedUntil.getTime())) return null; + const preset = usageLimitSnoozePreset(resetsAt, new Date(options.now)); + if (preset === null) return null; return { resetsAt, - snoozedUntil: snoozedUntil.toISOString(), + snoozedUntil: preset.snoozedUntil, snoozable: canSnooze(shell, options), }; } diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index b06cd93ac4af..bbccdea531b3 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -12,6 +12,7 @@ import { threadRaisedHandWhileSnoozed, threadWokeAt, usageLimitSnoozeOffer, + usageLimitSnoozePreset, type ThreadSnoozeShell, } from "./threadSettled.ts"; import type { OrchestrationThreadShell } from "@t3tools/contracts"; @@ -453,3 +454,80 @@ describe("usageLimitSnoozeOffer", () => { expect(offer?.snoozable).toBe(true); }); }); + +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("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); + }); + + it("agrees with usageLimitSnoozeOffer on the wake time", () => { + const now = "2026-04-10T12:00:00.000Z"; + const shell = makeShell({}); + const offer = usageLimitSnoozeOffer( + { ...shell, latestUserMessageAt: null }, + { resetsAt: RESETS_AT, now }, + ); + const preset = resolveSnoozePresets(new Date(now), { limitsResetAt: RESETS_AT }).find( + (candidate) => candidate.id === "limits-reset", + ); + expect(preset?.snoozedUntil).toBe(offer?.snoozedUntil); + }); +}); From 053a7e934f9051cc9e089a4b56845f86b67007f6 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sat, 5 Sep 2026 10:32:21 +0200 Subject: [PATCH 3/8] refactor(web): trim the usage-limit snooze to one shared preset The banner now builds its offer from the same preset the menus use, and loses its own toast, tooltip and in-flight state: the parked-thread banner that replaces it already offers Wake now. --- apps/web/src/components/ChatView.tsx | 128 ++++++------------ apps/web/src/components/Sidebar.snooze.ts | 17 +-- apps/web/src/components/Sidebar.tsx | 22 +-- .../client-runtime/src/state/threadSettled.ts | 43 +----- .../src/state/threadSnoozed.test.ts | 104 ++------------ 5 files changed, 72 insertions(+), 242 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0c9ac0c5acb..abfe274cf48e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -40,9 +40,10 @@ import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { + canSnooze, effectiveSnoozed, threadWokeAt, - usageLimitSnoozeOffer, + usageLimitSnoozePreset, } from "@t3tools/client-runtime/state/thread-settled"; import { parseCodexFeedbackCommand, @@ -1386,8 +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, snoozeThread, unsnoozeThread } = - useThreadActions(); + const { settleThread, pinThread, confirmAndUnpinThread, snoozeThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -5348,79 +5348,37 @@ export default function ChatView(props: ChatViewProps) { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsnoozeThreadMutation]); - // Derived from the provider snapshot #9507 already publishes: the latest - // reset among the instance's exhausted windows, or null while it is serving. + // 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], ); - // Minute-quantized like the settle rules, so the offer expires on the same - // shared tick instead of needing a timer of its own. - const usageLimitOffer = useMemo( + // 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( () => - activeThreadShell === null || !supportsSnooze + usageLimitResetsAt === null || !supportsSnooze || activeThreadSnoozed ? null - : usageLimitSnoozeOffer(activeThreadShell, { - resetsAt: usageLimitResetsAt, - now: nowMinuteIso, - }), - [activeThreadShell, nowMinuteIso, supportsSnooze, usageLimitResetsAt], + : usageLimitSnoozePreset(usageLimitResetsAt, nowMinuteDate), + [activeThreadSnoozed, nowMinuteDate, supportsSnooze, usageLimitResetsAt], ); - const [snoozingUsageLimitKey, setSnoozingUsageLimitKey] = useState(null); - const isSnoozingUsageLimit = - snoozingUsageLimitKey !== null && snoozingUsageLimitKey === activeThreadKey; const handleSnoozeUntilUsageLimitReset = useCallback(async () => { - if (activeThreadRef === null || usageLimitOffer === null) return; - const threadRef = activeThreadRef; - const threadKey = scopedThreadKey(threadRef); - setSnoozingUsageLimitKey(threadKey); - try { - const result = await snoozeThread(threadRef, usageLimitOffer.snoozedUntil); - if (result._tag === "Failure") { - if (!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.", - }), - ); - } - return; - } + 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: "success", - title: `Snoozed until ${snoozeWakeDescription(usageLimitOffer.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - // Undo targets the thread that was snoozed, not whatever is open - // when it is clicked: this toast outlives navigation by 5s, and - // handleUnsnoozeActiveThread resolves the active thread on click. - onClick: () => { - void unsnoozeThread(threadRef).then((undone) => { - if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { - const undoError = squashAtomCommandFailure(undone); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to wake thread", - description: - undoError instanceof Error ? undoError.message : "An error occurred.", - }), - ); - } - }); - }, - }, + type: "error", + title: "Failed to snooze thread", + description: error instanceof Error ? error.message : "An error occurred.", }), ); - } finally { - setSnoozingUsageLimitKey((current) => (current === threadKey ? null : current)); } - }, [activeThreadRef, snoozeThread, timestampFormat, unsnoozeThread, usageLimitOffer]); + }, [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 @@ -5658,55 +5616,51 @@ export default function ChatView(props: ChatViewProps) { new Set(), ); const usageLimitKey = - activeThread && usageLimitOffer ? `${activeThread.id}:${usageLimitOffer.resetsAt}` : null; + 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 ( - usageLimitOffer === null || + usageLimitPreset === null || + usageLimitResetsAt === null || usageLimitKey === null || + activeThreadShell === null || dismissedUsageLimitKeys.has(usageLimitKey) ) { return null; } - const snoozeAction = ( - - ); + const snoozable = canSnooze(activeThreadShell, { now: nowMinuteIso }); return { id: `usage-limit:${usageLimitKey}`, variant: "warning", icon: , title: "Usage limit reached", - description: `Limits reset ${snoozeWakeDescription(usageLimitOffer.resetsAt, nowMinuteDate, timestampFormat)}`, - actions: usageLimitOffer.snoozable ? ( - snoozeAction - ) : ( - - {snoozeAction}} /> - Snoozing is unavailable while work is pending - + description: `Limits reset ${snoozeWakeDescription(usageLimitResetsAt, nowMinuteDate, timestampFormat)}`, + actions: ( + ), dismissLabel: "Dismiss usage limit notice", onDismiss: () => setDismissedUsageLimitKeys((keys) => new Set(keys).add(usageLimitKey)), }; }, [ + activeThreadShell, dismissedUsageLimitKeys, handleSnoozeUntilUsageLimitReset, - isSnoozingUsageLimit, nowMinuteDate, + nowMinuteIso, timestampFormat, usageLimitKey, - usageLimitOffer, + 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 diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index bdb93b4a09a1..78a023514f93 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -25,7 +25,10 @@ export function resolveSnoozePresets( if (wake === null) return preset; const time = timeOfDayLabel(wake, timestampFormat); if (preset.id === "limits-reset") { - return { ...preset, whenLabel: dayAwareWhenLabel(wake, now, time) }; + return { + ...preset, + whenLabel: snoozeWakeDescription(preset.snoozedUntil, now, timestampFormat), + }; } return { ...preset, @@ -37,18 +40,6 @@ export function resolveSnoozePresets( }); } -/** Time only when `wake` falls on the same calendar day as `now`, otherwise - weekday + time — the same day-aware split `snoozeWakeDescription` uses, - without its "tomorrow"/date-beyond-a-week special cases. */ -function dayAwareWhenLabel(wake: Date, now: Date, time: string): string { - const startOfToday = new Date(now); - startOfToday.setHours(0, 0, 0, 0); - const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); - if (dayDelta === 0) return time; - const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); - return `${weekday} ${time}`; -} - /** * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", * "17:30" (today). diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fd2a634b9705..13c56559b8fb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -31,6 +31,7 @@ import { type EnvironmentMachineKind, type ProjectIconOverride, type ScopedThreadRef, + type ServerProviderUsageLimits, type ThreadId, } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; @@ -435,14 +436,19 @@ function SnoozePopoverButton(props: { onOpenChange: (open: boolean) => void; onSnooze: (preset: SnoozePreset) => void; timestampFormat: TimestampFormat; - limitsResetAt: string | null; + usageLimits: ServerProviderUsageLimits | undefined; }) { - const { open, onOpenChange, onSnooze, timestampFormat, limitsResetAt } = 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, { limitsResetAt }) : []), - [open, timestampFormat, limitsResetAt], + () => + open + ? resolveSnoozePresets(new Date(), timestampFormat, { + limitsResetAt: exhaustedUntil(usageLimits, Date.now()), + }) + : [], + [open, timestampFormat, usageLimits], ); return ( @@ -1339,12 +1345,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useEffect(() => { if (!showSnoozeButton) setSnoozeMenuOpen(false); }, [showSnoozeButton]); - // Only worth reading while the popover is actually open — same lazy - // pattern as the presets themselves resolving at open time. - const snoozeLimitsResetAt = useMemo( - () => (snoozeMenuOpen ? exhaustedUntil(providerEntry?.snapshot.usageLimits, Date.now()) : null), - [snoozeMenuOpen, providerEntry], - ); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (!pr?.url) return; @@ -1855,7 +1855,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} timestampFormat={props.timestampFormat} - limitsResetAt={snoozeLimitsResetAt} + usageLimits={providerEntry?.snapshot.usageLimits} /> ) : null} {props.settlementSupported ? ( diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 5ee734e308e6..9e7dccd6db04 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -226,10 +226,16 @@ export function usageLimitSnoozePreset(resetsAt: string, now: Date): SnoozePrese 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: snoozeTimeOfDayLabel(wake), + whenLabel: + wake.toDateString() === now.toDateString() + ? time + : `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}`, snoozedUntil: wake.toISOString(), }; } @@ -300,41 +306,6 @@ export function resolveSnoozePresets( return limitsPreset != null ? [limitsPreset, ...presets] : presets; } -export interface UsageLimitSnoozeOffer { - /** The reset the provider reported, for the "limits reset at" label. */ - readonly resetsAt: string; - /** Wake time to snooze to, a minute past the reset. */ - readonly snoozedUntil: string; - /** - * Whether snoozing is available right now. Telling the user about the limit - * matters even when it is not — the canonical case is a message the provider - * rejected, which leaves a queued turn start that snooze refuses for its - * grace window, exactly while the user is staring at the stall. - */ - readonly snoozable: boolean; -} - -/** - * The usage-limit offer for a thread, or null when there is nothing to say. A - * reset in the past is stale provider state, and a thread the user already - * parked has answered the offer. - */ -export function usageLimitSnoozeOffer( - shell: ThreadSnoozeShell & Pick, - options: { readonly resetsAt: string | null; readonly now: string }, -): UsageLimitSnoozeOffer | null { - const { resetsAt } = options; - if (resetsAt == null) return null; - if (effectiveSnoozed(shell, options)) return null; - const preset = usageLimitSnoozePreset(resetsAt, new Date(options.now)); - if (preset === null) return null; - return { - resetsAt, - snoozedUntil: preset.snoozedUntil, - snoozable: canSnooze(shell, options), - }; -} - /** * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes * round up so a snooze never reads "0m" while still hidden. Shared by web diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index bbccdea531b3..c7b6cef6256f 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -11,7 +11,6 @@ import { snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, - usageLimitSnoozeOffer, usageLimitSnoozePreset, type ThreadSnoozeShell, } from "./threadSettled.ts"; @@ -374,87 +373,6 @@ describe("resolveSnoozePresets", () => { }); }); -describe("usageLimitSnoozeOffer", () => { - const RESETS_AT = "2026-04-10T14:00:00.000Z"; - - function makeLimitShell( - input: Parameters[0] & { readonly latestUserMessageAt?: string }, - ) { - return { ...makeShell(input), latestUserMessageAt: input.latestUserMessageAt ?? null }; - } - - it("offers a snooze one minute past the reported reset", () => { - expect(usageLimitSnoozeOffer(makeLimitShell({}), { resetsAt: RESETS_AT, now: NOW })).toEqual({ - resetsAt: RESETS_AT, - snoozedUntil: "2026-04-10T14:01:00.000Z", - snoozable: true, - }); - }); - - it("offers nothing when no limit was reported", () => { - expect( - usageLimitSnoozeOffer(makeLimitShell({ sessionStatus: "ready" }), { - resetsAt: null, - now: NOW, - }), - ).toBeNull(); - }); - - it("offers nothing once the reset has passed", () => { - expect( - usageLimitSnoozeOffer(makeLimitShell({}), { - resetsAt: "2026-04-10T11:00:00.000Z", - now: NOW, - }), - ).toBeNull(); - }); - - it("offers nothing on malformed reset data", () => { - expect( - usageLimitSnoozeOffer(makeLimitShell({}), { resetsAt: "not-a-date", now: NOW }), - ).toBeNull(); - }); - - it("stops offering once the user has already snoozed the thread", () => { - expect( - usageLimitSnoozeOffer(makeLimitShell({ snoozedUntil: FUTURE_WAKE }), { - resetsAt: RESETS_AT, - now: NOW, - }), - ).toBeNull(); - }); - - // Visibility and actionability are separate: the reset time is worth showing - // even in the states snooze refuses, which is exactly when a user is stuck. - it("still reports the limit while the agent is blocked on the user, with snooze off", () => { - const offer = usageLimitSnoozeOffer(makeLimitShell({ pending: "approval" }), { - resetsAt: RESETS_AT, - now: NOW, - }); - expect(offer?.resetsAt).toBe(RESETS_AT); - expect(offer?.snoozable).toBe(false); - }); - - // The canonical path: the user sends a message, the provider rejects it for - // the limit, and no turn adopts it — leaving a queued turn start. - it("still reports the limit during the queued-turn-start grace, with snooze off", () => { - const offer = usageLimitSnoozeOffer( - makeLimitShell({ latestUserMessageAt: "2026-04-10T11:59:30.000Z" }), - { resetsAt: RESETS_AT, now: NOW }, - ); - expect(offer?.resetsAt).toBe(RESETS_AT); - expect(offer?.snoozable).toBe(false); - }); - - it("re-enables snooze once the queued-turn-start grace expires", () => { - const offer = usageLimitSnoozeOffer( - makeLimitShell({ latestUserMessageAt: "2026-04-10T11:50:00.000Z" }), - { resetsAt: RESETS_AT, now: NOW }, - ); - expect(offer?.snoozable).toBe(true); - }); -}); - describe("usageLimitSnoozePreset", () => { const RESETS_AT = "2026-04-10T14:00:00.000Z"; @@ -467,6 +385,15 @@ describe("usageLimitSnoozePreset", () => { }); }); + 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(); }); @@ -517,17 +444,4 @@ describe("resolveSnoozePresets with limitsResetAt", () => { ), ).toBe(false); }); - - it("agrees with usageLimitSnoozeOffer on the wake time", () => { - const now = "2026-04-10T12:00:00.000Z"; - const shell = makeShell({}); - const offer = usageLimitSnoozeOffer( - { ...shell, latestUserMessageAt: null }, - { resetsAt: RESETS_AT, now }, - ); - const preset = resolveSnoozePresets(new Date(now), { limitsResetAt: RESETS_AT }).find( - (candidate) => candidate.id === "limits-reset", - ); - expect(preset?.snoozedUntil).toBe(offer?.snoozedUntil); - }); }); From a47220c2b0d1759eb258a407271e13039fb7f18a Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sat, 5 Sep 2026 11:19:32 +0200 Subject: [PATCH 4/8] fix(web): describe wake times by calendar day, not 24-hour buckets A DST day is 23 or 25 hours long, so dividing elapsed time since midnight by a fixed day filed a wake just past midnight on the wrong day. --- apps/web/src/components/Sidebar.snooze.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index 78a023514f93..6f9159c3f88b 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -52,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" }); From d46aaf84fa26d70cd2389c81d1a0d5316a9750b9 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sat, 5 Sep 2026 11:33:59 +0200 Subject: [PATCH 5/8] fix(web): shape the usage-limit action like the other banner actions Ghost button, and the shared Tooltip primitive for the disabled reason, mirroring the resume-compaction action in the same stack. --- apps/web/src/components/ChatView.tsx | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index abfe274cf48e..73f685473ef3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5631,22 +5631,29 @@ export default function ChatView(props: ChatViewProps) { 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: ( - + actions: snoozable ? ( + snoozeAction + ) : ( + + {snoozeAction}} /> + Snoozing is unavailable while work is pending + ), dismissLabel: "Dismiss usage limit notice", onDismiss: () => setDismissedUsageLimitKeys((keys) => new Set(keys).add(usageLimitKey)), From 333b9423c9af6a24c83b6ea70af1a26f6cf2d902 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 7 Sep 2026 16:05:18 +0200 Subject: [PATCH 6/8] fix(web): ignore a limits-reset snooze row once its reset has passed The popover resolves its presets when it opens, so a menu left open past the reset would snooze the thread into the past. --- apps/web/src/components/Sidebar.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 13c56559b8fb..862d8a6240fc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -480,6 +480,9 @@ function SnoozePopoverButton(props: { onClick={(event) => { event.stopPropagation(); onOpenChange(false); + // A menu left open past the limit's reset would snooze into the + // past; that row simply stops applying, like mobile's menu. + if (Date.parse(preset.snoozedUntil) <= Date.now()) 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" From 35661c1d1c5b1cb062691c40707cea902bbbf0a2 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 7 Sep 2026 16:09:47 +0200 Subject: [PATCH 7/8] fix(web): refresh the row menu's limits-reset preset when providers change The context-menu callback read providerEntriesByEnvironment without listing it, so a newly exhausted limit did not reach the menu until an unrelated dependency changed. --- apps/web/src/components/Sidebar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 862d8a6240fc..aa18ff02672d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4143,6 +4143,7 @@ export default function Sidebar() { markThreadUnread, openProjectSettings, projectCwdByKey, + providerEntriesByEnvironment, serverConfigs, startThreadRename, updateThreadMetadata, From 7816f8b1af1b9448c8dd4c49553d68d476a247e4 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 7 Sep 2026 16:22:13 +0200 Subject: [PATCH 8/8] fix(web): skip an expired limits-reset preset in every snooze menu The row context menu and the chat-header menu resolve presets when they open, like the popover, so share one check across the three. --- apps/web/src/components/Sidebar.snooze.ts | 9 +++++++++ apps/web/src/components/Sidebar.tsx | 7 +++---- apps/web/src/hooks/useThreadActionMenu.ts | 8 ++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index 6f9159c3f88b..848916f2e2b7 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -40,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). diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aa18ff02672d..bcd8e7cb43b0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -191,6 +191,7 @@ import { } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, + snoozePresetExpired, snoozeWakeDescription, snoozeWakeLabel, type SnoozePreset, @@ -480,9 +481,7 @@ function SnoozePopoverButton(props: { onClick={(event) => { event.stopPropagation(); onOpenChange(false); - // A menu left open past the limit's reset would snooze into the - // past; that row simply stops applying, like mobile's menu. - if (Date.parse(preset.snoozedUntil) <= Date.now()) return; + 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" @@ -3967,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) { diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index ed7d314cf1b6..afcb5af6f078 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -11,7 +11,11 @@ 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, @@ -158,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)) {