cc-haha/desktop/src/components/chat/MessageList.tsx
程序员阿江(Relakkes) 80291715b4 fix: stop restored chats snapping to latest
Restored transcripts can still present stale running state while their final markdown and tool output continue to reflow. Resize-driven auto-follow now stays enabled only for live resizable output, so readers can scroll up after completion while active streams still remain pinned.

Constraint: Old H5 sessions can reconnect with stale non-idle UI state even when the transcript has an end_turn.
Rejected: Disable resize following entirely | active H5 streaming would regress when rendered content grows after tokens arrive.
Confidence: high
Scope-risk: narrow
Directive: Do not broaden ResizeObserver auto-follow without a restored completed-session regression test.
Tested: cd desktop && bun run test -- MessageList.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: Browser smoke on session 85862799-ec6b-43d0-b3b8-6e9fd9130954 kept scrollTop at 320 after content resize.
2026-05-15 21:58:31 +08:00

1137 lines
39 KiB
TypeScript

import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Settings } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
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 type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { ConfirmDialog } from '../shared/ConfirmDialog'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
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 TurnChangeCardModel = {
target: RewindTurnTarget
checkpoint: SessionTurnCheckpoint
workDir: string | null
isLatest: boolean
}
type ChatMessageRole = 'user' | 'assistant'
type ChatSelectionState = {
text: string
x: number
y: number
}
const CHAT_SELECTION_MENU_OFFSET = 8
function clampValue(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
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 }) {
const rect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: null
const rootRect = root.getBoundingClientRect()
const pointerInsideRoot =
pointer.clientX >= rootRect.left &&
pointer.clientX <= rootRect.right &&
pointer.clientY >= rootRect.top &&
pointer.clientY <= rootRect.bottom
const fallbackX = rect && rect.width > 0
? rect.left + rect.width / 2
: rect?.left ?? rootRect.left + 24
const fallbackY = rect
? rect.bottom + CHAT_SELECTION_MENU_OFFSET
: rootRect.top + 24
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
const unclampedY = pointerInsideRoot ? pointer.clientY + CHAT_SELECTION_MENU_OFFSET : fallbackY
const minX = Math.max(12, rootRect.left + 8)
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
const minY = Math.max(12, rootRect.top + 8)
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
return {
x: clampValue(unclampedX, minX, maxX),
y: clampValue(unclampedY, minY, maxY),
}
}
function getChatSelectionFromContainer(
root: HTMLElement | null,
pointer: { clientX: number; clientY: number },
): 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 ChatSelectionMenu({
selection,
onAdd,
}: {
selection: ChatSelectionState | null
onAdd: () => void
}) {
const t = useTranslation()
if (!selection) return null
return (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={onAdd}
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
style={{ left: selection.x, top: selection.y }}
>
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span>
<span>{t('chat.addSelectionToChat')}</span>
</button>
)
}
function SelectableChatMessage({
sessionId,
messageId,
role,
content,
children,
}: {
sessionId?: string | null
messageId: string
role: ChatMessageRole
content: string
children: ReactNode
}) {
const rootRef = useRef<HTMLDivElement>(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)
}, [content, messageId])
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)
window.getSelection()?.removeAllRanges()
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
return (
<div
ref={rootRef}
onMouseUp={(event) => {
setSelectionMenu(getChatSelectionFromContainer(rootRef.current, event))
}}
onKeyDown={(event) => {
if (event.key === 'Escape') setSelectionMenu(null)
}}
>
{children}
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
</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[]): RenderModel {
const items: RenderItem[] = []
const toolResultMap = new Map<string, ToolResult>()
const childToolCallsByParent = new Map<string, ToolCall[]>()
const toolUseIds = new Set<string>()
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 === 'assistant_text' && !msg.content.trim()) {
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') {
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<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
}
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 MAX_SCROLL_SNAPSHOTS = 100
const VIRTUALIZE_MIN_ITEMS = 160
const VIRTUAL_INITIAL_ITEMS = 96
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(' ')
type SessionScrollSnapshot = {
scrollTop: number
wasAtBottom: boolean
}
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 clampScrollTop(element: HTMLElement, scrollTop: number) {
return Math.max(0, Math.min(scrollTop, getBottomScrollTop(element)))
}
function getBottomScrollTop(element: HTMLElement) {
return Math.max(0, element.scrollHeight - element.clientHeight)
}
function getRenderItemKey(item: RenderItem) {
return item.kind === 'tool_group' ? item.id : item.message.id
}
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 shouldFollowContentResize =
streamingText.trim().length > 0 ||
chatState === 'streaming' ||
chatState === 'tool_executing' ||
(chatState === 'thinking' && Boolean(activeThinkingId))
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const shouldAutoScrollRef = useRef(true)
const isProgrammaticScrollingRef = useRef(false)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
const updateVirtualWindowRef = useRef<((element: HTMLElement) => void) | null>(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 [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
shouldAutoScrollRef.current = true
isProgrammaticScrollingRef.current = true
const container = scrollContainerRef.current
const targetScrollTop = container ? getBottomScrollTop(container) : null
if (container) {
const nextScrollTop = targetScrollTop ?? 0
if (typeof container.scrollTo === 'function') {
try {
container.scrollTo({ top: nextScrollTop, behavior })
} catch {
container.scrollTo(0, nextScrollTop)
}
}
container.scrollTop = nextScrollTop
}
if (container && resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: getBottomScrollTop(container),
wasAtBottom: true,
})
}
setShowJumpToLatest(false)
// Reset flag after the scroll event(s) from scrollIntoView have fired
requestAnimationFrame(() => {
const latestContainer = scrollContainerRef.current
if (
shouldAutoScrollRef.current &&
latestContainer &&
(
targetScrollTop === null ||
latestContainer.scrollTop === targetScrollTop ||
isNearScrollBottom(latestContainer)
)
) {
latestContainer.scrollTop = getBottomScrollTop(latestContainer)
if (resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: getBottomScrollTop(latestContainer),
wasAtBottom: true,
})
}
}
isProgrammaticScrollingRef.current = false
})
}, [resolvedSessionId])
const updateAutoScrollState = useCallback(() => {
// Ignore scroll events triggered by our own programmatic scrolling to
// prevent the jump-to-latest button from flickering during auto-scroll.
if (isProgrammaticScrollingRef.current) return
const container = scrollContainerRef.current
if (!container) return
const isAtBottom = isNearScrollBottom(container)
shouldAutoScrollRef.current = isAtBottom
setShowJumpToLatest(!isAtBottom)
if (resolvedSessionId) {
rememberSessionScroll(resolvedSessionId, container)
}
updateVirtualWindowRef.current?.(container)
}, [resolvedSessionId])
useLayoutEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
lastSessionIdRef.current = resolvedSessionId
const container = scrollContainerRef.current
if (container && snapshot && !snapshot.wasAtBottom) {
container.scrollTop = clampScrollTop(container, snapshot.scrollTop)
setShowJumpToLatest(true)
} else {
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])
const handleJumpToLatest = useCallback(() => {
scrollToBottom('auto')
}, [scrollToBottom])
useEffect(() => {
const content = scrollContentRef.current
if (!content || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => {
if (!shouldFollowContentResize) return
if (!shouldAutoScrollRef.current) return
scrollToBottom('auto')
})
observer.observe(content)
return () => observer.disconnect()
}, [scrollToBottom, shouldFollowContentResize])
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
() => buildRenderModel(messages),
[messages],
)
const shouldVirtualize = renderItems.length > VIRTUALIZE_MIN_ITEMS
const [virtualRange, setVirtualRange] = useState(() => ({ start: 0, end: 0 }))
const updateVirtualWindow = useCallback((container: HTMLElement | null) => {
if (!shouldVirtualize) {
setVirtualRange((current) =>
current.start === 0 && current.end === renderItems.length
? current
: { start: 0, end: renderItems.length },
)
return
}
if (!container || shouldAutoScrollRef.current) {
const start = Math.max(0, renderItems.length - VIRTUAL_INITIAL_ITEMS)
setVirtualRange((current) =>
current.start === start && current.end === renderItems.length
? current
: { start, end: renderItems.length },
)
return
}
setVirtualRange((current) =>
current.start === 0 && current.end === renderItems.length
? current
: { start: 0, end: renderItems.length },
)
}, [renderItems.length, shouldVirtualize])
useEffect(() => {
updateVirtualWindowRef.current = updateVirtualWindow
return () => {
if (updateVirtualWindowRef.current === updateVirtualWindow) {
updateVirtualWindowRef.current = null
}
}
}, [updateVirtualWindow])
useLayoutEffect(() => {
updateVirtualWindow(scrollContainerRef.current)
}, [renderItems.length, resolvedSessionId, updateVirtualWindow])
const visibleRenderItems = shouldVirtualize
? renderItems.slice(virtualRange.start, virtualRange.end)
: renderItems
const topVirtualSpacerHeight = shouldVirtualize
? Math.max(0, virtualRange.start * 96)
: 0
const bottomVirtualSpacerHeight = shouldVirtualize
? Math.max(0, renderItems.length - virtualRange.end) * 96
: 0
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),
)
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, 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 (
<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]'}
role={shouldVirtualize ? 'list' : undefined}
>
{topVirtualSpacerHeight > 0 && (
<div aria-hidden="true" style={{ height: topVirtualSpacerHeight }} />
)}
{visibleRenderItems.map((item, visibleIndex) => {
const index = shouldVirtualize ? virtualRange.start + visibleIndex : visibleIndex
const itemKey = getRenderItemKey(item)
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
return (
<div
key={itemKey}
data-virtual-message-item={shouldVirtualize ? itemKey : undefined}
role={shouldVirtualize ? 'listitem' : undefined}
aria-posinset={shouldVirtualize ? index + 1 : undefined}
aria-setsize={shouldVirtualize ? renderItems.length : undefined}
>
{item.kind === 'tool_group' ? (
<ToolCallGroup
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'
? (() => {
const result = toolResultMap.get(item.message.toolUseId)
return result ? { content: result.content, isError: result.isError } : null
})()
: null
}
/>
)}
{resolvedSessionId && cardsForItem.map((card) => (
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
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)
}}
/>
))}
</div>
)
})}
{bottomVirtualSpacerHeight > 0 && (
<div aria-hidden="true" style={{ height: bottomVirtualSpacerHeight }} />
)}
{streamingText.trim() && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}
{/* 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)) && (
<StreamingIndicator />
)}
{!isLoadingTurnChangeCards && turnChangeCards.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>
{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,
}: {
sessionId?: string | null
message: UIMessage
activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification>
toolResult?: { content: unknown; isError: boolean } | null
}) {
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}
/>
</SelectableChatMessage>
)
case 'assistant_text':
return (
<SelectableChatMessage
sessionId={sessionId}
messageId={message.id}
role="assistant"
content={message.content}
>
<AssistantMessage content={message.content} />
</SelectableChatMessage>
)
case 'thinking':
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
case 'tool_use':
if (message.toolName === 'AskUserQuestion') {
return (
<AskUserQuestion
sessionId={sessionId}
toolUseId={message.toolUseId}
input={message.input}
result={toolResult?.content}
/>
)
}
return (
<ToolCallBlock
toolName={message.toolName}
input={message.input}
result={toolResult}
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 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 (
<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>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 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
{message.content}
</div>
)
}
})