diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 833d599374..d54bf977fc 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -108,6 +108,16 @@ test.describe("context window meter", () => { const tooltip = page.getByTestId("context-window-meter-tooltip"); await expect(tooltip.getByText("Mock plan", { exact: true })).toBeVisible(); await expect(tooltip.getByText("42% used", { exact: true })).toBeVisible(); + const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); + await pin.click(); + await expect(pin).toBeChecked(); + + await tooltip.getByRole("button", { name: "Refresh Mock plan" }).click(); + await usage.waitForListRequests(2); + expect(usage.listRequests()[1]).toMatchObject({ + forceRefresh: true, + reportIds: ["mock:account"], + }); } finally { await session.cleanup(); } diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index bda4751073..d6ceb2412a 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -150,7 +150,7 @@ test.describe("usage settings", () => { label: "Credits available for additional usage", remaining: 999_999_999_999_999, unit: "credits", - resetsAt: "2026-12-31T23:59:00.000Z", + resetsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }, ], }, diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 615b37dc66..50016ae668 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -142,12 +142,12 @@ export function ContextWindowMeter({ typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; return ( - + void; + cancelClose: () => void; + scheduleClose: () => void; triggerRef: React.RefObject; enabled: boolean; openOnPress: boolean; delayDuration: number; + interactive: boolean; } const TooltipContext = createContext(null); @@ -231,6 +234,7 @@ export function Tooltip({ delayDuration = 0, enabledOnDesktop = true, enabledOnMobile = false, + interactive = false, children, }: PropsWithChildren<{ open?: boolean; @@ -239,8 +243,11 @@ export function Tooltip({ delayDuration?: number; enabledOnDesktop?: boolean; enabledOnMobile?: boolean; + /** Keep the surface open while people use controls inside it. */ + interactive?: boolean; }>): ReactElement { const triggerRef = useRef(null); + const closeTimerRef = useRef | null>(null); const [isOpen, setIsOpen] = useControllableOpenState({ open, defaultOpen, @@ -250,17 +257,48 @@ export function Tooltip({ const isCompact = useIsCompactFormFactor(); const opensOnPress = isNative || isCompact; const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop; + const cancelClose = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + const scheduleClose = useCallback(() => { + cancelClose(); + if (!interactive) { + setIsOpen(false); + return; + } + closeTimerRef.current = setTimeout(() => { + setIsOpen(false); + closeTimerRef.current = null; + }, 100); + }, [cancelClose, interactive, setIsOpen]); + + useEffect(() => cancelClose, [cancelClose]); const value = useMemo( () => ({ open: isOpen, setOpen: setIsOpen, + cancelClose, + scheduleClose, triggerRef, enabled, openOnPress: opensOnPress, delayDuration, + interactive, }), - [isOpen, setIsOpen, enabled, opensOnPress, delayDuration], + [ + isOpen, + setIsOpen, + cancelClose, + scheduleClose, + enabled, + opensOnPress, + delayDuration, + interactive, + ], ); return {children}; @@ -306,7 +344,7 @@ export function TooltipTrigger({ const close = useCallback(() => { clearOpenTimer(); - ctx.setOpen(false); + ctx.scheduleClose(); }, [clearOpenTimer, ctx]); useEffect(() => { @@ -358,6 +396,7 @@ export function TooltipTrigger({ } if (ctx.openOnPress) { clearOpenTimer(); + ctx.cancelClose(); ctx.setOpen(true); return; } @@ -509,6 +548,8 @@ export function TooltipContent({ const contentStyle = useMemo(() => [styles.content, style], [style]); const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]); + const handleContentEnter = useCallback(() => ctx.cancelClose(), [ctx]); + const handleContentLeave = useCallback(() => ctx.scheduleClose(), [ctx]); if (!ctx.open || !ctx.enabled) return null; @@ -517,14 +558,18 @@ export function TooltipContent({ // exact same positioning math as DropdownMenu, without hover feedback loops. if (isWeb) { return createPortal( - + @@ -543,26 +588,51 @@ export function TooltipContent({ statusBarTranslucent={Platform.OS === "android"} onRequestClose={handleDismiss} > - - - {children} - - + {ctx.interactive ? ( + + + + {children} + + + ) : ( + + + {children} + + + )} ); } const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1 }, + dismissOverlay: { ...StyleSheet.absoluteFillObject }, portalOverlay: { position: "absolute", top: 0, diff --git a/packages/app/src/usage/context-window-section.tsx b/packages/app/src/usage/context-window-section.tsx index 2770f38bf7..7aa20f67aa 100644 --- a/packages/app/src/usage/context-window-section.tsx +++ b/packages/app/src/usage/context-window-section.tsx @@ -1,5 +1,6 @@ import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { FloatingScrollView } from "@/components/ui/floating"; import { UsageCard } from "./card"; import { useUsagePreferences } from "./display"; import { useHostUsage } from "./queries"; @@ -13,11 +14,17 @@ export function ContextWindowUsageTooltip({ serverId }: { serverId: string }) { return ( <> - + {view.reports.map((entry) => ( ))} - + ); } @@ -33,4 +40,8 @@ const styles = StyleSheet.create((theme) => ({ alignSelf: "stretch", gap: theme.spacing[3], }, + reportScroll: { + alignSelf: "stretch", + maxHeight: 240, + }, }));