diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b5359dbd7b0b6..e3fd2ee6743c8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3114,7 +3114,7 @@ export default function ChatView(props: ChatViewProps) { onOpenProviderSetup: openProviderSetupStable, onOpenTerminal: () => setTerminalOpenStable(true), onProviderModelSelect: onProviderModelSelectStable, - onShowLimits: usageLimitsOffered && usageLimitsKey !== null ? openUsageLimits : null, + limitsAvailable: usageLimitsOffered && usageLimitsKey !== null, threadId: activeThread?.id ?? null, threadError: timelineThreadError, threadErrorClass: threadErrorIsServerRuntimeError diff --git a/apps/web/src/components/chat/ChatBannerLane.tsx b/apps/web/src/components/chat/ChatBannerLane.tsx index 95025e4745120..b00830b679c1b 100644 --- a/apps/web/src/components/chat/ChatBannerLane.tsx +++ b/apps/web/src/components/chat/ChatBannerLane.tsx @@ -31,7 +31,7 @@ export function useChatBannerLane(input: { readonly onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; readonly onOpenTerminal: () => void; readonly onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; - readonly onShowLimits: (() => void) | null; + readonly limitsAvailable: boolean; readonly threadId: ThreadId | null; readonly threadError: string | null; readonly threadErrorClass: OrchestrationV2ProviderFailureClass | null; @@ -54,7 +54,7 @@ export function useChatBannerLane(input: { onOpenProviderSetup, onOpenTerminal, onProviderModelSelect, - onShowLimits, + limitsAvailable, threadId, threadError, threadErrorClass, @@ -106,7 +106,15 @@ export function useChatBannerLane(input: { switchTarget: lowLimitSwitchTarget, onSwitch: (target) => onProviderModelSelect(target.instanceId, getDefaultServerModel([target], target.driver)), - onShowLimits, + limits: + limitsAvailable && activeProviderStatus !== null + ? { + environmentId, + instanceId: activeProviderStatus.instanceId, + providers: providerStatuses, + sources: usageLimitSources, + } + : null, onDismissed: () => setTrayNoticeDismissTick((tick) => tick + 1), }), ); diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 64994ce07bf1d..48b78652cab59 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -1,6 +1,7 @@ import { type EnvironmentId, isProviderAvailable, + type ProviderInstanceId, type ServerProvider, type ServerProviderUsageWindow, type UsageLimitSourceSnapshots, @@ -9,6 +10,7 @@ import { import type { TimestampFormat } from "@t3tools/contracts/settings"; import { collectLimitAccounts, + collectProviderUsageLimits, formatResetsIn, formatRunsOutIn, isLowLimit, @@ -17,11 +19,13 @@ import { tightestSessionLimit, } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; +import { useRef, useState } from "react"; import { useNowMinute } from "../../hooks/useNowMinute"; import { ensureLocalApi } from "../../localApi"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; @@ -165,63 +169,77 @@ function UsageLimitsBannerBody({ readonly report: UsageLimitsReport; readonly environmentId: EnvironmentId; }) { - const now = Date.parse(report.createdAt); return ( - {report.accounts.map((account) => { - const resetCreditInput = - account.resetCreditInput ?? - (account.instanceId ? { instanceId: account.instanceId } : undefined); - const notice = limitsNotice(account.limits); - const externalUsage = account.limits.externalUsage; - return ( -
- - - - {notice ? ( - {notice} - ) : ( - - )} - {externalUsage ? ( - - ) : null} - {resetCreditInput && account.limits.resetCredits ? ( - - ) : null} -
- ); - })} - {report.notices.map((notice) => ( - - {notice} - - ))} +
); } +function UsageLimitsAccounts({ + report, + environmentId, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; +}) { + const now = Date.parse(report.createdAt); + return ( + <> + {report.accounts.map((account) => { + const resetCreditInput = + account.resetCreditInput ?? + (account.instanceId ? { instanceId: account.instanceId } : undefined); + const notice = limitsNotice(account.limits); + const externalUsage = account.limits.externalUsage; + return ( +
+ + + + {notice ? ( + {notice} + ) : ( + + )} + {externalUsage ? ( + + ) : null} + {resetCreditInput && account.limits.resetCredits ? ( + + ) : null} +
+ ); + })} + {report.notices.map((notice) => ( + + {notice} + + ))} + + ); +} + /** * The session a low-limit notice watches: the selected instance's own account * when it reports, else the tightest account of that driver a hub reports. @@ -340,18 +358,64 @@ function LowLimitTiming({ window }: { readonly window: ServerProviderUsageWindow ); } +export interface LimitsSource { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly providers: readonly ServerProvider[]; + readonly sources: UsageLimitSourceSnapshots; +} + +function LimitsPopover({ source }: { readonly source: LimitsSource }) { + const popupRef = useRef(null); + return ( + + }>Limits + + + + + ); +} + +function LimitsPopoverReport({ source }: { readonly source: LimitsSource }) { + const [openedAt] = useState(Date.now); + const report = collectProviderUsageLimits( + source.instanceId, + source.providers, + source.sources, + openedAt, + ); + return ( +
+ {report ? ( + + ) : ( + Usage limits are unavailable + )} +
+ ); +} + /** The notice for a session under a quarter left, with a way over to another provider. */ export function lowLimitBannerItem({ signal, switchTarget, onSwitch, - onShowLimits, + limits, onDismissed, }: { readonly signal: LowLimitSignal; readonly switchTarget: ServerProvider | null; readonly onSwitch: (provider: ServerProvider) => void; - readonly onShowLimits: (() => void) | null; + readonly limits: LimitsSource | null; readonly onDismissed: () => void; }): ComposerBannerStackItem { return { @@ -362,11 +426,7 @@ export function lowLimitBannerItem({ description: , actions: ( <> - {onShowLimits ? ( - - ) : null} + {limits ? : null} {switchTarget ? (