import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { enhanceAIGroup, type PrecedingSlashInfo } from '@renderer/utils/aiGroupEnhancer'; import { extractSlashInfo, isCommandContent } from '@shared/utils/contentSanitizer'; import { getModelColorClass } from '@shared/utils/modelParser'; import { estimateTokens } from '@shared/utils/tokenFormatting'; import { format } from 'date-fns'; import { Bot, ChevronDown, Clock } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { TokenUsageDisplay } from '../common/TokenUsageDisplay'; import { ContextBadge } from './ContextBadge'; import { DisplayItemList } from './DisplayItemList'; import { LastOutputDisplay } from './LastOutputDisplay'; import type { ContextStats } from '@renderer/types/contextInjection'; import type { AIGroup, AIGroupDisplayItem, EnhancedAIGroup, UserGroup, } from '@renderer/types/groups'; import type { TriggerColor } from '@shared/constants/triggerColors'; /** * Extract slash info from a UserGroup's message content. * Returns PrecedingSlashInfo if the user message was a slash invocation, * null otherwise. */ function extractPrecedingSlashInfo( userGroup: UserGroup | undefined ): PrecedingSlashInfo | undefined { if (!userGroup) return undefined; const msg = userGroup.message; const content = msg.content; // Check if this is a slash message (has tags) if (typeof content === 'string' && isCommandContent(content)) { const slashInfo = extractSlashInfo(content); if (slashInfo) { return { name: slashInfo.name, message: slashInfo.message, args: slashInfo.args, commandMessageUuid: msg.uuid, timestamp: new Date(msg.timestamp), }; } } return undefined; } /** * Format duration in milliseconds to human-readable string. * Examples: "1.2s", "45s", "1m 30s", "5m" */ function formatDuration(ms: number): string { if (ms < 1000) { return `${ms}ms`; } const seconds = Math.floor(ms / 1000); if (seconds < 60) { const decimal = ms % 1000 >= 100 ? `.${Math.floor((ms % 1000) / 100)}` : ''; return `${seconds}${decimal}s`; } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (remainingSeconds === 0) { return `${minutes}m`; } return `${minutes}m ${remainingSeconds}s`; } interface AIChatGroupProps { aiGroup: AIGroup; /** Tool use ID to highlight for error deep linking */ highlightToolUseId?: string; /** Custom highlight color from trigger */ highlightColor?: TriggerColor; /** Register ref for individual tool items (for precise scroll targeting) */ registerToolRef?: (toolId: string, el: HTMLElement | null) => void; } /** * Checks if a tool ID exists within the display items (including nested subagents). */ function containsToolUseId(items: AIGroupDisplayItem[], toolUseId: string): boolean { for (const item of items) { if (item.type === 'tool' && item.tool.id === toolUseId) { return true; } // Check nested subagent messages for the tool ID if (item.type === 'subagent' && item.subagent.messages) { for (const msg of item.subagent.messages) { if (msg.toolCalls?.some((tc) => tc.id === toolUseId)) { return true; } if (msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)) { return true; } } } } return false; } /** * AIChatGroup displays an AI response using a clean, minimal card-based design. * * Features: * - Card container with subtle zinc styling * - Clickable header with Bot icon, "Claude" label, and items summary * - LastOutputDisplay: Always visible last output (text or tool result) * - DisplayItemList: Shows items when expanded with inline expansion support * - Manages local expansion state and inline item expansion */ const AIChatGroupInner = ({ aiGroup, highlightToolUseId, highlightColor, registerToolRef, }: Readonly): React.JSX.Element => { // Per-tab UI state for expansion (completely isolated per tab) const { tabId, isAIGroupExpanded: isAIGroupExpandedForTab, toggleAIGroupExpansion, getExpandedDisplayItemIds, toggleDisplayItemExpansion, expandDisplayItem, } = useTabUI(); // Per-tab session data, falling back to global state const projectRoot = useStore((s) => { const td = tabId ? s.tabSessionData[tabId] : null; return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath; }); const isSessionOngoing = useStore((s) => { const id = s.selectedSessionId; if (!id) return false; return s.sessions.find((sess) => sess.id === id)?.isOngoing ?? false; }); // Per-tab session data subscriptions, falling back to global state const { sessionClaudeMdStats, sessionContextStats, sessionPhaseInfo, conversation, searchExpandedAIGroupIds, searchExpandedSubagentIds, searchCurrentDisplayItemId, } = useStore( useShallow((s) => { const td = tabId ? s.tabSessionData[tabId] : null; return { sessionClaudeMdStats: td?.sessionClaudeMdStats ?? s.sessionClaudeMdStats, sessionContextStats: td?.sessionContextStats ?? s.sessionContextStats, sessionPhaseInfo: td?.sessionPhaseInfo ?? s.sessionPhaseInfo, conversation: td?.conversation ?? s.conversation, searchExpandedAIGroupIds: s.searchExpandedAIGroupIds, searchExpandedSubagentIds: s.searchExpandedSubagentIds, searchCurrentDisplayItemId: s.searchCurrentDisplayItemId, }; }) ); // Notification color map for tool item dots const notifications = useStore((s) => s.notifications); const notificationColorMap = useMemo(() => { const map = new Map(); for (const n of notifications) { if (n.toolUseId && n.triggerColor) { map.set(n.toolUseId, n.triggerColor); } } return map; }, [notifications]); // Derived state from store values const claudeMdStats = sessionClaudeMdStats?.get(aiGroup.id); const contextStats: ContextStats | undefined = sessionContextStats?.get(aiGroup.id); // Phase data for this AI group const phaseNumber = sessionPhaseInfo?.aiGroupPhaseMap.get(aiGroup.id); const totalPhases = sessionPhaseInfo?.phases.length ?? 0; // Find the preceding UserGroup for this AIGroup to extract slash info // eslint-disable-next-line react-hooks/preserve-manual-memoization -- React Compiler can't preserve this; manual memo needed for O(n) traversal const precedingSlash = useMemo(() => { if (!conversation?.items) return undefined; // Find the index of this AIGroup in the conversation const aiGroupIndex = conversation.items.findIndex( (item) => item.type === 'ai' && item.group.id === aiGroup.id ); if (aiGroupIndex <= 0) return undefined; // Look backwards for the nearest UserGroup for (let i = aiGroupIndex - 1; i >= 0; i--) { const item = conversation.items[i]; if (item.type === 'user') { return extractPrecedingSlashInfo(item.group); } // Stop if we hit another AI group (shouldn't happen in normal flow) if (item.type === 'ai') break; } return undefined; }, [conversation?.items, aiGroup.id]); // Enhance the AI group to get display-ready data const enhanced: EnhancedAIGroup = useMemo( () => enhanceAIGroup(aiGroup, claudeMdStats, precedingSlash), [aiGroup, claudeMdStats, precedingSlash] ); // Check if this group should be expanded for search results const shouldExpandForSearch = searchExpandedAIGroupIds.has(aiGroup.id); // Check if this group contains the highlighted error tool const containsHighlightedError = useMemo(() => { if (!highlightToolUseId) return false; return containsToolUseId(enhanced.displayItems, highlightToolUseId); }, [enhanced.displayItems, highlightToolUseId]); // Get the LAST assistant message's usage (represents current context window snapshot) // This is the correct metric to display - not the summed values across all messages const lastUsage = useMemo(() => { const responses = aiGroup.responses || []; // Find the last assistant message with usage data for (let i = responses.length - 1; i >= 0; i--) { const msg = responses[i]; if (msg.type === 'assistant' && msg.usage) { return msg.usage; } } return null; }, [aiGroup.responses]); // Calculate thinking and text output tokens from assistant message content blocks // These are estimated from the actual content, providing breakdown of output token usage const { thinkingTokens, textOutputTokens } = useMemo(() => { let thinking = 0; let textOutput = 0; const responses = aiGroup.responses || []; for (const msg of responses) { if (msg.type === 'assistant' && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === 'thinking' && block.thinking) { thinking += estimateTokens(block.thinking); } else if (block.type === 'text' && block.text) { textOutput += estimateTokens(block.text); } } } } return { thinkingTokens: thinking, textOutputTokens: textOutput }; }, [aiGroup.responses]); // Auto-expand if contains error or search result, or if manually expanded const isExpanded = isAIGroupExpandedForTab(aiGroup.id) || containsHighlightedError || shouldExpandForSearch; // Helper function to find the item ID containing the highlighted tool const findHighlightedItemId = useCallback( (toolUseId: string): string | null => { for (let i = 0; i < enhanced.displayItems.length; i++) { const item = enhanced.displayItems[i]; if (item.type === 'tool' && item.tool.id === toolUseId) { return `tool-${item.tool.id}-${i}`; } // For subagents, expand the subagent item if (item.type === 'subagent' && item.subagent.messages) { for (const msg of item.subagent.messages) { if ( msg.toolCalls?.some((tc) => tc.id === toolUseId) || msg.toolResults?.some((tr) => tr.toolUseId === toolUseId) ) { return `subagent-${item.subagent.id}-${i}`; } } } } return null; }, [enhanced.displayItems] ); // Get expanded item IDs for this AI group (per-tab) const expandedItemIds = useMemo( () => getExpandedDisplayItemIds(aiGroup.id), [getExpandedDisplayItemIds, aiGroup.id] ); // Track which highlightToolUseId we've already processed to prevent infinite loops const processedHighlightRef = useRef(null); // Effect to auto-expand display item when highlightToolUseId is set // AI group expansion is now handled by the navigation coordinator // This only handles display item expansion which requires enhanced data useEffect(() => { if (!highlightToolUseId || !containsHighlightedError) { // Reset ref when highlight is cleared if (!highlightToolUseId) { processedHighlightRef.current = null; } return; } // Skip if we've already processed this exact highlight if (processedHighlightRef.current === highlightToolUseId) { return; } // Mark as processed BEFORE making any state changes processedHighlightRef.current = highlightToolUseId; // Find and expand the display item containing the highlighted tool // No delay needed - navigation coordinator ensures DOM is stable before highlight const itemId = findHighlightedItemId(highlightToolUseId); if (itemId) { expandDisplayItem(aiGroup.id, itemId); } }, [ highlightToolUseId, containsHighlightedError, aiGroup.id, expandDisplayItem, findHighlightedItemId, ]); // Track which search we've already processed to prevent infinite loops const processedSearchRef = useRef(null); // Effect to auto-expand display items when search navigates to this group // Note: AI group expansion is handled by derived isExpanded (shouldExpandForSearch) useEffect(() => { if (!shouldExpandForSearch) { processedSearchRef.current = null; return; } // Create a unique key for this search state const searchKey = `${searchCurrentDisplayItemId ?? ''}-${Array.from(searchExpandedSubagentIds).join(',')}`; if (processedSearchRef.current === searchKey) { return; } processedSearchRef.current = searchKey; // Expand the specific display item containing the search result (uses per-tab state) if (searchCurrentDisplayItemId) { expandDisplayItem(aiGroup.id, searchCurrentDisplayItemId); } // If any subagents in this group need their trace expanded for search, expand them for (let i = 0; i < enhanced.displayItems.length; i++) { const item = enhanced.displayItems[i]; if (item.type === 'subagent' && searchExpandedSubagentIds.has(item.subagent.id)) { const subagentItemId = `subagent-${item.subagent.id}-${i}`; expandDisplayItem(aiGroup.id, subagentItemId); } } }, [ shouldExpandForSearch, searchCurrentDisplayItemId, searchExpandedSubagentIds, enhanced.displayItems, aiGroup.id, expandDisplayItem, ]); // Determine if there's content to toggle const hasToggleContent = enhanced.displayItems.length > 0; // Handle item click - toggle inline expansion using store action const handleItemClick = (itemId: string): void => { toggleDisplayItemExpansion(aiGroup.id, itemId); }; return (
{/* Header Row */} {hasToggleContent && (
{/* Clickable toggle area */}
toggleAIGroupExpansion(aiGroup.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleAIGroupExpansion(aiGroup.id); } }} > Claude {/* Main agent model */} {enhanced.mainModel && ( {enhanced.mainModel.name} )} {/* Subagent models if different */} {enhanced.subagentModels.length > 0 && ( <> → {enhanced.subagentModels.map((m, i) => ( {i > 0 && ', '} {m.name} ))} )} · {enhanced.itemsSummary}
{/* Right side: Context badge, Token usage, Timestamp (non-clickable) */}
{/* Context injection badge (CLAUDE.md, mentioned files, tool outputs) */} {contextStats && } {/* Token usage - show last assistant message's usage (context window snapshot) */} {lastUsage && ( )} {/* Duration */} {aiGroup.durationMs > 0 && ( {formatDuration(aiGroup.durationMs)} )} {/* Timestamp - receded for visual hierarchy */} {enhanced.lastOutput?.timestamp && ( {format(enhanced.lastOutput.timestamp, 'h:mm:ss a')} )}
)} {/* Expandable Content */} {hasToggleContent && isExpanded && (
)} {/* Always-visible Output */}
); }; export const AIChatGroup = React.memo(AIChatGroupInner);