import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import { useTeamStore } from '../../stores/teamStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n/locales/en' import { UserMessage } from './UserMessage' import { AssistantMessage } from './AssistantMessage' import { ThinkingBlock } from './ThinkingBlock' import { ToolCallBlock } from './ToolCallBlock' import { ToolCallGroup } from './ToolCallGroup' import { ToolResultBlock } from './ToolResultBlock' import { PermissionDialog } from './PermissionDialog' import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { ConfirmDialog } from '../shared/ConfirmDialog' type ToolCall = Extract type ToolResult = Extract type RenderItem = | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } | { kind: 'message'; message: UIMessage } type RenderModel = { renderItems: RenderItem[] toolResultMap: Map childToolCallsByParent: Map } type RewindTurnTarget = { messageId: string userMessageIndex: number content: string expectedContent: string attachments?: Extract['attachments'] } type TurnChangeCardModel = { target: RewindTurnTarget checkpoint: SessionTurnCheckpoint workDir: string | null isLatest: boolean } function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, toolCall: ToolCall, ) { const siblings = childToolCallsByParent.get(parentToolUseId) if (siblings) { siblings.push(toolCall) } else { childToolCallsByParent.set(parentToolUseId, [toolCall]) } } export function buildRenderModel(messages: UIMessage[]): RenderModel { const items: RenderItem[] = [] const toolResultMap = new Map() const childToolCallsByParent = new Map() const toolUseIds = new Set() let pendingToolCalls: ToolCall[] = [] const flushGroup = () => { if (pendingToolCalls.length > 0) { items.push({ kind: 'tool_group', toolCalls: [...pendingToolCalls], id: `group-${pendingToolCalls[0]!.id}`, }) pendingToolCalls = [] } } for (const msg of messages) { if (msg.type === 'tool_use') { toolUseIds.add(msg.toolUseId) } if (msg.type === 'tool_result') { toolResultMap.set(msg.toolUseId, msg) } } for (const msg of messages) { if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) { continue } if (msg.type === 'tool_result' && msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { continue } if (msg.type === 'tool_use') { if (msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { flushGroup() appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg) continue } if (msg.toolName === 'AskUserQuestion') { flushGroup() items.push({ kind: 'message', message: msg }) } else { pendingToolCalls.push(msg) } } else { flushGroup() items.push({ kind: 'message', message: msg }) } } flushGroup() return { renderItems: items, toolResultMap, childToolCallsByParent } } function isTurnResponseMessage(message: UIMessage) { return ( message.type === 'assistant_text' || message.type === 'tool_use' || message.type === 'tool_result' || message.type === 'error' || message.type === 'task_summary' ) } export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] { let userMessageIndex = -1 const completedTurns: RewindTurnTarget[] = [] let currentTarget: RewindTurnTarget | null = null let hasResponseForCurrentTarget = false for (const message of messages) { if (message.type === 'user_text' && !message.pending) { if (currentTarget && hasResponseForCurrentTarget) { completedTurns.push(currentTarget) } userMessageIndex += 1 currentTarget = { messageId: message.id, userMessageIndex, content: message.content, expectedContent: message.modelContent ?? message.content, attachments: message.attachments, } hasResponseForCurrentTarget = false continue } if (currentTarget && isTurnResponseMessage(message)) { hasResponseForCurrentTarget = true } } if (currentTarget && hasResponseForCurrentTarget) { completedTurns.push(currentTarget) } return completedTurns } export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null { const completedTurns = getCompletedTurnTargets(messages) return completedTurns.length > 0 ? completedTurns[completedTurns.length - 1] ?? null : null } function buildTurnCardInsertionMap( renderItems: RenderItem[], turnChangeCards: TurnChangeCardModel[], ) { const lastResponseIndexByTurnId = new Map() let activeTurnId: string | null = null renderItems.forEach((item, index) => { if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) { activeTurnId = item.message.id return } if (activeTurnId) { lastResponseIndexByTurnId.set(activeTurnId, index) } }) const cardsByRenderIndex = new Map() turnChangeCards.forEach((card) => { const renderIndex = lastResponseIndexByTurnId.get(card.target.messageId) if (renderIndex === undefined) return const existing = cardsByRenderIndex.get(renderIndex) if (existing) { existing.push(card) } else { cardsByRenderIndex.set(renderIndex, [card]) } }) return cardsByRenderIndex } function getApiErrorMessage(error: unknown) { return error instanceof ApiError ? typeof error.body === 'object' && error.body && 'message' in error.body ? String((error.body as { message: unknown }).message) : error.message : error instanceof Error ? error.message : String(error) } function isSessionTurnCheckpoint(value: unknown): value is SessionTurnCheckpoint { if (!value || typeof value !== 'object') return false const checkpoint = value as Partial return ( Boolean(checkpoint.target) && typeof checkpoint.target?.targetUserMessageId === 'string' && typeof checkpoint.target?.userMessageIndex === 'number' && Boolean(checkpoint.code) && typeof checkpoint.code?.available === 'boolean' && Array.isArray(checkpoint.code?.filesChanged) ) } function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] { if (!response || typeof response !== 'object') return [] const checkpoints = (response as { checkpoints?: unknown }).checkpoints if (!Array.isArray(checkpoints)) return [] return checkpoints.filter(isSessionTurnCheckpoint) } type MessageListProps = { sessionId?: string | null compact?: boolean } const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 function isNearScrollBottom(element: HTMLElement) { return ( element.scrollHeight - element.scrollTop - element.clientHeight <= AUTO_SCROLL_BOTTOM_THRESHOLD_PX ) } export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId const sessionState = useChatStore((s) => resolvedSessionId ? s.sessions[resolvedSessionId] : undefined, ) const stopGeneration = useChatStore((s) => s.stopGeneration) const reloadHistory = useChatStore((s) => s.reloadHistory) const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill) const isMemberSession = useTeamStore((s) => resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false, ) const addToast = useUIStore((s) => s.addToast) const messages = sessionState?.messages ?? [] const chatState = sessionState?.chatState ?? 'idle' const streamingText = sessionState?.streamingText ?? '' const activeThinkingId = sessionState?.activeThinkingId ?? null const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {} const scrollContainerRef = useRef(null) const bottomRef = useRef(null) const shouldAutoScrollRef = useRef(true) const lastSessionIdRef = useRef(resolvedSessionId) const t = useTranslation() const [turnChangeCards, setTurnChangeCards] = useState([]) const [turnChangeLoadError, setTurnChangeLoadError] = useState(null) const [turnActionErrors, setTurnActionErrors] = useState>({}) const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false) const [rewindingTurnId, setRewindingTurnId] = useState(null) const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState(null) const updateAutoScrollState = useCallback(() => { const container = scrollContainerRef.current if (!container) return shouldAutoScrollRef.current = isNearScrollBottom(container) }, []) useEffect(() => { if (lastSessionIdRef.current !== resolvedSessionId) { shouldAutoScrollRef.current = true lastSessionIdRef.current = resolvedSessionId } if (!shouldAutoScrollRef.current) return bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) }, [messages.length, resolvedSessionId, streamingText]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages), [messages], ) const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages]) const latestCompletedTurnId = completedTurnTargets.length > 0 ? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null : null const turnCardsByRenderIndex = useMemo( () => buildTurnCardInsertionMap(renderItems, turnChangeCards), [renderItems, turnChangeCards], ) const confirmTurnCard = useMemo( () => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null, [turnChangeCards, turnUndoConfirmTargetId], ) useEffect(() => { if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) { setTurnChangeCards([]) setTurnChangeLoadError(null) setIsLoadingTurnChangeCards(false) return } if (chatState !== 'idle') { setTurnChangeLoadError(null) setIsLoadingTurnChangeCards(false) return } let cancelled = false setIsLoadingTurnChangeCards(true) setTurnChangeLoadError(null) Promise.all([ sessionsApi.getTurnCheckpoints(resolvedSessionId), sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null), ]) .then(([checkpointResponse, workspaceStatus]) => { if (cancelled) return const targetByMessageId = new Map( completedTurnTargets.map((target) => [target.messageId, target] as const), ) setTurnChangeCards( normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => { const target = targetByMessageId.get(checkpoint.target.targetUserMessageId) if (!target || !checkpoint.code.available || checkpoint.code.filesChanged.length === 0) { return [] } return [{ target, checkpoint, workDir: checkpoint.workDir ?? workspaceStatus?.workDir ?? null, isLatest: target.messageId === latestCompletedTurnId, }] }), ) }) .catch((error) => { if (cancelled) return setTurnChangeCards([]) setTurnChangeLoadError(getApiErrorMessage(error)) }) .finally(() => { if (!cancelled) { setIsLoadingTurnChangeCards(false) } }) return () => { cancelled = true } }, [chatState, completedTurnTargets, isMemberSession, latestCompletedTurnId, resolvedSessionId]) const handleUndoCurrentTurn = useCallback(async () => { if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId) return const target = confirmTurnCard.target setRewindingTurnId(target.messageId) setTurnActionErrors((current) => { if (!(target.messageId in current)) return current const next = { ...current } delete next[target.messageId] return next }) try { if (chatState !== 'idle') { stopGeneration(resolvedSessionId) } const result = await sessionsApi.rewind(resolvedSessionId, { targetUserMessageId: target.messageId, userMessageIndex: target.userMessageIndex, expectedContent: target.expectedContent, }) await reloadHistory(resolvedSessionId) queueComposerPrefill(resolvedSessionId, { text: target.content, attachments: target.attachments, }) addToast({ type: 'success', message: result.code.available ? t('chat.rewindSuccessWithCode', { count: result.conversation.messagesRemoved, }) : t('chat.rewindSuccessConversationOnly', { count: result.conversation.messagesRemoved, }), }) setTurnUndoConfirmTargetId(null) } catch (error) { setTurnActionErrors((current) => ({ ...current, [target.messageId]: getApiErrorMessage(error), })) setTurnUndoConfirmTargetId(null) } finally { setRewindingTurnId(null) } }, [ addToast, chatState, confirmTurnCard, queueComposerPrefill, reloadHistory, resolvedSessionId, rewindingTurnId, stopGeneration, t, ]) return (
{renderItems.map((item, index) => { const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] return (
{item.kind === 'tool_group' ? ( !toolResultMap.has(tc.toolUseId)) } /> ) : ( { const result = toolResultMap.get(item.message.toolUseId) return result ? { content: result.content, isError: result.isError } : null })() : null } /> )} {resolvedSessionId && cardsForItem.map((card) => ( { setTurnUndoConfirmTargetId(card.target.messageId) }} /> ))}
) })} {streamingText && ( )} {/* Show StreamingIndicator when: - tool_executing: tool is running - thinking but no active ThinkingBlock yet: the gap between sending a message and receiving the first thinking delta */} {(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && ( )} {!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
{turnChangeLoadError}
)}
{ if (!rewindingTurnId) { setTurnUndoConfirmTargetId(null) } }} onConfirm={handleUndoCurrentTurn} title={confirmTurnCard?.isLatest ? t('chat.turnChangesLatestConfirmTitle') : t('chat.turnChangesHistoricalConfirmTitle')} body={confirmTurnCard?.isLatest ? t('chat.turnChangesLatestConfirmBody') : t('chat.turnChangesHistoricalConfirmBody')} confirmLabel={confirmTurnCard?.isLatest ? t('chat.turnChangesLatestConfirmUndo') : t('chat.turnChangesHistoricalConfirmUndo')} cancelLabel={t('common.cancel')} confirmVariant="danger" loading={Boolean(rewindingTurnId)} />
) } export const MessageBlock = memo(function MessageBlock({ message, activeThinkingId, agentTaskNotifications, toolResult, }: { message: UIMessage activeThinkingId: string | null agentTaskNotifications: Record toolResult?: { content: unknown; isError: boolean } | null }) { const t = useTranslation() switch (message.type) { case 'user_text': return ( ) case 'assistant_text': return case 'thinking': return case 'tool_use': if (message.toolName === 'AskUserQuestion') { return ( ) } return ( ) case 'tool_result': return ( ) case 'permission_request': return ( ) case 'error': { const errorKey = message.code ? `error.${message.code}` as TranslationKey : null const errorText = errorKey ? t(errorKey) : null const displayMessage = (errorText && errorText !== errorKey) ? errorText : message.message const showRawDetail = Boolean(message.message) && message.message.trim() !== '' && message.message !== displayMessage return (
Error: {displayMessage} {showRawDetail && (
{message.message}
)}
) } case 'task_summary': return case 'system': return (
{message.content}
) } })