Files
openchamber/packages/ui/src/components/chat/MessageList.tsx
T

1655 lines
66 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
2025-12-07 19:32:53 +02:00
import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
2025-12-07 19:32:53 +02:00
import { PermissionCard } from './PermissionCard';
2026-01-09 21:38:22 +02:00
import { QuestionCard } from './QuestionCard';
import TurnItem from './components/TurnItem';
import TurnList from './components/TurnList';
2026-01-06 21:31:04 +02:00
import type { PermissionRequest } from '@/types/permission';
2026-01-09 21:38:22 +02:00
import type { QuestionRequest } from '@/types/question';
2025-12-07 19:32:53 +02:00
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
import { useUIStore } from '@/stores/useUIStore';
import { useStreamingStore } from '@/sync/streaming';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionStatus } from '@/sync/sync-context';
import { useDeviceInfo } from '@/lib/device';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { StatusRowContainer } from './StatusRowContainer';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
const handlerRef = React.useRef(handler);
React.useEffect(() => {
handlerRef.current = handler;
}, [handler]);
return React.useCallback((...args: TArgs) => handlerRef.current(...args), []);
};
const USER_SHELL_MARKER = 'The following tool was executed by the user';
const resolveMessageRole = (message: ChatMessageEntry): string | null => {
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
return (typeof info.clientRole === 'string' ? info.clientRole : null)
?? (typeof info.role === 'string' ? info.role : null)
?? null;
};
const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => {
const info = message.info as { time?: { completed?: unknown }; status?: unknown };
const completed = info.time?.completed;
const status = info.status;
if (typeof completed !== 'number' || completed <= 0) {
return false;
}
if (typeof status === 'string') {
return status === 'completed';
}
return true;
};
const isUserSubtaskMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
return message.parts.some((part) => part?.type === 'subtask');
};
const getMessageId = (message: ChatMessageEntry | undefined): string | null => {
if (!message) return null;
const id = (message.info as unknown as { id?: unknown }).id;
return typeof id === 'string' && id.trim().length > 0 ? id : null;
};
const getMessageParentId = (message: ChatMessageEntry): string | null => {
const parentID = (message.info as unknown as { parentID?: unknown }).parentID;
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
};
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
return message.parts.some((part) => {
if (part?.type !== 'text') return false;
const text = (part as unknown as { text?: unknown }).text;
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER);
});
};
type ShellBridgeDetails = {
command?: string;
output?: string;
status?: string;
};
const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParentId: string | null): { hide: boolean; details: ShellBridgeDetails | null } => {
if (resolveMessageRole(message) !== 'assistant') {
return { hide: false, details: null };
}
if (expectedParentId && getMessageParentId(message) !== expectedParentId) {
return { hide: false, details: null };
}
if (message.parts.length !== 1) {
return { hide: false, details: null };
}
const part = message.parts[0] as unknown as {
type?: unknown;
tool?: unknown;
state?: {
status?: unknown;
input?: { command?: unknown };
output?: unknown;
metadata?: { output?: unknown };
};
};
if (part.type !== 'tool') {
return { hide: false, details: null };
}
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
if (toolName !== 'bash') {
return { hide: false, details: null };
}
const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined;
const output =
(typeof part.state?.output === 'string' ? part.state.output : undefined)
?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined);
const status = typeof part.state?.status === 'string' ? part.state.status : undefined;
return {
hide: true,
details: {
command,
output,
status,
},
};
};
const readTaskSessionId = (toolPart: Part): string | null => {
const partRecord = toolPart as unknown as {
state?: {
metadata?: {
sessionId?: unknown;
sessionID?: unknown;
};
output?: unknown;
};
};
const metadata = partRecord.state?.metadata;
const fromMetadata =
(typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0
? metadata.sessionID.trim()
: null)
?? (typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0
? metadata.sessionId.trim()
: null);
if (fromMetadata) return fromMetadata;
const output = partRecord.state?.output;
if (typeof output === 'string') {
const match = output.match(/task_id\s*:\s*([^\s<"']+)/i);
if (match?.[1]) {
return match[1];
}
}
return null;
};
const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: boolean; taskSessionId: string | null } => {
if (resolveMessageRole(message) !== 'assistant') {
return { hide: false, taskSessionId: null };
}
if (message.parts.length !== 1) {
return { hide: false, taskSessionId: null };
}
const onlyPart = message.parts[0] as unknown as {
type?: unknown;
tool?: unknown;
};
if (onlyPart.type !== 'tool') {
return { hide: false, taskSessionId: null };
}
const toolName = typeof onlyPart.tool === 'string' ? onlyPart.tool.toLowerCase() : '';
if (toolName !== 'task') {
return { hide: false, taskSessionId: null };
}
return {
hide: true,
taskSessionId: readTaskSessionId(message.parts[0]),
};
};
const withSubtaskSessionId = (message: ChatMessageEntry, taskSessionId: string | null): ChatMessageEntry => {
if (!taskSessionId) return message;
const nextParts = message.parts.map((part) => {
if (part?.type !== 'subtask') return part;
const existing = (part as unknown as { taskSessionID?: unknown }).taskSessionID;
if (typeof existing === 'string' && existing.trim().length > 0) return part;
return {
...part,
taskSessionID: taskSessionId,
} as Part;
});
return {
...message,
parts: nextParts,
};
};
const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeDetails | null): ChatMessageEntry => {
const command = typeof details?.command === 'string' ? details.command.trim() : '';
const output = typeof details?.output === 'string' ? details.output : '';
const status = typeof details?.status === 'string' ? details.status.trim() : '';
const nextParts: Part[] = [];
let injected = false;
for (const part of message.parts) {
if (!injected && part?.type === 'text') {
const text = (part as unknown as { text?: unknown }).text;
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
if (synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER)) {
nextParts.push({
type: 'text',
text: '/shell',
shellAction: {
...(command ? { command } : {}),
...(output ? { output } : {}),
...(status ? { status } : {}),
},
} as unknown as Part);
injected = true;
continue;
}
}
nextParts.push(part);
}
if (!injected) {
nextParts.push({
type: 'text',
text: '/shell',
shellAction: {
...(command ? { command } : {}),
...(output ? { output } : {}),
...(status ? { status } : {}),
},
} as unknown as Part);
}
return {
...message,
parts: nextParts,
};
};
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
const cached = normalizedMessageBySource.get(message);
if (cached) {
return cached;
}
const filteredParts = filterSyntheticParts(message.parts);
const normalized = filteredParts === message.parts
? message
: {
...message,
parts: filteredParts,
};
normalizedMessageBySource.set(message, normalized);
return normalized;
};
const isAssistantTextOnlyMessage = (message: ChatMessageEntry): boolean => {
if (resolveMessageRole(message) !== 'assistant') {
return false;
}
return message.parts.length > 0 && message.parts.every((part) => part?.type === 'text');
};
2025-12-07 19:32:53 +02:00
interface MessageListProps {
sessionKey: string;
turnStart: number;
disableStaging?: boolean;
messages: ChatMessageEntry[];
2026-01-06 21:31:04 +02:00
permissions: PermissionRequest[];
2026-01-09 21:38:22 +02:00
questions: QuestionRequest[];
2025-12-07 19:32:53 +02:00
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
hasMoreAbove: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
2026-01-20 03:23:39 +02:00
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
}
export interface MessageListHandle {
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior }) => boolean;
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
}
type RenderEntry =
| {
kind: 'ungrouped';
key: string;
message: ChatMessageEntry;
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type TurnUiState = { isExpanded: boolean };
interface MessageRowProps {
message: ChatMessageEntry;
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
isInActiveTurn?: boolean;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
onContentChange: (reason?: ContentChangeReason) => void;
animationHandlers: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
2025-12-07 19:32:53 +02:00
}
const MessageRow = React.memo<MessageRowProps>(({
message,
previousMessage,
nextMessage,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn,
animateUserOnMount,
onUserAnimationConsumed,
onContentChange,
animationHandlers,
scrollToBottom,
}) => {
return (
<ChatMessage
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
animateUserOnMount={animateUserOnMount}
onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onContentChange}
animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext}
assistantHeaderMessageId={assistantHeaderMessageId}
isInActiveTurn={isInActiveTurn}
/>
);
}, (prev, next) => {
const prevTurn = prev.turnGroupingContext;
const nextTurn = next.turnGroupingContext;
return areRenderRelevantMessagesEqual(prev.message, next.message)
&& areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage)
&& areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage)
&& prev.animateUserOnMount === next.animateUserOnMount
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
&& prev.onContentChange === next.onContentChange
&& prev.scrollToBottom === next.scrollToBottom
&& prevTurn?.turnId === nextTurn?.turnId
&& prevTurn?.isFirstAssistantInTurn === nextTurn?.isFirstAssistantInTurn
&& prevTurn?.isLastAssistantInTurn === nextTurn?.isLastAssistantInTurn
&& prevTurn?.activityOwnerMessageId === nextTurn?.activityOwnerMessageId
&& prevTurn?.isWorking === nextTurn?.isWorking
&& prevTurn?.isGroupExpanded === nextTurn?.isGroupExpanded
&& prevTurn?.toggleGroup === nextTurn?.toggleGroup
&& prevTurn?.activityGroupSegments === nextTurn?.activityGroupSegments
&& prevTurn?.activityParts === nextTurn?.activityParts
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk
&& prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete
&& prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate
&& prev.animationHandlers?.onAnimationStart === next.animationHandlers?.onAnimationStart
&& prev.animationHandlers?.onReservationCancelled === next.animationHandlers?.onReservationCancelled
&& prev.animationHandlers?.onReasoningBlock === next.animationHandlers?.onReasoningBlock
&& prev.animationHandlers?.onAnimatedHeightChange === next.animationHandlers?.onAnimatedHeightChange;
});
MessageRow.displayName = 'MessageRow';
interface TurnBlockProps {
turn: TurnRecord;
isLastTurn: boolean;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
stickyUserHeader?: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
}
const TurnBlock: React.FC<TurnBlockProps> = ({
turn,
isLastTurn,
sessionIsWorking,
defaultActivityExpanded,
turnUiStates,
onToggleTurnGroup,
chatRenderMode,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
stickyUserHeader = true,
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
}) => {
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
const handleToggleTurnGroup = React.useCallback(() => {
onToggleTurnGroup(turn.turnId);
}, [onToggleTurnGroup, turn.turnId]);
const messageOrder = React.useMemo(() => {
const ordered = [turn.userMessage, ...turn.assistantMessages];
const lookup = new Map<string, number>();
ordered.forEach((message, index) => {
lookup.set(message.info.id, index);
});
return { ordered, lookup };
}, [turn.assistantMessages, turn.userMessage]);
const streamingAssistantMessageId = React.useMemo(() => {
if (activeStreamingMessageId && turn.assistantMessages.some((assistant) => assistant.info.id === activeStreamingMessageId)) {
return activeStreamingMessageId;
}
for (let index = turn.assistantMessages.length - 1; index >= 0; index -= 1) {
const assistant = turn.assistantMessages[index];
if (!isAssistantMessageCompleted(assistant)) {
return assistant.info.id;
}
}
return null;
}, [activeStreamingMessageId, turn.assistantMessages]);
const visibleAssistantMessages = React.useMemo(() => {
if (chatRenderMode === 'live') {
return turn.assistantMessages;
}
const completed = turn.assistantMessages.filter(isAssistantMessageCompleted);
if (completed.length === turn.assistantMessages.length) {
return turn.assistantMessages;
}
if (streamingAssistantMessageId) {
const completedIds = new Set(completed.map((assistant) => assistant.info.id));
return turn.assistantMessages.filter((assistant) => (
completedIds.has(assistant.info.id)
|| assistant.info.id === streamingAssistantMessageId
));
}
if (completed.length > 0) {
return completed;
}
const firstAssistant = turn.assistantMessages[0];
return firstAssistant ? [firstAssistant] : [];
}, [chatRenderMode, streamingAssistantMessageId, turn.assistantMessages]);
const completedAssistantMessages = React.useMemo(() => {
if (chatRenderMode !== 'sorted') {
return turn.assistantMessages;
}
return turn.assistantMessages.filter(isAssistantMessageCompleted);
}, [chatRenderMode, turn.assistantMessages]);
const visibleAssistantIds = React.useMemo(() => {
const ids = new Map<string, number>();
visibleAssistantMessages.forEach((assistant, index) => {
ids.set(assistant.info.id, index);
});
return ids;
}, [visibleAssistantMessages]);
const completedAssistantIdSet = React.useMemo(() => {
return new Set(completedAssistantMessages.map((assistant) => assistant.info.id));
}, [completedAssistantMessages]);
const visibleActivityMessageIdSet = React.useMemo(() => {
const ids = new Set(completedAssistantIdSet);
if (streamingAssistantMessageId) {
ids.add(streamingAssistantMessageId);
}
return ids;
}, [completedAssistantIdSet, streamingAssistantMessageId]);
const turnIsInActiveStream = React.useMemo(() => {
return turnContainsMessageId(turn, streamingAssistantMessageId);
}, [turn, streamingAssistantMessageId]);
const activityOwnerMessageId = React.useMemo(() => {
if (turnIsInActiveStream && streamingAssistantMessageId) {
return streamingAssistantMessageId;
}
return visibleAssistantMessages[0]?.info.id;
}, [streamingAssistantMessageId, turnIsInActiveStream, visibleAssistantMessages]);
const visibleActivityParts = React.useMemo(() => {
if (chatRenderMode !== 'sorted') {
return turn.activityParts;
}
if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) {
return turn.activityParts;
}
return turn.activityParts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId));
}, [chatRenderMode, visibleActivityMessageIdSet, turn.activityParts, turn.assistantMessages.length]);
const visibleActivitySegments = React.useMemo(() => {
if (chatRenderMode !== 'sorted') {
return turn.activitySegments;
}
if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) {
return turn.activitySegments;
}
return turn.activitySegments
.map((segment) => {
const parts = segment.parts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId));
if (parts.length === 0) {
return null;
}
const anchorMessageId = visibleActivityMessageIdSet.has(segment.anchorMessageId)
? segment.anchorMessageId
: parts[0]?.messageId;
if (!anchorMessageId) {
return null;
}
return {
...segment,
anchorMessageId,
parts,
};
})
.filter((segment): segment is NonNullable<typeof segment> => segment !== null);
}, [chatRenderMode, visibleActivityMessageIdSet, turn.activitySegments, turn.assistantMessages.length]);
const turnGroupingContextBase = React.useMemo(() => {
const userCreatedAt = (turn.userMessage.info.time as { created?: number } | undefined)?.created;
const rawVariant = (turn.userMessage.info as { variant?: unknown } | undefined)?.variant;
const userMessageVariant = typeof rawVariant === 'string' && rawVariant.trim().length > 0
? rawVariant
: undefined;
return {
turnId: turn.turnId,
summaryBody: turn.summaryText,
activityParts: visibleActivityParts,
activityGroupSegments: visibleActivitySegments,
headerMessageId: turn.headerMessageId,
hasTools: turn.hasTools,
hasReasoning: turn.hasReasoning,
diffStats: turn.diffStats,
userMessageCreatedAt: typeof userCreatedAt === 'number' ? userCreatedAt : undefined,
userMessageVariant,
};
}, [turn.diffStats, turn.hasReasoning, turn.hasTools, turn.headerMessageId, turn.summaryText, turn.turnId, turn.userMessage.info, visibleActivityParts, visibleActivitySegments]);
const renderMessage = React.useCallback(
(message: ChatMessageEntry) => {
const messageRole = resolveMessageRole(message);
const isUserMessage = messageRole === 'user';
const messageIndex = messageOrder.lookup.get(message.info.id);
const assistantIndex = visibleAssistantIds.get(message.info.id) ?? -1;
const isAssistantMessage = assistantIndex >= 0;
const isFirstAssistant = assistantIndex === 0;
const isLastAssistant = assistantIndex === visibleAssistantMessages.length - 1;
const isActivityOwner = Boolean(activityOwnerMessageId) && message.info.id === activityOwnerMessageId;
const shouldAttachFullTurnContext = chatRenderMode === 'sorted'
? isAssistantMessage
: (isActivityOwner || isFirstAssistant || isLastAssistant);
const assistantHeaderMessageId = visibleAssistantMessages[0]?.info.id ?? turn.headerMessageId;
const previousMessage = isUserMessage
? undefined
: (isAssistantMessage
? (isFirstAssistant
? turn.userMessage
: undefined)
: (typeof messageIndex === 'number' && messageIndex > 0
? messageOrder.ordered[messageIndex - 1]
: undefined));
const nextMessage = undefined;
const turnGroupingContext = isAssistantMessage
? {
turnId: turn.turnId,
activityOwnerMessageId,
isFirstAssistantInTurn: isFirstAssistant,
isLastAssistantInTurn: isLastAssistant,
isWorking: isLastTurn && sessionIsWorking && message.info.id === streamingAssistantMessageId,
hasTools: turn.hasTools,
hasReasoning: turn.hasReasoning,
...(shouldAttachFullTurnContext ? {
summaryBody: turnGroupingContextBase.summaryBody,
activityParts: turnGroupingContextBase.activityParts,
activityGroupSegments: turnGroupingContextBase.activityGroupSegments,
headerMessageId: turnGroupingContextBase.headerMessageId,
diffStats: turnGroupingContextBase.diffStats,
userMessageCreatedAt: turnGroupingContextBase.userMessageCreatedAt,
userMessageVariant: turnGroupingContextBase.userMessageVariant,
isGroupExpanded: turnUiState.isExpanded,
toggleGroup: handleToggleTurnGroup,
} : {}),
} satisfies TurnGroupingContext
: undefined;
return (
<MessageRow
key={message.info.id}
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
turnGroupingContext={turnGroupingContext}
assistantHeaderMessageId={assistantHeaderMessageId}
isInActiveTurn={Boolean(streamingAssistantMessageId) && message.info.id === streamingAssistantMessageId}
animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
},
[
getAnimationHandlers,
isLastTurn,
messageOrder.lookup,
messageOrder.ordered,
onMessageContentChange,
scrollToBottom,
sessionIsWorking,
chatRenderMode,
turn.headerMessageId,
turn.hasReasoning,
turn.hasTools,
turn.turnId,
turn.userMessage,
turnUiState.isExpanded,
turnGroupingContextBase,
streamingAssistantMessageId,
visibleAssistantMessages,
visibleAssistantIds,
activityOwnerMessageId,
shouldAnimateUserMessage,
onUserAnimationConsumed,
handleToggleTurnGroup,
]
);
const renderableTurn = React.useMemo(() => {
if (visibleAssistantMessages === turn.assistantMessages) {
return turn;
}
return {
...turn,
assistantMessages: visibleAssistantMessages,
};
}, [turn, visibleAssistantMessages]);
return (
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
);
};
TurnBlock.displayName = 'TurnBlock';
interface UngroupedMessageRowProps {
message: ChatMessageEntry;
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
}
const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = React.memo(({
message,
previousMessage,
nextMessage,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
}) => {
return (
<MessageRow
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
animateUserOnMount={shouldAnimateUserMessage(message)}
onUserAnimationConsumed={onUserAnimationConsumed}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId}
/>
);
}, (prev, next) => {
return areRenderRelevantMessagesEqual(prev.message, next.message)
&& areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage)
&& areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage)
&& prev.onMessageContentChange === next.onMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.scrollToBottom === next.scrollToBottom
&& prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
&& prev.activeStreamingMessageId === next.activeStreamingMessageId;
});
UngroupedMessageRow.displayName = 'UngroupedMessageRow';
interface MessageListEntryProps {
entry: RenderEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
stickyUserHeader?: boolean;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
}
const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | undefined): boolean => {
if (!messageId) {
return false;
}
if (turn.userMessage.info.id === messageId) {
return true;
}
return turn.assistantMessages.some((assistant) => assistant.info.id === messageId);
};
const MessageListEntry: React.FC<MessageListEntryProps> = React.memo(({
entry,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
stickyUserHeader,
sessionIsWorking,
defaultActivityExpanded,
turnUiStates,
onToggleTurnGroup,
chatRenderMode,
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
}) => {
if (entry.kind === 'ungrouped') {
return (
<UngroupedMessageRow
message={entry.message}
previousMessage={entry.previousMessage}
nextMessage={entry.nextMessage}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
/>
);
}
return (
<TurnBlock
turn={entry.turn}
isLastTurn={entry.isLastTurn}
sessionIsWorking={sessionIsWorking}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
onToggleTurnGroup={onToggleTurnGroup}
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader}
/>
);
}, areMessageListEntryPropsEqual);
MessageListEntry.displayName = 'MessageListEntry';
function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextProps: MessageListEntryProps): boolean {
if (prevProps.stickyUserHeader !== nextProps.stickyUserHeader) return false;
if (prevProps.chatRenderMode !== nextProps.chatRenderMode) return false;
if (prevProps.shouldAnimateUserMessage !== nextProps.shouldAnimateUserMessage) return false;
const prevEntry = prevProps.entry;
const nextEntry = nextProps.entry;
if (prevEntry.kind !== nextEntry.kind) return false;
if (prevEntry.key !== nextEntry.key) return false;
if (prevEntry.kind === 'turn' && nextEntry.kind === 'turn') {
if (prevEntry.turn !== nextEntry.turn || prevEntry.isLastTurn !== nextEntry.isLastTurn) {
return false;
}
const prevExpanded = (prevProps.turnUiStates.get(prevEntry.turn.turnId) ?? { isExpanded: prevProps.defaultActivityExpanded }).isExpanded;
const nextExpanded = (nextProps.turnUiStates.get(nextEntry.turn.turnId) ?? { isExpanded: nextProps.defaultActivityExpanded }).isExpanded;
if (prevExpanded !== nextExpanded) {
return false;
}
if (prevEntry.isLastTurn && prevProps.sessionIsWorking !== nextProps.sessionIsWorking) {
return false;
}
if (prevProps.activeStreamingMessageId !== nextProps.activeStreamingMessageId) {
const prevAffected = turnContainsMessageId(prevEntry.turn, prevProps.activeStreamingMessageId);
const nextAffected = turnContainsMessageId(nextEntry.turn, nextProps.activeStreamingMessageId);
if (prevAffected || nextAffected) {
return false;
}
}
return true;
}
if (prevEntry.kind === 'ungrouped' && nextEntry.kind === 'ungrouped') {
if (prevProps.activeStreamingMessageId !== nextProps.activeStreamingMessageId) {
const messageId = prevEntry.message.info.id;
const prevActive = prevProps.activeStreamingMessageId === messageId;
const nextActive = nextProps.activeStreamingMessageId === messageId;
if (prevActive !== nextActive) {
return false;
}
}
return (
areRenderRelevantMessagesEqual(prevEntry.message, nextEntry.message)
&& areOptionalRenderRelevantMessagesEqual(prevEntry.previousMessage, nextEntry.previousMessage)
&& areOptionalRenderRelevantMessagesEqual(prevEntry.nextMessage, nextEntry.nextMessage)
);
}
return false;
}
// Inner component that renders staged turn entries.
const MessageListContent: React.FC<{
entries: RenderEntry[];
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
stickyUserHeader: boolean;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId }) => {
const renderEntry = React.useCallback((entry: RenderEntry) => {
return (
<MessageListEntry
key={entry.key}
entry={entry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader}
sessionIsWorking={sessionIsWorking}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
onToggleTurnGroup={onToggleTurnGroup}
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
/>
);
}, [activeStreamingMessageId, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
return (
<TurnList entries={entries} renderEntry={renderEntry} />
);
};
const StreamingTailContent: React.FC<{
entry: RenderEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
stickyUserHeader: boolean;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
}> = React.memo(({
entry,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
stickyUserHeader,
sessionIsWorking,
defaultActivityExpanded,
turnUiStates,
onToggleTurnGroup,
chatRenderMode,
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
}) => {
return (
<MessageListEntry
entry={entry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader}
sessionIsWorking={sessionIsWorking}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
onToggleTurnGroup={onToggleTurnGroup}
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
/>
);
}, (prev, next) => {
return prev.entry === next.entry
&& prev.onMessageContentChange === next.onMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.scrollToBottom === next.scrollToBottom
&& prev.stickyUserHeader === next.stickyUserHeader
&& prev.sessionIsWorking === next.sessionIsWorking
&& prev.defaultActivityExpanded === next.defaultActivityExpanded
&& prev.turnUiStates === next.turnUiStates
&& prev.onToggleTurnGroup === next.onToggleTurnGroup
&& prev.chatRenderMode === next.chatRenderMode
&& prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
&& prev.activeStreamingMessageId === next.activeStreamingMessageId;
});
StreamingTailContent.displayName = 'StreamingTailContent';
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
sessionKey,
turnStart,
disableStaging: _disableStaging,
2025-12-07 19:32:53 +02:00
messages,
permissions,
2026-01-09 21:38:22 +02:00
questions,
2025-12-07 19:32:53 +02:00
onMessageContentChange,
getAnimationHandlers,
hasMoreAbove,
isLoadingOlder,
onLoadOlder,
scrollToBottom,
scrollRef,
}, ref) => {
streamPerfCount('ui.message_list.render');
void _disableStaging;
const { isMobile } = useDeviceInfo();
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const activeStreamingMessageId = useStreamingStore((state) => state.streamingMessageIds.get(sessionKey) ?? null);
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
const defaultActivityExpanded = activityRenderMode === 'summary';
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map());
const userAnimationRef = React.useRef<{
sessionKey: string | undefined;
previousOrder: string[];
animatedIds: Set<string>;
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
const baseDisplayCacheRef = React.useRef<{
input: ChatMessageEntry[];
output: ChatMessageEntry[];
outputIndexById: Map<string, number>;
} | null>(null);
const staticRenderEntriesCacheRef = React.useRef<{
input: ChatMessageEntry[];
output: RenderEntry[];
staticTurns: TurnRecord[];
lastTurnId: string | null;
ungroupedMessageIds: Set<string>;
} | null>(null);
const stableOnMessageContentChange = useStableEvent(onMessageContentChange);
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
const stableOnLoadOlder = useStableEvent(onLoadOlder);
const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => {
scrollToBottom?.(options);
});
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
2026-01-09 21:38:22 +02:00
if (permissions.length === 0 && questions.length === 0) {
2025-12-07 19:32:53 +02:00
return;
}
stableOnMessageContentChange('permission');
}, [permissions, questions, stableOnMessageContentChange]);
React.useEffect(() => {
setTurnUiStates(new Map());
}, [activityRenderMode]);
const toggleTurnGroup = React.useCallback((turnId: string) => {
setTurnUiStates((previous) => {
const next = new Map(previous);
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
next.set(turnId, { isExpanded: !current.isExpanded });
return next;
});
}, [defaultActivityExpanded]);
2025-12-07 19:32:53 +02:00
const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => {
const cached = baseDisplayCacheRef.current;
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage));
if (cached && canUseTailFastPath && cached.input.length === messages.length && messages.length > 0) {
let changedCount = 0;
let changedIndex = -1;
let idsStable = true;
for (let index = 0; index < messages.length; index += 1) {
if (messages[index]?.info?.id !== cached.input[index]?.info?.id) {
idsStable = false;
break;
}
if (messages[index] !== cached.input[index]) {
changedCount += 1;
changedIndex = index;
if (changedCount > 1) {
break;
}
}
}
if (idsStable && changedCount === 1 && changedIndex === messages.length - 1) {
const changedMessage = messages[changedIndex];
const previousMessage = changedIndex > 0 ? messages[changedIndex - 1] : undefined;
const bridgeSensitive = isUserSubtaskMessage(previousMessage) || isUserShellMarkerMessage(previousMessage);
if (changedMessage && isAssistantTextOnlyMessage(changedMessage) && !bridgeSensitive) {
const outputIndex = cached.outputIndexById.get(changedMessage.info.id);
if (outputIndex !== undefined) {
const nextOutput = [...cached.output];
nextOutput[outputIndex] = getNormalizedMessageForDisplay(changedMessage);
baseDisplayCacheRef.current = {
input: messages,
output: nextOutput,
outputIndexById: cached.outputIndexById,
};
return nextOutput;
}
}
}
}
const seenIdsFromTail = new Set<string>();
const dedupedMessages: ChatMessageEntry[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const messageId = message.info?.id;
if (typeof messageId === 'string') {
if (seenIdsFromTail.has(messageId)) {
continue;
}
seenIdsFromTail.add(messageId);
}
dedupedMessages.push(getNormalizedMessageForDisplay(message));
}
dedupedMessages.reverse();
const output: ChatMessageEntry[] = [];
for (let index = 0; index < dedupedMessages.length; index += 1) {
const current = dedupedMessages[index];
const previous = output.length > 0 ? output[output.length - 1] : undefined;
if (isUserSubtaskMessage(previous)) {
const bridge = isSyntheticSubtaskBridgeAssistant(current);
if (bridge.hide) {
output[output.length - 1] = withSubtaskSessionId(previous as ChatMessageEntry, bridge.taskSessionId);
continue;
}
}
if (isUserShellMarkerMessage(previous)) {
const bridge = getShellBridgeAssistantDetails(current, getMessageId(previous));
if (bridge.hide) {
output[output.length - 1] = withShellBridgeDetails(previous as ChatMessageEntry, bridge.details);
continue;
}
}
output.push(current);
}
const outputIndexById = new Map<string, number>();
output.forEach((message, index) => {
const id = message.info?.id;
if (typeof id === 'string' && id.length > 0) {
outputIndexById.set(id, index);
}
});
baseDisplayCacheRef.current = {
input: messages,
output,
outputIndexById,
};
return output;
}), [messages]);
const currentSessionIdForRetry = useSessionUIStore((s) => s.currentSessionId);
const retryStatusRaw = useSessionStatus(currentSessionIdForRetry ?? '');
const activeRetryStatus = React.useMemo(() => {
if (!currentSessionIdForRetry) return null;
const status = retryStatusRaw;
if (!status || status.type !== 'retry') return null;
const rawMessage = typeof (status as { message?: string }).message === 'string' ? ((status as { message?: string }).message ?? '').trim() : '';
return {
sessionId: currentSessionIdForRetry,
message: rawMessage || 'Quota limit reached. Retrying automatically.',
confirmedAt: (status as { confirmedAt?: number }).confirmedAt,
};
}, [currentSessionIdForRetry, retryStatusRaw]);
const activeRetrySessionId = activeRetryStatus?.sessionId ?? null;
const activeRetryMessage = activeRetryStatus?.message
?? 'Quota limit reached. Retrying automatically.';
const activeRetryConfirmedAt = activeRetryStatus?.confirmedAt;
const [fallbackRetryTimestamp, setFallbackRetryTimestamp] = React.useState<number>(0);
const fallbackRetrySessionRef = React.useRef<string | null>(null);
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
if (scrollRef?.current) {
return scrollRef.current;
}
if (typeof document === 'undefined') {
return null;
}
return document.querySelector<HTMLDivElement>('[data-scrollbar="chat"]');
}, [scrollRef]);
React.useEffect(() => {
if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') {
fallbackRetrySessionRef.current = null;
setFallbackRetryTimestamp(0);
return;
}
if (fallbackRetrySessionRef.current !== activeRetryStatus.sessionId) {
fallbackRetrySessionRef.current = activeRetryStatus.sessionId;
setFallbackRetryTimestamp(Date.now());
}
}, [activeRetryStatus, activeRetryStatus?.sessionId, activeRetryStatus?.confirmedAt]);
const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => {
return applyRetryOverlay(baseDisplayMessages, {
sessionId: activeRetrySessionId,
message: activeRetryMessage,
confirmedAt: activeRetryConfirmedAt,
fallbackTimestamp: fallbackRetryTimestamp,
});
}), [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]);
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
sessionKey,
showTextJustificationActivity: chatRenderMode === 'sorted',
});
const staticRenderEntries = React.useMemo<RenderEntry[]>(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => {
const cached = staticRenderEntriesCacheRef.current;
const lastMessage = displayMessages.length > 0 ? displayMessages[displayMessages.length - 1] : undefined;
const hasTrailingCandidate = Boolean(lastMessage) && (
(streamingTurn
? (streamingTurn.userMessage.info.id === lastMessage?.info.id
|| streamingTurn.assistantMessages.some((assistant) => assistant.info.id === lastMessage?.info.id))
: false)
|| (lastMessage ? projection.ungroupedMessageIds.has(lastMessage.info.id) : false)
);
if (
cached
&& hasTrailingCandidate
&& cached.input.length === displayMessages.length
&& cached.staticTurns === staticTurns
&& cached.lastTurnId === projection.lastTurnId
&& cached.ungroupedMessageIds === projection.ungroupedMessageIds
&& displayMessages.length > 0
) {
let changedCount = 0;
let changedIndex = -1;
let idsStable = true;
for (let index = 0; index < displayMessages.length; index += 1) {
if (displayMessages[index]?.info?.id !== cached.input[index]?.info?.id) {
idsStable = false;
break;
}
if (displayMessages[index] !== cached.input[index]) {
changedCount += 1;
changedIndex = index;
if (changedCount > 1) {
break;
}
}
}
if (idsStable && changedCount === 1 && changedIndex === displayMessages.length - 1) {
return cached.output;
}
}
const turnEntries = staticTurns.map((turn) => ({
kind: 'turn' as const,
key: `turn:${turn.turnId}`,
turn,
isLastTurn: turn.turnId === projection.lastTurnId,
}));
if (projection.ungroupedMessageIds.size === 0) {
return turnEntries;
}
const turnEntryByUserMessageId = new Map<string, RenderEntry>();
turnEntries.forEach((entry) => {
turnEntryByUserMessageId.set(entry.turn.userMessage.info.id, entry);
});
const orderedEntries: RenderEntry[] = [];
displayMessages.forEach((message, index) => {
const turnEntry = turnEntryByUserMessageId.get(message.info.id);
if (turnEntry) {
orderedEntries.push(turnEntry);
return;
}
if (!projection.ungroupedMessageIds.has(message.info.id)) {
return;
}
orderedEntries.push({
kind: 'ungrouped',
key: `msg:${message.info.id}`,
message,
previousMessage: index > 0 ? displayMessages[index - 1] : undefined,
nextMessage: index < displayMessages.length - 1 ? displayMessages[index + 1] : undefined,
});
});
staticRenderEntriesCacheRef.current = {
input: displayMessages,
output: orderedEntries,
staticTurns,
lastTurnId: projection.lastTurnId,
ungroupedMessageIds: projection.ungroupedMessageIds,
};
return orderedEntries;
}), [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, staticTurns, streamingTurn]);
const trailingStreamingEntry = React.useMemo<RenderEntry | undefined>(() => {
if (streamingTurn) {
return {
kind: 'turn',
key: `turn:${streamingTurn.turnId}`,
turn: streamingTurn,
isLastTurn: streamingTurn.turnId === projection.lastTurnId,
} satisfies RenderEntry;
}
if (projection.ungroupedMessageIds.size === 0) {
return undefined;
}
const lastMessage = displayMessages[displayMessages.length - 1];
if (!lastMessage || !projection.ungroupedMessageIds.has(lastMessage.info.id)) {
return undefined;
}
return {
kind: 'ungrouped',
key: `msg:${lastMessage.info.id}`,
message: lastMessage,
previousMessage: displayMessages.length > 1 ? displayMessages[displayMessages.length - 2] : undefined,
nextMessage: undefined,
} satisfies RenderEntry;
}, [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, streamingTurn]);
if (trailingStreamingEntry) {
streamPerfCount('ui.message_list.render.streaming');
}
const historyEntries = staticRenderEntries;
const allEntries = React.useMemo(() => {
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
}, [historyEntries, trailingStreamingEntry]);
const currentUserOrder = React.useMemo(() => {
return messages
.filter((message) => resolveMessageRole(message) === 'user')
.map((message) => message.info.id);
}, [messages]);
// Detect new user messages SYNCHRONOUSLY during render.
// Must happen during render (not in useEffect) so that ToolRevealOnMount
// receives animate=true on the FIRST render of the new message,
// starting it hidden (opacity 0). An effect-based approach causes
// the message to flash visible before the animation starts.
{
const anim = userAnimationRef.current;
// Reset on session switch
if (anim.sessionKey !== sessionKey) {
anim.sessionKey = sessionKey;
anim.previousOrder = currentUserOrder;
anim.animatedIds = new Set();
}
// Detect appended user messages
const prev = anim.previousOrder;
if (currentUserOrder.length > prev.length) {
const isAppendOnly = prev.every((id, i) => currentUserOrder[i] === id);
if (isAppendOnly && hasPendingUserSendAnimation(sessionKey)) {
for (let i = prev.length; i < currentUserOrder.length; i += 1) {
const id = currentUserOrder[i];
if (id && !anim.animatedIds.has(id)) {
if (!consumePendingUserSendAnimation(sessionKey)) break;
anim.animatedIds.add(id);
}
}
}
}
anim.previousOrder = currentUserOrder;
}
const shouldAnimateUserMessage = React.useCallback((message: ChatMessageEntry): boolean => {
if (resolveMessageRole(message) !== 'user') return false;
return userAnimationRef.current.animatedIds.has(message.info.id);
}, []);
const onUserAnimationConsumed = React.useCallback((messageId: string) => {
userAnimationRef.current.animatedIds.delete(messageId);
}, []);
const messageIndexMap = React.useMemo(() => {
const indexMap = new Map<string, number>();
allEntries.forEach((entry, index) => {
if (entry.kind === 'ungrouped') {
indexMap.set(entry.message.info.id, index);
return;
}
indexMap.set(entry.turn.userMessage.info.id, index);
entry.turn.assistantMessages.forEach((message) => {
indexMap.set(message.info.id, index);
});
});
return indexMap;
}, [allEntries]);
const turnIndexMap = React.useMemo(() => {
const indexMap = new Map<string, number>();
allEntries.forEach((entry, index) => {
if (entry.kind === 'turn') {
indexMap.set(entry.turn.turnId, index);
}
});
return indexMap;
}, [allEntries]);
const findMessageElement = React.useCallback((messageId: string): HTMLElement | null => {
const container = resolveScrollContainer();
if (!container) {
return null;
}
return container.querySelector(`[data-message-id="${messageId}"]`);
}, [resolveScrollContainer]);
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
const container = resolveScrollContainer();
if (!container) {
return false;
}
const messageElement = findMessageElement(messageId);
if (!messageElement) {
return false;
}
const containerRect = container.getBoundingClientRect();
const messageRect = messageElement.getBoundingClientRect();
const offset = 50;
const top = messageRect.top - containerRect.top + container.scrollTop - offset;
container.scrollTo({ top, behavior });
return true;
}, [findMessageElement, resolveScrollContainer]);
React.useEffect(() => {
if (!ref) {
return;
}
const handle: MessageListHandle = {
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior }) => {
const behavior = options?.behavior ?? 'auto';
const index = turnIndexMap.get(turnId);
if (index === undefined) {
return false;
}
const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length;
if (targetIsTail) {
return false;
}
const container = resolveScrollContainer();
if (!container) {
return false;
}
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
if (!turnElement) {
return false;
}
turnElement.scrollIntoView({ behavior, block: 'start' });
return true;
},
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
const behavior = options?.behavior ?? 'auto';
const index = messageIndexMap.get(messageId);
if (index === undefined) {
return false;
}
const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length;
if (targetIsTail) {
return false;
}
return scrollMessageElementIntoView(messageId, behavior);
},
captureViewportAnchor: () => {
const container = resolveScrollContainer();
if (!container) {
return null;
}
const containerRect = container.getBoundingClientRect();
const nodes: HTMLElement[] = Array.from(container.querySelectorAll<HTMLElement>('[data-message-id]'));
const firstVisible = nodes.find((node) => {
const rect = node.getBoundingClientRect();
if (rect.bottom <= containerRect.top + 1) {
return false;
}
if (typeof window === 'undefined') {
return true;
}
const computed = window.getComputedStyle(node);
const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1;
return !isStuckSticky;
}) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
if (!firstVisible) {
return null;
}
const messageId = firstVisible.dataset.messageId;
if (!messageId) {
return null;
}
return {
messageId,
offsetTop: firstVisible.getBoundingClientRect().top - containerRect.top,
};
},
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => {
const container = resolveScrollContainer();
if (!container) {
return false;
}
if (!messageIndexMap.has(anchor.messageId)) {
return false;
}
const applyAnchor = (): boolean => {
const element = findMessageElement(anchor.messageId);
if (!element) {
return false;
}
const containerRect = container.getBoundingClientRect();
const targetTop = element.getBoundingClientRect().top - containerRect.top;
const delta = targetTop - anchor.offsetTop;
if (delta !== 0) {
container.scrollTop += delta;
}
return true;
};
return applyAnchor();
},
};
if (typeof ref === 'function') {
ref(handle);
return () => {
ref(null);
};
}
const objectRef = ref;
objectRef.current = handle;
return () => {
objectRef.current = null;
};
}, [findMessageElement, historyEntries.length, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, trailingStreamingEntry, turnIndexMap, ref]);
const disableFadeIn = false;
2025-12-07 19:32:53 +02:00
return (
<div>
{(turnStart > 0 || hasMoreAbove) && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
Loading
</span>
) : (
<button
type="button"
onClick={stableOnLoadOlder}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Load older messages
</button>
)}
</div>
)}
2025-12-07 19:32:53 +02:00
<FadeInDisabledProvider disabled={disableFadeIn}>
<div className="relative w-full">
<MessageListContent
entries={historyEntries}
onMessageContentChange={stableOnMessageContentChange}
getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom}
stickyUserHeader={stickyUserHeader}
sessionIsWorking={sessionIsWorking}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
onToggleTurnGroup={toggleTurnGroup}
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
/>
{trailingStreamingEntry ? (
<StreamingTailContent
entry={trailingStreamingEntry}
onMessageContentChange={stableOnMessageContentChange}
getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom}
stickyUserHeader={stickyUserHeader}
sessionIsWorking={sessionIsWorking}
defaultActivityExpanded={defaultActivityExpanded}
turnUiStates={turnUiStates}
onToggleTurnGroup={toggleTurnGroup}
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
/>
) : null}
</div>
</FadeInDisabledProvider>
2025-12-07 19:32:53 +02:00
{(questions.length > 0 || permissions.length > 0) && (
<div>
{questions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{permissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
<div className="mb-3">
<StatusRowContainer />
</div>
{/* Bottom spacer */}
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</div>
2025-12-07 19:32:53 +02:00
);
});
MessageList.displayName = 'MessageList';
2025-12-07 19:32:53 +02:00
export default React.memo(MessageList);