cc-haha/desktop/src/components/chat/MessageList.tsx
2026-07-11 14:48:23 +08:00

2457 lines
90 KiB
TypeScript

import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { SETTINGS_TAB_ID, 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 {
buildConversationNavigationItems,
ConversationNavigator,
type ConversationNavigationItem,
} from './ConversationNavigator'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { formatTokenCount } from '../../lib/formatTokenCount'
import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { isTouchH5Document } from '../../lib/touchH5'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
import {
getHeightsForSession,
getMetricsForSession,
type VirtualRenderItemMetric,
} from './virtualHeightCache'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
type GoalEvent = Extract<UIMessage, { type: 'goal_event' }>
type BackgroundTaskEvent = Extract<UIMessage, { type: 'background_task' }>
type CompactSummaryEvent = Extract<UIMessage, { type: 'compact_summary' }>
type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
| { kind: 'message'; message: UIMessage }
type RenderModel = {
renderItems: RenderItem[]
toolResultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
}
type RewindTurnTarget = {
messageId: string
userMessageIndex: number
content: string
expectedContent: string
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
}
type BranchableMessageTarget = {
uiMessageId: string
transcriptMessageId: string
}
type TurnChangeCardModel = {
target: RewindTurnTarget
checkpoint: SessionTurnCheckpoint
workDir: string | null
isLatest: boolean
}
const EMPTY_TURN_CHANGE_CARDS: TurnChangeCardModel[] = []
type ChatMessageRole = 'user' | 'assistant'
type ChatSelectionState = {
text: string
x: number
y: number
}
type SelectionPointer = {
clientX: number
clientY: number
}
const CHAT_SELECTION_MENU_OFFSET = 10
const CHAT_SELECTION_MENU_WIDTH = 158
const CHAT_SELECTION_MENU_HEIGHT = 44
function getElementForNode(node: Node | null): Element | null {
if (!node) return null
return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement
}
function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) {
return getSelectionPopoverPosition(range, root, {
menuWidth: CHAT_SELECTION_MENU_WIDTH,
menuHeight: CHAT_SELECTION_MENU_HEIGHT,
offset: CHAT_SELECTION_MENU_OFFSET,
fallbackPointer: pointer,
})
}
function getChatSelectionFromContainer(
root: HTMLElement | null,
pointer: SelectionPointer,
): ChatSelectionState | null {
if (!root) return null
const selection = window.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
const startElement = getElementForNode(range.startContainer)
const endElement = getElementForNode(range.endContainer)
if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) {
return null
}
const text = selection.toString().trim()
if (!text) return null
return {
...getChatSelectionPosition(range, root, pointer),
text,
}
}
function getSelectionPointer(event: SelectionPointer): SelectionPointer {
return {
clientX: event.clientX,
clientY: event.clientY,
}
}
function ChatSelectionMenu({
selection,
onAdd,
popoverRef,
}: {
selection: ChatSelectionState | null
onAdd: () => void
popoverRef: { current: HTMLButtonElement | null }
}) {
const t = useTranslation()
if (!selection) return null
return createPortal(
<button
ref={popoverRef}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={onAdd}
className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
style={{ left: selection.x, top: selection.y }}
>
<MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" />
<span>{t('chat.addSelectionToChat')}</span>
</button>,
document.body,
)
}
function getCompactSummaryTitle(message: CompactSummaryEvent, t: ReturnType<typeof useTranslation>) {
if (message.trigger === 'auto') return t('chat.compactSummary.autoTitle')
if (message.trigger === 'manual') return t('chat.compactSummary.manualTitle')
if (!message.title || message.title === 'Context compacted' || message.title === 'Conversation compacted') {
return t('chat.compactSummary.title')
}
return message.title
}
function CompactStatusDivider({ message, state }: { message?: CompactSummaryEvent; state: 'compacting' | 'complete' }) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const hasSummary = Boolean(message?.summary?.trim())
const meta = [
message?.trigger ? t(`chat.compactSummary.trigger.${message.trigger}` as TranslationKey) : null,
typeof message?.preTokens === 'number'
? t('chat.compactSummary.tokens', { count: formatTokenCount(message.preTokens) })
: null,
typeof message?.messagesSummarized === 'number'
? t('chat.compactSummary.messages', { count: String(message.messagesSummarized) })
: null,
].filter((item): item is string => Boolean(item))
const hasDetails = hasSummary || meta.length > 0
const title = state === 'compacting'
? t('chat.compactSummary.compacting')
: message
? getCompactSummaryTitle(message, t)
: t('chat.compactSummary.title')
return (
<section data-testid="compact-status-divider" className="my-4 w-full px-1">
<div className="flex w-full items-center gap-3">
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
<button
type="button"
aria-expanded={hasDetails ? expanded : undefined}
onClick={() => hasDetails && setExpanded((value) => !value)}
disabled={!hasDetails}
className="group inline-flex min-h-8 max-w-[min(78vw,520px)] items-center gap-2 rounded-md px-2.5 py-1 text-[13px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/30"
>
{state === 'compacting' ? (
<LoaderCircle size={16} strokeWidth={2.1} className="shrink-0 animate-spin text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<FileStack size={16} strokeWidth={2.05} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
)}
<span className="min-w-0 truncate font-medium text-[var(--color-text-primary)]">
{title}
</span>
</button>
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
</div>
{hasDetails && expanded && (
<div className="mx-auto mt-1.5 w-full max-w-[620px] rounded-md border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)] px-3 py-2">
{meta.length > 0 && (
<div className="mb-1.5 flex flex-wrap gap-x-2 gap-y-1 text-[11px] font-medium text-[var(--color-text-tertiary)]">
{meta.map((item) => <span key={item}>{item}</span>)}
</div>
)}
{message?.summary && (
<div className="max-h-[220px] overflow-auto whitespace-pre-wrap break-words text-[12px] leading-5 text-[var(--color-text-secondary)]">
{message.summary}
</div>
)}
</div>
)}
</section>
)
}
function GoalEventCard({ message }: { message: GoalEvent }) {
const t = useTranslation()
const [expanded, setExpanded] = useState(true)
const titleKey = `chat.goalEvent.${message.action === 'status' ? 'statusTitle' : message.action}` as TranslationKey
const title = t(titleKey) === titleKey ? t('chat.goalEvent.message') : t(titleKey)
const metaDetails = [
message.status ? t('chat.goalEvent.statusValue', { value: message.status }) : null,
message.budget ? t('chat.goalEvent.budget', { value: message.budget }) : null,
message.continuations ? t('chat.goalEvent.continuations', { value: message.continuations }) : null,
].filter((detail): detail is string => detail !== null)
return (
<div className="mb-2">
<div
data-testid="goal-event-card"
className="overflow-hidden rounded-lg border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
{expanded ? (
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
)}
<Target size={15} className="shrink-0 text-[var(--color-memory-accent)]" strokeWidth={2.25} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{title}
</span>
{message.status ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[12px] text-[var(--color-text-tertiary)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-memory-accent)]" aria-hidden="true" />
{message.status}
</span>
) : null}
</button>
{expanded ? (
<div className="border-t border-[var(--color-border)]/55 px-3 py-2.5">
<div className="space-y-1.5">
{message.objective ? (
<div className="line-clamp-2 rounded-md px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{t('chat.goalEvent.objective', { value: message.objective })}
</div>
) : message.message ? (
<div className="whitespace-pre-wrap rounded-md px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{message.message}
</div>
) : null}
{metaDetails.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 px-2 pt-0.5">
{metaDetails.map((detail) => (
<span
key={detail}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]"
>
{detail}
</span>
))}
</div>
)}
</div>
</div>
) : null}
</div>
</div>
)
}
function GoalContinuationDivider({ message }: { message: GoalEvent }) {
const t = useTranslation()
const reason = message.message?.replace(/^Goal continuing:\s*/i, '').trim()
return (
<section data-testid="goal-continuation-divider" className="my-4 w-full px-1">
<div className="flex w-full items-center gap-3">
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
<div className="inline-flex min-h-8 max-w-[min(78vw,620px)] items-center gap-2 rounded-md px-2.5 py-1 text-[13px] font-medium text-[var(--color-text-secondary)]">
<Target size={16} strokeWidth={2.1} className="shrink-0 text-[var(--color-memory-accent)]" aria-hidden="true" />
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
{t('chat.goalEvent.continuing')}
</span>
{reason ? (
<span className="min-w-0 truncate text-[12px] text-[var(--color-text-tertiary)]" title={reason}>
{reason}
</span>
) : null}
</div>
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
</div>
</section>
)
}
function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent }) {
const t = useTranslation()
const { task } = message
const isRunning = task.status === 'running'
const isFailed = task.status === 'failed'
const isStopped = task.status === 'stopped'
const duration = formatDurationMs(task.usage?.durationMs, t)
const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId
const label = getBackgroundTaskLabel(task.taskType, t)
return (
<div className="mb-2">
<div
data-testid="background-task-event-card"
data-status={task.status}
className="flex min-w-0 items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
{isRunning ? (
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-accent)]" aria-hidden="true" />
) : isFailed ? (
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
) : isStopped ? (
<CircleStop size={15} strokeWidth={2.25} className="text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<CheckCircle2 size={15} strokeWidth={2.25} className="text-[var(--color-success)]" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<Bot size={14} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
{label}
</span>
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
{t(`chat.backgroundAgents.status.${task.status}`)}
</span>
{task.usage?.totalTokens ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{t('chat.backgroundAgents.tokens', { count: formatTokenCount(task.usage.totalTokens) })}
</span>
) : null}
{duration ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{duration}
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-[12px] leading-5 text-[var(--color-text-secondary)]">
{detail}
</div>
</div>
</div>
</div>
)
}
function isAgentBackgroundTaskMessage(message: UIMessage): boolean {
if (message.type !== 'background_task') return false
if (
message.task.taskType === 'local_agent' ||
message.task.taskType === 'remote_agent' ||
message.task.taskType === 'dream'
) {
return true
}
return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test(
message.task.summary ?? '',
)
}
function getBackgroundTaskLabel(
taskType: string | undefined,
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
if (taskType === 'local_bash') return t('chat.backgroundTasks.command')
if (taskType === 'local_workflow') return t('chat.backgroundTasks.workflow')
return t('chat.backgroundTasks.task')
}
function SelectableChatMessage({
sessionId,
messageId,
role,
content,
children,
}: {
sessionId?: string | null
messageId: string
role: ChatMessageRole
content: string
children: ReactNode
}) {
const rootRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const lastSelectionPointerRef = useRef<SelectionPointer | null>(null)
const selectionUpdateFrameRef = useRef<number | null>(null)
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
const t = useTranslation()
const sourceName = role === 'assistant'
? t('chat.assistantMessageReference')
: t('chat.userMessageReference')
useEffect(() => {
setSelectionMenu(null)
lastSelectionPointerRef.current = null
}, [content, messageId])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
const queueSelectionMenuUpdate = useCallback((pointer?: SelectionPointer) => {
if (pointer) lastSelectionPointerRef.current = pointer
if (selectionUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
}
selectionUpdateFrameRef.current = window.requestAnimationFrame(() => {
selectionUpdateFrameRef.current = window.requestAnimationFrame(() => {
selectionUpdateFrameRef.current = null
const root = rootRef.current
const rootRect = root?.getBoundingClientRect()
const fallbackPointer = lastSelectionPointerRef.current ?? {
clientX: (rootRect?.left ?? 0) + 24,
clientY: (rootRect?.top ?? 0) + 24,
}
setSelectionMenu(getChatSelectionFromContainer(root, fallbackPointer))
})
})
}, [])
useEffect(() => {
return () => {
if (selectionUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
}
}
}, [])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
lastSelectionPointerRef.current = getSelectionPointer(event)
}
const handlePointerUp = (event: PointerEvent) => {
queueSelectionMenuUpdate(getSelectionPointer(event))
}
const handleMouseUp = (event: MouseEvent) => {
queueSelectionMenuUpdate(getSelectionPointer(event))
}
const handleSelectionChange = () => {
queueSelectionMenuUpdate()
}
const handleKeyUp = () => {
queueSelectionMenuUpdate()
}
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('pointerup', handlePointerUp, true)
document.addEventListener('mouseup', handleMouseUp, true)
document.addEventListener('selectionchange', handleSelectionChange)
document.addEventListener('keyup', handleKeyUp, true)
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('pointerup', handlePointerUp, true)
document.removeEventListener('mouseup', handleMouseUp, true)
document.removeEventListener('selectionchange', handleSelectionChange)
document.removeEventListener('keyup', handleKeyUp, true)
}
}, [queueSelectionMenuUpdate])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const addCurrentSelectionToChat = useCallback(() => {
if (!sessionId || !selectionMenu) return
addReference(sessionId, {
kind: 'chat-selection',
path: `chat://${role}/${messageId}`,
name: sourceName,
quote: selectionMenu.text,
sourceRole: role,
messageId,
})
setSelectionMenu(null)
clearWindowSelection()
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
return (
<div
ref={rootRef}
data-chat-selectable-message={role}
onPointerDown={(event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return
lastSelectionPointerRef.current = getSelectionPointer(event)
}}
onMouseUp={(event) => {
queueSelectionMenuUpdate(getSelectionPointer(event))
}}
onKeyDown={(event) => {
if (event.key === 'Escape') setSelectionMenu(null)
}}
>
{children}
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div>
)
}
function appendChildToolCall(
childToolCallsByParent: Map<string, ToolCall[]>,
parentToolUseId: string,
toolCall: ToolCall,
) {
const siblings = childToolCallsByParent.get(parentToolUseId)
if (siblings) {
siblings.push(toolCall)
} else {
childToolCallsByParent.set(parentToolUseId, [toolCall])
}
}
export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToolUseId?: string | null): RenderModel {
const items: RenderItem[] = []
const toolResultMap = new Map<string, ToolResult>()
const childToolCallsByParent = new Map<string, ToolCall[]>()
const toolUseIds = new Set<string>()
const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map<string, number>()
let lastUnresolvedAskUserQuestionIndex: number | null = null
let pendingToolCalls: ToolCall[] = []
const flushGroup = () => {
if (pendingToolCalls.length > 0) {
items.push({
kind: 'tool_group',
toolCalls: [...pendingToolCalls],
id: `group-${pendingToolCalls[0]!.id}`,
})
pendingToolCalls = []
}
}
const appendRootToolCall = (toolCall: ToolCall) => {
const nextIsAgent = toolCall.toolName === 'Agent'
const pendingIsAgentGroup = pendingToolCalls.every((pendingToolCall) => pendingToolCall.toolName === 'Agent')
if (pendingToolCalls.length > 0 && pendingIsAgentGroup !== nextIsAgent) {
flushGroup()
}
pendingToolCalls.push(toolCall)
}
for (const msg of messages) {
if (msg.type === 'tool_use') {
toolUseIds.add(msg.toolUseId)
}
if (msg.type === 'tool_result') {
toolResultMap.set(msg.toolUseId, msg)
}
}
messages.forEach((msg, index) => {
if (
msg.type === 'tool_use' &&
msg.toolName === 'AskUserQuestion' &&
!toolResultMap.has(msg.toolUseId)
) {
lastUnresolvedAskUserQuestionIndexByToolUseId.set(msg.toolUseId, index)
lastUnresolvedAskUserQuestionIndex = index
}
})
for (const msg of messages) {
if (msg.type === 'assistant_text' && !msg.content.trim()) {
continue
}
if (isAgentBackgroundTaskMessage(msg)) {
continue
}
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') {
const isResolved = toolResultMap.has(msg.toolUseId)
const lastUnresolvedIndex = lastUnresolvedAskUserQuestionIndexByToolUseId.get(msg.toolUseId)
if (!isResolved && lastUnresolvedIndex !== undefined && messages[lastUnresolvedIndex] !== msg) {
continue
}
if (
!isResolved &&
activeAskUserQuestionToolUseId &&
msg.toolUseId !== activeAskUserQuestionToolUseId
) {
continue
}
if (
!isResolved &&
!activeAskUserQuestionToolUseId &&
lastUnresolvedAskUserQuestionIndex !== null &&
messages[lastUnresolvedAskUserQuestionIndex] !== msg
) {
continue
}
flushGroup()
items.push({ kind: 'message', message: msg })
} else {
appendRootToolCall(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 === 'background_task' && !isAgentBackgroundTaskMessage(message)) ||
message.type === 'error' ||
message.type === 'task_summary'
)
}
function getBranchableMessageTargets(messages: UIMessage[]): Map<string, BranchableMessageTarget> {
const branchableTargets = new Map<string, BranchableMessageTarget>()
let currentTurnCandidates: Array<Extract<UIMessage, { type: 'user_text' | 'assistant_text' }>> = []
let hasResponseForCurrentTurn = false
const markCurrentTurnBranchable = () => {
if (!hasResponseForCurrentTurn) return
for (const candidate of currentTurnCandidates) {
if (!candidate.transcriptMessageId) continue
branchableTargets.set(candidate.id, {
uiMessageId: candidate.id,
transcriptMessageId: candidate.transcriptMessageId,
})
}
}
for (const message of messages) {
if (message.type === 'user_text') {
markCurrentTurnBranchable()
currentTurnCandidates = []
hasResponseForCurrentTurn = false
if (!message.pending && message.transcriptMessageId) {
currentTurnCandidates = [message]
}
continue
}
if (currentTurnCandidates.length === 0) continue
if (isTurnResponseMessage(message)) {
hasResponseForCurrentTurn = true
}
if (message.type === 'assistant_text' && message.transcriptMessageId) {
currentTurnCandidates.push(message)
}
}
markCurrentTurnBranchable()
return branchableTargets
}
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<string, number>()
const userIndexByTurnId = new Map<string, number>()
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
userIndexByTurnId.set(activeTurnId, index)
return
}
if (activeTurnId) {
lastResponseIndexByTurnId.set(activeTurnId, index)
}
})
const cardsByRenderIndex = new Map<number, TurnChangeCardModel[]>()
turnChangeCards.forEach((card) => {
const renderIndex =
lastResponseIndexByTurnId.get(card.target.messageId) ??
userIndexByTurnId.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
}
/**
* Map each render item to the REAL changed files of the turn it belongs to, so an
* assistant message can anchor its output chips on files that were actually
* written this turn instead of guessing paths from the prose. Items are attributed
* to the most recent preceding non-pending user message (the turn boundary).
*/
function buildChangedFilesByRenderIndex(
renderItems: RenderItem[],
turnChangeCards: TurnChangeCardModel[],
): Map<number, string[]> {
const filesByTurnId = new Map<string, string[]>()
for (const card of turnChangeCards) {
if (card.checkpoint.code.filesChanged.length > 0) {
filesByTurnId.set(card.target.messageId, card.checkpoint.code.filesChanged)
}
}
if (filesByTurnId.size === 0) return new Map()
const filesByRenderIndex = new Map<number, string[]>()
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) {
const files = filesByTurnId.get(activeTurnId)
if (files) filesByRenderIndex.set(index, files)
}
})
return filesByRenderIndex
}
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<SessionTurnCheckpoint>
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)
}
function memoryFileLabel(path: string) {
const normalized = path.replace(/\\/g, '/')
return normalized.split('/').pop() || normalized
}
function openMemorySettings(path?: string) {
const ui = useUIStore.getState()
if (path) ui.setPendingMemoryPath(path)
ui.setPendingSettingsTab('memory')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
}
function MemoryEventCard({ message }: { message: MemoryEvent }) {
const t = useTranslation()
const visibleFiles = message.files.slice(0, 3)
const hiddenCount = Math.max(0, message.files.length - visibleFiles.length)
return (
<div className="mb-3 flex justify-center px-3">
<div className="w-full max-w-2xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs shadow-sm">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)]">
<BookMarked size={15} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-medium text-[var(--color-text-primary)]">
{t('chat.memorySavedTitle', { count: message.files.length })}
</div>
<button
type="button"
onClick={() => openMemorySettings(message.files[0]?.path)}
className="inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/50 hover:text-[var(--color-text-primary)]"
>
<Settings size={13} aria-hidden="true" />
{t('chat.memoryOpenSettings')}
</button>
</div>
{message.message ? (
<div className="mt-1 text-[var(--color-text-tertiary)]">{message.message}</div>
) : null}
<div className="mt-2 flex flex-wrap gap-1.5">
{visibleFiles.map((file) => (
<span
key={file.path}
title={file.path}
className="max-w-full truncate rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-secondary)]"
>
{memoryFileLabel(file.path)}
</span>
))}
{hiddenCount > 0 ? (
<span className="rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-tertiary)]">
{t('chat.memoryMoreFiles', { count: hiddenCount })}
</span>
) : null}
</div>
</div>
</div>
</div>
</div>
)
}
type MessageListProps = {
sessionId?: string | null
compact?: boolean
}
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
const SCROLL_BOTTOM_SENTINEL = 1_000_000_000
const MAX_SCROLL_SNAPSHOTS = 100
const VIRTUALIZE_MIN_RENDER_ITEMS = 120
const VIRTUALIZE_MIN_CONTENT_CHARS = 120_000
// Touch-H5 disables content-visibility paint skipping for selection
// correctness (globals.css), which makes virtualization the only paint bound
// for long transcripts there — so it kicks in at half the desktop thresholds.
const TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS = 60
const TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS = 60_000
const VIRTUAL_OVERSCAN_PX = 1200
const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720
const VIRTUAL_MIN_ITEM_HEIGHT = 48
const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
// Windows WebView2 can report 1px oscillations for live chat content; don't
// convert those into bottom-scroll corrections.
const CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX = 2
const CONVERSATION_NAVIGATION_MIN_ITEMS = 4
const STREAMING_ASSISTANT_NAVIGATION_KEY = 'streaming-assistant-message'
const EMPTY_MESSAGES: UIMessage[] = []
const EMPTY_AGENT_TASK_NOTIFICATIONS: Record<string, AgentTaskNotification> = {}
const CHAT_SCROLL_AREA_CLASS = [
'chat-scroll-area',
'[scrollbar-width:auto]',
'[scrollbar-color:color-mix(in_srgb,var(--color-outline)_72%,transparent)_transparent]',
'[&::-webkit-scrollbar]:w-2.5',
'[&::-webkit-scrollbar-track]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-thumb]:border-[3px]',
'[&::-webkit-scrollbar-thumb]:border-transparent',
'[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--color-outline)_74%,transparent)]',
'[&::-webkit-scrollbar-thumb]:bg-clip-content',
'[&::-webkit-scrollbar-thumb:hover]:border-2',
'[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_90%,transparent)]',
].join(' ')
const CHAT_RENDER_ITEM_CLASS = [
'chat-render-item',
].join(' ')
type SessionScrollSnapshot = {
scrollTop: number
wasAtBottom: boolean
}
type VirtualViewport = {
scrollTop: number
viewportHeight: number
}
type VirtualTranscriptItem = {
item: RenderItem
index: number
}
type VirtualTranscriptWindow = {
enabled: boolean
beforeHeight: number
afterHeight: number
items: VirtualTranscriptItem[]
offsets: number[]
totalHeight: number
}
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
function isNearScrollBottom(element: HTMLElement) {
return (
element.scrollHeight - element.scrollTop - element.clientHeight <=
AUTO_SCROLL_BOTTOM_THRESHOLD_PX
)
}
function rememberSessionScroll(sessionId: string, element: HTMLElement) {
if (sessionScrollSnapshots.size >= MAX_SCROLL_SNAPSHOTS && !sessionScrollSnapshots.has(sessionId)) {
const oldestSessionId = sessionScrollSnapshots.keys().next().value
if (oldestSessionId) {
sessionScrollSnapshots.delete(oldestSessionId)
}
}
sessionScrollSnapshots.set(sessionId, {
scrollTop: element.scrollTop,
wasAtBottom: isNearScrollBottom(element),
})
}
function getBottomScrollTop(element: HTMLElement) {
return Math.max(0, element.scrollHeight - element.clientHeight)
}
function setScrollTopWithoutLayoutRead(element: HTMLElement, scrollTop: number) {
element.scrollTop = Math.max(0, scrollTop)
}
function setScrollToBottomWithoutLayoutRead(element: HTMLElement, behavior: ScrollBehavior) {
if (typeof element.scrollTo === 'function') {
try {
element.scrollTo({ top: SCROLL_BOTTOM_SENTINEL, behavior })
} catch {
element.scrollTo(0, SCROLL_BOTTOM_SENTINEL)
}
}
element.scrollTop = SCROLL_BOTTOM_SENTINEL
// Browsers clamp the large value to the true bottom without needing us to
// synchronously read layout metrics. JSDOM test doubles do not clamp, so keep
// the old numeric behavior there as a fallback.
if (element.scrollTop === SCROLL_BOTTOM_SENTINEL) {
element.scrollTop = getBottomScrollTop(element)
}
}
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function getRenderItemKey(item: RenderItem) {
return item.kind === 'tool_group' ? item.id : item.message.id
}
function isRecordValue(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function getShallowStringWeight(value: unknown, depth = 0): number {
if (typeof value === 'string') return value.length
if (!value || depth > 1) return 0
if (Array.isArray(value)) {
return value.slice(0, 12).reduce((total, item) => total + getShallowStringWeight(item, depth + 1), 0)
}
if (!isRecordValue(value)) return 0
let total = 0
for (const item of Object.values(value).slice(0, 24)) {
total += getShallowStringWeight(item, depth + 1)
if (total >= VIRTUALIZE_MIN_CONTENT_CHARS) return total
}
return total
}
function getMessageContentWeight(message: UIMessage): number {
switch (message.type) {
case 'user_text':
case 'assistant_text':
case 'thinking':
case 'system':
return message.content.length
case 'tool_use':
return getShallowStringWeight(message.input) + (message.partialInput?.length ?? 0)
case 'tool_result':
return getShallowStringWeight(message.content)
case 'permission_request':
return getShallowStringWeight(message.input) + (message.description?.length ?? 0)
case 'error':
return message.message.length
case 'compact_summary':
return message.title.length + (message.summary?.length ?? 0)
case 'goal_event':
return (message.objective?.length ?? 0) + (message.message?.length ?? 0)
case 'memory_event':
return (message.message?.length ?? 0) + message.files.reduce((total, file) => total + file.path.length + (file.summary?.length ?? 0), 0)
case 'background_task':
return getShallowStringWeight(message.task)
case 'task_summary':
return message.tasks.reduce((total, task) => total + task.subject.length + (task.activeForm?.length ?? 0), 0)
}
}
function getRenderItemContentWeight(item: RenderItem): number {
if (item.kind === 'message') return getMessageContentWeight(item.message)
return item.toolCalls.reduce((total, toolCall) => total + getMessageContentWeight(toolCall), 0)
}
export function shouldVirtualizeRenderItems(
metrics: VirtualRenderItemMetric[],
touchH5 = isTouchH5Document(),
) {
const minRenderItems = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS : VIRTUALIZE_MIN_RENDER_ITEMS
const minContentChars = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS : VIRTUALIZE_MIN_CONTENT_CHARS
if (metrics.length >= minRenderItems) return true
let totalWeight = 0
for (const metric of metrics) {
totalWeight += metric.contentWeight
if (totalWeight >= minContentChars) return true
}
return false
}
function countLineBreaksCapped(content: string, maxLines: number) {
let lineBreaks = 0
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
lineBreaks += 1
if (lineBreaks >= maxLines) return lineBreaks
}
}
return lineBreaks
}
function estimateTextHeight(content: string, baseHeight: number) {
const sample = content.length > 12_000 ? content.slice(0, 12_000) : content
const sampledLineBreaks = countLineBreaksCapped(sample, 900)
const explicitLines = content.length > sample.length
? Math.ceil((sampledLineBreaks + 1) * (content.length / sample.length))
: sampledLineBreaks + 1
const wrappedLines = Math.ceil(content.length / 76)
const estimated = baseHeight + Math.max(explicitLines, wrappedLines) * 22
return clampNumber(estimated, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)
}
function estimateMessageHeight(message: UIMessage): number {
switch (message.type) {
case 'user_text':
return estimateTextHeight(message.content, message.attachments?.length ? 140 : 74)
case 'assistant_text':
return estimateTextHeight(message.content, 96)
case 'thinking':
return estimateTextHeight(message.content, 88)
case 'tool_use':
return clampNumber(92 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 72, 2200)
case 'tool_result':
return clampNumber(88 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 64, 2200)
case 'background_task':
case 'goal_event':
case 'memory_event':
case 'permission_request':
case 'task_summary':
return 110
case 'compact_summary':
return message.summary ? clampNumber(92 + Math.ceil(message.summary.length / 90) * 20, 80, 1800) : 70
case 'error':
case 'system':
return 64
}
}
function estimateRenderItemHeight(item: RenderItem): number {
if (item.kind === 'message') return estimateMessageHeight(item.message)
const textWeight = getRenderItemContentWeight(item)
return clampNumber(92 + item.toolCalls.length * 78 + Math.ceil(textWeight / 140) * 16, 88, 2600)
}
function getMessageMetricSignature(message: UIMessage): string {
switch (message.type) {
case 'user_text':
return `${message.type}:${message.content.length}:${message.attachments?.length ?? 0}:${message.pending ? 1 : 0}`
case 'assistant_text':
case 'thinking':
case 'system':
return `${message.type}:${message.content.length}`
case 'tool_use':
return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}:${message.status ?? ''}`
case 'tool_result':
return `${message.type}:${message.toolUseId}:${message.isError ? 1 : 0}`
case 'compact_summary':
return `${message.type}:${message.phase ?? ''}:${message.title.length}:${message.summary?.length ?? 0}`
case 'goal_event':
return `${message.type}:${message.action}:${message.status ?? ''}:${message.objective?.length ?? 0}:${message.message?.length ?? 0}`
case 'memory_event':
return `${message.type}:${message.event}:${message.files.length}:${message.message?.length ?? 0}`
case 'background_task':
return `${message.type}:${message.task.taskId}:${message.task.status}:${message.task.updatedAt}`
case 'permission_request':
return `${message.type}:${message.requestId}:${message.toolUseId ?? ''}:${message.description?.length ?? 0}`
case 'error':
return `${message.type}:${message.code}:${message.message.length}`
case 'task_summary':
return `${message.type}:${message.tasks.length}:${message.tasks.map((task) => task.id).join(',')}`
}
}
function getRenderItemMetricSignature(item: RenderItem): string {
if (item.kind === 'message') return getMessageMetricSignature(item.message)
return item.toolCalls.map(getMessageMetricSignature).join('|')
}
function findVirtualStartIndex(offsets: number[], target: number) {
let low = 0
let high = offsets.length - 1
while (low < high) {
const mid = Math.floor((low + high) / 2)
if ((offsets[mid + 1] ?? offsets[mid] ?? 0) < target) {
low = mid + 1
} else {
high = mid
}
}
return Math.max(0, low)
}
function findVirtualEndIndex(offsets: number[], target: number) {
let low = 0
let high = offsets.length - 1
while (low < high) {
const mid = Math.floor((low + high) / 2)
if ((offsets[mid] ?? 0) <= target) {
low = mid + 1
} else {
high = mid
}
}
return clampNumber(low + 1, 0, offsets.length - 1)
}
export function buildVirtualItemOffsets(
itemKeys: string[],
metrics: VirtualRenderItemMetric[],
measuredHeights: Map<string, number>,
) {
const offsets = new Array<number>(itemKeys.length + 1)
offsets[0] = 0
for (let index = 0; index < itemKeys.length; index += 1) {
const measuredHeight = measuredHeights.get(itemKeys[index]!)
const height = measuredHeight && measuredHeight > 0
? measuredHeight
: metrics[index]?.estimatedHeight ?? VIRTUAL_MIN_ITEM_HEIGHT
offsets[index + 1] = offsets[index]! + height
}
return offsets
}
const CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO = 0.25
export function getActiveConversationNavigationItemId(
items: ConversationNavigationItem[],
offsets: number[],
scrollTop: number,
viewportHeight: number,
) {
if (items.length === 0) return null
if (scrollTop <= 1) return items[0]!.id
const readingAnchor = scrollTop + viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
let activeItem = items[0]!
for (const item of items) {
if ((offsets[item.renderIndex] ?? 0) > readingAnchor) break
activeItem = item
}
return activeItem.id
}
export function getConversationNavigationTargetScrollTop(
item: ConversationNavigationItem,
offsets: number[],
viewportHeight: number,
totalHeight: number,
) {
const targetTop = offsets[item.renderIndex] ?? 0
const readingAnchor = viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
return clampNumber(targetTop - readingAnchor, 0, Math.max(0, totalHeight - viewportHeight))
}
function buildVirtualTranscriptWindow(
renderItems: RenderItem[],
itemKeys: string[],
metrics: VirtualRenderItemMetric[],
measuredHeights: Map<string, number>,
viewport: VirtualViewport,
overscanPx: number,
): VirtualTranscriptWindow {
const offsets = buildVirtualItemOffsets(itemKeys, metrics, measuredHeights)
const totalHeight = offsets[renderItems.length] ?? 0
if (!shouldVirtualizeRenderItems(metrics)) {
return {
enabled: false,
beforeHeight: 0,
afterHeight: 0,
items: renderItems.map((item, index) => ({ item, index })),
offsets,
totalHeight,
}
}
const viewportHeight = viewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
const maxScrollTop = Math.max(0, totalHeight - viewportHeight)
const scrollTop = clampNumber(viewport.scrollTop, 0, maxScrollTop)
const windowTop = Math.max(0, scrollTop - overscanPx)
const windowBottom = Math.min(totalHeight, scrollTop + viewportHeight + overscanPx)
const startIndex = findVirtualStartIndex(offsets, windowTop)
const endIndex = Math.min(renderItems.length, findVirtualEndIndex(offsets, windowBottom))
return {
enabled: true,
beforeHeight: offsets[startIndex] ?? 0,
afterHeight: totalHeight - (offsets[endIndex] ?? totalHeight),
items: renderItems.slice(startIndex, endIndex).map((item, offset) => ({
item,
index: startIndex + offset,
})),
offsets,
totalHeight,
}
}
const VIRTUAL_SPACER_CHUNK_PX = 800
function VirtualSpacer({ height, position }: { height: number; position: 'top' | 'bottom' }) {
if (height <= 0) return null
if (height <= VIRTUAL_SPACER_CHUNK_PX) {
return (
<div
data-virtual-spacer={position}
aria-hidden="true"
style={{ height }}
/>
)
}
// Splitting the spacer into chunks lets the WebView keep painting placeholder
// boxes via content-visibility:auto + contain-intrinsic-size, instead of
// leaving a single huge area unpainted while React reconciles the window.
const chunkCount = Math.max(1, Math.ceil(height / VIRTUAL_SPACER_CHUNK_PX))
const chunkHeight = Math.floor(height / chunkCount)
const remainder = height - chunkHeight * chunkCount
const chunks: Array<{ key: string; px: number }> = []
for (let i = 0; i < chunkCount; i++) {
const px = i === chunkCount - 1 ? chunkHeight + remainder : chunkHeight
chunks.push({ key: `${position}-${i}`, px })
}
return (
<div data-virtual-spacer={position} aria-hidden="true">
{chunks.map((chunk) => (
<div
key={chunk.key}
data-virtual-spacer-chunk={position}
style={{
height: chunk.px,
contentVisibility: 'auto',
containIntrinsicSize: `0 ${chunk.px}px`,
}}
/>
))}
</div>
)
}
const MeasuredRenderItem = memo(function MeasuredRenderItem({
itemKey,
onHeightChange,
highlighted,
children,
}: {
itemKey: string
onHeightChange: (itemKey: string, height: number) => void
highlighted: boolean
children: ReactNode
}) {
const itemRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const node = itemRef.current
if (!node) return undefined
if (typeof ResizeObserver === 'undefined') return undefined
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry && Number.isFinite(entry.contentRect.height) && entry.contentRect.height > 0) {
onHeightChange(itemKey, Math.ceil(entry.contentRect.height))
}
})
observer.observe(node)
return () => observer.disconnect()
}, [itemKey, onHeightChange])
return (
<div
ref={itemRef}
data-virtual-message-item={itemKey}
data-chat-render-item-key={itemKey}
className={`${CHAT_RENDER_ITEM_CLASS} ${highlighted ? 'chat-render-item--navigation-target' : ''}`}
>
{children}
</div>
)
})
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 branchSession = useSessionStore((s) => s.branchSession)
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 ?? EMPTY_MESSAGES
const chatState = sessionState?.chatState ?? 'idle'
const streamingText = sessionState?.streamingText ?? ''
const streamingToolInput = sessionState?.streamingToolInput ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
const activeAskUserQuestionToolUseId =
listPendingPermissions(sessionState)
.find((permission) => permission.toolName === 'AskUserQuestion')?.toolUseId ?? null
const shouldFollowContentResize =
streamingText.trim().length > 0 ||
chatState === 'streaming' ||
chatState === 'compacting' ||
chatState === 'tool_executing' ||
(chatState === 'thinking' && Boolean(activeThinkingId))
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const virtualItemHeightsRef = useRef<Map<string, number>>(
resolvedSessionId ? getHeightsForSession(resolvedSessionId) : new Map<string, number>(),
)
const virtualItemMetricCacheRef = useRef<Map<string, VirtualRenderItemMetric>>(
resolvedSessionId ? getMetricsForSession(resolvedSessionId) : new Map<string, VirtualRenderItemMetric>(),
)
const pendingMeasuredHeightsRef = useRef(false)
const measureFlushFrameRef = useRef<number | null>(null)
const navigationHighlightTimerRef = useRef<number | null>(null)
const lastAutoScrollAtRef = useRef(0)
const lastContentResizeFollowHeightRef = useRef<number | null>(null)
const shouldAutoScrollRef = useRef(true)
const isProgrammaticScrollingRef = useRef(false)
const ignoreProgrammaticScrollUntilRef = useRef(0)
const ignoreProgrammaticScrollTopRef = useRef<number | null>(null)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
const t = useTranslation()
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
const [turnActionErrors, setTurnActionErrors] = useState<Record<string, string>>({})
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
const [branchingMessageId, setBranchingMessageId] = useState<string | null>(null)
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
const [virtualViewport, setVirtualViewport] = useState<VirtualViewport>({
scrollTop: SCROLL_BOTTOM_SENTINEL,
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
})
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
const [highlightedNavigationItemKey, setHighlightedNavigationItemKey] = useState<string | null>(null)
const branchActionsDisabled =
isMemberSession ||
chatState !== 'idle' ||
hasRunningBackgroundTasks ||
streamingText.trim().length > 0 ||
Boolean(activeThinkingId) ||
Boolean(sessionState?.activeToolUseId) ||
Boolean(sessionState?.activeToolName)
const hasCompactingDivider = messages.some((message) =>
message.type === 'compact_summary' && message.phase === 'compacting')
useEffect(() => () => {
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
}
if (navigationHighlightTimerRef.current !== null) {
window.clearTimeout(navigationHighlightTimerRef.current)
}
}, [])
const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => {
const nextScrollTop = container.scrollTop
const nextViewportHeight = container.clientHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
setVirtualViewport((current) => {
if (
Math.abs(current.scrollTop - nextScrollTop) < 1 &&
Math.abs(current.viewportHeight - nextViewportHeight) < 1
) {
return current
}
return {
scrollTop: nextScrollTop,
viewportHeight: nextViewportHeight,
}
})
}, [])
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
shouldAutoScrollRef.current = true
isProgrammaticScrollingRef.current = true
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
lastAutoScrollAtRef.current = performance.now()
const container = scrollContainerRef.current
let requestedScrollTop: number | null = null
if (container) {
setScrollToBottomWithoutLayoutRead(container, behavior)
requestedScrollTop = container.scrollTop
ignoreProgrammaticScrollTopRef.current = requestedScrollTop
}
setVirtualViewport((current) => ({
scrollTop: SCROLL_BOTTOM_SENTINEL,
viewportHeight: current.viewportHeight,
}))
if (container && resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: container.scrollTop,
wasAtBottom: true,
})
}
setShowJumpToLatest(false)
// Reset flag after the scroll event(s) from scrollIntoView have fired
requestAnimationFrame(() => {
const latestContainer = scrollContainerRef.current
if (
shouldAutoScrollRef.current &&
latestContainer &&
(
requestedScrollTop === null ||
latestContainer.scrollTop === requestedScrollTop
)
) {
setScrollToBottomWithoutLayoutRead(latestContainer, 'auto')
if (resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: latestContainer.scrollTop,
wasAtBottom: true,
})
}
}
isProgrammaticScrollingRef.current = false
})
}, [resolvedSessionId])
const flushMeasuredHeightVersion = useCallback(() => {
if (!pendingMeasuredHeightsRef.current) return
pendingMeasuredHeightsRef.current = false
setMeasuredItemsVersion((version) => version + 1)
}, [])
const handleVirtualItemHeightChange = useCallback((itemKey: string, height: number) => {
const measuredHeight = clampNumber(height, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)
const previousHeight = virtualItemHeightsRef.current.get(itemKey)
if (previousHeight !== undefined && Math.abs(previousHeight - measuredHeight) < 1) return
virtualItemHeightsRef.current.set(itemKey, measuredHeight)
if (typeof requestAnimationFrame === 'undefined') {
pendingMeasuredHeightsRef.current = true
flushMeasuredHeightVersion()
} else if (!pendingMeasuredHeightsRef.current) {
pendingMeasuredHeightsRef.current = true
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
}
measureFlushFrameRef.current = requestAnimationFrame(() => {
measureFlushFrameRef.current = null
flushMeasuredHeightVersion()
})
}
}, [flushMeasuredHeightVersion])
const updateAutoScrollState = useCallback(() => {
// Ignore scroll events triggered by our own programmatic scrolling to
// prevent the jump-to-latest button from flickering during auto-scroll.
const container = scrollContainerRef.current
if (!container) return
const matchesProgrammaticScrollTop =
ignoreProgrammaticScrollTopRef.current !== null &&
Math.abs(container.scrollTop - ignoreProgrammaticScrollTopRef.current) < 1
const shouldIgnoreRecentProgrammaticScroll =
matchesProgrammaticScrollTop &&
(
isProgrammaticScrollingRef.current ||
performance.now() < ignoreProgrammaticScrollUntilRef.current
)
if (shouldIgnoreRecentProgrammaticScroll) {
syncVirtualViewportFromContainer(container)
return
}
syncVirtualViewportFromContainer(container)
const isAtBottom = isNearScrollBottom(container)
shouldAutoScrollRef.current = isAtBottom
setShowJumpToLatest(!isAtBottom)
if (resolvedSessionId) {
rememberSessionScroll(resolvedSessionId, container)
}
}, [resolvedSessionId, syncVirtualViewportFromContainer])
useLayoutEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
lastSessionIdRef.current = resolvedSessionId
virtualItemHeightsRef.current = resolvedSessionId
? getHeightsForSession(resolvedSessionId)
: new Map<string, number>()
virtualItemMetricCacheRef.current = resolvedSessionId
? getMetricsForSession(resolvedSessionId)
: new Map<string, VirtualRenderItemMetric>()
pendingMeasuredHeightsRef.current = false
lastContentResizeFollowHeightRef.current = null
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
measureFlushFrameRef.current = null
}
setMeasuredItemsVersion((version) => version + 1)
const container = scrollContainerRef.current
if (container && snapshot && !snapshot.wasAtBottom) {
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
ignoreProgrammaticScrollTopRef.current = snapshot.scrollTop
setScrollTopWithoutLayoutRead(container, snapshot.scrollTop)
setVirtualViewport((current) => ({
scrollTop: snapshot.scrollTop,
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
}))
setShowJumpToLatest(true)
} else if (container) {
// Switch to a session we were at the bottom of (or first visit): write
// the bottom sentinel without going through scrollToBottom's read path,
// so we never force a layout flush during the switch's commit.
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
ignoreProgrammaticScrollTopRef.current = null
lastAutoScrollAtRef.current = performance.now()
shouldAutoScrollRef.current = true
setScrollToBottomWithoutLayoutRead(container, 'auto')
setVirtualViewport((current) => ({
scrollTop: SCROLL_BOTTOM_SENTINEL,
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
}))
setShowJumpToLatest(false)
if (resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: container.scrollTop,
wasAtBottom: true,
})
}
} else {
// No container yet (initial mount before ref settles): fall back to the
// existing scrollToBottom path which is safe pre-mount.
scrollToBottom('auto')
}
}
}, [resolvedSessionId, scrollToBottom])
const tailMessage = messages[messages.length - 1] ?? null
const tailMessageId = tailMessage?.id ?? null
const tailMessageType = tailMessage?.type ?? null
useEffect(() => {
if (!resolvedSessionId) return
const previousTailMessageId = lastTailMessageIdBySessionRef.current.get(resolvedSessionId)
lastTailMessageIdBySessionRef.current.set(resolvedSessionId, tailMessageId)
if (previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return
if (tailMessageType === 'user_text') {
scrollToBottom('auto')
}
}, [resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType])
useEffect(() => {
if (!shouldAutoScrollRef.current) {
setShowJumpToLatest(true)
return
}
scrollToBottom('auto')
}, [messages.length, resolvedSessionId, scrollToBottom, streamingText, streamingToolInput])
const handleJumpToLatest = useCallback(() => {
scrollToBottom('auto')
}, [scrollToBottom])
useEffect(() => {
const content = scrollContentRef.current
if (!content || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver((entries) => {
const nextHeight = entries[0]?.contentRect.height
if (typeof nextHeight === 'number' && Number.isFinite(nextHeight)) {
const previousFollowHeight = lastContentResizeFollowHeightRef.current
if (
previousFollowHeight !== null &&
Math.abs(nextHeight - previousFollowHeight) < CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX
) {
return
}
lastContentResizeFollowHeightRef.current = nextHeight
}
if (!shouldFollowContentResize) return
if (!shouldAutoScrollRef.current) return
scrollToBottom('auto')
})
observer.observe(content)
return () => observer.disconnect()
}, [scrollToBottom, shouldFollowContentResize])
// Touch-H5 only: the visual-viewport fit (touchH5.ts) shrinks the scroll
// container when the soft keyboard opens. If the user was reading the tail,
// keep the latest message pinned above the keyboard instead of letting the
// shorter container cut it off.
useEffect(() => {
if (!isTouchH5Document()) return
const container = scrollContainerRef.current
if (!container || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => {
if (!shouldAutoScrollRef.current) return
scrollToBottom('auto')
})
observer.observe(container)
return () => observer.disconnect()
}, [scrollToBottom])
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
() => buildRenderModel(messages, activeAskUserQuestionToolUseId),
[activeAskUserQuestionToolUseId, messages],
)
// Defer the per-message branchable / completed-turn computations so the first
// commit on tab switch can render the virtualization window without doing two
// additional O(N) walks synchronously. They re-run in a low-priority render
// once the initial frame is painted.
const deferredMessages = useDeferredValue(messages)
const branchableMessageTargets = useMemo(
() => branchActionsDisabled
? new Map<string, BranchableMessageTarget>()
: getBranchableMessageTargets(deferredMessages),
[branchActionsDisabled, deferredMessages],
)
const completedTurnTargets = useMemo(
() => getCompletedTurnTargets(deferredMessages),
[deferredMessages],
)
const latestCompletedTurnId =
completedTurnTargets.length > 0
? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null
: null
const visibleTurnChangeCards = hasRunningBackgroundTasks ? EMPTY_TURN_CHANGE_CARDS : turnChangeCards
const turnCardsByRenderIndex = useMemo(
() => buildTurnCardInsertionMap(renderItems, visibleTurnChangeCards),
[renderItems, visibleTurnChangeCards],
)
const changedFilesByRenderIndex = useMemo(
() => buildChangedFilesByRenderIndex(renderItems, visibleTurnChangeCards),
[renderItems, visibleTurnChangeCards],
)
const renderItemKeys = useMemo(
() => renderItems.map(getRenderItemKey),
[renderItems],
)
const renderItemMetrics = useMemo(
() => renderItems.map((item, index) => {
const key = renderItemKeys[index]!
const signature = getRenderItemMetricSignature(item)
const cached = virtualItemMetricCacheRef.current.get(key)
if (cached?.signature === signature) return cached
const metric = {
signature,
contentWeight: getRenderItemContentWeight(item),
estimatedHeight: estimateRenderItemHeight(item),
}
virtualItemMetricCacheRef.current.set(key, metric)
return metric
}),
[renderItemKeys, renderItems],
)
const conversationNavigationHistoryItems = useMemo(() => {
const sources = renderItems.flatMap((item, renderIndex) => item.kind === 'message'
? [{
message: item.message,
renderIndex,
renderItemKey: getRenderItemKey(item),
}]
: [])
return buildConversationNavigationItems(sources)
}, [renderItems])
const streamingConversationNavigationItem = useMemo(() => {
if (!streamingText.trim()) return null
return buildConversationNavigationItems([{
message: {
id: `${STREAMING_ASSISTANT_NAVIGATION_KEY}-${resolvedSessionId ?? 'session'}`,
type: 'assistant_text',
content: streamingText,
timestamp: 0,
},
renderIndex: renderItems.length,
renderItemKey: STREAMING_ASSISTANT_NAVIGATION_KEY,
}])[0] ?? null
}, [renderItems, resolvedSessionId, streamingText])
const conversationNavigationItems = useMemo(
() => streamingConversationNavigationItem
? [...conversationNavigationHistoryItems, streamingConversationNavigationItem]
: conversationNavigationHistoryItems,
[conversationNavigationHistoryItems, streamingConversationNavigationItem],
)
const virtualTranscriptWindow = useMemo(
() => buildVirtualTranscriptWindow(
renderItems,
renderItemKeys,
renderItemMetrics,
virtualItemHeightsRef.current,
virtualViewport,
VIRTUAL_OVERSCAN_PX,
),
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport],
)
const activeConversationNavigationItemId = useMemo(
() => getActiveConversationNavigationItemId(
conversationNavigationItems,
virtualTranscriptWindow.offsets,
virtualViewport.scrollTop,
virtualViewport.viewportHeight,
),
[conversationNavigationItems, virtualTranscriptWindow.offsets, virtualViewport],
)
const showConversationNavigator =
!compact &&
!isTouchH5Document() &&
conversationNavigationItems.length >= CONVERSATION_NAVIGATION_MIN_ITEMS
const confirmTurnCard = useMemo(
() => visibleTurnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
[turnUndoConfirmTargetId, visibleTurnChangeCards],
)
useEffect(() => {
const liveKeys = new Set(renderItemKeys)
let removed = false
for (const key of virtualItemHeightsRef.current.keys()) {
if (!liveKeys.has(key)) {
virtualItemHeightsRef.current.delete(key)
removed = true
}
}
for (const key of virtualItemMetricCacheRef.current.keys()) {
if (!liveKeys.has(key)) {
virtualItemMetricCacheRef.current.delete(key)
}
}
if (removed) setMeasuredItemsVersion((version) => version + 1)
}, [renderItemKeys])
useEffect(() => {
if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) {
setTurnChangeCards([])
setTurnChangeLoadError(null)
setIsLoadingTurnChangeCards(false)
return
}
if (hasRunningBackgroundTasks) {
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),
)
const targetByUserMessageIndex = new Map(
completedTurnTargets.map((target) => [target.userMessageIndex, target] as const),
)
setTurnChangeCards(
normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
const target =
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
targetByUserMessageIndex.get(checkpoint.target.userMessageIndex)
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, hasRunningBackgroundTasks, isMemberSession, latestCompletedTurnId, resolvedSessionId])
const handleUndoCurrentTurn = useCallback(async () => {
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId || hasRunningBackgroundTasks) 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,
hasRunningBackgroundTasks,
queueComposerPrefill,
reloadHistory,
resolvedSessionId,
rewindingTurnId,
stopGeneration,
t,
])
const handleBranchMessage = useCallback(async (target: BranchableMessageTarget) => {
if (!resolvedSessionId || branchingMessageId) return
setBranchingMessageId(target.uiMessageId)
try {
const result = await branchSession(resolvedSessionId, target.transcriptMessageId)
const title = result.title.trim() || t('sidebar.newSession')
useTabStore.getState().openTab(result.sessionId, title)
useChatStore.getState().connectToSession(result.sessionId)
addToast({
type: 'success',
message: t('chat.branchSuccess', { title }),
})
} catch (error) {
addToast({
type: 'error',
message: t('chat.branchError', { detail: getApiErrorMessage(error) }),
})
} finally {
setBranchingMessageId(null)
}
}, [addToast, branchSession, branchingMessageId, resolvedSessionId, t])
// Pre-compute per-message branchAction + toolResult lookups so MessageBlock's
// memo barrier is not broken by inline object literals on every render.
const branchActionByMessageId = useMemo(() => {
if (branchableMessageTargets.size === 0) {
return new Map<string, { label: string; loading: boolean; onBranch: () => void }>()
}
const result = new Map<string, { label: string; loading: boolean; onBranch: () => void }>()
const label = t('chat.branchFromHere')
for (const [uiMessageId, target] of branchableMessageTargets) {
result.set(uiMessageId, {
label,
loading: branchingMessageId === target.uiMessageId,
onBranch: () => { void handleBranchMessage(target) },
})
}
return result
}, [branchableMessageTargets, branchingMessageId, handleBranchMessage, t])
const toolResultByToolUseId = useMemo(() => {
if (toolResultMap.size === 0) return new Map<string, { content: unknown; isError: boolean }>()
const result = new Map<string, { content: unknown; isError: boolean }>()
for (const [toolUseId, toolResult] of toolResultMap) {
result.set(toolUseId, { content: toolResult.content, isError: toolResult.isError })
}
return result
}, [toolResultMap])
const handleNavigateToConversationItem = useCallback((item: ConversationNavigationItem) => {
const container = scrollContainerRef.current
if (!container) return
const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
const isTranscriptTail =
item.renderItemKey === STREAMING_ASSISTANT_NAVIGATION_KEY ||
item.renderIndex === renderItems.length - 1
setHighlightedNavigationItemKey(item.renderItemKey)
const scheduleHighlightClear = () => {
if (navigationHighlightTimerRef.current !== null) {
window.clearTimeout(navigationHighlightTimerRef.current)
}
navigationHighlightTimerRef.current = window.setTimeout(() => {
setHighlightedNavigationItemKey((current) => current === item.renderItemKey ? null : current)
navigationHighlightTimerRef.current = null
}, 1400)
}
if (isTranscriptTail) {
scrollToBottom('auto')
requestAnimationFrame(scheduleHighlightClear)
return
}
const targetScrollTop = getConversationNavigationTargetScrollTop(
item,
virtualTranscriptWindow.offsets,
viewportHeight,
virtualTranscriptWindow.totalHeight,
)
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
const isNearby = Math.abs(container.scrollTop - targetScrollTop) <= viewportHeight * 1.25
shouldAutoScrollRef.current = false
setShowJumpToLatest(true)
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
ignoreProgrammaticScrollTopRef.current = targetScrollTop
if (isNearby && !prefersReducedMotion && typeof container.scrollTo === 'function') {
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
} else {
setScrollTopWithoutLayoutRead(container, targetScrollTop)
}
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
requestAnimationFrame(() => {
const targetNode = Array.from(
scrollContentRef.current?.querySelectorAll<HTMLElement>('[data-chat-render-item-key]') ?? [],
).find((node) => node.dataset.chatRenderItemKey === item.renderItemKey)
if (targetNode) {
const targetRect = targetNode.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
if (targetRect.height > 0) {
const correction = targetRect.top - containerRect.top - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
if (Math.abs(correction) >= 1) {
setScrollTopWithoutLayoutRead(container, container.scrollTop + correction)
syncVirtualViewportFromContainer(container)
}
}
}
scheduleHighlightClear()
})
}, [
renderItems.length,
scrollToBottom,
syncVirtualViewportFromContainer,
virtualTranscriptWindow.offsets,
virtualTranscriptWindow.totalHeight,
virtualViewport.viewportHeight,
])
const renderTranscriptItem = (item: RenderItem, index: number) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
return (
<>
{item.kind === 'tool_group' ? (
<ToolCallGroup
sessionId={resolvedSessionId}
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
) : (
<MessageBlock
sessionId={resolvedSessionId}
message={item.message}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? toolResultByToolUseId.get(item.message.toolUseId) ?? null
: null
}
branchAction={branchActionByMessageId.get(item.message.id)}
turnChangedFiles={changedFilesByRenderIndex.get(index)}
/>
)}
{resolvedSessionId && cardsForItem.map((card) => (
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
checkpoint={card.checkpoint}
workDir={card.workDir}
error={turnActionErrors[card.target.messageId] ?? null}
isUndoing={rewindingTurnId === card.target.messageId}
isLatest={card.isLatest}
onUndo={() => {
setTurnUndoConfirmTargetId(card.target.messageId)
}}
/>
))}
</>
)
}
return (
<div className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
>
{virtualTranscriptWindow.enabled ? (
<VirtualSpacer height={virtualTranscriptWindow.beforeHeight} position="top" />
) : null}
{virtualTranscriptWindow.items.map(({ item, index }) => {
const itemKey = getRenderItemKey(item)
const content = renderTranscriptItem(item, index)
return virtualTranscriptWindow.enabled ? (
<MeasuredRenderItem
key={itemKey}
itemKey={itemKey}
onHeightChange={handleVirtualItemHeightChange}
highlighted={highlightedNavigationItemKey === itemKey}
>
{content}
</MeasuredRenderItem>
) : (
<div
key={itemKey}
data-chat-render-item-key={itemKey}
className={`${CHAT_RENDER_ITEM_CLASS} chat-render-item--cv ${highlightedNavigationItemKey === itemKey ? 'chat-render-item--navigation-target' : ''}`}
>
{content}
</div>
)
})}
{virtualTranscriptWindow.enabled ? (
<VirtualSpacer height={virtualTranscriptWindow.afterHeight} position="bottom" />
) : null}
{streamingText.trim() && (
<div
data-chat-render-item-key={STREAMING_ASSISTANT_NAVIGATION_KEY}
className={highlightedNavigationItemKey === STREAMING_ASSISTANT_NAVIGATION_KEY ? 'chat-render-item--navigation-target' : ''}
>
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
</div>
)}
{chatState === 'compacting' && !hasCompactingDivider && (
<CompactStatusDivider state="compacting" />
)}
{/* Show StreamingIndicator when:
- tool_executing: background work 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)) && (
<StreamingIndicator />
)}
{!isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{turnChangeLoadError}
</div>
)}
<div />
</div>
</div>
{showConversationNavigator ? (
<ConversationNavigator
items={conversationNavigationItems}
activeItemId={activeConversationNavigationItemId}
onNavigate={handleNavigateToConversationItem}
/>
) : null}
{showJumpToLatest && (
<button
type="button"
onClick={handleJumpToLatest}
title={t('chat.jumpToLatest')}
aria-label={t('chat.jumpToLatest')}
className="absolute bottom-4 right-5 z-20 flex h-9 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:border-[var(--color-brand)]/50 hover:bg-[var(--color-surface-container-low)]"
>
<ArrowDown size={15} aria-hidden="true" />
<span>{t('chat.jumpToLatest')}</span>
</button>
)}
<ConfirmDialog
open={Boolean(confirmTurnCard)}
onClose={() => {
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)}
/>
</div>
)
}
export const MessageBlock = memo(function MessageBlock({
sessionId,
message,
activeThinkingId,
agentTaskNotifications,
toolResult,
branchAction,
turnChangedFiles,
}: {
sessionId?: string | null
message: UIMessage
activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification>
toolResult?: { content: unknown; isError: boolean } | null
branchAction?: {
label: string
loading?: boolean
onBranch: () => void
}
turnChangedFiles?: string[]
}) {
const t = useTranslation()
switch (message.type) {
case 'user_text':
return (
<SelectableChatMessage
sessionId={sessionId}
messageId={message.id}
role="user"
content={message.content}
>
<UserMessage
content={message.content}
attachments={message.attachments}
branchAction={branchAction}
timestamp={message.timestamp}
/>
</SelectableChatMessage>
)
case 'assistant_text':
return (
<SelectableChatMessage
sessionId={sessionId}
messageId={message.id}
role="assistant"
content={message.content}
>
<AssistantMessage
content={message.content}
branchAction={branchAction}
sessionId={sessionId ?? undefined}
timestamp={message.timestamp}
turnChangedFiles={turnChangedFiles}
/>
</SelectableChatMessage>
)
case 'thinking':
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
case 'tool_use':
if (message.toolName === 'AskUserQuestion' && !message.isPending) {
return (
<AskUserQuestion
sessionId={sessionId}
toolUseId={message.toolUseId}
input={message.input}
result={toolResult?.content}
/>
)
}
return (
<ToolCallBlock
toolName={message.toolName}
input={message.input}
result={toolResult}
isPending={message.isPending}
status={message.status}
partialInput={message.partialInput}
agentTaskNotification={
message.toolName === 'Agent'
? agentTaskNotifications[message.toolUseId]
: undefined
}
/>
)
case 'tool_result':
return (
<ToolResultBlock
content={message.content}
isError={message.isError}
standalone
/>
)
case 'permission_request':
return (
<PermissionDialog
sessionId={sessionId}
requestId={message.requestId}
toolName={message.toolName}
input={message.input}
description={message.description}
/>
)
case 'error': {
const businessErrorKey = message.businessErrorCode
? `businessError.${message.businessErrorCode}` as TranslationKey
: null
const businessErrorText = businessErrorKey ? t(businessErrorKey) : null
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
const errorText = errorKey ? t(errorKey) : null
const displayMessage =
businessErrorText && businessErrorText !== businessErrorKey
? businessErrorText
: (errorText && errorText !== errorKey)
? errorText
: message.message
const showRawDetail =
!message.businessErrorCode &&
Boolean(message.message) &&
message.message.trim() !== '' &&
message.message !== displayMessage
return (
<div className="mb-3 px-4 py-2.5 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 text-sm text-[var(--color-error)]">
<strong>{t('common.error')}:</strong> {displayMessage}
{showRawDetail && (
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]/85">
{message.message}
</div>
)}
</div>
)
}
case 'task_summary':
return <InlineTaskSummary tasks={message.tasks} />
case 'memory_event':
return <MemoryEventCard message={message} />
case 'compact_summary':
return <CompactStatusDivider message={message} state={message.phase === 'compacting' ? 'compacting' : 'complete'} />
case 'goal_event':
return message.action === 'status' && message.status === 'continuing'
? <GoalContinuationDivider message={message} />
: <GoalEventCard message={message} />
case 'background_task':
return <BackgroundTaskEventCard message={message} />
case 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
{message.content}
</div>
)
}
})