import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { XCircle } from 'lucide-react';
import type {
ChatMessage,
ChatRunMode,
ClaudeWorkStatus,
PilotDeckWorkStatus,
PilotDeckPermissionSuggestion,
PermissionGrantResult,
} from '../chat/types/types';
import { isBackgroundTaskSession, type Project, type ProjectSession, type SessionProvider } from '../../types/app';
import { getIntrinsicMessageKey } from '../chat/utils/messageKeys';
import MessageRowV2 from './MessageRowV2';
import { ProcessLiveStatus, ProcessRunHeader, type ProcessTraceStep } from './ProcessTrace';
import { formatProcessDuration } from './processTraceUtils';
import {
buildRenderableMessageItems,
getLiveProcessDetailMessages,
getLiveProcessGroups,
getLiveProcessGroupStep,
shouldRenderLiveProcessGroup,
type LiveProcessGroup,
type RenderableMessageItem,
} from './processGrouping';
type DiffLine = { type: string; content: string; lineNum: number };
type MessagesPaneV2Props = {
scrollContainerRef: RefObject<HTMLDivElement>;
onWheel: () => void;
onTouchMove: () => void;
isLoadingSessionMessages: boolean;
sessionLoadError?: string | null;
onRetrySessionLoad?: () => void;
chatMessages: ChatMessage[];
activityMessages?: ChatMessage[];
visibleMessages: ChatMessage[];
visibleMessageCount: number;
isLoadingMoreMessages: boolean;
hasMoreMessages: boolean;
totalMessages: number;
loadEarlierMessages: () => void;
loadAllMessages: () => void;
allMessagesLoaded: boolean;
isLoadingAllMessages: boolean;
provider: SessionProvider;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantSessionToolPermission?: (
suggestion: PilotDeckPermissionSuggestion,
) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
setInput: Dispatch<SetStateAction<string>>;
isAssistantWorking?: boolean;
workingStatus?: ClaudeWorkStatus | PilotDeckWorkStatus | null;
runMode?: ChatRunMode;
};
type KeyedRenderableMessageItem = RenderableMessageItem & {
itemKey: string;
renderIndex: number;
estimatedHeight: number;
};
export type VirtualMessageWindow = {
startIndex: number;
endIndex: number;
topPadding: number;
bottomPadding: number;
totalHeight: number;
};
const MESSAGE_VIRTUALIZATION_THRESHOLD = 160;
const MESSAGE_WINDOW_OVERSCAN = 12;
const MESSAGE_GAP_PX = 16;
function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function upperBound(values: number[], target: number): number {
let low = 0;
let high = values.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (values[mid] <= target) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
function getMessageTextLength(message: ChatMessage): number {
const contentLength = typeof message.content === 'string' ? message.content.length : 0;
const toolInputLength = typeof message.toolInput === 'string' ? message.toolInput.length : 0;
const outputLength = typeof message.toolResult?.content === 'string' ? message.toolResult.content.length : 0;
return contentLength + Math.min(toolInputLength + outputLength, 2400);
}
export function estimateMessageItemHeight(item: RenderableMessageItem): number {
const textLength = getMessageTextLength(item.message);
const roughLines = Math.ceil(textLength / 92);
const baseHeight = item.message.type === 'user' ? 64 : 92;
const processSummaryCount =
item.beforeProcessAttachments.length + item.afterProcessAttachments.length;
const processSummaryHeight = processSummaryCount * 32;
const runHeaderHeight = (item.beforeRunAttachment ? 34 : 0) + (item.afterRunAttachment ? 34 : 0);
const attachmentHeight = Array.isArray(item.message.attachments) && item.message.attachments.length > 0 ? 56 : 0;
const imageHeight = Array.isArray(item.message.images) && item.message.images.length > 0 ? 180 : 0;
const toolHeight = item.message.isToolUse || item.message.toolName ? 140 : 0;
return clampNumber(
baseHeight + roughLines * 20 + runHeaderHeight + processSummaryHeight + attachmentHeight + imageHeight + toolHeight + MESSAGE_GAP_PX,
72,
720,
);
}
export function getVirtualMessageWindow(
itemHeights: number[],
scrollTop: number,
viewportHeight: number,
overscan = MESSAGE_WINDOW_OVERSCAN,
): VirtualMessageWindow {
if (itemHeights.length === 0) {
return { startIndex: 0, endIndex: 0, topPadding: 0, bottomPadding: 0, totalHeight: 0 };
}
const prefixOffsets = [0];
for (const height of itemHeights) {
prefixOffsets.push(prefixOffsets[prefixOffsets.length - 1] + Math.max(1, height));
}
const totalHeight = prefixOffsets[prefixOffsets.length - 1];
const safeScrollTop = clampNumber(Number.isFinite(scrollTop) ? scrollTop : 0, 0, totalHeight);
const safeViewportHeight = Math.max(1, Number.isFinite(viewportHeight) && viewportHeight > 0 ? viewportHeight : 900);
const rawStart = Math.max(0, upperBound(prefixOffsets, safeScrollTop) - 1);
const rawEnd = Math.min(itemHeights.length, upperBound(prefixOffsets, safeScrollTop + safeViewportHeight));
const startIndex = Math.max(0, rawStart - overscan);
const endIndex = Math.min(itemHeights.length, Math.max(startIndex + 1, rawEnd + overscan));
return {
startIndex,
endIndex,
topPadding: prefixOffsets[startIndex],
bottomPadding: Math.max(0, totalHeight - prefixOffsets[endIndex]),
totalHeight,
};
}
function MeasuredMessageItem({
itemKey,
message,
isLast,
compactBottomSpacing = false,
onHeightChange,
children,
}: {
itemKey: string;
message: ChatMessage;
isLast: boolean;
compactBottomSpacing?: boolean;
onHeightChange: (itemKey: string, height: number) => void;
children: ReactNode;
}) {
const itemRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const node = itemRef.current;
if (!node) return undefined;
const reportHeight = () => {
onHeightChange(itemKey, node.getBoundingClientRect().height);
};
reportHeight();
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(reportHeight);
observer.observe(node);
return () => observer.disconnect();
}, [itemKey, onHeightChange]);
return (
<div
ref={itemRef}
className={`chat-message ${isLast ? '' : compactBottomSpacing ? 'pb-2' : 'pb-4'}`}
data-message-timestamp={message.timestamp ? String(message.timestamp) : undefined}
>
{children}
</div>
);
}
export default function MessagesPaneV2({
scrollContainerRef,
onWheel,
onTouchMove,
isLoadingSessionMessages,
sessionLoadError,
onRetrySessionLoad,
chatMessages,
activityMessages = [],
visibleMessages,
visibleMessageCount,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
loadEarlierMessages,
loadAllMessages,
allMessagesLoaded,
isLoadingAllMessages,
provider,
selectedProject,
selectedSession,
createDiff,
onFileOpen,
onShowSettings,
onGrantSessionToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
setInput,
isAssistantWorking = false,
workingStatus,
runMode = 'agent',
}: MessagesPaneV2Props) {
const { t } = useTranslation('chat');
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
const generatedMessageKeyCounterRef = useRef(0);
const measuredHeightsRef = useRef<Map<string, number>>(new Map());
const heightVersionRafRef = useRef<number | null>(null);
const [heightVersion, setHeightVersion] = useState(0);
const [scrollViewport, setScrollViewport] = useState({ scrollTop: 0, height: 0 });
const [expandedProcessRows, setExpandedProcessRows] = useState<Map<string, boolean>>(() => new Map());
const getMessageKey = useCallback((message: ChatMessage, index: number) => {
const existingKey = messageKeyMapRef.current.get(message);
if (existingKey) return existingKey;
const intrinsicKey = getIntrinsicMessageKey(message);
if (intrinsicKey) {
messageKeyMapRef.current.set(message, intrinsicKey);
return intrinsicKey;
}
generatedMessageKeyCounterRef.current += 1;
const candidateKey = `message-generated-${index}-${generatedMessageKeyCounterRef.current}`;
messageKeyMapRef.current.set(message, candidateKey);
return candidateKey;
}, []);
const isProcessExpanded = useCallback((processKey: string, defaultExpanded = false) => (
expandedProcessRows.get(processKey) ?? defaultExpanded
), [expandedProcessRows]);
const handleProcessExpandedChange = useCallback((processKey: string, expanded: boolean) => {
setExpandedProcessRows((currentRows) => {
const currentExpanded = currentRows.get(processKey) ?? false;
if (currentExpanded === expanded) {
return currentRows;
}
const nextRows = new Map(currentRows);
if (expanded) {
nextRows.set(processKey, true);
} else {
nextRows.delete(processKey);
}
return nextRows;
});
}, []);
const suggestedPrompts: string[] = [
t('emptyChat.prompts.plan', { defaultValue: 'Plan a refactor for this project' }),
t('emptyChat.prompts.summary', { defaultValue: 'Summarize recent changes' }),
t('emptyChat.prompts.review', { defaultValue: 'Review the most recent file I touched' }),
];
const isEmpty = !isLoadingSessionMessages && chatMessages.length === 0;
const hasSessionLoadError = Boolean(!isLoadingSessionMessages && sessionLoadError && chatMessages.length === 0);
const isNewConversationEmpty = isEmpty && !selectedSession;
const isExistingConversationEmpty = isEmpty && Boolean(selectedSession) && !hasSessionLoadError;
const isReadOnlyBackgroundSession = isBackgroundTaskSession(selectedSession);
const liveActivities = useMemo(
() => activityMessages.filter((message) => message.isAgentActivity),
[activityMessages],
);
const renderableMessages = useMemo(
() => visibleMessages.filter((message) => !message.isAgentActivity),
[visibleMessages],
);
const liveProcessDetailMessages = useMemo(
() => isAssistantWorking ? getLiveProcessDetailMessages(renderableMessages) : [],
[isAssistantWorking, renderableMessages],
);
const liveProcessGroups = useMemo(
() => isAssistantWorking
? getLiveProcessGroups(renderableMessages, { isAssistantWorking })
.filter((group) => shouldRenderLiveProcessGroup(group, runMode))
: [],
[isAssistantWorking, renderableMessages, runMode],
);
const liveProcessGroupsByAnchor = useMemo(() => {
const groupsByAnchor = new Map<number, LiveProcessGroup[]>();
for (const group of liveProcessGroups) {
const groups = groupsByAnchor.get(group.afterOriginalIndex) || [];
groups.push(group);
groupsByAnchor.set(group.afterOriginalIndex, groups);
}
return groupsByAnchor;
}, [liveProcessGroups]);
const renderableMessageItems = useMemo(
() => buildRenderableMessageItems(renderableMessages, { isAssistantWorking }),
[isAssistantWorking, renderableMessages],
);
const keyedMessageItems = useMemo<KeyedRenderableMessageItem[]>(
() => renderableMessageItems.map((item, index) => ({
...item,
itemKey: getMessageKey(item.message, index),
renderIndex: index,
estimatedHeight: estimateMessageItemHeight(item),
})),
[getMessageKey, renderableMessageItems],
);
const measuredItemHeights = useMemo(() => {
void heightVersion;
return keyedMessageItems.map((item) => measuredHeightsRef.current.get(item.itemKey) ?? item.estimatedHeight);
}, [heightVersion, keyedMessageItems]);
const shouldVirtualizeMessages = keyedMessageItems.length > MESSAGE_VIRTUALIZATION_THRESHOLD;
const virtualWindow = useMemo(
() => shouldVirtualizeMessages
? getVirtualMessageWindow(
measuredItemHeights,
scrollViewport.scrollTop,
scrollViewport.height,
MESSAGE_WINDOW_OVERSCAN,
)
: {
startIndex: 0,
endIndex: keyedMessageItems.length,
topPadding: 0,
bottomPadding: 0,
totalHeight: measuredItemHeights.reduce((sum, height) => sum + height, 0),
},
[keyedMessageItems.length, measuredItemHeights, scrollViewport.height, scrollViewport.scrollTop, shouldVirtualizeMessages],
);
const windowedMessageItems = shouldVirtualizeMessages
? keyedMessageItems.slice(virtualWindow.startIndex, virtualWindow.endIndex)
: keyedMessageItems;
const liveProcessHeaderIndex = useMemo(() => {
if (!isAssistantWorking) return -1;
for (let index = keyedMessageItems.length - 1; index >= 0; index -= 1) {
if (keyedMessageItems[index].message.type === 'user') {
return Math.min(index + 1, keyedMessageItems.length);
}
}
return keyedMessageItems.length > 0 ? 0 : -1;
}, [isAssistantWorking, keyedMessageItems]);
const liveProcessStartedAtMs = useMemo(() => {
if (!isAssistantWorking || liveProcessHeaderIndex <= 0) return null;
const anchorMessage = keyedMessageItems[liveProcessHeaderIndex - 1]?.message;
if (anchorMessage?.type !== 'user' || anchorMessage.timestamp == null) return null;
const parsed = Date.parse(String(anchorMessage.timestamp));
return Number.isFinite(parsed) ? parsed : null;
}, [isAssistantWorking, keyedMessageItems, liveProcessHeaderIndex]);
const hasLiveAssistantContent = useMemo(() => {
if (!isAssistantWorking || liveProcessHeaderIndex < 0) return false;
return keyedMessageItems.slice(liveProcessHeaderIndex).some((item) => (
item.message.type === 'assistant' &&
!item.message.isThinking &&
!item.message.isToolUse &&
typeof item.message.content === 'string' &&
item.message.content.trim().length > 0
));
}, [isAssistantWorking, keyedMessageItems, liveProcessHeaderIndex]);
const liveStatusStep = useMemo(
() => getLiveStatusStep(liveActivities, workingStatus, hasLiveAssistantContent, t),
[hasLiveAssistantContent, liveActivities, t, workingStatus],
);
const hasOpenEndedLiveProcessGroup = liveProcessGroups.some((group) => group.isRunning);
const shouldRenderBottomLiveStatus = isAssistantWorking && !hasOpenEndedLiveProcessGroup;
const bumpHeightVersion = useCallback(() => {
if (heightVersionRafRef.current !== null) return;
heightVersionRafRef.current = requestAnimationFrame(() => {
heightVersionRafRef.current = null;
setHeightVersion((version) => version + 1);
});
}, []);
const handleMeasuredItemHeight = useCallback((itemKey: string, height: number) => {
const normalizedHeight = Math.max(1, Math.ceil(height));
const currentHeight = measuredHeightsRef.current.get(itemKey);
if (currentHeight !== undefined && Math.abs(currentHeight - normalizedHeight) < 2) {
return;
}
measuredHeightsRef.current.set(itemKey, normalizedHeight);
bumpHeightVersion();
}, [bumpHeightVersion]);
useEffect(() => () => {
if (heightVersionRafRef.current !== null) {
cancelAnimationFrame(heightVersionRafRef.current);
}
}, []);
useEffect(() => {
const validKeys = new Set(keyedMessageItems.map((item) => item.itemKey));
let changed = false;
for (const itemKey of measuredHeightsRef.current.keys()) {
if (!validKeys.has(itemKey)) {
measuredHeightsRef.current.delete(itemKey);
changed = true;
}
}
if (changed) {
bumpHeightVersion();
}
}, [bumpHeightVersion, keyedMessageItems]);
useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container) return undefined;
let frame = 0;
const updateViewport = () => {
frame = 0;
setScrollViewport({
scrollTop: container.scrollTop,
height: container.clientHeight,
});
};
const scheduleViewportUpdate = () => {
if (frame) return;
frame = requestAnimationFrame(updateViewport);
};
updateViewport();
container.addEventListener('scroll', scheduleViewportUpdate, { passive: true });
if (typeof ResizeObserver === 'undefined') {
return () => {
if (frame) cancelAnimationFrame(frame);
container.removeEventListener('scroll', scheduleViewportUpdate);
};
}
const resizeObserver = new ResizeObserver(scheduleViewportUpdate);
resizeObserver.observe(container);
return () => {
if (frame) cancelAnimationFrame(frame);
container.removeEventListener('scroll', scheduleViewportUpdate);
resizeObserver.disconnect();
};
}, [scrollContainerRef]);
const renderLiveProcessDetailMessages = useCallback((detailMessages: ChatMessage[], groupId: string) => (
detailMessages.map((message: ChatMessage, index: number) => (
<MessageRowV2
key={`${groupId}-${getMessageKey(message, index)}`}
message={message}
prevMessage={index > 0 ? detailMessages[index - 1] : null}
nextMessage={index < detailMessages.length - 1 ? detailMessages[index + 1] : null}
provider={provider}
selectedProject={selectedProject}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantSessionToolPermission={onGrantSessionToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
isProcessExpanded={isProcessExpanded}
onProcessExpandedChange={handleProcessExpandedChange}
/>
))
), [
autoExpandTools,
createDiff,
getMessageKey,
onFileOpen,
onGrantSessionToolPermission,
onShowSettings,
provider,
selectedProject,
isProcessExpanded,
handleProcessExpandedChange,
showRawParameters,
showThinking,
]);
const renderLiveProcessGroup = useCallback((group: LiveProcessGroup, index: number) => {
const isLatestGroup = liveProcessGroups[liveProcessGroups.length - 1]?.id === group.id;
const step = getLiveProcessGroupStep(group, t, group.isRunning && isLatestGroup ? liveStatusStep : null);
return (
<ProcessLiveStatus
key={group.id || `${group.afterOriginalIndex}-${index}`}
step={step}
compact
expanded={isProcessExpanded(group.id)}
onExpandedChange={(expanded) => handleProcessExpandedChange(group.id, expanded)}
>
{group.detailMessages.length > 0
? renderLiveProcessDetailMessages(group.detailMessages, group.id)
: null}
</ProcessLiveStatus>
);
}, [
handleProcessExpandedChange,
isProcessExpanded,
liveProcessGroups,
liveStatusStep,
renderLiveProcessDetailMessages,
t,
]);
const renderMessageItem = useCallback((item: KeyedRenderableMessageItem) => {
const previousMessage = item.renderIndex > 0 ? keyedMessageItems[item.renderIndex - 1].message : null;
const nextMessage = item.renderIndex < keyedMessageItems.length - 1
? keyedMessageItems[item.renderIndex + 1].message
: null;
const isLast = !isAssistantWorking && item.renderIndex === keyedMessageItems.length - 1;
const anchoredLiveGroups = liveProcessGroupsByAnchor.get(item.originalIndex) || [];
const rendersLiveHeaderAfterItem = item.renderIndex === liveProcessHeaderIndex - 1;
return (
<Fragment key={item.itemKey}>
{liveProcessHeaderIndex === 0 && item.renderIndex === 0 ? (
<LiveProcessHeader
activities={liveActivities}
startedAtMs={liveProcessStartedAtMs}
t={t}
/>
) : null}
<MeasuredMessageItem
itemKey={item.itemKey}
message={item.message}
isLast={isLast}
compactBottomSpacing={anchoredLiveGroups.length > 0 || rendersLiveHeaderAfterItem}
onHeightChange={handleMeasuredItemHeight}
>
{item.beforeRunAttachment ? (
<CompletedProcessHeader
durationMs={item.beforeRunAttachment.durationMs}
t={t}
/>
) : null}
<MessageRowV2
message={item.message}
prevMessage={previousMessage}
nextMessage={nextMessage}
beforeProcessAttachments={item.beforeProcessAttachments}
afterProcessAttachments={item.afterProcessAttachments}
provider={provider}
selectedProject={selectedProject}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantSessionToolPermission={onGrantSessionToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
isProcessExpanded={isProcessExpanded}
onProcessExpandedChange={handleProcessExpandedChange}
/>
{rendersLiveHeaderAfterItem ? (
<LiveProcessHeader
activities={liveActivities}
startedAtMs={liveProcessStartedAtMs}
t={t}
/>
) : null}
{item.afterRunAttachment ? (
<CompletedProcessHeader
durationMs={item.afterRunAttachment.durationMs}
t={t}
/>
) : null}
{anchoredLiveGroups.length > 0 ? (
<div className="mt-2 flex min-w-0 flex-col gap-2">
{anchoredLiveGroups.map(renderLiveProcessGroup)}
</div>
) : null}
</MeasuredMessageItem>
</Fragment>
);
}, [
autoExpandTools,
createDiff,
handleMeasuredItemHeight,
handleProcessExpandedChange,
isProcessExpanded,
isAssistantWorking,
keyedMessageItems,
liveActivities,
liveProcessHeaderIndex,
liveProcessStartedAtMs,
liveProcessGroupsByAnchor,
onFileOpen,
onGrantSessionToolPermission,
onShowSettings,
provider,
renderLiveProcessGroup,
selectedProject,
showRawParameters,
showThinking,
t,
]);
return (
<div
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="relative flex-1 overflow-y-auto overflow-x-hidden bg-white dark:bg-neutral-950"
>
{hasSessionLoadError ? (
<div className="mx-auto flex h-full max-w-[720px] flex-col items-center justify-center gap-3 px-6 py-10 text-center">
<XCircle className="h-5 w-5 text-amber-600 dark:text-amber-400" strokeWidth={1.75} />
<div className="text-[15px] font-medium text-neutral-900 dark:text-neutral-100">
{t('session.loadFailedTitle', { defaultValue: 'Could not load this conversation' })}
</div>
<div className="max-w-[520px] text-[13px] leading-5 text-neutral-500 dark:text-neutral-400">
{sessionLoadError}
</div>
{onRetrySessionLoad ? (
<button
type="button"
onClick={onRetrySessionLoad}
className="inline-flex h-8 items-center rounded-md border border-neutral-200 px-3 text-[13px] font-medium text-neutral-700 transition hover:bg-neutral-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-900"
>
{t('session.retryLoad', { defaultValue: 'Retry' })}
</button>
) : null}
</div>
) : isLoadingSessionMessages && chatMessages.length === 0 ? (
<div className="mx-auto flex h-full max-w-[720px] items-center justify-center px-6 py-10 text-[13px] text-neutral-500 dark:text-neutral-400">
<div className="flex items-center gap-2">
<div className="h-3.5 w-3.5 animate-spin rounded-full border-b-2 border-neutral-400" />
<span>{t('loading', { defaultValue: 'Loading...' })}</span>
</div>
</div>
) : isNewConversationEmpty ? (
<div className="mx-auto flex h-full max-w-[720px] flex-col items-center justify-center gap-4 px-6 py-10 text-center">
<div className="text-[15px] font-medium text-neutral-900 dark:text-neutral-100">
{selectedProject
? t('emptyChat.title', { defaultValue: 'Start a new conversation' })
: t('emptyChat.noProject', { defaultValue: 'Pick a project from the sidebar' })}
</div>
{selectedProject ? (
<div className="flex flex-col gap-1.5">
{suggestedPrompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => setInput(prompt)}
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-left text-[13px] text-neutral-700 transition hover:bg-neutral-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-900"
>
{prompt}
</button>
))}
</div>
) : null}
</div>
) : isExistingConversationEmpty ? (
<div className="mx-auto flex h-full max-w-[720px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
<div className="text-[15px] font-medium text-neutral-900 dark:text-neutral-100">
{isReadOnlyBackgroundSession
? t('emptyChat.readonlyBackgroundTitle', {
defaultValue: 'No displayable messages in this task transcript',
})
: t('emptyChat.emptySessionTitle', {
defaultValue: 'No displayable messages in this conversation',
})}
</div>
<div className="max-w-[520px] text-[13px] leading-5 text-neutral-500 dark:text-neutral-400">
{isReadOnlyBackgroundSession
? t('emptyChat.readonlyBackgroundDescription', {
defaultValue:
'This read-only background task transcript only contains records the chat view cannot display.',
})
: t('emptyChat.emptySessionDescription', {
defaultValue:
'This conversation exists, but it does not contain messages that can be rendered here.',
})}
</div>
</div>
) : (
<div
className="mx-auto max-w-[860px] px-6 py-10"
data-virtualized-messages={shouldVirtualizeMessages ? 'true' : undefined}
data-rendered-message-count={windowedMessageItems.length}
data-total-message-count={keyedMessageItems.length}
>
{isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded ? (
<div className="pb-3 text-center text-[12px] text-neutral-500 dark:text-neutral-400">
{t('messages.loadingOlder', { defaultValue: 'Loading older messages...' })}
</div>
) : null}
{hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded ? (
<div className="mb-8 flex items-center justify-between border-b border-neutral-200 pb-3 text-[12px] text-neutral-500 dark:border-neutral-800 dark:text-neutral-400">
<span>
{t('messages.showingOf', {
shown: chatMessages.length,
total: totalMessages,
defaultValue: `Showing ${chatMessages.length} of ${totalMessages}`,
})}
</span>
<button
type="button"
onClick={loadEarlierMessages}
className="text-[12px] text-neutral-700 underline-offset-2 hover:underline dark:text-neutral-300"
>
{t('messages.loadEarlier', { defaultValue: 'Load earlier' })}
</button>
</div>
) : null}
{!hasMoreMessages && chatMessages.length > visibleMessageCount ? (
<div className="mb-8 flex items-center justify-between border-b border-neutral-200 pb-3 text-[12px] text-neutral-500 dark:border-neutral-800 dark:text-neutral-400">
<span>
{t('messages.showingLast', {
count: visibleMessageCount,
total: chatMessages.length,
defaultValue: `Showing last ${visibleMessageCount} of ${chatMessages.length}`,
})}
</span>
<button
type="button"
onClick={loadAllMessages}
className="text-[12px] text-neutral-700 underline-offset-2 hover:underline dark:text-neutral-300"
>
{t('messages.loadAll', { defaultValue: 'Load all' })}
</button>
</div>
) : null}
{shouldVirtualizeMessages && virtualWindow.topPadding > 0 ? (
<div aria-hidden="true" style={{ height: virtualWindow.topPadding }} />
) : null}
{windowedMessageItems.map(renderMessageItem)}
{shouldVirtualizeMessages && virtualWindow.bottomPadding > 0 ? (
<div aria-hidden="true" style={{ height: virtualWindow.bottomPadding }} />
) : null}
{isAssistantWorking &&
liveProcessHeaderIndex === keyedMessageItems.length &&
keyedMessageItems[liveProcessHeaderIndex - 1]?.message.type !== 'user' ? (
<LiveProcessHeader
activities={liveActivities}
startedAtMs={liveProcessStartedAtMs}
t={t}
/>
) : null}
{shouldRenderBottomLiveStatus ? (
<ProcessLiveStatus step={liveStatusStep}>
{liveProcessDetailMessages.length > 0
? renderLiveProcessDetailMessages(liveProcessDetailMessages, 'bottom-live-process')
: null}
</ProcessLiveStatus>
) : null}
</div>
)}
</div>
);
}
function getLatestActivity(activities: ChatMessage[]): ChatMessage | null {
const byId = new Map<string, ChatMessage>();
for (const activity of activities) {
const key = activity.activityId || activity.id || `${activity.runId}-${activity.timestamp}`;
byId.set(key, activity);
}
const latest = Array.from(byId.values());
return [...latest].reverse().find((activity) => activity.state === 'running') || null;
}
function activityToLiveStep(activity: ChatMessage): ProcessTraceStep {
return {
id: activity.activityId || activity.id,
title: activity.title || activity.content || activity.toolName || '',
detail: activity.detail || '',
state: activity.state || 'running',
severity: activity.severity,
phase: activity.phase,
toolName: activity.toolName,
};
}
function getLiveStatusStep(
activities: ChatMessage[],
workingStatus: ClaudeWorkStatus | PilotDeckWorkStatus | null | undefined,
hasAssistantContent: boolean,
t: (key: string, options?: Record<string, unknown>) => string,
): ProcessTraceStep {
const latestActivity = getLatestActivity(activities);
if (latestActivity) {
return activityToLiveStep(latestActivity);
}
if (workingStatus?.compactProgress) {
const progress = workingStatus.compactProgress;
return {
id: 'live-compact',
title: t('working.compacting', { defaultValue: 'Compacting context...' }),
detail: progress.label || progress.stage || '',
phase: 'compact',
state: progress.state || 'running',
};
}
const rawStatus = String(workingStatus?.text || '').toLowerCase();
if (rawStatus.includes('permission')) {
return {
id: 'live-permission',
title: t('working.waitingForPermission', { defaultValue: 'Waiting for permission' }),
phase: 'permission',
state: 'running',
severity: 'warning',
};
}
if (rawStatus.includes('compact')) {
return {
id: 'live-compact',
title: t('working.compacting', { defaultValue: 'Compacting context...' }),
phase: 'compact',
state: 'running',
};
}
return hasAssistantContent
? {
id: 'live-generation',
title: t('working.generating', { defaultValue: 'Generating response' }),
phase: 'generation',
state: 'running',
}
: {
id: 'live-thinking',
title: t('working.thinking', { defaultValue: 'Thinking' }),
phase: 'thinking',
state: 'running',
};
}
function getLiveProcessStartedAtMs(activities: ChatMessage[], fallbackStartedAtMs: number): number {
if (activities.length === 0) return fallbackStartedAtMs;
const latestActivity = activities[activities.length - 1];
const latestRunId = latestActivity?.runId;
const currentRunActivities = latestRunId
? activities.filter((activity) => activity.runId === latestRunId)
: activities;
let earliestMs = Number.POSITIVE_INFINITY;
for (const activity of currentRunActivities) {
const value = activity.startedAt || activity.timestamp;
const parsed = value ? Date.parse(String(value)) : NaN;
if (Number.isFinite(parsed) && parsed < earliestMs) {
earliestMs = parsed;
}
}
return Number.isFinite(earliestMs) ? earliestMs : fallbackStartedAtMs;
}
function LiveProcessHeader({
activities,
startedAtMs,
t,
}: {
activities: ChatMessage[];
startedAtMs: number | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const fallbackStartedAtRef = useRef(Date.now());
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const elapsedMs = useMemo(() => {
const effectiveStartedAtMs = startedAtMs
?? getLiveProcessStartedAtMs(activities, fallbackStartedAtRef.current);
return Math.max(0, nowMs - effectiveStartedAtMs);
}, [activities, nowMs, startedAtMs]);
const duration = formatProcessDuration(elapsedMs);
const label = t('process.summary.processed', {
duration,
defaultValue: `Processed ${duration}`,
});
return <ProcessRunHeader label={label} />;
}
function CompletedProcessHeader({
durationMs,
t,
}: {
durationMs: number;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const duration = formatProcessDuration(durationMs);
const label = t('process.summary.processed', {
duration,
defaultValue: `Processed ${duration}`,
});
return <ProcessRunHeader label={label} />;
}