2026-08-01 21:16:36 +03:00
|
|
|
import React from 'react';
|
|
|
|
|
|
|
|
|
|
import { Icon } from '@/components/icon/Icon';
|
|
|
|
|
import type { IconName } from '@/components/icon/icons';
|
|
|
|
|
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
|
2026-08-02 16:25:16 +03:00
|
|
|
import { useTabletLayout } from '@/lib/device';
|
2026-08-01 21:16:36 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-08-09 19:30:25 +03:00
|
|
|
import { clampPercent, resolveUsageTone } from '@/lib/quota';
|
|
|
|
|
import { UsageProviderCards } from '@/components/usage/UsageProviderCards';
|
|
|
|
|
import { useUsageProviderGroups, type UsageProviderGroup } from '@/components/usage/usageGroups';
|
2026-08-01 21:16:36 +03:00
|
|
|
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';
|
|
|
|
|
|
2026-08-02 16:25:16 +03:00
|
|
|
const TABLET_METADATA_POPOVER_WIDTH = 380;
|
2026-08-01 21:16:36 +03:00
|
|
|
|
|
|
|
|
const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => {
|
|
|
|
|
if (!limit || typeof limit !== 'object') return undefined;
|
|
|
|
|
const value = (limit as Partial<Record<'context' | 'output', unknown>>)[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 (
|
|
|
|
|
<svg
|
|
|
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
|
|
|
className="size-[18px] -rotate-90"
|
|
|
|
|
role="progressbar"
|
|
|
|
|
aria-valuenow={Math.round(progressPct)}
|
|
|
|
|
aria-valuemin={0}
|
|
|
|
|
aria-valuemax={100}
|
|
|
|
|
>
|
|
|
|
|
<circle
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
r={radius}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="var(--interactive-border)"
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
/>
|
|
|
|
|
<circle
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
r={radius}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke={progressColor}
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeDasharray={circumference}
|
|
|
|
|
strokeDashoffset={circumference * (1 - progressPct / 100)}
|
|
|
|
|
className="transition-[stroke-dashoffset,stroke] duration-300"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MetadataRow: React.FC<{
|
|
|
|
|
icon?: IconName;
|
|
|
|
|
iconNode?: React.ReactNode;
|
|
|
|
|
label: string;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
}> = ({ icon, iconNode, label, children }) => (
|
|
|
|
|
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
|
|
|
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
|
|
|
{iconNode ?? (icon ? <Icon name={icon} className="size-[18px]" /> : null)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const SessionMetadataOverlay: React.FC<{
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
|
|
|
contextDisplay: ContextDisplay;
|
2026-08-09 19:30:25 +03:00
|
|
|
usageGroups: UsageProviderGroup[];
|
2026-08-01 21:16:36 +03:00
|
|
|
usageDisplayMode: 'usage' | 'remaining';
|
|
|
|
|
isUsageLoading: boolean;
|
|
|
|
|
timeFormatPreference: TimeFormatPreference;
|
|
|
|
|
}> = ({ open, onClose, anchorRef, contextDisplay, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
const [shouldRender, setShouldRender] = React.useState(open);
|
|
|
|
|
const [isExiting, setIsExiting] = React.useState(false);
|
2026-08-02 16:25:16 +03:00
|
|
|
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
2026-08-01 21:16:36 +03:00
|
|
|
// broken — render a popover anchored to the metadata button instead.
|
2026-08-02 16:25:16 +03:00
|
|
|
const { enabled: isTabletLayout } = useTabletLayout();
|
2026-08-01 21:16:36 +03:00
|
|
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
2026-08-02 16:25:16 +03:00
|
|
|
const [anchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
2026-08-01 21:16:36 +03:00
|
|
|
|
|
|
|
|
// 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(() => {
|
2026-08-02 16:25:16 +03:00
|
|
|
if (!open || !isTabletLayout || !shouldRender) return;
|
2026-08-01 21:16:36 +03:00
|
|
|
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),
|
2026-08-02 16:25:16 +03:00
|
|
|
Math.max(8, wrapperRect.width - TABLET_METADATA_POPOVER_WIDTH - 8),
|
2026-08-01 21:16:36 +03:00
|
|
|
);
|
|
|
|
|
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();
|
2026-08-02 16:25:16 +03:00
|
|
|
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
2026-08-01 21:16:36 +03:00
|
|
|
|
2026-08-02 16:25:16 +03:00
|
|
|
const isPopover = isTabletLayout && anchorLeft !== null;
|
2026-08-01 21:16:36 +03:00
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
|
|
|
|
<div
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-label={t('mobile.header.openMetadataAria')}
|
|
|
|
|
className={cn(
|
|
|
|
|
'overflow-y-auto overscroll-contain rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
2026-08-02 16:25:16 +03:00
|
|
|
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
2026-08-01 21:16:36 +03:00
|
|
|
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
|
|
|
|
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
2026-08-02 16:25:16 +03:00
|
|
|
...(isPopover
|
2026-08-01 21:16:36 +03:00
|
|
|
? {
|
|
|
|
|
top: 8,
|
2026-08-02 16:25:16 +03:00
|
|
|
left: anchorLeft ?? 8,
|
|
|
|
|
width: `min(${TABLET_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
2026-08-01 21:16:36 +03:00
|
|
|
}
|
|
|
|
|
: null),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{contextDisplay ? (
|
|
|
|
|
<MetadataRow
|
|
|
|
|
iconNode={<ContextProgressIcon percentage={contextDisplay.percentage} />}
|
|
|
|
|
label={t('mobile.header.metadata.context')}
|
|
|
|
|
>
|
|
|
|
|
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
|
|
|
|
|
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
|
|
|
|
|
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</MetadataRow>
|
|
|
|
|
) : null}
|
|
|
|
|
<MobileUsageLimits
|
|
|
|
|
groups={usageGroups}
|
|
|
|
|
displayMode={usageDisplayMode}
|
|
|
|
|
isLoading={isUsageLoading}
|
|
|
|
|
timeFormatPreference={timeFormatPreference}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<style>{`
|
|
|
|
|
@keyframes session-metadata-in {
|
|
|
|
|
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
|
|
|
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes session-metadata-out {
|
|
|
|
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
|
|
|
|
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
`}</style>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MobileUsageLimits: React.FC<{
|
2026-08-09 19:30:25 +03:00
|
|
|
groups: UsageProviderGroup[];
|
2026-08-01 21:16:36 +03:00
|
|
|
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 (
|
|
|
|
|
<div className="flex items-center justify-center gap-2 px-2.5 py-6 text-muted-foreground">
|
|
|
|
|
<Icon name="loader-4" className="size-4 animate-spin" aria-hidden />
|
|
|
|
|
<span className="typography-ui-label">{t('common.loading')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="pt-2.5">
|
|
|
|
|
<div className="flex min-w-0 items-center gap-3 px-2.5 pb-1.5">
|
|
|
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
|
|
|
<Icon name="timer" className="size-[18px]" />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="shrink-0 typography-ui-label text-muted-foreground">
|
|
|
|
|
{t('mobile.header.metadata.usage')}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5 typography-ui-label text-muted-foreground">
|
|
|
|
|
{isLoading ? <Icon name="refresh" className="size-3.5 animate-spin" /> : null}
|
|
|
|
|
<span className="truncate">{modeLabel}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
<UsageProviderCards
|
|
|
|
|
groups={groups}
|
|
|
|
|
displayMode={displayMode}
|
|
|
|
|
timeFormatPreference={timeFormatPreference}
|
|
|
|
|
/>
|
2026-08-01 21:16:36 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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<HTMLButtonElement>(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?: {
|
2026-08-14 23:06:56 +02:00
|
|
|
total?: unknown;
|
2026-08-01 21:16:36 +03:00
|
|
|
input?: unknown;
|
|
|
|
|
output?: unknown;
|
|
|
|
|
reasoning?: unknown;
|
|
|
|
|
cache?: { read?: unknown; write?: unknown };
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
if (message.role !== 'assistant' || !message.tokens) continue;
|
2026-08-14 23:06:56 +02:00
|
|
|
// 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;
|
2026-08-01 21:16:36 +03:00
|
|
|
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;
|
|
|
|
|
|
2026-08-09 19:30:25 +03:00
|
|
|
const usageGroups = useUsageProviderGroups();
|
2026-08-01 21:16:36 +03:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!open || usageGroups.length === 0) return;
|
|
|
|
|
preloadProviderLogos(usageGroups.map((group) => group.providerId));
|
|
|
|
|
}, [open, usageGroups]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
ref={metadataTriggerRef}
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
aria-label={t('mobile.header.openMetadataAria')}
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
onClick={() => onOpenChange((currentOpen) => !currentOpen)}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
{/* Live context gauge doubles as the metadata trigger: filled by the
|
|
|
|
|
session's context usage, an empty ring on a fresh draft. */}
|
|
|
|
|
<ContextProgressIcon percentage={contextDisplay?.percentage ?? 0} />
|
|
|
|
|
</button>
|
|
|
|
|
<SessionMetadataOverlay
|
|
|
|
|
open={open}
|
|
|
|
|
onClose={() => onOpenChange(false)}
|
|
|
|
|
anchorRef={metadataTriggerRef}
|
|
|
|
|
contextDisplay={contextDisplay}
|
|
|
|
|
usageGroups={usageGroups}
|
|
|
|
|
usageDisplayMode={quotaDisplayMode}
|
|
|
|
|
isUsageLoading={isQuotaLoading}
|
|
|
|
|
timeFormatPreference={timeFormatPreference}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
});
|