Files
paseo/packages/app/src/components/ui/tooltip.tsx
T
Mohamed Boudra 5eb4c8afad feat(usage): show each agent's own plan usage in its context window details (#5975)
* feat(usage): stream reports for live agent accounts

Resolve account scope from the running harness environment without invoking lifecycle hooks. Keep the client aggregate result while delivering report updates as each fetch settles; the app presentation follows separately.

* fix(usage): retain account scope for open agent sessions

Capture the launch environment once per session, independently of lazy child-process startup or exit. Use a session UUID for registry identity. Update remaining global discovery fixtures to the scoped SDK contract.

* test(usage): update browser fixtures for scoped report streams

* feat(usage): let a usage card show window rows without pins

UsageCard takes a required pinnable prop. When false, a window row is a
plain bar: no Pressable, hover highlight, or pin glyph. The settings
Usage list passes true.

* feat(usage): show the agent's usage in the context window popover

Reports stream into the query cache as each update arrives, for the
host list and for a new per-agent query keyed by agent. The context
window popover fetches its agent's reports while open and renders one
unpinnable card per report as it lands, a loading sentence before the
first, the request error as a sentence, and nothing on hosts without
usage reports.

* feat(usage): hide the Refresh button on popover usage cards

UsageCard takes a required refreshable prop. The popover passes false
because its content takes no pointer events, so the button could not
be pressed; the card then shows its freshness inline. The settings
Usage list keeps the button.

* feat(usage): make the sidebar Usage summary opt-in

The sidebar Usage item starts hidden on every layout, not only on
compact ones, so resolveSidebarNavItems drops its compact input. While
the summary is off, the Usage list renders window rows without pins;
saved pins stay in preferences and return when it is turned on. Specs
that read the summary now turn it on first.

* docs(glossary): describe the popover usage card and the opt-in sidebar summary

* fix(usage): show a popover request error without retrying

A daemon error for an agent's usage, such as an unknown agent, does not
heal on retry, and reopening the popover fetches again. Retrying held
the loading sentence for about seven seconds before the error.

* test(usage): cover agent usage in the context window popover

The usage fixture records agentId, streams reports held behind
promises, and can switch host support between reloads. The meter spec
walks loading, streaming, a report problem, a request error and a host
without usage reports, on desktop and compact in both themes.

* fix(usage): keep the plain context window tooltip on hosts without usage

The popover widened to fit usage cards on every host. Hosts that cannot
report usage now keep the tooltip's default padding and width, as
before, and mount no usage section.

* fix(usage): let a report's Refresh replace it in the agent's list too

Refresh wrote the refreshed report only into the host's list, so a card
rendered from an agent's list kept the old report. Both lists now sit
under one host key and Refresh replaces the report in each that holds it.

* feat(usage): open the context window details in a sheet on compact screens

Wide screens keep the tooltip. On compact screens pressing the meter opens
a content-sized bottom sheet titled Context window. Both render one
ContextWindowDetails body; only the sheet's usage cards have Refresh,
since the tooltip cannot be pressed. Pins stay off in both.

* test(usage): cover the compact context window sheet

On the compact viewport the meter opens the sheet; its cards have Refresh,
a Refresh replaces the card, and Close dismisses the sheet. The glossary
names the sheet.

* feat(ui): give tooltips and the context window sheet accessible roles

Tooltip content is role=tooltip, and the context window sheet is a dialog
named Context window, so assistive tech and tests find them by role. The
agent usage message drops its test ID; it is found by its text.

* test(usage): reshape the context window and Usage switch specs as journeys

The context window spec runs one tooltip journey and one sheet journey per
theme, with no layout branches. Its fixture script, meter actions and
report builders move to a context-window helper, and it finds the meter,
tooltip, sheet and Refresh buttons by role. The theme comes from the
system colour scheme. The Usage switch test uses named helpers for the
Usage icon and the Summary in sidebar switch.

* fix(usage): say an agent's usage request failed instead of showing older reports

A failed request keeps whatever reports the query held: those from an
earlier open, or those that streamed in before the failure. The context
window details showed them as if they were the agent's current usage.
The request error now wins over held reports.

* test(usage): let a fixture clean up the context window spec's agent

The tooltip and sheet tests no longer wrap their steps in try/finally;
a spec-local fixture seeds the agent and removes it after the test.

* fix(usage): keep a refreshed report when the list it is in finishes

Pressing Refresh on one card while another source was still streaming
put the pre-refresh report back: the finished list replaced the query's
data with the copies the request had fetched when it started. A report
now keeps whichever copy was fetched later, both when a streamed copy
arrives and when the finished list replaces the streamed one.

* fix(usage): show only the login a resumed agent runs under now

An agent resumed under another login keeps its ID, so its usage query
kept the previous login's reports: reopening the context window showed
them while the new login's reports loaded, then both side by side until
the request finished. The agent's usage is now dropped as soon as its
tooltip or sheet closes, so each open loads the login the agent runs
under at that moment.

* fix(usage): stop a closed agent usage request from writing reports

Closing the context window details cancels the agent's usage request and
drops reports still on their way, so reopening after a resume under
another login sends a new request instead of joining the old one.
2026-10-03 23:12:57 +01:00

587 lines
16 KiB
TypeScript

import {
Children,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
Modal,
Pressable,
StatusBar,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isNative, isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface TooltipContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext(componentName: string): TooltipContextValue {
const ctx = useContext(TooltipContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <Tooltip />`);
}
return ctx;
}
// Tooltips should open on hover or keyboard focus, not when focus is restored
// programmatically (e.g. when a Modal closes and returns focus to its opener).
// Track the last input modality on web so TooltipTrigger can ignore focus
// events that weren't keyboard-driven. Native has no equivalent scenario.
let lastInputWasKeyboard = false;
if (isWeb && typeof window !== "undefined") {
const markKeyboard = () => {
lastInputWasKeyboard = true;
};
const markPointer = () => {
lastInputWasKeyboard = false;
};
window.addEventListener("keydown", markKeyboard, true);
window.addEventListener("mousedown", markPointer, true);
window.addEventListener("pointerdown", markPointer, true);
window.addEventListener("touchstart", markPointer, true);
}
function shouldOpenOnFocus(): boolean {
return !isWeb || lastInputWasKeyboard;
}
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
return typeof fn === "function";
}
function composeEventHandlers(
original: unknown,
injected: (event: unknown) => void,
): (event: unknown) => void {
return (event: unknown) => {
if (isCallable(original)) {
original(event);
}
injected(event);
};
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function resolveActualSide(args: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
side: Side;
}): Side {
const { triggerRect, contentSize, displayArea, side } = args;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
if (side === "bottom" && spaceBottom < contentSize.height && spaceTop > spaceBottom) return "top";
if (side === "top" && spaceTop < contentSize.height && spaceBottom > spaceTop) return "bottom";
if (side === "left" && spaceLeft < contentSize.width && spaceRight > spaceLeft) return "right";
if (side === "right" && spaceRight < contentSize.width && spaceLeft > spaceRight) return "left";
return side;
}
function resolveAlignedCoordinate(args: {
align: Align;
start: number;
size: number;
contentSize: number;
}): number {
const { align, start, size, contentSize } = args;
if (align === "start") return start;
if (align === "end") return start + size - contentSize;
return start + (size - contentSize) / 2;
}
function computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
side: Side;
align: Align;
offset: number;
}): { x: number; y: number; actualSide: Side } {
const { width: contentWidth, height: contentHeight } = contentSize;
const actualSide = resolveActualSide({ triggerRect, contentSize, displayArea, side });
let x = 0;
let y = 0;
if (actualSide === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
x = resolveAlignedCoordinate({
align,
start: triggerRect.x,
size: triggerRect.width,
contentSize: contentWidth,
});
} else if (actualSide === "top") {
y = triggerRect.y - contentHeight - offset;
x = resolveAlignedCoordinate({
align,
start: triggerRect.x,
size: triggerRect.width,
contentSize: contentWidth,
});
} else if (actualSide === "left") {
x = triggerRect.x - contentWidth - offset;
y = resolveAlignedCoordinate({
align,
start: triggerRect.y,
size: triggerRect.height,
contentSize: contentHeight,
});
} else {
x = triggerRect.x + triggerRect.width + offset;
y = resolveAlignedCoordinate({
align,
start: triggerRect.y,
size: triggerRect.height,
contentSize: contentHeight,
});
}
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y),
);
return { x, y, actualSide };
}
export function Tooltip({
open,
defaultOpen,
onOpenChange,
delayDuration = 0,
enabledOnDesktop = true,
enabledOnMobile = false,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
delayDuration?: number;
enabledOnDesktop?: boolean;
enabledOnMobile?: boolean;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const isCompact = useIsCompactFormFactor();
const opensOnPress = isNative || isCompact;
const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: opensOnPress,
delayDuration,
}),
[isOpen, setIsOpen, enabled, opensOnPress, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
export function TooltipTrigger({
children,
disabled,
onHoverIn,
onHoverOut,
onFocus,
onBlur,
onPress,
asChild = false,
triggerRefProp = "ref",
...props
}: PressableProps & {
asChild?: boolean;
triggerRefProp?: string;
}): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const scheduleOpen = useCallback(() => {
if (!ctx.enabled || disabled) return;
clearOpenTimer();
if (ctx.delayDuration <= 0) {
ctx.setOpen(true);
return;
}
openTimerRef.current = setTimeout(() => {
ctx.setOpen(true);
openTimerRef.current = null;
}, ctx.delayDuration);
}, [clearOpenTimer, ctx, disabled]);
const close = useCallback(() => {
clearOpenTimer();
ctx.setOpen(false);
}, [clearOpenTimer, ctx]);
useEffect(() => {
return () => {
clearOpenTimer();
};
}, [clearOpenTimer]);
const handleHoverIn = useCallback(
(e?: unknown) => {
if (isCallable(onHoverIn)) onHoverIn(e);
scheduleOpen();
},
[onHoverIn, scheduleOpen],
);
const handleHoverOut = useCallback(
(e?: unknown) => {
if (isCallable(onHoverOut)) onHoverOut(e);
close();
},
[onHoverOut, close],
);
const handleFocus = useCallback(
(e: unknown) => {
if (isCallable(onFocus)) onFocus(e);
if (!ctx.enabled || disabled) return;
if (!shouldOpenOnFocus()) return;
clearOpenTimer();
ctx.setOpen(true);
},
[clearOpenTimer, ctx, disabled, onFocus],
);
const handleBlur = useCallback(
(e: unknown) => {
if (isCallable(onBlur)) onBlur(e);
close();
},
[close, onBlur],
);
const handlePress = useCallback(
(e: unknown) => {
if (isCallable(onPress)) onPress(e);
if (!ctx.enabled || disabled) {
return;
}
if (ctx.openOnPress) {
clearOpenTimer();
ctx.setOpen(true);
return;
}
close();
},
[clearOpenTimer, close, ctx, disabled, onPress],
);
const triggerProps = {
...props,
disabled,
onHoverIn: handleHoverIn,
onHoverOut: handleHoverOut,
onFocus: handleFocus,
onBlur: handleBlur,
onPress: handlePress,
...(isWeb
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
onPointerLeave: handleHoverOut,
onMouseEnter: handleHoverIn,
onMouseLeave: handleHoverOut,
} as object)
: null),
};
if (asChild) {
const child = Children.only(children);
if (!isValidElement(child)) {
throw new Error("TooltipTrigger with asChild expects a single React element child");
}
const rawProps: unknown = child.props;
if (typeof rawProps !== "object" || rawProps === null) {
throw new Error("TooltipTrigger asChild child must have props object");
}
const mergedProps: Record<string, unknown> = {
...Object.assign({}, rawProps),
...triggerProps,
disabled: Reflect.get(rawProps, "disabled") || disabled,
onHoverIn: composeEventHandlers(Reflect.get(rawProps, "onHoverIn"), handleHoverIn),
onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut),
onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus),
onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur),
onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress),
onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn),
onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut),
onMouseEnter: composeEventHandlers(Reflect.get(rawProps, "onMouseEnter"), handleHoverIn),
onMouseLeave: composeEventHandlers(Reflect.get(rawProps, "onMouseLeave"), handleHoverOut),
};
const existingRefProp = Reflect.get(rawProps, triggerRefProp);
mergedProps[triggerRefProp] = (node: View) => {
if (isCallable(existingRefProp)) {
existingRefProp(node);
} else if (existingRefProp && typeof existingRefProp === "object") {
Object.assign(existingRefProp, { current: node });
}
Object.assign(ctx.triggerRef, { current: node });
};
return cloneElement(child, mergedProps);
}
return (
<Pressable {...triggerProps} ref={ctx.triggerRef} collapsable={false}>
{children}
</Pressable>
);
}
export function TooltipContent({
children,
side = "top",
align = "center",
offset = 6,
style,
testID,
maxWidth = 280,
}: PropsWithChildren<{
side?: Side;
align?: Align;
offset?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(ctx.triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
return () => {
cancelled = true;
};
}, [ctx.enabled, ctx.open, ctx.triggerRef]);
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
const result = computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
});
setPosition({ x: result.x, y: result.y });
}, [triggerRect, contentSize, side, align, offset]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[],
);
const frameStyle = useMemo(
() => [
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
maxWidth,
},
],
[maxWidth, position?.x, position?.y],
);
const contentStyle = useMemo(() => [styles.content, style], [style]);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
if (!ctx.open || !ctx.enabled) return null;
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (isWeb) {
return createPortal(
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>,
getOverlayRoot(),
);
}
return (
<Modal
visible={ctx.open}
transparent
animationType="none"
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}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: { flex: 1 },
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: OVERLAY_Z.tooltip,
},
content: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
...theme.shadow.md,
zIndex: 1000,
},
}));