/**
* 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
)}
);
};