/** * ContextBadge - Displays a compact badge showing unified context injections. * Shows count of NEW injections (CLAUDE.md, mentioned files, tool outputs) with hover popover. * Replaces the standalone ClaudeMdBadge with a unified view of all context sources. */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { COLOR_BORDER, COLOR_BORDER_SUBTLE, COLOR_SURFACE_RAISED, COLOR_TEXT, COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; import { resolveAbsolutePath, shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { formatTokensCompact as formatTokens } from '@shared/utils/tokenFormatting'; import { ChevronRight } from 'lucide-react'; import { CopyablePath } from '../common/CopyablePath'; import type { ClaudeMdContextInjection, ContextInjection, ContextStats, MentionedFileInjection, TaskCoordinationInjection, ThinkingTextInjection, ToolOutputInjection, UserMessageInjection, } from '@renderer/types/contextInjection'; interface ContextBadgeProps { stats: ContextStats; projectRoot?: string; } /** * Type guard for ClaudeMdContextInjection. */ function isClaudeMdInjection(inj: ContextInjection): inj is ClaudeMdContextInjection { return inj.category === 'claude-md'; } /** * Type guard for MentionedFileInjection. */ function isMentionedFileInjection(inj: ContextInjection): inj is MentionedFileInjection { return inj.category === 'mentioned-file'; } /** * Type guard for ToolOutputInjection. */ function isToolOutputInjection(inj: ContextInjection): inj is ToolOutputInjection { return inj.category === 'tool-output'; } /** * Type guard for ThinkingTextInjection. */ function isThinkingTextInjection(inj: ContextInjection): inj is ThinkingTextInjection { return inj.category === 'thinking-text'; } /** * Type guard for TaskCoordinationInjection. */ function isTaskCoordinationInjection(inj: ContextInjection): inj is TaskCoordinationInjection { return inj.category === 'task-coordination'; } /** * Type guard for UserMessageInjection. */ function isUserMessageInjection(inj: ContextInjection): inj is UserMessageInjection { return inj.category === 'user-message'; } /** * Section component for expandable groups in the popover. */ const PopoverSection = ({ title, count, tokenCount, children, defaultExpanded = false, }: Readonly<{ title: string; count: number; tokenCount: number; children: React.ReactNode; defaultExpanded?: boolean; }>): React.ReactElement => { const [expanded, setExpanded] = useState(defaultExpanded); return (
{/* Section header */}
{ e.stopPropagation(); setExpanded(!expanded); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setExpanded(!expanded); } }} > {title} ({count}) ~{formatTokens(tokenCount)} tokens
{/* Section content */} {expanded &&
{children}
}
); }; export const ContextBadge = ({ stats, projectRoot, }: Readonly): React.ReactElement | null => { const [showPopover, setShowPopover] = useState(false); const [popoverStyle, setPopoverStyle] = useState({}); const [arrowStyle, setArrowStyle] = useState({}); const containerRef = useRef(null); const popoverRef = useRef(null); // Calculate total new count const totalNew = useMemo( () => stats.newCounts.claudeMd + stats.newCounts.mentionedFiles + stats.newCounts.toolOutputs + stats.newCounts.thinkingText + stats.newCounts.taskCoordination + stats.newCounts.userMessages, [stats.newCounts] ); // Filter new injections by category const newClaudeMdInjections = useMemo( () => stats.newInjections.filter(isClaudeMdInjection), [stats.newInjections] ); const newMentionedFileInjections = useMemo( () => stats.newInjections.filter(isMentionedFileInjection), [stats.newInjections] ); const newToolOutputInjections = useMemo( () => stats.newInjections.filter(isToolOutputInjection), [stats.newInjections] ); const newThinkingTextInjections = useMemo( () => stats.newInjections.filter(isThinkingTextInjection), [stats.newInjections] ); const newTaskCoordinationInjections = useMemo( () => stats.newInjections.filter(isTaskCoordinationInjection), [stats.newInjections] ); const newUserMessageInjections = useMemo( () => stats.newInjections.filter(isUserMessageInjection), [stats.newInjections] ); // Calculate total new tokens const totalNewTokens = useMemo( () => stats.newInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [stats.newInjections] ); // Calculate token totals per section const claudeMdTokens = useMemo( () => newClaudeMdInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [newClaudeMdInjections] ); const mentionedFileTokens = useMemo( () => newMentionedFileInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [newMentionedFileInjections] ); const toolOutputTokens = useMemo( () => newToolOutputInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [newToolOutputInjections] ); const thinkingTextTokens = useMemo( () => newThinkingTextInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [newThinkingTextInjections] ); const taskCoordinationTokens = useMemo( () => newTaskCoordinationInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [newTaskCoordinationInjections] ); // Compute actual item counts (not injection-object counts) for accurate badge display const toolOutputCount = useMemo( () => newToolOutputInjections.reduce((sum, inj) => sum + inj.toolCount, 0), [newToolOutputInjections] ); const taskCoordinationCount = useMemo( () => newTaskCoordinationInjections.reduce((sum, inj) => sum + inj.breakdown.length, 0), [newTaskCoordinationInjections] ); const userMessageTokens = useMemo( () => newUserMessageInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [newUserMessageInjections] ); // Linear-style neutral badge — uses theme-aware CSS variables const badgeStyle: React.CSSProperties = { backgroundColor: COLOR_SURFACE_RAISED, border: `1px solid ${COLOR_BORDER}`, color: COLOR_TEXT_SECONDARY, }; // Calculate popover position based on trigger element useEffect(() => { if (showPopover && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const popoverWidth = 300; const margin = 12; // Determine if popover should open left or right const openLeft = rect.left + popoverWidth > viewportWidth - 20; // Determine if popover should open above or below const spaceBelow = viewportHeight - rect.bottom - margin; const spaceAbove = rect.top - margin; const openAbove = spaceBelow < 200 && spaceAbove > spaceBelow; const maxHeight = Math.max(openAbove ? spaceAbove : spaceBelow, 120) - 8; queueMicrotask(() => { setPopoverStyle({ position: 'fixed', ...(openAbove ? { bottom: viewportHeight - rect.top + 4 } : { top: rect.bottom + 4 }), left: openLeft ? rect.right - popoverWidth : rect.left, minWidth: 260, maxWidth: 340, maxHeight, overflowY: 'auto', zIndex: 99999, }); setArrowStyle({ position: 'absolute', ...(openAbove ? { bottom: -4, borderRight: `1px solid ${COLOR_BORDER}`, borderBottom: `1px solid ${COLOR_BORDER}`, borderLeft: 'none', borderTop: 'none', } : { top: -4, borderLeft: `1px solid ${COLOR_BORDER}`, borderTop: `1px solid ${COLOR_BORDER}`, borderRight: 'none', borderBottom: 'none', }), [openLeft ? 'right' : 'left']: 12, width: 8, height: 8, transform: 'rotate(45deg)', backgroundColor: COLOR_SURFACE_RAISED, }); }); } }, [showPopover]); // Handle click outside and scroll to close popover useEffect(() => { if (!showPopover) return; const isInsideRect = (el: HTMLElement | null, x: number, y: number): boolean => { if (!el) return false; const rect = el.getBoundingClientRect(); return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; }; const handleClickOutside = (e: MouseEvent): void => { // Use coordinate-based hit test — reliable with portals, scrollbars, and re-renders if ( isInsideRect(popoverRef.current, e.clientX, e.clientY) || isInsideRect(containerRef.current, e.clientX, e.clientY) ) { return; } setShowPopover(false); }; const handleScroll = (e: Event): void => { // Don't close if scrolling inside the popover if (popoverRef.current && e.target instanceof Node && popoverRef.current.contains(e.target)) { return; } setShowPopover(false); }; document.addEventListener('mousedown', handleClickOutside); window.addEventListener('scroll', handleScroll, true); return () => { document.removeEventListener('mousedown', handleClickOutside); window.removeEventListener('scroll', handleScroll, true); }; }, [showPopover]); // Only render if there are new injections if (totalNew === 0) { return null; } return (
{ e.stopPropagation(); setShowPopover(!showPopover); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setShowPopover(!showPopover); } }} > {/* Badge */} Context +{totalNew} {/* Popover - rendered via Portal to escape stacking context */} {showPopover && createPortal( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events -- dialog uses stopPropagation only, not interactive
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > {/* Arrow pointer */}
{/* Title */}
New Context Injected In This Turn
{/* Sections */}
{/* User Messages section */} {newUserMessageInjections.length > 0 && ( {newUserMessageInjections.map((injection) => (
Turn {injection.turnIndex + 1} ~{formatTokens(injection.estimatedTokens)} tokens
{injection.textPreview && (
{injection.textPreview}
)}
))}
)} {/* CLAUDE.md Files section */} {newClaudeMdInjections.length > 0 && ( {newClaudeMdInjections.map((injection) => { const displayPath = shortenDisplayPath(injection.path, projectRoot) || injection.displayName; const absolutePath = resolveAbsolutePath(injection.path, projectRoot); return (
~{formatTokens(injection.estimatedTokens)} tokens
); })}
)} {/* Mentioned Files section */} {newMentionedFileInjections.length > 0 && ( {newMentionedFileInjections.map((injection) => { const displayPath = shortenDisplayPath(injection.path, projectRoot); const absolutePath = resolveAbsolutePath(injection.path, projectRoot); return (
~{formatTokens(injection.estimatedTokens)} tokens
); })}
)} {/* Tool Outputs section */} {newToolOutputInjections.length > 0 && ( {newToolOutputInjections.map((injection) => injection.toolBreakdown.map((tool, idx) => (
{tool.toolName} ~{formatTokens(tool.tokenCount)} tokens
)) )}
)} {/* Task Coordination section */} {newTaskCoordinationInjections.length > 0 && ( {newTaskCoordinationInjections.map((injection) => injection.breakdown.map((item, idx) => (
{item.label} ~{formatTokens(item.tokenCount)} tokens
)) )}
)} {/* Thinking + Text section */} {newThinkingTextInjections.length > 0 && ( {newThinkingTextInjections.map((injection) => (
Turn {injection.turnIndex + 1}
{injection.breakdown.map((item, idx) => (
{item.type === 'thinking' ? 'Thinking' : 'Text'} ~{formatTokens(item.tokenCount)} tokens
))}
))}
)}
{/* Total tokens footer */}
Total new tokens ~{formatTokens(totalNewTokens)} tokens
, document.body )}
); };