import React from 'react'; import { Icon } from '@/components/icon/Icon'; import type { IconName } from '@/components/icon/icons'; import { preloadProviderLogos } from '@/hooks/useProviderLogo'; import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; import { clampPercent, resolveUsageTone } from '@/lib/quota'; import { UsageProviderCards } from '@/components/usage/UsageProviderCards'; import { useUsageProviderGroups, type UsageProviderGroup } from '@/components/usage/usageGroups'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionMessages } from '@/sync/sync-context'; const TABLET_METADATA_POPOVER_WIDTH = 380; const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => { if (!limit || typeof limit !== 'object') return undefined; const value = (limit as Partial>)[key]; return typeof value === 'number' && Number.isFinite(value) ? value : undefined; }; const getTokenCount = (value: unknown): number => ( typeof value === 'number' && Number.isFinite(value) ? value : 0 ); const formatTokens = (value: number): string => { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; return String(value); }; type ContextDisplay = { percentage: number; tokens: string; colorClass: string; } | null; const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => { const progressPct = clampPercent(percentage) ?? 0; const tone = resolveUsageTone(percentage); const progressColor = tone === 'critical' ? 'var(--status-error)' : tone === 'warn' ? 'var(--status-warning)' : 'var(--status-success)'; const size = 18; const stroke = 3; const radius = (size - stroke) / 2; const circumference = 2 * Math.PI * radius; return ( ); }; const MetadataRow: React.FC<{ icon?: IconName; iconNode?: React.ReactNode; label: string; children: React.ReactNode; }> = ({ icon, iconNode, label, children }) => (
{iconNode ?? (icon ? : null)} {label} {children}
); const SessionMetadataOverlay: React.FC<{ open: boolean; onClose: () => void; anchorRef: React.RefObject; contextDisplay: ContextDisplay; usageGroups: UsageProviderGroup[]; usageDisplayMode: 'usage' | 'remaining'; isUsageLoading: boolean; timeFormatPreference: TimeFormatPreference; }> = ({ open, onClose, anchorRef, contextDisplay, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => { const { t } = useI18n(); const panelRef = React.useRef(null); const [shouldRender, setShouldRender] = React.useState(open); const [isExiting, setIsExiting] = React.useState(false); // Tablet: a phone-width sheet stretched across the whole chat column looks // broken — render a popover anchored to the metadata button instead. const { enabled: isTabletLayout } = useTabletLayout(); const wrapperRef = React.useRef(null); const [anchorLeft, setIpadAnchorLeft] = React.useState(null); // The shell has transformed ancestors, so the fixed wrapper's containing // block is the chat column, NOT the viewport. Anchor the popover in the // wrapper's own coordinate space — viewport-based lefts would double-count // the sidebar offset. React.useLayoutEffect(() => { if (!open || !isTabletLayout || !shouldRender) return; const compute = () => { const anchorRect = anchorRef.current?.getBoundingClientRect(); const wrapperRect = wrapperRef.current?.getBoundingClientRect(); if (!anchorRect || !wrapperRect) { setIpadAnchorLeft(null); return; } const relativeLeft = anchorRect.left - wrapperRect.left; const left = Math.min( Math.max(relativeLeft, 8), Math.max(8, wrapperRect.width - TABLET_METADATA_POPOVER_WIDTH - 8), ); setIpadAnchorLeft(left); }; compute(); // Re-anchor if the chat column shifts while the popover is open (sidebar // toggle/resize, orientation change) — the header buttons move with it. const wrapper = wrapperRef.current; if (typeof ResizeObserver === 'undefined' || !wrapper) return; const observer = new ResizeObserver(compute); observer.observe(wrapper); return () => observer.disconnect(); }, [anchorRef, isTabletLayout, open, shouldRender]); const isPopover = isTabletLayout && anchorLeft !== null; React.useEffect(() => { if (open) { setShouldRender(true); setIsExiting(false); return; } if (!shouldRender) return; setIsExiting(true); const timeoutId = window.setTimeout(() => { setShouldRender(false); setIsExiting(false); }, 140); return () => window.clearTimeout(timeoutId); }, [open, shouldRender]); React.useEffect(() => { if (!open) return; const handleKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [onClose, open]); React.useEffect(() => { if (!open) return; const closeIfOutside = (event: PointerEvent | WheelEvent) => { const target = event.target; if (!(target instanceof Node)) { onClose(); return; } if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return; onClose(); }; document.addEventListener('pointerdown', closeIfOutside, true); document.addEventListener('wheel', closeIfOutside, true); return () => { document.removeEventListener('pointerdown', closeIfOutside, true); document.removeEventListener('wheel', closeIfOutside, true); }; }, [anchorRef, onClose, open]); if (!shouldRender) return null; return (
{contextDisplay ? ( } label={t('mobile.header.metadata.context')} > {contextDisplay.percentage.toFixed(1)}% {contextDisplay.tokens} ) : null}
); }; const MobileUsageLimits: React.FC<{ groups: UsageProviderGroup[]; displayMode: 'usage' | 'remaining'; isLoading: boolean; timeFormatPreference: TimeFormatPreference; }> = ({ groups, displayMode, isLoading, timeFormatPreference }) => { const { t } = useI18n(); const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used'); // First open often races the quota fetch (~2s) — show an explicit loading // row instead of collapsing to an empty overlay. if (groups.length === 0) { if (!isLoading) return null; return (
{t('common.loading')}
); } return (
{t('mobile.header.metadata.usage')} {isLoading ? : null} {modeLabel}
); }; export const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({ open, onOpenChange, currentSessionId, effectiveDirectory, isNewSessionDraftOpen, }: { open: boolean; onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void; currentSessionId: string | null; effectiveDirectory: string | null; isNewSessionDraftOpen: boolean; }) { const { t } = useI18n(); const metadataTriggerRef = React.useRef(null); const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined); const providers = useConfigStore((state) => state.providers); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); const getModelMetadata = useConfigStore((state) => state.getModelMetadata); useConfigStore((state) => state.modelsMetadata.size); const savedSessionModel = useSelectionStore( React.useCallback( (state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null), [currentSessionId], ), ); const quotaResults = useQuotaStore((state) => state.results); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); const quotaDisplayMode = useQuotaStore((state) => state.displayMode); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); useQuotaAutoRefresh(); React.useEffect(() => { void loadQuotaSettings(); }, [loadQuotaSettings]); React.useEffect(() => { preloadProviderLogos(dropdownProviderIds); }, [dropdownProviderIds]); React.useEffect(() => { if (!open || isQuotaLoading) return; const missingEnabledProvider = dropdownProviderIds.some((providerId) => ( !quotaResults.some((result) => result.providerId === providerId) )); if (!missingEnabledProvider) return; void fetchAllQuotas(); }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]); const latestMessageModel = React.useMemo(() => { for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { model?: { providerID?: string; modelID?: string }; }; if (message.role !== 'user') continue; const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 ? message.model.providerID : undefined; const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 ? message.model.modelID : undefined; if (providerID && modelID) return { providerID, modelID }; } return null; }, [activeSessionMessages]); const modelRef = latestMessageModel ?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null) ?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null); const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined; const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID); const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined; const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context') ?? metadata?.limit?.context ?? 0; const totalTokens = React.useMemo(() => { for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { tokens?: { total?: unknown; input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown }; }; }; if (message.role !== 'assistant' || !message.tokens) continue; // Multi-step turns accumulate the fields across API round-trips, so // summing them overstates the window. The server-reported total is the // final round-trip's window; sum only when the server did not send it. const reportedTotal = getTokenCount(message.tokens.total); if (reportedTotal > 0) return reportedTotal; const total = getTokenCount(message.tokens.input) + getTokenCount(message.tokens.output) + getTokenCount(message.tokens.reasoning) + getTokenCount(message.tokens.cache?.read) + getTokenCount(message.tokens.cache?.write); if (total > 0) return total; } return 0; }, [activeSessionMessages]); const contextPercentage = !isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0 ? Math.min((totalTokens / contextLimit) * 100, 999) : null; const contextTokens = contextPercentage !== null ? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}` : null; const contextColorClass = contextPercentage === null ? '' : contextPercentage >= 90 ? 'text-[var(--status-error)]' : contextPercentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]'; const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens ? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass } : null; const usageGroups = useUsageProviderGroups(); React.useEffect(() => { if (!open || usageGroups.length === 0) return; preloadProviderLogos(usageGroups.map((group) => group.providerId)); }, [open, usageGroups]); return ( <> onOpenChange(false)} anchorRef={metadataTriggerRef} contextDisplay={contextDisplay} usageGroups={usageGroups} usageDisplayMode={quotaDisplayMode} isUsageLoading={isQuotaLoading} timeFormatPreference={timeFormatPreference} /> ); });