/** * Display Item Builder - Build display items from semantic steps or messages * * Creates a flat chronological list of display items for the AI Group UI. */ import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers'; import { extractSlashes, type PrecedingSlashInfo } from './slashCommandExtractor'; import { linkToolCallsToResults } from './toolLinkingEngine'; import type { ParsedMessage, Process, SemanticStep } from '../types/data'; import type { AIGroupDisplayItem, AIGroupLastOutput, LinkedToolItem } from '../types/groups'; /** * Get the timestamp from a display item for sorting. */ function getDisplayItemTimestamp(item: AIGroupDisplayItem): Date { switch (item.type) { case 'thinking': case 'output': return toDate(item.timestamp); case 'tool': return toDate(item.tool.startTime); case 'subagent': return toDate(item.subagent.startTime); case 'slash': return toDate(item.slash.timestamp); case 'teammate_message': return toDate(item.teammateMessage.timestamp); case 'subagent_input': case 'compact_boundary': return toDate(item.timestamp); } } /** * Sort display items chronologically. */ function sortDisplayItemsChronologically(items: AIGroupDisplayItem[]): void { items.sort((a, b) => getDisplayItemTimestamp(a).getTime() - getDisplayItemTimestamp(b).getTime()); } /** * Link TeammateMessages to their triggering SendMessage calls. * For each TeammateMessage, scans backwards through chronologically sorted items * to find the most recent SendMessage to that teammate. * Only matches type: "message" or "broadcast" (not shutdown_request/shutdown_response). * Proactive messages (no preceding SendMessage) get no badge. */ function linkTeammateReplies(items: AIGroupDisplayItem[]): void { for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type !== 'teammate_message') continue; const tmMsg = item.teammateMessage; // Scan backwards for the most recent SendMessage to this teammate for (let j = i - 1; j >= 0; j--) { const prev = items[j]; if (prev.type !== 'tool') continue; if (prev.tool.name !== 'SendMessage') continue; const input = prev.tool.input; // Only match outbound messages (not shutdown_request, shutdown_response, etc.) if (input.type !== 'message' && input.type !== 'broadcast') continue; // Match by recipient (broadcast goes to all, so always matches) if (input.type === 'message' && input.recipient !== tmMsg.teammateId) continue; tmMsg.replyToSummary = (input.summary as string) || 'message'; tmMsg.replyToToolId = prev.tool.id; break; } } } /** * Build a flat chronological list of display items for the AI Group. * * Strategy: * 1. Skip the step that represents lastOutput (to avoid duplication) * 2. For tool_call steps, use the LinkedToolItem (which includes the result) * 3. Skip standalone tool_result steps (already linked to calls) * 4. Skip Task tool_call steps that have associated subagents (avoid duplication) * 5. Include thinking, subagent, and output steps * 6. Return items in chronological order * * @param steps - Semantic steps from the AI Group * @param lastOutput - The last output to skip * @param subagents - Subagents associated with this group * @param responses - Optional raw messages for extracting slash instructions * @param precedingSlash - Optional slash info from the preceding UserGroup * @returns Flat array of display items */ export function buildDisplayItems( steps: SemanticStep[], lastOutput: AIGroupLastOutput | null, subagents: Process[], responses?: ParsedMessage[], precedingSlash?: PrecedingSlashInfo ): AIGroupDisplayItem[] { const displayItems: AIGroupDisplayItem[] = []; const linkedTools = linkToolCallsToResults(steps, responses); // Build set of Task IDs that have associated subagents // This prevents duplicate display of Task tool calls when subagents are shown const taskIdsWithSubagents = new Set( subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id) ); // Find the exact lastOutput step to skip it without accidentally // dropping the paired tool_call, which shares the same step id. let lastOutputStepRef: | { id: string; type: SemanticStep['type']; } | undefined; if (lastOutput) { for (let i = steps.length - 1; i >= 0; i--) { const step = steps[i]; if ( lastOutput.type === 'text' && step.type === 'output' && step.content.outputText === lastOutput.text ) { lastOutputStepRef = { id: step.id, type: step.type }; break; } if ( lastOutput.type === 'tool_result' && step.type === 'tool_result' && step.content.toolResultContent === lastOutput.toolResult ) { lastOutputStepRef = { id: step.id, type: step.type }; break; } if ( lastOutput.type === 'interruption' && step.type === 'interruption' && step.content.interruptionText === lastOutput.interruptionMessage ) { lastOutputStepRef = { id: step.id, type: step.type }; break; } } } // Build display items for (const step of steps) { // Skip the last output step if (step.id === lastOutputStepRef?.id && step.type === lastOutputStepRef.type) { continue; } switch (step.type) { case 'thinking': if (step.content.thinkingText) { displayItems.push({ type: 'thinking', content: step.content.thinkingText, timestamp: step.startTime, tokenCount: estimateTokens(step.content.thinkingText), }); } break; case 'tool_call': { const linkedTool = linkedTools.get(step.id); if (linkedTool) { // Skip Task tool calls that have associated subagents // The subagent will be shown separately, so showing the Task call is redundant const isTaskWithSubagent = linkedTool.name === 'Task' && taskIdsWithSubagents.has(step.id); if (!isTaskWithSubagent) { displayItems.push({ type: 'tool', tool: linkedTool, }); } } break; } case 'tool_result': // Skip - these are already included in LinkedToolItem break; case 'subagent': { const subagentId = step.content.subagentId; const subagent = subagents.find((s) => s.id === subagentId); if (subagent) { displayItems.push({ type: 'subagent', subagent: subagent, }); } break; } case 'output': if (step.content.outputText) { displayItems.push({ type: 'output', content: step.content.outputText, timestamp: step.startTime, tokenCount: estimateTokens(step.content.outputText), }); } break; case 'interruption': if (step.content.interruptionText) { displayItems.push({ type: 'output', content: step.content.interruptionText, timestamp: step.startTime, tokenCount: estimateTokens(step.content.interruptionText), }); } break; } } // Add slashes as display items if (responses) { const slashes = extractSlashes(responses, precedingSlash); for (const slash of slashes) { displayItems.push({ type: 'slash', slash, }); } } // Add teammate messages from responses (one user message may contain multiple blocks) if (responses) { for (const msg of responses) { if (msg.type !== 'user' || msg.isMeta) continue; const rawText = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? msg.content .filter((b) => b.type === 'text') .map((b) => b.text) .join('') : ''; const parsedBlocks = parseAllTeammateMessages(rawText); for (const parsed of parsedBlocks) { displayItems.push({ type: 'teammate_message', teammateMessage: { id: `${msg.uuid}-${parsed.teammateId}-${displayItems.length}`, teammateId: parsed.teammateId, color: parsed.color, summary: parsed.summary, content: parsed.content, timestamp: toDate(msg.timestamp), tokenCount: estimateTokens(parsed.content), }, }); } } } // Sort all items chronologically to ensure slashes appear in correct order sortDisplayItemsChronologically(displayItems); // Link TeammateMessages to their triggering SendMessage calls linkTeammateReplies(displayItems); return displayItems; } /** * Build display items from raw ParsedMessages (used by subagents). * This mirrors the logic of buildDisplayItems but works with messages instead of SemanticSteps. * * Strategy: * 1. Extract thinking blocks from assistant messages * 2. Extract tool_use blocks from assistant messages -> collect in a Map by ID * 3. Extract text output blocks from assistant messages * 4. Extract tool_result blocks from user messages (isMeta or toolResults exist) * 5. Link tool calls with their results using LinkedToolItem structure * 6. Filter Task tool calls that have matching subagents * 7. Include subagents as separate items * 8. Sort all items chronologically * * @param messages - Raw ParsedMessages to process * @param subagents - Subagents associated with these messages * @returns Flat array of display items */ export function buildDisplayItemsFromMessages( messages: ParsedMessage[], subagents: Process[] = [] ): AIGroupDisplayItem[] { const displayItems: AIGroupDisplayItem[] = []; // Maps for tool call/result linking const toolCallsById = new Map< string, { id: string; name: string; input: Record; timestamp: Date; sourceMessageId: string; sourceModel?: string; } >(); const toolResultsById = new Map< string, { content: string | unknown[]; isError: boolean; toolUseResult?: Record; timestamp: Date; } >(); // Map to collect skill instructions by source tool use ID // Skill tools have follow-up isMeta:true messages with instructions starting with "Base directory for this skill:" const skillInstructionsById = new Map(); // Build set of Task IDs that have associated subagents // This prevents duplicate display of Task tool calls when subagents are shown const taskIdsWithSubagents = new Set( subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id) ); // Track compaction events for compact_boundary display items let compactionCount = 0; // Helper to get the last assistant's total input tokens before a given index // Note: don't filter by isSidechain — subagent messages all have isSidechain=true function getLastAssistantInputTokens(idx: number): number { for (let i = idx - 1; i >= 0; i--) { const m = messages[i]; if (m.type === 'assistant' && m.usage && m.model !== '') { return ( (m.usage.input_tokens ?? 0) + (m.usage.cache_read_input_tokens ?? 0) + (m.usage.cache_creation_input_tokens ?? 0) ); } } return 0; } // Helper to get the first assistant's total input tokens after a given index function getFirstAssistantInputTokens(idx: number): number { for (let i = idx + 1; i < messages.length; i++) { const m = messages[i]; if (m.type === 'assistant' && m.usage && m.model !== '') { return ( (m.usage.input_tokens ?? 0) + (m.usage.cache_read_input_tokens ?? 0) + (m.usage.cache_creation_input_tokens ?? 0) ); } } return 0; } // First pass: collect tool calls and tool results from messages for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const msg = messages[messageIndex]; const msgTimestamp = toDate(msg.timestamp); // Detect compact boundary (before regular user message handling) if (msg.isCompactSummary) { const preTokens = getLastAssistantInputTokens(messageIndex); const postTokens = getFirstAssistantInputTokens(messageIndex); const rawText = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? msg.content .filter((b: { type: string; text?: string }) => b.type === 'text') .map((b: { type: string; text?: string }) => b.text ?? '') .join('\n\n') : ''; displayItems.push({ type: 'compact_boundary', content: rawText, timestamp: msgTimestamp, tokenDelta: preTokens > 0 ? { preCompactionTokens: preTokens, postCompactionTokens: postTokens, delta: postTokens - preTokens, } : undefined, phaseNumber: compactionCount + 2, }); compactionCount++; continue; } // Check for teammate messages (non-meta user messages with content) // One user message may contain multiple blocks if (msg.type === 'user' && !msg.isMeta) { const rawText = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? msg.content .filter((b) => b.type === 'text') .map((b) => b.text) .join('') : ''; const parsedBlocks = parseAllTeammateMessages(rawText); if (parsedBlocks.length > 0) { for (const parsed of parsedBlocks) { displayItems.push({ type: 'teammate_message', teammateMessage: { id: `${msg.uuid}-${parsed.teammateId}-${displayItems.length}`, teammateId: parsed.teammateId, color: parsed.color, summary: parsed.summary, content: parsed.content, timestamp: msgTimestamp, tokenCount: estimateTokens(parsed.content), }, }); } continue; } // Only treat as subagent input if there are NO tool_result blocks in this message const hasToolResults = Array.isArray(msg.content) && msg.content.some((b) => b.type === 'tool_result'); if (rawText.trim() && !hasToolResults) { displayItems.push({ type: 'subagent_input', content: rawText.trim(), timestamp: msgTimestamp, tokenCount: estimateTokens(rawText), }); continue; } // Fall through to tool result processing below if message has tool_results } if (msg.type === 'assistant' && Array.isArray(msg.content)) { // Process assistant message content blocks for (const block of msg.content) { if (block.type === 'thinking' && block.thinking) { // Add thinking block displayItems.push({ type: 'thinking', content: block.thinking, timestamp: msgTimestamp, tokenCount: estimateTokens(block.thinking), }); } else if (block.type === 'tool_use' && block.id && block.name) { // Collect tool call for later linking toolCallsById.set(block.id, { id: block.id, name: block.name, input: block.input ?? {}, timestamp: msgTimestamp, sourceMessageId: msg.uuid, sourceModel: msg.model, }); } else if (block.type === 'text' && block.text) { // Add text output displayItems.push({ type: 'output', content: block.text, timestamp: msgTimestamp, tokenCount: estimateTokens(block.text), }); } } } else if (msg.type === 'user' && (msg.isMeta || msg.toolResults.length > 0)) { // Process tool results from internal user messages if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === 'tool_result' && block.tool_use_id) { // Collect tool result for linking toolResultsById.set(block.tool_use_id, { content: block.content ?? '', isError: block.is_error ?? false, toolUseResult: msg.toolUseResult, timestamp: msgTimestamp, }); } // Check for skill instructions: isMeta:true messages with sourceToolUseID // containing text starting with "Base directory for this skill:" if (block.type === 'text' && block.text && msg.sourceToolUseID) { const text = block.text; if (text.startsWith('Base directory for this skill:')) { skillInstructionsById.set(msg.sourceToolUseID, text); } } } } // Also check msg.toolResults array (pre-extracted results) for (const result of msg.toolResults) { if (!toolResultsById.has(result.toolUseId)) { toolResultsById.set(result.toolUseId, { content: result.content, isError: result.isError, toolUseResult: msg.toolUseResult, timestamp: msgTimestamp, }); } } } } // Second pass: Build LinkedToolItems by matching calls with results for (const [toolId, call] of toolCallsById.entries()) { const result = toolResultsById.get(toolId); // Skip Task tool calls that have associated subagents // The subagent will be shown separately, so showing the Task call is redundant const isTaskWithSubagent = call.name === 'Task' && taskIdsWithSubagents.has(toolId); if (isTaskWithSubagent) { continue; } // Get skill instructions for Skill tool calls const skillInstructions = call.name === 'Skill' ? skillInstructionsById.get(toolId) : undefined; const linkedItem: LinkedToolItem = { id: toolId, name: call.name, input: call.input, result: result ? { content: result.content, isError: result.isError, toolUseResult: result.toolUseResult, } : undefined, inputPreview: formatToolInput(call.input), outputPreview: result ? formatToolResult(result.content) : undefined, startTime: call.timestamp, endTime: result?.timestamp, durationMs: result?.timestamp ? result.timestamp.getTime() - call.timestamp.getTime() : undefined, isOrphaned: !result, sourceModel: call.sourceModel, skillInstructions, skillInstructionsTokenCount: skillInstructions ? estimateTokens(skillInstructions) : undefined, }; displayItems.push({ type: 'tool', tool: linkedItem, }); } // Add subagents as display items for (const subagent of subagents) { displayItems.push({ type: 'subagent', subagent: subagent, }); } // Add slashes as display items const slashes = extractSlashes(messages); for (const slash of slashes) { displayItems.push({ type: 'slash', slash, }); } // Sort all items chronologically sortDisplayItemsChronologically(displayItems); // Link TeammateMessages to their triggering SendMessage calls linkTeammateReplies(displayItems); return displayItems; }