diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 2bfb100d1f..7187adfa6d 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -1,5 +1,5 @@ import { FileIcon, ZapIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useRef, useState } from "react"; import type { ChatMessagePart } from "#/api/typesGenerated"; import { Popover, @@ -9,6 +9,7 @@ import { import { Tooltip, TooltipContent, + TooltipProvider, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; @@ -73,9 +74,36 @@ const RING_STROKE = 2.5; const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; +// Delay before the popover closes after the mouse leaves, giving +// the user time to move into the popover content. +const HOVER_CLOSE_DELAY_MS = 150; + export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ usage, }) => { + const [open, setOpen] = useState(false); + const closeTimerRef = useRef | null>(null); + + const cancelClose = () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }; + + const scheduleClose = () => { + cancelClose(); + closeTimerRef.current = setTimeout(() => { + setOpen(false); + closeTimerRef.current = null; + }, HOVER_CLOSE_DELAY_MS); + }; + + const handleMouseEnter = () => { + cancelClose(); + setOpen(true); + }; + const usedTokens = hasFiniteTokenValue(usage?.usedTokens) ? usage.usedTokens : undefined; @@ -108,6 +136,89 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ usage?.lastInjectedContext?.filter((p) => p.type === "skill") ?? []; const hasInjectedContext = contextFiles.length > 0 || skills.length > 0; + const panelContent = ( +
+ {hasPercent + ? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` + : "Context usage unavailable"} + {hasPercent && + usage?.compressionThreshold !== undefined && + usage.compressionThreshold > 0 && ( +
+ {`Compacts at ${usage.compressionThreshold}%`} +
+ )} + {hasInjectedContext && ( +
+ {contextFiles.length > 0 && ( +
+ + Context files + + {contextFiles.map((part) => { + if (part.type !== "context-file") return null; + return ( +
+ + + {basename(part.context_file_path)} + + {part.context_file_truncated && ( + + (truncated) + + )} +
+ ); + })} +
+ )} + {skills.length > 0 && ( +
+ Skills + + {skills.map((part) => { + if (part.type !== "skill") return null; + const row = ( +
+ + {part.skill_name} +
+ ); + if (!part.skill_description) { + return
{row}
; + } + return ( + + +
{row}
+
+ + {part.skill_description} + +
+ ); + })} +
+
+ )} +
+ )} +
+ ); + const triggerButton = ( ); - const tooltipContent = ( -
- {hasPercent - ? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` - : "Context usage unavailable"} - {hasPercent && - usage?.compressionThreshold !== undefined && - usage.compressionThreshold > 0 && ( -
- {`Compacts at ${usage.compressionThreshold}%`}{" "} -
- )} - {hasInjectedContext && ( -
- {" "} - {contextFiles.length > 0 && ( -
- - Context files - {" "} - {contextFiles.map((part) => { - if (part.type !== "context-file") return null; - return ( -
- - - {basename(part.context_file_path)} - - {part.context_file_truncated && ( - - (truncated) - - )} -
- ); - })} -
- )} - {skills.length > 0 && ( -
- Skills{" "} - {skills.map((part) => { - if (part.type !== "skill") return null; - return ( -
- - {part.skill_name} - {part.skill_description && ( - - – {part.skill_description} - - )} -
- ); - })} -
- )} -
- )} -
- ); - - // On mobile viewports, Radix Tooltip only opens on hover which - // doesn't exist on touch devices. Use a Popover instead so a tap - // toggles the context-usage info. + // On mobile, a tap toggles the popover. On desktop, hover opens + // it like a dropdown menu and skill descriptions appear as + // nested tooltips to the right (same pattern as ModelSelector). if (isMobileViewport()) { return ( {triggerButton} - {tooltipContent} + {panelContent} ); } return ( - - {triggerButton} - - {tooltipContent} - - + + +
+ {triggerButton} +
+
+ e.preventDefault()} + > + {panelContent} + +
); };