/** * ChatMessageList - Renders the list of chat messages * * Claude-Code-style: user messages in bordered bubbles (right-aligned), * assistant responses as plain text (left-aligned, no border/bg). * No avatars. Thinking blocks are collapsible. */ import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types'; import { Dialog, DialogContent, DialogTitle } from '../ui/dialog'; import { Conversation, ConversationContent, ConversationScrollButton, } from '../ai-elements/conversation'; import { LazyMessageResponse } from '../ai-elements/LazyMessageResponse'; import { Message, MessageContent } from '../ai-elements/messageShell'; import { AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS, AI_MARKDOWN_WARMUP_RESUME_DELAY_MS, isAiComposerTyping, scheduleAiMarkdownWarmup, } from './aiMarkdownWarmup'; import { ToolCall } from '../ai-elements/tool-call'; import ThinkingBlock from './ThinkingBlock'; import AgentActivityGroup from './AgentActivityGroup'; import ToolCallGroup from './ToolCallGroup'; import { CodexUserInputCard } from './CodexUserInputCard'; import { CodebuddyElicitationCard } from './CodebuddyElicitationCard'; import { VaultArtifactNavigationProvider, type VaultArtifactNavSection, } from './toolArtifacts/VaultArtifactNavigationContext'; import { parseTerminalToolArtifact } from './toolArtifacts/terminalToolArtifact'; import { TerminalArtifactToolResult } from './toolArtifacts/TerminalArtifactToolResult'; import { inferArtifactToolNameFromCliArgs, normalizeArtifactToolName, } from './toolArtifacts/toolArtifactNames'; import { parseVaultToolArtifact } from './toolArtifacts/vaultToolArtifact'; import { VaultArtifactToolResult } from './toolArtifacts/VaultArtifactToolResult'; import type { Host, Snippet, VaultNote } from '../../types'; import { onApprovalRequest, onApprovalCleared, replayPendingApprovals, resolveApproval, type ApprovalRequest, } from '../../infrastructure/ai/shared/approvalGate'; import { onCodexAppServerInteraction, onCodexAppServerInteractionCleared, replayPendingCodexAppServerInteractions, respondCodexUserInput, type CodexAppServerInteraction, } from '../../infrastructure/ai/shared/codexAppServerInteractions'; import { onCodebuddyElicitation, onCodebuddyElicitationCleared, replayPendingCodebuddyElicitations, respondCodebuddyElicitation, type CodebuddyElicitation, type CodebuddyElicitationAction, } from '../../infrastructure/ai/shared/codebuddyElicitations'; import { buildGrantsFromApproval, resolveCapabilityId, } from '../../infrastructure/ai/harness/permissionGrants'; import { compactionStatusText, resolveCompactionStatusText, type ActiveCompactionUi, } from '../../application/state/useAgentCompactionUi'; import { getAIPanelDiagnosticHiddenParts, getAIPanelProfilerProps, isAIPanelDiagnosticPartHidden, } from './aiPanelDiagnostics'; import { buildChatJumpEntries, chatMessageDomId, resolveTailCountForJumpTarget, } from '../../domain/chatJumpNav'; import ChatJumpNav from './ChatJumpNav'; interface ChatMessageListProps { messages: ChatMessage[]; isStreaming?: boolean; /** Active chat session ID — used to filter standalone MCP approval blocks */ activeSessionId?: string | null; activeCompaction?: ActiveCompactionUi | null; notes?: VaultNote[]; hosts?: Host[]; snippets?: Snippet[]; onOpenVaultNote?: (noteId: string) => void; onOpenVaultHost?: (hostId: string) => void; onOpenVaultSnippet?: (snippetId: string) => void; onOpenVaultSection?: (section: VaultArtifactNavSection) => void; } interface VaultArtifactNavigationCallbackOptions { onOpenVaultNote?: (noteId: string) => void; onOpenVaultHost?: (hostId: string) => void; onOpenVaultSnippet?: (snippetId: string) => void; onOpenVaultSection?: (section: VaultArtifactNavSection) => void; } export function shouldProvideVaultArtifactNavigation({ onOpenVaultNote, onOpenVaultHost, onOpenVaultSnippet, onOpenVaultSection, }: VaultArtifactNavigationCallbackOptions): boolean { return Boolean(onOpenVaultNote || onOpenVaultHost || onOpenVaultSnippet || onOpenVaultSection); } export function shouldRenderAssistantAsPlainText(options: { hideMarkdown: boolean; }): boolean { // Streaming stays on Streamdown with isAnimating so incomplete markdown // updates live. Only diagnostic hideMarkdown forces plain text. return options.hideMarkdown; } const ASSISTANT_PLAIN_TEXT_CLASS = 'whitespace-pre-wrap break-words text-[13px] leading-[1.45]'; export interface CodexApprovalRenderEntry { approvalId: string; request: ApprovalRequest; } export function buildCodexApprovalRenderPlan( pendingApprovals: ReadonlyMap, renderedPendingToolCallIds: ReadonlySet, activeSessionId?: string | null, ): { byItemId: Map; standalone: CodexApprovalRenderEntry[]; } { const byItemId = new Map(); const standalone: CodexApprovalRenderEntry[] = []; for (const [approvalId, request] of pendingApprovals) { if (request.source !== 'codex-app-server') continue; if (activeSessionId && request.chatSessionId !== activeSessionId) continue; const entry = { approvalId, request }; if (request.itemId && renderedPendingToolCallIds.has(request.itemId)) { const entries = byItemId.get(request.itemId) ?? []; entries.push(entry); byItemId.set(request.itemId, entries); } else { standalone.push(entry); } } return { byItemId, standalone }; } const MESSAGE_RENDER_BATCH = 50; const MESSAGE_RENDER_STEP = 50; export function pruneResolvedApprovals( previous: ReadonlyMap, messages: readonly ChatMessage[], ): Map { const visibleToolCallIds = new Set(); for (const message of messages) { for (const toolCall of message.toolCalls ?? []) visibleToolCallIds.add(toolCall.id); } const next = new Map(); for (const [toolCallId, approved] of previous) { if (visibleToolCallIds.has(toolCallId)) next.set(toolCallId, approved); } return next; } const ChatMessageList: React.FC = ({ messages, isStreaming, activeSessionId, activeCompaction = null, notes = [], hosts = [], snippets = [], onOpenVaultNote, onOpenVaultHost, onOpenVaultSnippet, onOpenVaultSection, }) => { // Track pending approvals from the approval gate const [pendingApprovals, setPendingApprovals] = useState>(new Map()); const [resolvedApprovals, setResolvedApprovals] = useState>(new Map()); const [pendingCodexInteractions, setPendingCodexInteractions] = useState>(new Map()); const [pendingCodebuddyElicitations, setPendingCodebuddyElicitations] = useState>(new Map()); useEffect(() => { setResolvedApprovals((previous) => pruneResolvedApprovals(previous, messages)); }, [activeSessionId, messages]); // Subscribe to approval gate events (SDK + MCP tool calls) useEffect(() => { const handler = (request: ApprovalRequest) => { setPendingApprovals(prev => new Map(prev).set(request.toolCallId, request)); }; const unsub = onApprovalRequest(handler); // Replay any approvals that fired while this component was unmounted replayPendingApprovals(handler); return unsub; }, []); // Subscribe to approval cleared/removed events (fired on session stop or timeout) useEffect(() => { return onApprovalCleared((clearedIds) => { setPendingApprovals(prev => { const m = new Map(prev); for (const id of clearedIds) m.delete(id); return m; }); }); }, []); useEffect(() => { const handler = (interaction: CodexAppServerInteraction) => { setPendingCodexInteractions((current) => new Map(current).set(interaction.interactionId, interaction)); }; const unsubscribe = onCodexAppServerInteraction(handler); replayPendingCodexAppServerInteractions(handler); return unsubscribe; }, []); useEffect(() => onCodexAppServerInteractionCleared((interactionIds) => { setPendingCodexInteractions((current) => { const next = new Map(current); for (const interactionId of interactionIds) next.delete(interactionId); return next; }); }), []); useEffect(() => { const handler = (elicitation: CodebuddyElicitation) => { setPendingCodebuddyElicitations((current) => new Map(current).set(elicitation.elicitationId, elicitation)); }; const unsubscribe = onCodebuddyElicitation(handler); replayPendingCodebuddyElicitations(handler); return unsubscribe; }, []); useEffect(() => onCodebuddyElicitationCleared((elicitationIds) => { setPendingCodebuddyElicitations((current) => { const next = new Map(current); for (const elicitationId of elicitationIds) next.delete(elicitationId); return next; }); }), []); const handleApproveOnce = useCallback((toolCallId: string) => { const request = pendingApprovals.get(toolCallId); resolveApproval(toolCallId, request?.source === 'codex-app-server' ? { approved: true, scope: 'once' } : true); setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; }); setResolvedApprovals(prev => new Map(prev).set(request?.itemId ?? toolCallId, true)); }, [pendingApprovals]); const handleAlwaysAllow = useCallback((toolCallId: string, request: ApprovalRequest) => { if (request.source === 'codex-app-server') { resolveApproval(toolCallId, { approved: true, scope: 'session' }); setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; }); setResolvedApprovals(prev => new Map(prev).set(request.itemId ?? toolCallId, true)); return; } const capabilityId = request.capabilityId ?? resolveCapabilityId(request.toolName); const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId); resolveApproval(toolCallId, { approved: true, persistGrants }); setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; }); setResolvedApprovals(prev => new Map(prev).set(toolCallId, true)); }, []); const handleReject = useCallback((toolCallId: string) => { const request = pendingApprovals.get(toolCallId); resolveApproval(toolCallId, false); setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; }); setResolvedApprovals(prev => new Map(prev).set(request?.itemId ?? toolCallId, false)); }, [pendingApprovals]); const handleCodexUserInput = useCallback(( interactionId: string, answers: Record, ) => { void respondCodexUserInput(interactionId, answers).catch((error) => { console.error('[Codex App Server] Failed to answer request_user_input:', error); }); }, []); const handleCodebuddyElicitation = useCallback(( elicitationId: string, action: CodebuddyElicitationAction, content?: Record, ) => respondCodebuddyElicitation(elicitationId, action, content), []); const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); const [zoom, setZoom] = useState(100); const [dragged, setDragged] = useState(false); const imgRef = useRef(null); const dragPos = useRef({ x: 0, y: 0 }); const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => { if (!imgRef.current) return; imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none'; imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`; }, []); const zoomRef = useRef(100); const setZoomAndRef = useCallback((fn: (z: number) => number) => { setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; }); }, []); const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]); const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]); const onWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -10 : 10; setZoomAndRef(z => { const nz = Math.max(25, Math.min(200, z + delta)); applyTransform(nz, dragPos.current.x, dragPos.current.y, false); return nz; }); }, [applyTransform, setZoomAndRef]); const openPreview = useCallback((src: string, name: string) => { setZoom(100); zoomRef.current = 100; setDragged(false); dragPos.current = { x: 0, y: 0 }; setPreview({ src, name }); }, []); const resetPreview = useCallback(() => { setZoom(100); zoomRef.current = 100; setDragged(false); dragPos.current = { x: 0, y: 0 }; applyTransform(100, 0, 0, true); }, [applyTransform]); const onPointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault(); (e.target as HTMLElement).setPointerCapture(e.pointerId); dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y }; }, []); const onPointerMove = useCallback((e: React.PointerEvent) => { if (!dragStart.current) return; if ((e.buttons & 1) === 0) { dragStart.current = null; return; } const x = dragStart.current.origX + (e.clientX - dragStart.current.startX); const y = dragStart.current.origY + (e.clientY - dragStart.current.startY); dragPos.current = { x, y }; applyTransform(zoomRef.current, x, y, false); }, [applyTransform]); const endDrag = useCallback(() => { if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) { setDragged(true); } dragStart.current = null; }, []); const { t } = useI18n(); const hiddenParts = getAIPanelDiagnosticHiddenParts(); const hideAttachments = isAIPanelDiagnosticPartHidden('attachments', hiddenParts); const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts); const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts); const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH); const [activeJumpMessageId, setActiveJumpMessageId] = useState(null); const [pendingJumpMessageId, setPendingJumpMessageId] = useState(null); useEffect(() => { setRenderedTailCount(MESSAGE_RENDER_BATCH); setActiveJumpMessageId(null); setPendingJumpMessageId(null); }, [activeSessionId]); const hasAssistantMarkdown = useMemo( () => messages.some((message) => message.role === 'assistant' && Boolean(message.content)), [messages], ); // Do not start Streamdown on expand. Import cannot be cancelled, and idle // right after open collides with the first few keystrokes. History stays // plaintext until send, composer blur, or a long unfocused delay. useEffect(() => { if (!hasAssistantMarkdown) return undefined; return scheduleAiMarkdownWarmup({ isBusy: isAiComposerTyping, initialDelayMs: AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS, resumeDelayMs: AI_MARKDOWN_WARMUP_RESUME_DELAY_MS, }); }, [hasAssistantMarkdown]); const visibleMessages = useMemo( () => messages.filter((message) => message.role !== 'system'), [messages], ); // While a jump target is active, re-resolve the tail against the current list // so streaming appends cannot slide the window past the selected message. const effectiveTailCount = activeJumpMessageId ? resolveTailCountForJumpTarget(visibleMessages, activeJumpMessageId, renderedTailCount) : renderedTailCount; const hiddenMessageCount = Math.max(0, visibleMessages.length - effectiveTailCount); const displayedMessages = hiddenMessageCount > 0 ? visibleMessages.slice(-effectiveTailCount) : visibleMessages; const jumpEntries = useMemo( () => buildChatJumpEntries(visibleMessages, { emptyLabel: t('ai.chat.jumpUntitled'), }), [t, visibleMessages], ); const handleJumpToMessage = useCallback((messageId: string) => { setActiveJumpMessageId(messageId); // Persist the expanded window so releasing the pin (or a spurious isAtBottom // flip) cannot unmount the jump target. Load-earlier progress is preserved // because we never reset renderedTailCount on pin release. setRenderedTailCount((count) => resolveTailCountForJumpTarget(visibleMessages, messageId, count)); setPendingJumpMessageId(messageId); }, [visibleMessages]); const handleReleaseJumpPin = useCallback(() => { setActiveJumpMessageId(null); setPendingJumpMessageId(null); }, []); useEffect(() => { if (!pendingJumpMessageId) return; const target = document.getElementById(chatMessageDomId(pendingJumpMessageId)); if (!target) return; target.scrollIntoView({ behavior: 'smooth', block: 'start' }); setPendingJumpMessageId(null); }, [displayedMessages, pendingJumpMessageId]); const resolvedToolCallIds = new Set( displayedMessages .filter((m) => m.role === 'tool') .flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []), ); const renderedPendingToolCallIds = new Set( displayedMessages .filter((message) => message.role === 'assistant') .flatMap((message) => (message.toolCalls ?? []) .filter((toolCall) => !resolvedToolCallIds.has(toolCall.id)) .map((toolCall) => toolCall.id)), ); const { byItemId: codexApprovalsByItemId, standalone: standaloneCodexApprovals, } = buildCodexApprovalRenderPlan( pendingApprovals, renderedPendingToolCallIds, activeSessionId, ); // Build maps from toolCallId → toolName / toolArgs for display const toolCallNames = new Map(); const toolCallArgs = new Map>(); for (const m of displayedMessages) { if (m.role === 'assistant' && m.toolCalls) { for (const tc of m.toolCalls) { toolCallNames.set(tc.id, tc.name); if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments); } } } if (visibleMessages.length === 0 && !isStreaming) { return (

{t('ai.chat.emptyHint')}

); } const lastAssistantMessage = displayedMessages.findLast(m => m.role === 'assistant'); const showCompactionStatus = Boolean( activeCompaction && activeSessionId && activeCompaction.sessionId === activeSessionId, ); const renderPendingToolCallCards = ( toolCall: AgentToolCall, options: { historical: boolean; isToolRunning?: boolean }, ): React.ReactElement[] => { const codexApprovals = codexApprovalsByItemId.get(toolCall.id) ?? []; if (codexApprovals.length > 0) { return codexApprovals.map(({ approvalId, request }) => (
handleApproveOnce(approvalId)} onAlwaysAllow={request.allowSession === false ? undefined : () => handleAlwaysAllow(approvalId, request)} alwaysAllowLabel={request.allowSession === false ? undefined : t('ai.codex.appServer.approval.allowSession')} onReject={() => handleReject(approvalId)} />
)); } const pendingRequest = pendingApprovals.get(toolCall.id); const isPending = Boolean(pendingRequest); const resolved = resolvedApprovals.get(toolCall.id); const approvalStatus = isPending ? "pending" as const : resolved === true ? "approved" as const : resolved === false ? "denied" as const : undefined; return [(
handleApproveOnce(toolCall.id)} onAlwaysAllow={() => handleAlwaysAllow(toolCall.id, pendingRequest ?? { toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments ?? {}, chatSessionId: activeSessionId ?? undefined, })} onReject={() => handleReject(toolCall.id)} />
)]; }; const conversation = ( <> {hiddenMessageCount > 0 && ( )} {displayedMessages.map((message, idx) => { if (message.role === 'tool') { // Group consecutive tool messages into a collapsible section // Skip if this is NOT the first in a consecutive run const prevIsTool = idx > 0 && displayedMessages[idx - 1].role === "tool"; if (prevIsTool || hideToolCalls) return null; // Collect this run of consecutive tool messages let end = idx + 1; while (end < displayedMessages.length && displayedMessages[end].role === "tool") end++; const group = displayedMessages.slice(idx, end); const toolResults = group.flatMap((toolMsg) => (toolMsg.toolResults ?? []).map((tr) => { const args = toolCallArgs.get(tr.toolCallId); const resultToolName = typeof tr.toolName === 'string' ? tr.toolName : undefined; const pairedToolName = toolCallNames.get(tr.toolCallId); const artifactToolName = inferArtifactToolNameFromCliArgs(args) ?? normalizeArtifactToolName(resultToolName) ?? normalizeArtifactToolName(pairedToolName); return { toolCallId: tr.toolCallId, name: pairedToolName || resultToolName || tr.toolCallId, artifactToolName, args, content: tr.content, isError: tr.isError, }; }), ); const groupTotal = toolResults.length; // Expanded while the agent is still working (no assistant response follows) const hasAssistantAfter = end < displayedMessages.length && displayedMessages[end].role === "assistant"; const renderToolResultItem = (item: typeof toolResults[number]) => { const artifactToolName = item.artifactToolName ?? item.name; const terminalArtifact = parseTerminalToolArtifact(artifactToolName, item.content); if (terminalArtifact) { return ( ); } const artifact = parseVaultToolArtifact(artifactToolName, item.content); if (artifact) { return ( ); } return (
); }; if (groupTotal === 1) { return (
{renderToolResultItem(toolResults[0])}
); } return ( {toolResults.map(renderToolResultItem)} ); } const isUser = message.role === 'user'; const isLastAssistant = message === lastAssistantMessage; const isThisStreaming = isStreaming && isLastAssistant; return ( {/* Thinking block */} {!isUser && message.thinking && ( )} {!isUser && (message.agentActivities?.length || message.usage) && ( )} {/* User attachments (images, files) — fallback to legacy `images` field */} {isUser && !hideAttachments && (message.attachments ?? message.images)?.length && (
{(message.attachments ?? message.images)!.map((att, i) => ( att.terminalSelection ? (
{att.filename || 'terminal selection'}
) : att.vaultNoteId ? (
{att.vaultNoteTitle || att.filename || 'note'}
) : att.mediaType.startsWith('image/') ? ( {att.filename openPreview(`data:${att.mediaType};base64,${att.base64Data}`, att.filename || 'image')} /> ) : (
{att.filename || 'file'}
) ))}
)} {message.content && ( isUser ?
{message.content}
: shouldRenderAssistantAsPlainText({ hideMarkdown }) ? (
{message.content}
) : (
{message.content}
) )} {/* Pending tool calls from the *last* assistant message are rendered after all tool-result messages (see below) for chronological order. Unresolved tool calls from earlier or cancelled messages are shown inline — as interrupted, or with approval controls if still pending. */} {(() => { if (hideToolCalls) return null; if (message === lastAssistantMessage && message.executionStatus !== "cancelled") return null; const unresolvedTcs = message.toolCalls?.filter((tc) => !resolvedToolCallIds.has(tc.id)) ?? []; if (unresolvedTcs.length === 0) return null; const approvalCardCount = unresolvedTcs.reduce( (count, toolCall) => count + Math.max(1, codexApprovalsByItemId.get(toolCall.id)?.length ?? 0), 0, ); return ( {unresolvedTcs.flatMap((toolCall) => renderPendingToolCallCards(toolCall, { historical: true, }))} ); })()} {/* Status text with shimmer */} {message.statusText && (
{resolveCompactionStatusText(message.statusText, t)}
)} {/* Error info */} {message.errorInfo && (

{message.errorInfo.message}

{message.errorInfo.retryable && (

{t('ai.chat.retryHint')}

)}
)}
); })} {/* Pending tool calls from the last assistant message — rendered here (after all tool-result messages) so they appear at the bottom. */} {(() => { if (hideToolCalls) return null; const pendingTcs = lastAssistantMessage?.toolCalls?.filter((tc) => !resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== "cancelled", ) ?? []; if (pendingTcs.length === 0) return null; const isActive = lastAssistantMessage.executionStatus !== "error"; const isToolRunning = !!(isStreaming && lastAssistantMessage.executionStatus === "running"); const approvalCardCount = pendingTcs.reduce( (count, toolCall) => count + Math.max(1, codexApprovalsByItemId.get(toolCall.id)?.length ?? 0), 0, ); return ( {pendingTcs.flatMap((toolCall) => renderPendingToolCallCards(toolCall, { historical: false, isToolRunning, }))} ); })()} {/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */} {!hideToolCalls && Array.from(pendingApprovals.entries()) .filter(([id, req]) => { if (!id.startsWith('mcp_approval_')) return false; // External MCP approvals render in ExternalMcpApprovalsHost so they // remain visible even when the Catty AI panel is closed. if (req.chatSessionId === '__external_mcp__') return false; return !activeSessionId || req.chatSessionId === activeSessionId; }) .map(([id, req]) => { return (
handleApproveOnce(id)} onAlwaysAllow={() => handleAlwaysAllow(id, req)} onReject={() => handleReject(id)} />
); })} {!hideToolCalls && standaloneCodexApprovals .map(({ approvalId, request }) => (
handleApproveOnce(approvalId)} onAlwaysAllow={request.allowSession === false ? undefined : () => handleAlwaysAllow(approvalId, request)} alwaysAllowLabel={request.allowSession === false ? undefined : t('ai.codex.appServer.approval.allowSession')} onReject={() => handleReject(approvalId)} />
))} {Array.from(pendingCodexInteractions.values()) .filter((interaction): interaction is Extract => interaction.kind === 'user-input') .filter((interaction) => !activeSessionId || interaction.chatSessionId === activeSessionId) .map((interaction) => ( handleCodexUserInput(interaction.interactionId, answers)} onSkip={() => handleCodexUserInput(interaction.interactionId, {})} /> ))} {(Array.from(pendingCodebuddyElicitations.values()) as CodebuddyElicitation[]) .filter((elicitation) => !activeSessionId || elicitation.chatSessionId === activeSessionId) .map((elicitation) => ( handleCodebuddyElicitation(elicitation.elicitationId, action, content)} /> ))} {/* Transient compaction status — inline, no banner */} {showCompactionStatus && activeCompaction && (
{compactionStatusText(activeCompaction.trigger, t)}
)} {/* Streaming indicator — only when no content and no thinking yet */} {isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
)}
{/* Image preview lightbox */} { if (!open) setPreview(null); }}> {/* Title bar: filename | zoom controls | close — all in one flex row */}
{preview?.name}
{zoom}%
{/* Image area with drag support */} {preview && (
{preview.name}
)}
); if (shouldProvideVaultArtifactNavigation({ onOpenVaultNote, onOpenVaultHost, onOpenVaultSnippet, onOpenVaultSection, })) { return ( {conversation} ); } return conversation; }; function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean { if (prev.isStreaming !== next.isStreaming) return false; if (prev.activeSessionId !== next.activeSessionId) return false; if (prev.notes !== next.notes) return false; if (prev.hosts !== next.hosts) return false; if (prev.snippets !== next.snippets) return false; if (prev.onOpenVaultNote !== next.onOpenVaultNote) return false; if (prev.onOpenVaultHost !== next.onOpenVaultHost) return false; if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false; if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false; if (prev.messages.length !== next.messages.length) return false; if (prev.messages === next.messages) return true; // Shallow-compare each message by reference for (let i = 0; i < prev.messages.length; i++) { if (prev.messages[i] !== next.messages[i]) { // For the last message during streaming, compare by content to avoid // re-renders when only the array reference changed but content is the same const p = prev.messages[i]; const n = next.messages[i]; if ( p.id !== n.id || p.content !== n.content || p.thinking !== n.thinking || p.role !== n.role || p.statusText !== n.statusText || p.executionStatus !== n.executionStatus || p.errorInfo !== n.errorInfo || p.toolCalls !== n.toolCalls || p.toolResults !== n.toolResults || p.agentActivities !== n.agentActivities || p.usage !== n.usage ) { return false; } } } return true; } export default React.memo(ChatMessageList, areMessagesEqual);