fix(app): make context usage tooltip interactive

This commit is contained in:
Michael Wu
2026-10-04 05:22:35 +09:00
parent 0ee0551c4c
commit 71faa28997
5 changed files with 114 additions and 23 deletions
@@ -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();
}
@@ -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(),
},
],
},
@@ -142,12 +142,12 @@ export function ContextWindowMeter({
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile interactive>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityRole="button"
accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
})}
+88 -18
View File
@@ -44,10 +44,13 @@ interface Rect {
interface TooltipContextValue {
open: boolean;
setOpen: (open: boolean) => void;
cancelClose: () => void;
scheduleClose: () => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
interactive: boolean;
}
const TooltipContext = createContext<TooltipContextValue | null>(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<View>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | 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<TooltipContextValue>(
() => ({
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 <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -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(
<View pointerEvents="none" style={styles.portalOverlay}>
<View pointerEvents={ctx.interactive ? "box-none" : "none"} style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
pointerEvents={ctx.interactive ? "auto" : "none"}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
onPointerEnter={ctx.interactive ? handleContentEnter : undefined}
onPointerLeave={ctx.interactive ? handleContentLeave : undefined}
onFocus={ctx.interactive ? handleContentEnter : undefined}
onBlur={ctx.interactive ? handleContentLeave : undefined}
style={contentStyle}
frameStyle={frameStyle}
>
@@ -543,26 +588,51 @@ export function TooltipContent({
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={handleDismiss}
>
<Pressable testID="tooltip-dismiss" style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</Pressable>
{ctx.interactive ? (
<View pointerEvents="box-none" style={styles.overlay}>
<Pressable
testID="tooltip-dismiss"
style={styles.dismissOverlay}
onPress={handleDismiss}
/>
<FloatingSurface
pointerEvents="auto"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
onFocus={handleContentEnter}
onBlur={handleContentLeave}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
) : (
<Pressable testID="tooltip-dismiss" style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</Pressable>
)}
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: { flex: 1 },
dismissOverlay: { ...StyleSheet.absoluteFillObject },
portalOverlay: {
position: "absolute",
top: 0,
@@ -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 style={styles.divider} />
<View style={styles.reports}>
<FloatingScrollView
bounces={false}
contentContainerStyle={styles.reports}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
style={styles.reportScroll}
>
{view.reports.map((entry) => (
<UsageCard key={entry.id} serverId={serverId} entry={entry} display={display} compact />
))}
</View>
</FloatingScrollView>
</>
);
}
@@ -33,4 +40,8 @@ const styles = StyleSheet.create((theme) => ({
alignSelf: "stretch",
gap: theme.spacing[3],
},
reportScroll: {
alignSelf: "stretch",
maxHeight: 240,
},
}));