mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 16:52:40 +00:00
fix(app): make context usage tooltip interactive
This commit is contained in:
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user