2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-03-12 23:45:45 +02:00
|
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
2026-02-27 20:03:42 +02:00
|
|
|
import { flushSync } from 'react-dom';
|
|
|
|
|
import { elementScroll, observeElementOffset, observeElementRect, Virtualizer } from '@tanstack/react-virtual';
|
2026-02-16 14:15:19 +02:00
|
|
|
import { useShallow } from 'zustand/react/shallow';
|
2026-02-27 20:03:42 +02:00
|
|
|
import type { ReactVirtualizerOptions, VirtualItem } from '@tanstack/react-virtual';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
import ChatMessage from './ChatMessage';
|
|
|
|
|
import { PermissionCard } from './PermissionCard';
|
2026-01-09 21:38:22 +02:00
|
|
|
import { QuestionCard } from './QuestionCard';
|
2026-03-12 23:45:45 +02:00
|
|
|
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';
|
2026-03-12 23:45:45 +02:00
|
|
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
2025-12-27 02:37:29 +02:00
|
|
|
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
2026-03-12 23:45:45 +02:00
|
|
|
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
|
|
|
|
import { useTurnRecords } from './hooks/useTurnRecords';
|
|
|
|
|
import { useStageTurns } from './lib/turns/stageTurns';
|
|
|
|
|
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
2026-02-16 14:15:19 +02:00
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
2026-03-02 02:11:33 +02:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-02-24 23:38:12 +08:00
|
|
|
import { useDeviceInfo } from '@/lib/device';
|
2026-02-27 20:03:42 +02:00
|
|
|
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
2026-03-12 23:45:45 +02:00
|
|
|
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
|
|
|
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
|
|
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
|
|
|
import { StatusRow } from './StatusRow';
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
const MESSAGE_VIRTUALIZE_THRESHOLD = 40;
|
|
|
|
|
const MESSAGE_VIRTUAL_OVERSCAN_MOBILE = 2;
|
|
|
|
|
const MESSAGE_VIRTUAL_OVERSCAN_DESKTOP = 4;
|
2026-03-12 23:45:45 +02:00
|
|
|
const TURN_ESTIMATE_BASE_PX = 120;
|
|
|
|
|
const TURN_ESTIMATE_PER_ASSISTANT_PX = 120;
|
|
|
|
|
const TURN_ESTIMATE_MAX_PX = 1400;
|
|
|
|
|
|
|
|
|
|
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), []);
|
|
|
|
|
};
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
type MessageListVirtualizerOptions<TItemElement extends Element> = Omit<
|
|
|
|
|
ReactVirtualizerOptions<HTMLElement, TItemElement>,
|
|
|
|
|
'scrollToFn' | 'observeElementRect' | 'observeElementOffset'
|
|
|
|
|
>
|
|
|
|
|
|
|
|
|
|
const useMessageListVirtualizer = <TItemElement extends Element>(
|
|
|
|
|
options: MessageListVirtualizerOptions<TItemElement>,
|
|
|
|
|
): Virtualizer<HTMLElement, TItemElement> => {
|
|
|
|
|
const [, forceRender] = React.useReducer(() => ({}), {});
|
|
|
|
|
const { useFlushSync = true, onChange, ...baseOptions } = options;
|
|
|
|
|
|
|
|
|
|
const handleChange = React.useCallback((instance: Virtualizer<HTMLElement, TItemElement>, sync: boolean) => {
|
|
|
|
|
if (useFlushSync && sync) {
|
|
|
|
|
flushSync(forceRender);
|
|
|
|
|
} else {
|
|
|
|
|
forceRender();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onChange?.(instance, sync);
|
|
|
|
|
}, [onChange, useFlushSync]);
|
|
|
|
|
|
|
|
|
|
const [virtualizer] = React.useState(() => new Virtualizer<HTMLElement, TItemElement>({
|
|
|
|
|
...baseOptions,
|
|
|
|
|
onChange: handleChange,
|
|
|
|
|
observeElementRect,
|
|
|
|
|
observeElementOffset,
|
|
|
|
|
scrollToFn: elementScroll,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
virtualizer.setOptions({
|
|
|
|
|
...baseOptions,
|
|
|
|
|
onChange: handleChange,
|
|
|
|
|
observeElementRect,
|
|
|
|
|
observeElementOffset,
|
|
|
|
|
scrollToFn: elementScroll,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
React.useLayoutEffect(() => virtualizer._didMount(), [virtualizer]);
|
|
|
|
|
React.useLayoutEffect(() => virtualizer._willUpdate(), [virtualizer]);
|
|
|
|
|
|
|
|
|
|
return virtualizer;
|
|
|
|
|
};
|
2026-01-25 19:16:26 +02:00
|
|
|
|
2026-02-18 20:08:42 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 20:08:42 +02:00
|
|
|
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?: {
|
2026-03-12 23:45:45 +02:00
|
|
|
metadata?: {
|
|
|
|
|
sessionId?: unknown;
|
|
|
|
|
sessionID?: unknown;
|
|
|
|
|
};
|
2026-02-18 20:08:42 +02:00
|
|
|
output?: unknown;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
const metadata = partRecord.state?.metadata;
|
|
|
|
|
const fromMetadata =
|
2026-03-12 23:45:45 +02:00
|
|
|
(typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0
|
2026-02-18 20:08:42 +02:00
|
|
|
? metadata.sessionID.trim()
|
2026-03-12 23:45:45 +02:00
|
|
|
: null)
|
|
|
|
|
?? (typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0
|
|
|
|
|
? metadata.sessionId.trim()
|
2026-02-18 20:08:42 +02:00
|
|
|
: null);
|
|
|
|
|
if (fromMetadata) return fromMetadata;
|
|
|
|
|
|
|
|
|
|
const output = partRecord.state?.output;
|
|
|
|
|
if (typeof output === 'string') {
|
2026-03-12 23:45:45 +02:00
|
|
|
const match = output.match(/task_id\s*:\s*([^\s<"']+)/i);
|
2026-02-18 20:08:42 +02:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
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 {
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionKey: string;
|
|
|
|
|
turnStart: number;
|
|
|
|
|
disableStaging?: boolean;
|
2026-01-25 19:16:26 +02:00
|
|
|
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;
|
2026-01-25 19:16:26 +02:00
|
|
|
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
export interface MessageListHandle {
|
2026-03-12 23:45:45 +02:00
|
|
|
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior }) => boolean;
|
2026-02-27 20:03:42 +02:00
|
|
|
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
|
|
|
|
|
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
|
|
|
|
|
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RenderEntry =
|
2026-03-12 23:45:45 +02:00
|
|
|
| {
|
|
|
|
|
kind: 'ungrouped';
|
|
|
|
|
key: string;
|
|
|
|
|
message: ChatMessageEntry;
|
|
|
|
|
previousMessage?: ChatMessageEntry;
|
|
|
|
|
nextMessage?: ChatMessageEntry;
|
|
|
|
|
}
|
|
|
|
|
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
|
|
|
|
|
|
|
|
|
|
type TurnUiState = { isExpanded: boolean };
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
|
2026-01-25 19:16:26 +02:00
|
|
|
interface MessageRowProps {
|
|
|
|
|
message: ChatMessageEntry;
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage?: ChatMessageEntry;
|
|
|
|
|
nextMessage?: ChatMessageEntry;
|
|
|
|
|
turnGroupingContext?: TurnGroupingContext;
|
|
|
|
|
animateUserOnMount?: boolean;
|
|
|
|
|
onUserAnimationConsumed?: (messageId: string) => void;
|
2026-01-25 19:16:26 +02:00
|
|
|
onContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
animationHandlers: AnimationHandlers;
|
|
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const MessageRow = React.memo<MessageRowProps>(({
|
2026-01-25 19:16:26 +02:00
|
|
|
message,
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage,
|
|
|
|
|
nextMessage,
|
|
|
|
|
turnGroupingContext,
|
|
|
|
|
animateUserOnMount,
|
|
|
|
|
onUserAnimationConsumed,
|
2026-01-25 19:16:26 +02:00
|
|
|
onContentChange,
|
|
|
|
|
animationHandlers,
|
|
|
|
|
scrollToBottom,
|
|
|
|
|
}) => {
|
|
|
|
|
return (
|
|
|
|
|
<ChatMessage
|
|
|
|
|
message={message}
|
|
|
|
|
previousMessage={previousMessage}
|
|
|
|
|
nextMessage={nextMessage}
|
2026-03-12 23:45:45 +02:00
|
|
|
animateUserOnMount={animateUserOnMount}
|
|
|
|
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
2026-01-25 19:16:26 +02:00
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
animationHandlers={animationHandlers}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
turnGroupingContext={turnGroupingContext}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
MessageRow.displayName = 'MessageRow';
|
2026-01-25 19:16:26 +02:00
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
interface TurnBlockProps {
|
2026-03-12 23:45:45 +02:00
|
|
|
turn: TurnRecord;
|
2026-02-27 20:03:42 +02:00
|
|
|
isLastTurn: boolean;
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking: boolean;
|
|
|
|
|
defaultActivityExpanded: boolean;
|
|
|
|
|
turnUiStates: Map<string, TurnUiState>;
|
|
|
|
|
onToggleTurnGroup: (turnId: string) => void;
|
|
|
|
|
chatRenderMode: 'sorted' | 'live';
|
2026-02-11 19:28:22 +02:00
|
|
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
2026-02-27 20:03:42 +02:00
|
|
|
stickyUserHeader?: boolean;
|
2026-03-12 23:45:45 +02:00
|
|
|
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
|
|
|
|
onUserAnimationConsumed: (messageId: string) => void;
|
2026-02-11 19:28:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const TurnBlock: React.FC<TurnBlockProps> = ({
|
|
|
|
|
turn,
|
2026-02-27 20:03:42 +02:00
|
|
|
isLastTurn,
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking,
|
|
|
|
|
defaultActivityExpanded,
|
|
|
|
|
turnUiStates,
|
|
|
|
|
onToggleTurnGroup,
|
|
|
|
|
chatRenderMode,
|
2026-02-11 19:28:22 +02:00
|
|
|
onMessageContentChange,
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
scrollToBottom,
|
2026-02-27 20:03:42 +02:00
|
|
|
stickyUserHeader = true,
|
2026-03-12 23:45:45 +02:00
|
|
|
shouldAnimateUserMessage,
|
|
|
|
|
onUserAnimationConsumed,
|
2026-02-11 19:28:22 +02:00
|
|
|
}) => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
|
|
|
|
|
|
|
|
|
|
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 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 (completed.length > 0) {
|
|
|
|
|
return completed;
|
|
|
|
|
}
|
|
|
|
|
const firstAssistant = turn.assistantMessages[0];
|
|
|
|
|
return firstAssistant ? [firstAssistant] : [];
|
|
|
|
|
}, [chatRenderMode, 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 visibleActivityParts = React.useMemo(() => {
|
|
|
|
|
if (chatRenderMode !== 'sorted') {
|
|
|
|
|
return turn.activityParts;
|
|
|
|
|
}
|
|
|
|
|
if (completedAssistantMessages.length === turn.assistantMessages.length) {
|
|
|
|
|
return turn.activityParts;
|
|
|
|
|
}
|
|
|
|
|
return turn.activityParts.filter((activity) => completedAssistantIdSet.has(activity.messageId));
|
|
|
|
|
}, [chatRenderMode, completedAssistantIdSet, completedAssistantMessages.length, turn.activityParts, turn.assistantMessages.length]);
|
|
|
|
|
|
|
|
|
|
const visibleActivitySegments = React.useMemo(() => {
|
|
|
|
|
if (chatRenderMode !== 'sorted') {
|
|
|
|
|
return turn.activitySegments;
|
|
|
|
|
}
|
|
|
|
|
if (completedAssistantMessages.length === turn.assistantMessages.length) {
|
|
|
|
|
return turn.activitySegments;
|
|
|
|
|
}
|
|
|
|
|
return turn.activitySegments
|
|
|
|
|
.map((segment) => {
|
|
|
|
|
const parts = segment.parts.filter((activity) => completedAssistantIdSet.has(activity.messageId));
|
|
|
|
|
if (parts.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const anchorMessageId = completedAssistantIdSet.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, completedAssistantIdSet, completedAssistantMessages.length, 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]);
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const renderMessage = React.useCallback(
|
|
|
|
|
(message: ChatMessageEntry) => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const messageIndex = messageOrder.lookup.get(message.info.id);
|
|
|
|
|
const previousMessage = typeof messageIndex === 'number' && messageIndex > 0
|
|
|
|
|
? messageOrder.ordered[messageIndex - 1]
|
|
|
|
|
: undefined;
|
|
|
|
|
const nextMessage = typeof messageIndex === 'number' && messageIndex < messageOrder.ordered.length - 1
|
|
|
|
|
? messageOrder.ordered[messageIndex + 1]
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
const assistantIndex = visibleAssistantIds.get(message.info.id) ?? -1;
|
|
|
|
|
|
|
|
|
|
const turnGroupingContext = assistantIndex >= 0
|
|
|
|
|
? {
|
|
|
|
|
...turnGroupingContextBase,
|
|
|
|
|
isFirstAssistantInTurn: assistantIndex === 0,
|
|
|
|
|
isLastAssistantInTurn: assistantIndex === visibleAssistantMessages.length - 1,
|
|
|
|
|
isWorking: isLastTurn && sessionIsWorking,
|
|
|
|
|
isGroupExpanded: turnUiState.isExpanded,
|
|
|
|
|
toggleGroup: () => onToggleTurnGroup(turn.turnId),
|
|
|
|
|
} satisfies TurnGroupingContext
|
|
|
|
|
: undefined;
|
2026-02-11 19:28:22 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-03-12 23:45:45 +02:00
|
|
|
<MessageRow
|
2026-02-11 19:28:22 +02:00
|
|
|
key={message.info.id}
|
|
|
|
|
message={message}
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage={previousMessage}
|
|
|
|
|
nextMessage={nextMessage}
|
|
|
|
|
turnGroupingContext={turnGroupingContext}
|
|
|
|
|
animateUserOnMount={shouldAnimateUserMessage(message)}
|
|
|
|
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
2026-02-11 19:28:22 +02:00
|
|
|
onContentChange={onMessageContentChange}
|
|
|
|
|
animationHandlers={getAnimationHandlers(message.info.id)}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-03-12 23:45:45 +02:00
|
|
|
[
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
isLastTurn,
|
|
|
|
|
messageOrder.lookup,
|
|
|
|
|
messageOrder.ordered,
|
|
|
|
|
onMessageContentChange,
|
|
|
|
|
scrollToBottom,
|
|
|
|
|
sessionIsWorking,
|
|
|
|
|
turn.turnId,
|
|
|
|
|
turnUiState.isExpanded,
|
|
|
|
|
turnGroupingContextBase,
|
|
|
|
|
visibleAssistantMessages,
|
|
|
|
|
visibleAssistantIds,
|
|
|
|
|
shouldAnimateUserMessage,
|
|
|
|
|
onUserAnimationConsumed,
|
|
|
|
|
onToggleTurnGroup,
|
|
|
|
|
]
|
2026-02-11 19:28:22 +02:00
|
|
|
);
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const renderableTurn = React.useMemo(() => {
|
|
|
|
|
if (visibleAssistantMessages === turn.assistantMessages) {
|
|
|
|
|
return turn;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...turn,
|
|
|
|
|
assistantMessages: visibleAssistantMessages,
|
|
|
|
|
};
|
|
|
|
|
}, [turn, visibleAssistantMessages]);
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
return (
|
2026-03-12 23:45:45 +02:00
|
|
|
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
|
2026-02-11 19:28:22 +02:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
TurnBlock.displayName = 'TurnBlock';
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
interface UngroupedMessageRowProps {
|
|
|
|
|
message: ChatMessageEntry;
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage?: ChatMessageEntry;
|
|
|
|
|
nextMessage?: ChatMessageEntry;
|
2026-01-25 19:16:26 +02:00
|
|
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
2026-03-12 23:45:45 +02:00
|
|
|
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
|
|
|
|
onUserAnimationConsumed: (messageId: string) => void;
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
2026-02-11 19:28:22 +02:00
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = React.memo(({
|
|
|
|
|
message,
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage,
|
|
|
|
|
nextMessage,
|
2026-02-27 20:03:42 +02:00
|
|
|
onMessageContentChange,
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
scrollToBottom,
|
2026-03-12 23:45:45 +02:00
|
|
|
shouldAnimateUserMessage,
|
|
|
|
|
onUserAnimationConsumed,
|
2026-02-27 20:03:42 +02:00
|
|
|
}) => {
|
|
|
|
|
return (
|
2026-03-12 23:45:45 +02:00
|
|
|
<MessageRow
|
2026-02-27 20:03:42 +02:00
|
|
|
message={message}
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage={previousMessage}
|
|
|
|
|
nextMessage={nextMessage}
|
|
|
|
|
animateUserOnMount={shouldAnimateUserMessage(message)}
|
|
|
|
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
2026-02-27 20:03:42 +02:00
|
|
|
onContentChange={onMessageContentChange}
|
|
|
|
|
animationHandlers={getAnimationHandlers(message.info.id)}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
/>
|
2026-02-11 19:28:22 +02:00
|
|
|
);
|
2026-02-27 20:03:42 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
UngroupedMessageRow.displayName = 'UngroupedMessageRow';
|
|
|
|
|
|
|
|
|
|
interface MessageListEntryProps {
|
|
|
|
|
entry: RenderEntry;
|
|
|
|
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
|
|
|
|
stickyUserHeader?: boolean;
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking: boolean;
|
|
|
|
|
defaultActivityExpanded: boolean;
|
|
|
|
|
turnUiStates: Map<string, TurnUiState>;
|
|
|
|
|
onToggleTurnGroup: (turnId: string) => void;
|
|
|
|
|
chatRenderMode: 'sorted' | 'live';
|
|
|
|
|
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
|
|
|
|
onUserAnimationConsumed: (messageId: string) => void;
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MessageListEntry: React.FC<MessageListEntryProps> = React.memo(({
|
|
|
|
|
entry,
|
|
|
|
|
onMessageContentChange,
|
|
|
|
|
getAnimationHandlers,
|
|
|
|
|
scrollToBottom,
|
|
|
|
|
stickyUserHeader,
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking,
|
|
|
|
|
defaultActivityExpanded,
|
|
|
|
|
turnUiStates,
|
|
|
|
|
onToggleTurnGroup,
|
|
|
|
|
chatRenderMode,
|
|
|
|
|
shouldAnimateUserMessage,
|
|
|
|
|
onUserAnimationConsumed,
|
2026-02-27 20:03:42 +02:00
|
|
|
}) => {
|
|
|
|
|
if (entry.kind === 'ungrouped') {
|
|
|
|
|
return (
|
|
|
|
|
<UngroupedMessageRow
|
|
|
|
|
message={entry.message}
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage={entry.previousMessage}
|
|
|
|
|
nextMessage={entry.nextMessage}
|
2026-02-27 20:03:42 +02:00
|
|
|
onMessageContentChange={onMessageContentChange}
|
|
|
|
|
getAnimationHandlers={getAnimationHandlers}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
2026-03-12 23:45:45 +02:00
|
|
|
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
|
|
|
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
2026-02-27 20:03:42 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 19:16:26 +02:00
|
|
|
return (
|
2026-02-27 20:03:42 +02:00
|
|
|
<TurnBlock
|
|
|
|
|
turn={entry.turn}
|
|
|
|
|
isLastTurn={entry.isLastTurn}
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking={sessionIsWorking}
|
|
|
|
|
defaultActivityExpanded={defaultActivityExpanded}
|
|
|
|
|
turnUiStates={turnUiStates}
|
|
|
|
|
onToggleTurnGroup={onToggleTurnGroup}
|
|
|
|
|
chatRenderMode={chatRenderMode}
|
|
|
|
|
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
|
|
|
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
2026-02-27 20:03:42 +02:00
|
|
|
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;
|
2026-03-12 23:45:45 +02:00
|
|
|
if (prevProps.chatRenderMode !== nextProps.chatRenderMode) return false;
|
|
|
|
|
if (prevProps.shouldAnimateUserMessage !== nextProps.shouldAnimateUserMessage) return false;
|
2026-02-11 19:28:22 +02:00
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
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') {
|
2026-03-12 23:45:45 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (prevEntry.kind === 'ungrouped' && nextEntry.kind === 'ungrouped') {
|
2026-03-12 23:45:45 +02:00
|
|
|
return (
|
|
|
|
|
prevEntry.message === nextEntry.message
|
|
|
|
|
&& prevEntry.previousMessage === nextEntry.previousMessage
|
|
|
|
|
&& prevEntry.nextMessage === nextEntry.nextMessage
|
|
|
|
|
);
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
// Inner component that renders staged turn entries.
|
2026-02-27 20:03:42 +02:00
|
|
|
const MessageListContent: React.FC<{
|
|
|
|
|
entries: RenderEntry[];
|
|
|
|
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
|
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
2026-03-02 02:11:33 +02:00
|
|
|
stickyUserHeader: boolean;
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking: boolean;
|
|
|
|
|
defaultActivityExpanded: boolean;
|
|
|
|
|
turnUiStates: Map<string, TurnUiState>;
|
|
|
|
|
onToggleTurnGroup: (turnId: string) => void;
|
|
|
|
|
chatRenderMode: 'sorted' | 'live';
|
|
|
|
|
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
|
|
|
|
onUserAnimationConsumed: (messageId: string) => void;
|
|
|
|
|
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => {
|
|
|
|
|
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}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
return (
|
2026-03-12 23:45:45 +02:00
|
|
|
<TurnList entries={entries} renderEntry={renderEntry} />
|
2026-01-25 19:16:26 +02:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionKey,
|
|
|
|
|
turnStart,
|
|
|
|
|
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,
|
2026-02-27 20:03:42 +02:00
|
|
|
scrollRef,
|
|
|
|
|
}, ref) => {
|
2026-02-24 23:38:12 +08:00
|
|
|
const { isMobile } = useDeviceInfo();
|
2026-03-12 23:45:45 +02:00
|
|
|
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
|
|
|
|
const { working } = useAssistantStatus();
|
|
|
|
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
2026-03-02 02:11:33 +02:00
|
|
|
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
2026-03-12 23:45:45 +02:00
|
|
|
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() });
|
2026-03-20 01:01:03 +02:00
|
|
|
const baseDisplayCacheRef = React.useRef<{
|
|
|
|
|
input: ChatMessageEntry[];
|
|
|
|
|
output: ChatMessageEntry[];
|
|
|
|
|
outputIndexById: Map<string, number>;
|
|
|
|
|
} | null>(null);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
const stableOnMessageContentChange = useStableEvent(onMessageContentChange);
|
|
|
|
|
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
|
|
|
|
|
const stableOnLoadOlder = useStableEvent(onLoadOlder);
|
|
|
|
|
const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => {
|
|
|
|
|
scrollToBottom?.(options);
|
|
|
|
|
});
|
2026-02-25 14:32:23 +02:00
|
|
|
|
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;
|
|
|
|
|
}
|
2026-03-12 23:45:45 +02:00
|
|
|
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
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
const baseDisplayMessages = React.useMemo(() => {
|
2026-03-20 01:01:03 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-25 15:20:38 +02:00
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
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>();
|
2026-02-25 15:20:38 +02:00
|
|
|
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;
|
2025-12-20 02:34:01 +02:00
|
|
|
}
|
2026-02-25 15:20:38 +02:00
|
|
|
seenIdsFromTail.add(messageId);
|
|
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
dedupedMessages.push(getNormalizedMessageForDisplay(message));
|
2026-02-25 15:20:38 +02:00
|
|
|
}
|
|
|
|
|
dedupedMessages.reverse();
|
|
|
|
|
|
2026-02-18 20:08:42 +02:00
|
|
|
const output: ChatMessageEntry[] = [];
|
2026-03-20 01:01:03 +02:00
|
|
|
for (let index = 0; index < dedupedMessages.length; index += 1) {
|
|
|
|
|
const current = dedupedMessages[index];
|
2026-02-18 20:08:42 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 20:08:42 +02:00
|
|
|
return output;
|
2025-12-07 19:32:53 +02:00
|
|
|
}, [messages]);
|
|
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
const activeRetryStatus = useSessionStore(
|
|
|
|
|
useShallow((state) => {
|
|
|
|
|
const sessionId = state.currentSessionId;
|
|
|
|
|
if (!sessionId) return null;
|
|
|
|
|
const status = state.sessionStatus?.get(sessionId);
|
|
|
|
|
if (!status || status.type !== 'retry') return null;
|
|
|
|
|
const rawMessage = typeof status.message === 'string' ? status.message.trim() : '';
|
|
|
|
|
return {
|
|
|
|
|
sessionId,
|
|
|
|
|
message: rawMessage || 'Quota limit reached. Retrying automatically.',
|
|
|
|
|
confirmedAt: status.confirmedAt,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
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);
|
2026-03-12 23:45:45 +02:00
|
|
|
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"]');
|
2026-02-27 20:03:42 +02:00
|
|
|
}, [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]);
|
|
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
const displayMessages = React.useMemo(() => {
|
2026-03-12 23:45:45 +02:00
|
|
|
return applyRetryOverlay(baseDisplayMessages, {
|
|
|
|
|
sessionId: activeRetrySessionId,
|
2026-02-27 20:03:42 +02:00
|
|
|
message: activeRetryMessage,
|
2026-03-12 23:45:45 +02:00
|
|
|
confirmedAt: activeRetryConfirmedAt,
|
|
|
|
|
fallbackTimestamp: fallbackRetryTimestamp,
|
|
|
|
|
});
|
2026-02-27 20:03:42 +02:00
|
|
|
}, [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]);
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
|
|
|
|
showTextJustificationActivity: chatRenderMode === 'sorted',
|
|
|
|
|
});
|
|
|
|
|
const turns = React.useMemo(() => {
|
|
|
|
|
if (!streamingTurn) {
|
|
|
|
|
return staticTurns;
|
|
|
|
|
}
|
|
|
|
|
return [...staticTurns, streamingTurn];
|
|
|
|
|
}, [staticTurns, streamingTurn]);
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
const renderEntries = React.useMemo<RenderEntry[]>(() => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const turnEntries = turns.map((turn) => ({
|
|
|
|
|
kind: 'turn' as const,
|
|
|
|
|
key: `turn:${turn.turnId}`,
|
|
|
|
|
turn,
|
|
|
|
|
isLastTurn: turn.turnId === projection.lastTurnId,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (projection.ungroupedMessageIds.size === 0) {
|
|
|
|
|
return turnEntries;
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
2026-02-16 14:15:19 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const turnEntryByUserMessageId = new Map<string, RenderEntry>();
|
|
|
|
|
turnEntries.forEach((entry) => {
|
|
|
|
|
turnEntryByUserMessageId.set(entry.turn.userMessage.info.id, entry);
|
2026-02-27 20:03:42 +02:00
|
|
|
});
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const orderedEntries: RenderEntry[] = [];
|
|
|
|
|
displayMessages.forEach((message, index) => {
|
|
|
|
|
const turnEntry = turnEntryByUserMessageId.get(message.info.id);
|
|
|
|
|
if (turnEntry) {
|
|
|
|
|
orderedEntries.push(turnEntry);
|
2026-02-27 20:03:42 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
if (!projection.ungroupedMessageIds.has(message.info.id)) {
|
2026-02-27 20:03:42 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
orderedEntries.push({
|
2026-02-27 20:03:42 +02:00
|
|
|
kind: 'ungrouped',
|
|
|
|
|
key: `msg:${message.info.id}`,
|
|
|
|
|
message,
|
2026-03-12 23:45:45 +02:00
|
|
|
previousMessage: index > 0 ? displayMessages[index - 1] : undefined,
|
|
|
|
|
nextMessage: index < displayMessages.length - 1 ? displayMessages[index + 1] : undefined,
|
2026-02-27 20:03:42 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
return orderedEntries;
|
|
|
|
|
}, [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, turns]);
|
2026-02-27 20:03:42 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const staging = useStageTurns({
|
|
|
|
|
sessionKey,
|
|
|
|
|
turnStart,
|
|
|
|
|
totalTurns: renderEntries.length,
|
|
|
|
|
disabled: disableStaging,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const stagedEntries = React.useMemo(() => {
|
|
|
|
|
if (staging.stageStartIndex <= 0) {
|
|
|
|
|
return renderEntries;
|
|
|
|
|
}
|
|
|
|
|
return renderEntries.slice(staging.stageStartIndex);
|
|
|
|
|
}, [renderEntries, staging.stageStartIndex]);
|
|
|
|
|
|
|
|
|
|
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(() => {
|
|
|
|
|
// Animation plays once via ToolRevealOnMount; no cleanup needed.
|
|
|
|
|
// The ref-based animatedIds set is reset on session switch.
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const shouldVirtualize = Boolean(resolveScrollContainer()) && stagedEntries.length >= MESSAGE_VIRTUALIZE_THRESHOLD;
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
const estimateEntrySize = React.useCallback(
|
|
|
|
|
(index: number): number => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const entry = stagedEntries[index];
|
2026-02-27 20:03:42 +02:00
|
|
|
if (!entry) {
|
2026-03-12 23:45:45 +02:00
|
|
|
return 220;
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
|
|
|
|
if (entry.kind === 'turn') {
|
|
|
|
|
const assistantCount = entry.turn.assistantMessages.length;
|
2026-03-12 23:45:45 +02:00
|
|
|
return Math.min(
|
|
|
|
|
TURN_ESTIMATE_MAX_PX,
|
|
|
|
|
TURN_ESTIMATE_BASE_PX + assistantCount * TURN_ESTIMATE_PER_ASSISTANT_PX,
|
|
|
|
|
);
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
|
|
|
|
const role = resolveMessageRole(entry.message);
|
2026-03-12 23:45:45 +02:00
|
|
|
return role === 'user' ? 100 : 220;
|
2026-02-27 20:03:42 +02:00
|
|
|
},
|
2026-03-12 23:45:45 +02:00
|
|
|
[stagedEntries]
|
2026-02-27 20:03:42 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const virtualizer = useMessageListVirtualizer<Element>({
|
2026-03-12 23:45:45 +02:00
|
|
|
count: stagedEntries.length,
|
|
|
|
|
getScrollElement: resolveScrollContainer,
|
2026-02-27 20:03:42 +02:00
|
|
|
estimateSize: estimateEntrySize,
|
|
|
|
|
overscan: isMobile ? MESSAGE_VIRTUAL_OVERSCAN_MOBILE : MESSAGE_VIRTUAL_OVERSCAN_DESKTOP,
|
2026-03-12 23:45:45 +02:00
|
|
|
getItemKey: (index: number) => stagedEntries[index]?.key ?? index,
|
2026-02-27 20:03:42 +02:00
|
|
|
enabled: shouldVirtualize,
|
|
|
|
|
useFlushSync: false,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-17 13:18:54 +02:00
|
|
|
const isVirtualRowInRange = React.useCallback(
|
|
|
|
|
(row: VirtualItem) => row.index >= 0 && row.index < stagedEntries.length,
|
|
|
|
|
[stagedEntries.length],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const virtualRows = shouldVirtualize ? virtualizer.getVirtualItems().filter(isVirtualRowInRange) : [];
|
2026-03-12 23:45:45 +02:00
|
|
|
const lastNonEmptyVirtualRowsRef = React.useRef<VirtualItem[]>([]);
|
|
|
|
|
if (shouldVirtualize && virtualRows.length > 0) {
|
|
|
|
|
lastNonEmptyVirtualRowsRef.current = virtualRows;
|
2026-03-17 13:18:54 +02:00
|
|
|
} else if (!shouldVirtualize && lastNonEmptyVirtualRowsRef.current.length > 0) {
|
|
|
|
|
lastNonEmptyVirtualRowsRef.current = [];
|
2026-03-12 23:45:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 13:18:54 +02:00
|
|
|
const fallbackVirtualRows = shouldVirtualize
|
|
|
|
|
? lastNonEmptyVirtualRowsRef.current.filter(isVirtualRowInRange)
|
|
|
|
|
: [];
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const effectiveVirtualRows = shouldVirtualize
|
2026-03-17 13:18:54 +02:00
|
|
|
? (virtualRows.length > 0 ? virtualRows : fallbackVirtualRows)
|
2026-03-12 23:45:45 +02:00
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const renderVirtualized = shouldVirtualize && effectiveVirtualRows.length > 0;
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
const scrollVirtualizerToIndex = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
|
|
|
|
|
if (!virtualizer) {
|
|
|
|
|
return;
|
2026-02-25 14:32:23 +02:00
|
|
|
}
|
2026-02-27 20:03:42 +02:00
|
|
|
const normalizedBehavior: 'auto' | 'smooth' = behavior === 'instant' ? 'auto' : behavior;
|
|
|
|
|
virtualizer.scrollToIndex(index, { align: 'start', behavior: normalizedBehavior });
|
|
|
|
|
}, [virtualizer]);
|
2026-02-25 14:32:23 +02:00
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
const messageIndexMap = React.useMemo(() => {
|
|
|
|
|
const indexMap = new Map<string, number>();
|
2026-02-11 19:28:22 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
stagedEntries.forEach((entry, index) => {
|
2026-02-27 20:03:42 +02:00
|
|
|
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);
|
2026-02-11 19:28:22 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
return indexMap;
|
2026-03-12 23:45:45 +02:00
|
|
|
}, [stagedEntries]);
|
|
|
|
|
|
|
|
|
|
const turnIndexMap = React.useMemo(() => {
|
|
|
|
|
const indexMap = new Map<string, number>();
|
|
|
|
|
stagedEntries.forEach((entry, index) => {
|
|
|
|
|
if (entry.kind === 'turn') {
|
|
|
|
|
indexMap.set(entry.turn.turnId, index);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return indexMap;
|
|
|
|
|
}, [stagedEntries]);
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
const findMessageElement = React.useCallback((messageId: string): HTMLElement | null => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const container = resolveScrollContainer();
|
2026-02-27 20:03:42 +02:00
|
|
|
if (!container) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return container.querySelector(`[data-message-id="${messageId}"]`);
|
2026-03-12 23:45:45 +02:00
|
|
|
}, [resolveScrollContainer]);
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const container = resolveScrollContainer();
|
2026-02-27 20:03:42 +02:00
|
|
|
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;
|
2026-03-12 23:45:45 +02:00
|
|
|
}, [findMessageElement, resolveScrollContainer]);
|
2026-02-27 20:03:42 +02:00
|
|
|
|
|
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
if (!ref) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handle: MessageListHandle = {
|
2026-03-12 23:45:45 +02:00
|
|
|
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior }) => {
|
2026-02-27 20:03:42 +02:00
|
|
|
const behavior = options?.behavior ?? 'auto';
|
2026-03-12 23:45:45 +02:00
|
|
|
const index = turnIndexMap.get(turnId);
|
2026-02-27 20:03:42 +02:00
|
|
|
if (index === undefined) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldVirtualize) {
|
|
|
|
|
scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior);
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
window.requestAnimationFrame(() => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const container = resolveScrollContainer();
|
|
|
|
|
if (!container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
|
|
|
|
if (turnElement) {
|
|
|
|
|
turnElement.scrollIntoView({ behavior, block: 'start' });
|
|
|
|
|
}
|
2026-02-27 20:03:42 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldVirtualize) {
|
|
|
|
|
scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior);
|
|
|
|
|
if (scrollMessageElementIntoView(messageId, behavior)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
const retry = () => {
|
|
|
|
|
attempts += 1;
|
|
|
|
|
if (scrollMessageElementIntoView(messageId, behavior)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (attempts < 3) {
|
|
|
|
|
window.requestAnimationFrame(retry);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.requestAnimationFrame(retry);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
return scrollMessageElementIntoView(messageId, behavior);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
captureViewportAnchor: () => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const container = resolveScrollContainer();
|
2026-02-27 20:03:42 +02:00
|
|
|
if (!container) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
2026-03-12 23:45:45 +02:00
|
|
|
const nodes: HTMLElement[] = Array.from(container.querySelectorAll<HTMLElement>('[data-message-id]'));
|
2026-02-27 20:03:42 +02:00
|
|
|
const firstVisible = 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 }) => {
|
2026-03-12 23:45:45 +02:00
|
|
|
const container = resolveScrollContainer();
|
2026-02-27 20:03:42 +02:00
|
|
|
if (!container) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const index = messageIndexMap.get(anchor.messageId);
|
|
|
|
|
if (index === undefined) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shouldVirtualize) {
|
|
|
|
|
scrollVirtualizerToIndex(index, 'auto');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (applyAnchor()) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
if (typeof window !== 'undefined') {
|
2026-03-12 23:45:45 +02:00
|
|
|
let attempts = 0;
|
|
|
|
|
const retry = () => {
|
|
|
|
|
attempts += 1;
|
|
|
|
|
if (applyAnchor()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (attempts < 3) {
|
|
|
|
|
window.requestAnimationFrame(retry);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.requestAnimationFrame(retry);
|
2026-02-27 20:03:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
},
|
2026-02-25 14:32:23 +02:00
|
|
|
};
|
2026-02-11 19:28:22 +02:00
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
if (typeof ref === 'function') {
|
|
|
|
|
ref(handle);
|
|
|
|
|
return () => {
|
|
|
|
|
ref(null);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const objectRef = ref;
|
|
|
|
|
objectRef.current = handle;
|
|
|
|
|
return () => {
|
|
|
|
|
objectRef.current = null;
|
2026-02-11 19:28:22 +02:00
|
|
|
};
|
2026-03-12 23:45:45 +02:00
|
|
|
}, [findMessageElement, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, scrollVirtualizerToIndex, shouldVirtualize, turnIndexMap, ref]);
|
2026-02-25 14:32:23 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const disableFadeIn = isLoadingOlder || (renderVirtualized && virtualizer.isScrolling);
|
2026-02-11 19:28:22 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return (
|
2026-03-12 23:45:45 +02:00
|
|
|
<div>
|
|
|
|
|
{(turnStart > 0 || hasMoreAbove) && (
|
2026-01-25 19:16:26 +02:00
|
|
|
<div className="flex justify-center py-3">
|
|
|
|
|
{isLoadingOlder ? (
|
|
|
|
|
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
|
|
|
|
|
Loading…
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-03-12 23:45:45 +02:00
|
|
|
onClick={stableOnLoadOlder}
|
2026-01-25 19:16:26 +02:00
|
|
|
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
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
{staging.isStaging ? (
|
|
|
|
|
<div className="flex justify-center py-1">
|
|
|
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
|
|
|
|
Revealing history…
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
<FadeInDisabledProvider disabled={disableFadeIn}>
|
2026-03-12 23:45:45 +02:00
|
|
|
{renderVirtualized ? (
|
2026-02-27 20:03:42 +02:00
|
|
|
<div
|
|
|
|
|
className="relative w-full"
|
|
|
|
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
|
|
|
|
>
|
2026-03-12 23:45:45 +02:00
|
|
|
{effectiveVirtualRows.map((virtualRow: VirtualItem) => {
|
|
|
|
|
const entry = stagedEntries[virtualRow.index];
|
2026-02-27 20:03:42 +02:00
|
|
|
if (!entry) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={entry.key}
|
|
|
|
|
data-index={virtualRow.index}
|
|
|
|
|
ref={virtualizer.measureElement}
|
|
|
|
|
className="absolute left-0 top-0 w-full [overflow-anchor:none]"
|
|
|
|
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
|
|
|
|
>
|
|
|
|
|
<MessageListEntry
|
|
|
|
|
entry={entry}
|
2026-03-12 23:45:45 +02:00
|
|
|
onMessageContentChange={stableOnMessageContentChange}
|
|
|
|
|
getAnimationHandlers={stableGetAnimationHandlers}
|
|
|
|
|
scrollToBottom={stableScrollToBottom}
|
2026-02-27 20:03:42 +02:00
|
|
|
stickyUserHeader={false}
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking={sessionIsWorking}
|
|
|
|
|
defaultActivityExpanded={defaultActivityExpanded}
|
|
|
|
|
turnUiStates={turnUiStates}
|
|
|
|
|
onToggleTurnGroup={toggleTurnGroup}
|
|
|
|
|
chatRenderMode={chatRenderMode}
|
|
|
|
|
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
|
|
|
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
2026-02-27 20:03:42 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-03-12 23:45:45 +02:00
|
|
|
<div className="relative w-full">
|
2026-02-27 20:03:42 +02:00
|
|
|
<MessageListContent
|
2026-03-12 23:45:45 +02:00
|
|
|
entries={stagedEntries}
|
|
|
|
|
onMessageContentChange={stableOnMessageContentChange}
|
|
|
|
|
getAnimationHandlers={stableGetAnimationHandlers}
|
|
|
|
|
scrollToBottom={stableScrollToBottom}
|
2026-03-02 02:11:33 +02:00
|
|
|
stickyUserHeader={stickyUserHeader}
|
2026-03-12 23:45:45 +02:00
|
|
|
sessionIsWorking={sessionIsWorking}
|
|
|
|
|
defaultActivityExpanded={defaultActivityExpanded}
|
|
|
|
|
turnUiStates={turnUiStates}
|
|
|
|
|
onToggleTurnGroup={toggleTurnGroup}
|
|
|
|
|
chatRenderMode={chatRenderMode}
|
|
|
|
|
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
|
|
|
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
2026-02-27 20:03:42 +02:00
|
|
|
/>
|
2026-03-12 23:45:45 +02:00
|
|
|
</div>
|
2026-02-27 20:03:42 +02:00
|
|
|
)}
|
|
|
|
|
</FadeInDisabledProvider>
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-25 19:16:26 +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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
<div className="mb-3">
|
|
|
|
|
<StatusRow
|
|
|
|
|
isWorking={working.isWorking}
|
|
|
|
|
statusText={working.statusText}
|
|
|
|
|
isGenericStatus={working.isGenericStatus}
|
|
|
|
|
isWaitingForPermission={working.isWaitingForPermission}
|
|
|
|
|
wasAborted={working.wasAborted}
|
|
|
|
|
abortActive={working.abortActive}
|
|
|
|
|
retryInfo={working.retryInfo}
|
|
|
|
|
showAssistantStatus
|
|
|
|
|
showTodos={false}
|
|
|
|
|
agentName={currentAgentName}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-24 23:38:12 +08:00
|
|
|
{/* Bottom spacer */}
|
2026-03-12 23:45:45 +02:00
|
|
|
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
|
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
);
|
2026-02-27 20:03:42 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
MessageList.displayName = 'MessageList';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
export default React.memo(MessageList);
|