import React, { useCallback, useState } from 'react'; import { CODE_BG, CODE_BORDER, COLOR_TEXT_MUTED, TOOL_CALL_BG, TOOL_CALL_BORDER, TOOL_CALL_TEXT, } from '@renderer/constants/cssVariables'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { format } from 'date-fns'; import { ChevronRight, Layers, MailOpen } from 'lucide-react'; import { BaseItem } from './items/BaseItem'; import { LinkedToolItem } from './items/LinkedToolItem'; import { SlashItem } from './items/SlashItem'; import { SubagentItem } from './items/SubagentItem'; import { TeammateMessageItem } from './items/TeammateMessageItem'; import { TextItem } from './items/TextItem'; import { ThinkingItem } from './items/ThinkingItem'; import { MarkdownViewer } from './viewers/MarkdownViewer'; import type { AIGroupDisplayItem } from '@renderer/types/groups'; import type { TriggerColor } from '@shared/constants/triggerColors'; interface DisplayItemListProps { items: AIGroupDisplayItem[]; onItemClick: (itemId: string) => void; expandedItemIds: Set; aiGroupId: string; /** Tool use ID to highlight for error deep linking */ highlightToolUseId?: string; /** Custom highlight color from trigger */ highlightColor?: TriggerColor; /** Map of tool use ID to trigger color for notification dots */ notificationColorMap?: Map; /** Optional callback to register tool element refs for scroll targeting */ registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; } /** * Truncates text to a maximum length and adds ellipsis if needed. */ function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } return text.substring(0, maxLength) + '...'; } /** * Renders a flat list of AIGroupDisplayItem[] into the appropriate components. * * This component maps each display item to its corresponding component based on type: * - thinking -> ThinkingItem * - output -> TextItem * - tool -> LinkedToolItem * - subagent -> SubagentItem * - slash -> SlashItem * * The list is completely flat with no nested toggles or hierarchies. */ export const DisplayItemList = ({ items, onItemClick, expandedItemIds, aiGroupId, highlightToolUseId, highlightColor, notificationColorMap, registerToolRef, }: Readonly): React.JSX.Element => { // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair const [replyLinkToolId, setReplyLinkToolId] = useState(null); const handleReplyHover = useCallback((toolId: string | null) => { setReplyLinkToolId(toolId); }, []); /** Check if an item is part of the currently highlighted reply link */ const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { if (!replyLinkToolId) return false; if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId) return true; return false; }; if (!items || items.length === 0) { return (
No items to display
); } return (
{items.map((item, index) => { let itemKey = ''; let element: React.ReactNode = null; switch (item.type) { case 'thinking': { itemKey = `thinking-${index}`; const thinkingStep = { id: itemKey, type: 'thinking' as const, startTime: item.timestamp, endTime: item.timestamp, durationMs: 0, content: { thinkingText: item.content, tokenCount: item.tokenCount }, tokens: { input: 0, output: item.tokenCount ?? 0 }, context: 'main' as const, }; element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} /> ); break; } case 'output': { itemKey = `output-${index}`; const textStep = { id: itemKey, type: 'output' as const, startTime: item.timestamp, endTime: item.timestamp, durationMs: 0, content: { outputText: item.content, tokenCount: item.tokenCount }, tokens: { input: 0, output: item.tokenCount ?? 0 }, context: 'main' as const, }; element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} /> ); break; } case 'tool': { itemKey = `tool-${item.tool.id}-${index}`; element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} isHighlighted={highlightToolUseId === item.tool.id} highlightColor={highlightColor} notificationDotColor={notificationColorMap?.get(item.tool.id)} registerRef={ registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined } /> ); break; } case 'subagent': { itemKey = `subagent-${item.subagent.id}-${index}`; const subagentStep = { id: itemKey, type: 'subagent' as const, startTime: item.subagent.startTime, endTime: item.subagent.endTime, durationMs: item.subagent.durationMs, content: { subagentId: item.subagent.id, subagentDescription: item.subagent.description, }, isParallel: item.subagent.isParallel, context: 'main' as const, }; element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} aiGroupId={aiGroupId} highlightToolUseId={highlightToolUseId} highlightColor={highlightColor} notificationColorMap={notificationColorMap} registerToolRef={registerToolRef} /> ); break; } case 'slash': { itemKey = `slash-${item.slash.name}-${index}`; element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} /> ); break; } case 'teammate_message': { itemKey = `teammate-${item.teammateMessage.id}-${index}`; element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} onReplyHover={handleReplyHover} /> ); break; } case 'subagent_input': { itemKey = `input-${index}`; const inputContent = item.content; const inputTokenCount = item.tokenCount; element = ( } label="Input" summary={truncateText(inputContent, 80)} tokenCount={inputTokenCount} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} > ); break; } case 'compact_boundary': { itemKey = `compact-${index}`; const compactContent = item.content; const compactExpanded = expandedItemIds.has(itemKey); element = (
{compactExpanded && compactContent && (
)}
); break; } default: return null; } // Apply reply-link spotlight: dim items not in the highlighted pair const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); return (
{element}
); })}
); };