qwen-code/packages/web-shell/client/components/MessageList.tsx
ytahdn bd6816b7ac
fix(web-shell): keep errored turns expanded (#6424)
* fix(web-shell): keep errored turns expanded

* test(web-shell): cover errored turns with final answer

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
2026-07-07 09:00:04 +00:00

2882 lines
93 KiB
TypeScript

import {
forwardRef,
memo,
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useCallback,
useMemo,
useState,
type ReactNode,
type MouseEvent as ReactMouseEvent,
type MutableRefObject,
} from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Message, ACPToolCall, TurnCollapseHead } from '../adapters/types';
import type { PermissionRequest } from '../adapters/types';
import {
isBackgroundSubAgentToolCall,
isSubAgentToolCall,
} from '../adapters/toolClassification';
import { CompactModeContext } from '../App';
import { useWebShellCustomization } from '../customization';
import { useI18n } from '../i18n';
import { MessageItem } from './MessageItem';
import { MessageTimestamp } from './MessageTimestamp';
import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup';
import { useSharedNow } from '../hooks/useSharedNow';
import { toolContainsCallId } from './messages/toolFormatting';
import turnCollapseStyles from './TurnCollapseRow.module.css';
import flashStyles from './MessageLocateFlash.module.css';
import styles from './MessageList.module.css';
interface MessageListProps {
messages: Message[];
pendingApproval: PermissionRequest | null;
/** Run /context detail, exactly like typing it (context-usage panels). */
onShowContextDetail?: () => void;
loadingTranscript?: boolean;
catchingUp?: boolean;
/**
* True while the agent is still answering. The newest turn then stays
* expanded and un-collapsible so streaming output is never hidden.
*/
isResponding?: boolean;
welcomeHeader?: ReactNode;
workspaceCwd?: string;
tailContent?: ReactNode;
tailKey?: string;
virtualScrollThreshold?: number;
activeTurnStartedAt?: number;
/**
* When true, scroll the tail content into view the moment it first appears
* even if the user had scrolled up. Opt-in per caller so unrelated inline
* panels don't yank the reader to the bottom. Defaults to false.
*/
autoScrollTailIntoView?: boolean;
hideSessionTimeline?: boolean;
showRetryHint?: boolean;
onRetryClick?: () => void;
onBranchSession?: () => void;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
}
function getLastUserMessageId(messages: Message[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.role === 'user') return msg.id;
}
return null;
}
function getLastMessage(messages: Message[]): Message | undefined {
return messages[messages.length - 1];
}
function getLastTurnStartMessageId(messages: Message[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg && isTurnStartMessage(msg)) return msg.id;
}
return null;
}
export type DisplayItem =
| {
type: 'message';
key: string;
message: Message;
/** Metrics info for the final answer assistant message. */
turnCollapse?: TurnCollapseHead;
}
| {
type: 'turn_collapse';
key: string;
turnCollapse: TurnCollapseHead;
}
| {
type: 'turn_content';
key: string;
turnId: string;
collapsed: boolean;
items: DisplayItem[];
}
| {
type: 'parallel_agents';
key: string;
agents: ACPToolCall[];
/**
* Wall-clock time of the first grouped launch, carried so the grouped
* box reveals its time on hover exactly like a standalone message row.
*/
timestamp?: number;
};
interface LocateFlashTarget {
messageId: string;
callId?: string;
}
export type TurnTimelineNodeKind =
| 'thought'
| 'commentary'
| 'tool'
| 'agents'
| 'plan'
| 'status'
| 'none';
export interface TurnTimelineNode {
kind: TurnTimelineNodeKind;
timestamp?: number;
label?: string;
}
export interface SessionTimelineEntry {
id: string;
label: string;
detail: string;
timestamp?: number;
nodeKinds: TurnTimelineNodeKind[];
isScheduledTask?: boolean;
}
export interface SessionTimelineRange {
startIndex: number;
endIndex: number;
currentIndex: number;
}
function isAgentOnlyToolGroup(msg: Message): boolean {
return (
msg.role === 'tool_group' &&
msg.tools.length === 1 &&
isSubAgentToolCall(msg.tools[0])
);
}
function isBackgroundAgentOnlyToolGroup(msg: Message): boolean {
return (
msg.role === 'tool_group' &&
msg.tools.length === 1 &&
isBackgroundSubAgentToolCall(msg.tools[0])
);
}
function isBackgroundLaunchNarration(msg: Message): boolean {
// The daemon often streams short main-agent thought text between background
// launches, e.g. "agent A is running, now starting agent B". The CLI treats
// those as internal launch narration and shows a single Parallel agents box.
// Only skip thought-only messages here; any user-facing assistant content
// still breaks the group and remains visible.
return msg.role === 'thinking';
}
function isForceExpandGroup(
msg: Message,
pendingApproval: PermissionRequest | null,
): boolean {
if (msg.role !== 'tool_group') return false;
if (
pendingApproval?.toolCallId &&
msg.tools.some((t) => toolContainsCallId(t, pendingApproval.toolCallId!))
)
return true;
return false;
}
function isHiddenInCompactMode(msg: Message): boolean {
if (msg.role === 'thinking') return true;
return false;
}
function mergeCompactToolGroups(
messages: Message[],
pendingApproval: PermissionRequest | null,
): Message[] {
const result: Message[] = [];
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role !== 'tool_group' || isForceExpandGroup(msg, pendingApproval)) {
if (!isHiddenInCompactMode(msg)) {
result.push(msg);
}
i++;
continue;
}
const mergeableGroups: Message[] = [msg];
let lastMergedIdx = i;
let j = i + 1;
while (j < messages.length) {
const next = messages[j];
if (isHiddenInCompactMode(next)) {
j++;
continue;
}
if (
next.role === 'tool_group' &&
!isForceExpandGroup(next, pendingApproval)
) {
mergeableGroups.push(next);
lastMergedIdx = j;
j++;
continue;
}
break;
}
if (mergeableGroups.length === 1) {
result.push(msg);
i++;
continue;
}
const mergedTools = mergeableGroups.flatMap((g) =>
g.role === 'tool_group' ? g.tools : [],
);
result.push({
id: mergeableGroups[0].id,
role: 'tool_group',
tools: mergedTools,
});
i = lastMergedIdx + 1;
}
return result;
}
export function groupParallelAgents(messages: Message[]): DisplayItem[] {
const items: DisplayItem[] = [];
let i = 0;
while (i < messages.length) {
if (isBackgroundAgentOnlyToolGroup(messages[i])) {
const grouped: Message[] = [];
let j = i;
while (j < messages.length) {
const current = messages[j];
if (isBackgroundAgentOnlyToolGroup(current)) {
grouped.push(current);
j++;
continue;
}
if (isBackgroundLaunchNarration(current)) {
let nextAgentIdx = j + 1;
while (
nextAgentIdx < messages.length &&
isBackgroundLaunchNarration(messages[nextAgentIdx])
) {
nextAgentIdx++;
}
if (
nextAgentIdx < messages.length &&
isBackgroundAgentOnlyToolGroup(messages[nextAgentIdx])
) {
j = nextAgentIdx;
continue;
}
}
break;
}
if (grouped.length >= 2) {
items.push({
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
i = j;
continue;
}
}
if (isAgentOnlyToolGroup(messages[i])) {
const start = i;
while (i < messages.length && isAgentOnlyToolGroup(messages[i])) i++;
if (i - start >= 2) {
const grouped = messages.slice(start, i);
items.push({
type: 'parallel_agents',
key: `par-${grouped[0].id}`,
agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]),
timestamp: grouped[0].timestamp,
});
} else {
items.push({
type: 'message',
key: messages[start].id,
message: messages[start],
});
}
} else {
items.push({
type: 'message',
key: messages[i].id,
message: messages[i],
});
i++;
}
}
return items;
}
export function getDisplayItemVirtualKey(item: DisplayItem): string {
if (item.type === 'parallel_agents') return `group:${item.key}`;
if (item.type === 'turn_collapse') {
const liveKey = item.turnCollapse.liveStartedAt;
return liveKey === undefined
? `tc:${item.key}`
: `tc:${item.key}:${liveKey}`;
}
if (item.type === 'turn_content') return `turn-content:${item.key}`;
return `msg:${item.key}`;
}
export interface ApplyTurnCollapseOptions {
/**
* Per-turn user override keyed by the turn's user-message id:
* `true` = forced expanded, `false` = forced collapsed. Turns absent from the
* map follow the default (completed turns collapse).
*/
overrides: ReadonlyMap<string, boolean>;
/**
* True while the agent is still answering. The final turn then stays expanded
* and un-collapsible so live output is never hidden.
*/
isResponding: boolean;
activeTurnStartedAt?: number;
/**
* Tool-call id of a pending approval, if any. The turn containing it is
* force-expanded so the inline approve/reject UI is never folded away (mirrors
* compact mode's `isForceExpandGroup`).
*/
pendingApprovalCallId?: string | null;
/** Master switch; when false the items pass through untouched. */
enabled: boolean;
}
function isAssistantAnswer(item: DisplayItem): boolean {
return (
item.type === 'message' &&
item.message.role === 'assistant' &&
// `content` is typed `string`, but daemon SSE text can be undefined at
// runtime (transcriptToMessages copies `textBlock.text` through). Guard it:
// `applyTurnCollapse` runs in render, so a bare `.trim()` would blank the
// whole transcript.
!!item.message.content &&
item.message.content.trim().length > 0
);
}
function findFinalAnswerIndex(
items: readonly DisplayItem[],
start: number,
end: number,
): number {
let lastWorkStepIndex = start;
for (let i = end; i > start; i--) {
if (isExecutionWorkStep(items[i]!)) {
lastWorkStepIndex = i;
break;
}
}
for (let i = end; i > lastWorkStepIndex; i--) {
if (isAssistantAnswer(items[i]!)) return i;
}
return -1;
}
function collectFinalAssistantIdsByTurn(
items: readonly DisplayItem[],
isResponding: boolean,
): ReadonlySet<string> {
const userIdxs: number[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === 'message' && isTurnStartMessage(item.message)) {
userIdxs.push(i);
}
}
const ids = new Set<string>();
for (let k = 0; k < userIdxs.length; k++) {
if (k === userIdxs.length - 1 && isResponding) continue;
const start = userIdxs[k];
const end = (k + 1 < userIdxs.length ? userIdxs[k + 1] : items.length) - 1;
const answerIdx = findFinalAnswerIndex(items, start, end);
if (answerIdx < 0) continue;
const item = items[answerIdx];
if (item.type === 'message' && item.message.role === 'assistant') {
ids.add(item.message.id);
}
}
return ids;
}
/**
* A turn's hideable "steps": tool activity, plans, and mid-turn assistant text.
* The final answer and any system/shell/insight rows (errors, cancellations,
* command output) are kept visible even when the turn is collapsed.
*/
function isHideableStep(item: DisplayItem, isFinalAnswer: boolean): boolean {
if (item.type === 'parallel_agents') return true;
if (item.type === 'turn_collapse') return false;
if (item.type === 'turn_content') {
return item.items.some((child) => isHideableStep(child, isFinalAnswer));
}
switch (item.message.role) {
case 'tool_group':
case 'plan':
return true;
case 'assistant':
return !isFinalAnswer;
case 'thinking':
return true;
case 'system':
return isMidTurnInjectedDebugMessage(item.message);
case 'user':
case 'user_shell':
case 'btw':
case 'insight_progress':
case 'insight_ready':
case 'insight_error':
return false;
default: {
// Compile-time exhaustiveness: a newly added DaemonMessage role fails to
// assign to `never` here. At runtime (e.g. a newer daemon sending an
// unknown role) it falls through as not-hideable — kept visible rather
// than crashing the transcript or vanishing from a collapsed turn.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = item.message;
return false;
}
}
}
function isMidTurnInjectedDebugMessage(message: {
content?: string;
source?: string;
}): boolean {
return (
message.source === 'mid_turn_message_injected' ||
message.content?.startsWith(
'mid_turn_message_injected (unrecognized daemon event):',
) === true
);
}
export function getTurnTimelineNode(item: DisplayItem): TurnTimelineNode {
if (item.type === 'parallel_agents') {
return {
kind: 'agents',
timestamp: item.timestamp,
label: 'Parallel agents',
};
}
if (item.type !== 'message') return { kind: 'none' };
const { message } = item;
switch (message.role) {
case 'thinking':
return {
kind: 'thought',
timestamp: message.timestamp,
label: 'Thinking',
};
case 'assistant':
if (item.turnCollapse)
return { kind: 'none', timestamp: message.timestamp };
if (!compactTimelineText(message.content, 1))
return { kind: 'none', timestamp: message.timestamp };
return {
kind: 'commentary',
timestamp: message.timestamp,
label: 'Assistant update',
};
case 'tool_group': {
const count = message.tools.length;
return {
kind: 'tool',
timestamp: message.timestamp,
label: `${count} tool call${count === 1 ? '' : 's'}`,
};
}
case 'plan':
return {
kind: 'plan',
timestamp: message.timestamp,
label: 'Plan update',
};
case 'system':
return isMidTurnInjectedDebugMessage(message)
? {
kind: 'status',
timestamp: message.timestamp,
label: 'Status update',
}
: { kind: 'none', timestamp: message.timestamp };
case 'user':
case 'user_shell':
case 'btw':
case 'insight_progress':
case 'insight_ready':
case 'insight_error':
return { kind: 'none', timestamp: message.timestamp };
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = message;
return { kind: 'none' };
}
}
}
function compactTimelineText(
raw: string | null | undefined,
maxLength: number,
options: { stripMarkdown?: boolean } = {},
): string {
const source =
options.stripMarkdown === true ? cleanTimelineMarkdown(raw) : (raw ?? '');
const compact = source.replace(/\s+/g, ' ').trim();
if (maxLength <= 0) return '';
if (!compact) return '';
const chars = Array.from(compact);
return chars.length > maxLength
? `${chars.slice(0, maxLength - 1).join('')}`
: compact;
}
function cleanTimelineMarkdown(raw: string | null | undefined): string {
if (!raw) return '';
const inlinePlaceholders: string[] = [];
const stashInline = (value: string) => {
const key = `\u0000${inlinePlaceholders.length}\u0000`;
inlinePlaceholders.push(value);
return key;
};
let cleaned = raw
.replace(/```[^\n`]*\n?([\s\S]*?)```/g, (_match, code: string) =>
stashInline(code),
)
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`([^`\n]+)`/g, (_match, code: string) => stashInline(code))
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/^\s{0,3}>\s?/gm, '')
.replace(/^\s*[-*+]\s+/gm, '');
cleaned = stripBalancedTimelineMarker(cleaned, '~~');
cleaned = stripBalancedTimelineMarker(cleaned, '**');
cleaned = stripBalancedTimelineMarker(cleaned, '__');
cleaned = cleaned
.replace(/\*([^*\s][^*]*?\S)\*/g, '$1')
.replace(
/(^|[^\p{L}\p{N}_])_([^_\s][^_]*?\S)_(?=$|[^\p{L}\p{N}_])/gu,
'$1$2',
);
for (const [index, value] of inlinePlaceholders.entries()) {
cleaned = cleaned.split(`\u0000${index}\u0000`).join(value);
}
return cleaned;
}
function stripBalancedTimelineMarker(raw: string, marker: string): string {
let result = '';
let index = 0;
while (index < raw.length) {
const start = raw.indexOf(marker, index);
if (start === -1) return result + raw.slice(index);
const contentStart = start + marker.length;
const end = raw.indexOf(marker, contentStart);
if (end === -1) return result + raw.slice(index);
const content = raw.slice(contentStart, end);
result +=
content.trim().length === 0
? raw.slice(index, end + marker.length)
: raw.slice(index, start) + content;
index = end + marker.length;
}
return result;
}
function timelineLabelForTurn(message: Message): string {
const raw =
message.role === 'user'
? message.content
: message.role === 'user_shell'
? message.command
: '';
const compact = compactTimelineText(raw, 32);
if (!compact) return 'User turn';
return compact;
}
function isScheduledTaskMessage(message: {
role: Message['role'];
source?: string;
}): boolean {
return (
message.role === 'user' &&
(message.source === 'cron' || message.source === 'loop')
);
}
// Collapse and timeline turns start at chat prompts and shell prompts; new-chat
// auto-follow still uses getLastUserMessageId so shell prompts do not jump.
function isTurnStartMessage(message: Message): boolean {
return message.role === 'user' || message.role === 'user_shell';
}
function timelineDetailSnippetForMessage(message: Message): string {
switch (message.role) {
case 'thinking':
// Thinking content may include private model reasoning; keep details label-only.
return SESSION_TIMELINE_KIND_LABEL.thought;
case 'assistant':
return compactTimelineText(message.content, 120, { stripMarkdown: true });
case 'tool_group': {
const count = message.tools.length;
return `${count} tool call${count === 1 ? '' : 's'}`;
}
case 'plan':
return 'plan update';
case 'system':
return isMidTurnInjectedDebugMessage(message)
? compactTimelineText(message.content, 120, { stripMarkdown: true })
: '';
case 'user':
case 'user_shell':
case 'btw':
case 'insight_progress':
case 'insight_ready':
case 'insight_error':
return '';
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = message;
return '';
}
}
}
function timelineDetailSnippetForItem(item: DisplayItem): string {
if (item.type === 'parallel_agents') {
const count = item.agents.length;
return `${count} parallel agent${count === 1 ? '' : 's'}`;
}
if (item.type !== 'message') return '';
return timelineDetailSnippetForMessage(item.message);
}
function timelineDetailForTurn(
turnItems: readonly DisplayItem[],
finalAssistantId: string | null,
nodeKinds: readonly TurnTimelineNodeKind[],
): string {
if (finalAssistantId !== null) {
for (const item of turnItems) {
if (item.type !== 'message') continue;
const { message } = item;
if (message.id !== finalAssistantId || message.role !== 'assistant') {
continue;
}
const finalAnswerDetail = compactTimelineText(message.content, 180, {
stripMarkdown: true,
});
if (finalAnswerDetail) return finalAnswerDetail;
}
}
const snippets: string[] = [];
for (let i = 0; i < turnItems.length; i += 1) {
const item = turnItems[i]!;
if (
item.type === 'message' &&
finalAssistantId !== null &&
item.message.id === finalAssistantId
) {
continue;
}
const snippet = timelineDetailSnippetForItem(item);
if (snippet) snippets.push(snippet);
}
const detail = compactTimelineText(snippets.join(' · '), 180);
if (detail) return detail;
if (nodeKinds.length > 0) {
return nodeKinds
.map((kind) => SESSION_TIMELINE_KIND_LABEL[kind])
.join(' · ');
}
return 'No activity';
}
export function getSessionTimelineEntries(
messages: readonly Message[],
): SessionTimelineEntry[] {
const entries: SessionTimelineEntry[] = [];
let turnStart: Message | null = null;
let turnItems: Message[] = [];
const pushTurn = () => {
if (!turnStart) return;
const timelineItems = groupParallelAgents(turnItems);
const finalAssistantIndex = findFinalAnswerIndex(
timelineItems,
-1,
timelineItems.length - 1,
);
const finalAssistantItem =
finalAssistantIndex >= 0 ? timelineItems[finalAssistantIndex] : null;
const finalAssistantId =
finalAssistantItem?.type === 'message' &&
finalAssistantItem.message.role === 'assistant' &&
!finalAssistantItem.message.isStreaming &&
compactTimelineText(finalAssistantItem.message.content, 1, {
stripMarkdown: true,
}).length > 0
? finalAssistantItem.message.id
: null;
const nodeKinds: TurnTimelineNodeKind[] = [];
for (const item of timelineItems) {
if (
item.type === 'message' &&
finalAssistantId !== null &&
item.message.id === finalAssistantId
) {
continue;
}
const node = getTurnTimelineNode(item);
if (node.kind !== 'none' && !nodeKinds.includes(node.kind)) {
nodeKinds.push(node.kind);
}
}
entries.push({
id: turnStart.id,
label: timelineLabelForTurn(turnStart),
detail: timelineDetailForTurn(timelineItems, finalAssistantId, nodeKinds),
timestamp: turnStart.timestamp,
nodeKinds,
...(isScheduledTaskMessage(turnStart) ? { isScheduledTask: true } : {}),
});
};
for (const message of messages) {
if (isTurnStartMessage(message)) {
pushTurn();
turnStart = message;
turnItems = [];
continue;
}
if (turnStart) {
turnItems.push(message);
}
}
pushTurn();
return entries;
}
function TimelineClockIcon() {
return (
<svg
className={styles.sessionTimelineDetailsIcon}
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
>
<circle cx="8" cy="8" r="6.25" />
<path d="M8 4.5v4l-2.5 2" />
</svg>
);
}
function toolTimelineSignature(tool: ACPToolCall): string {
const rawOutput =
tool.rawOutput && typeof tool.rawOutput === 'object'
? (tool.rawOutput as Record<string, unknown>)
: undefined;
return [
tool.callId,
tool.toolName,
tool.kind ?? '',
tool.status,
tool.parentToolCallId ?? '',
tool.subContent ? 'sub-content' : '',
tool.subTools?.length ?? 0,
String(tool.args?.subagent_type ?? ''),
tool.args?.run_in_background === true ? 'background' : '',
String(rawOutput?.['type'] ?? ''),
String(rawOutput?.['status'] ?? ''),
].join(':');
}
export function getSessionTimelineSignature(
messages: readonly Message[],
): string {
return messages
.map((message) => {
const base = `${message.id}:${message.role}:${message.timestamp ?? ''}`;
switch (message.role) {
case 'assistant':
case 'thinking':
return `${base}:${message.isStreaming ? 'streaming' : message.content}`;
case 'tool_group':
return `${base}:${message.tools.map(toolTimelineSignature).join(',')}`;
case 'system':
return `${base}:${message.variant}:${message.source ?? ''}:${message.content}`;
case 'user':
return `${base}:${message.content}`;
case 'user_shell':
return `${base}:${message.command}`;
case 'plan':
case 'btw':
case 'insight_progress':
case 'insight_ready':
case 'insight_error':
return base;
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = message;
return base;
}
}
})
.join('|');
}
function isExecutionWorkStep(item: DisplayItem): boolean {
if (item.type === 'parallel_agents') return true;
if (item.type === 'turn_collapse') return false;
if (item.type === 'turn_content') return item.items.some(isExecutionWorkStep);
return item.message.role === 'tool_group' || item.message.role === 'plan';
}
function isActiveToolStatus(status: ACPToolCall['status'] | string): boolean {
return (
status === 'pending' || status === 'running' || status === 'in_progress'
);
}
function activeExecutionKey(item: DisplayItem): string | null {
if (item.type === 'turn_content') {
for (let i = item.items.length - 1; i >= 0; i--) {
const key = activeExecutionKey(item.items[i]!);
if (key) return key;
}
return null;
}
if (item.type === 'turn_collapse') {
if (item.turnCollapse.liveStartedAt === undefined) return null;
if (
item.turnCollapse.toolCallCount === undefined ||
item.turnCollapse.toolCallCount <= 0
) {
return null;
}
return `turn:${item.turnCollapse.turnId}:${item.turnCollapse.toolCallCount}`;
}
if (item.type === 'parallel_agents') {
const activeAgents = item.agents.filter((agent) =>
isActiveToolStatus(agent.status),
);
if (activeAgents.length === 0) return null;
return `agents:${item.key}:${activeAgents.map((agent) => agent.callId).join(',')}`;
}
if (item.message.role !== 'tool_group') return null;
const activeTools = item.message.tools.filter((tool) =>
isActiveToolStatus(tool.status),
);
if (activeTools.length === 0) return null;
return `tools:${item.message.id}:${activeTools.map((tool) => tool.callId).join(',')}`;
}
function latestActiveExecutionKey(
items: readonly DisplayItem[],
): string | null {
for (let i = items.length - 1; i >= 0; i--) {
const key = activeExecutionKey(items[i]!);
if (key) return key;
}
return null;
}
function terminalTurnTimestamp(item: DisplayItem): number | undefined {
if (item.type !== 'message' || item.message.role !== 'system') {
return undefined;
}
return item.message.source === 'prompt_cancelled' ||
item.message.source === 'turn_error'
? item.message.timestamp
: undefined;
}
function isTurnErrorItem(item: DisplayItem): boolean {
return (
item.type === 'message' &&
item.message.role === 'system' &&
item.message.source === 'turn_error'
);
}
function assistantContentTimestamp(item: DisplayItem): number | undefined {
if (item.type !== 'message' || item.message.role !== 'assistant') {
return undefined;
}
return item.message.content?.trim() ? item.message.timestamp : undefined;
}
/**
* Per-turn token usage contribution of a row. The SDK reducer folds each round's
* usage — including the sub-agent rounds a turn spawns — onto the turn's
* top-level assistant blocks, so summing the turn's assistant messages yields
* its true total cost.
*/
function itemAssistantUsage(item: DisplayItem):
| {
inputTokens: number;
outputTokens: number;
cachedTokens?: number;
}
| undefined {
return item.type === 'message' && item.message.role === 'assistant'
? item.message.usage
: undefined;
}
function itemToolCallCount(item: DisplayItem): number {
if (item.type === 'parallel_agents') return item.agents.length;
if (item.type === 'turn_collapse') return 0;
if (item.type === 'turn_content') {
return item.items.reduce((sum, child) => sum + itemToolCallCount(child), 0);
}
return item.message.role === 'tool_group' ? item.message.tools.length : 0;
}
/**
* Walk backwards from `index` to the user-message row that heads its turn and
* return that turn's id, or null when `index` precedes the first turn.
*/
export function findTurnIdForIndex(
items: readonly DisplayItem[],
index: number,
): string | null {
for (let i = Math.min(index, items.length - 1); i >= 0; i--) {
const item = items[i];
if (item.type === 'message' && isTurnStartMessage(item.message)) {
return item.message.id;
}
}
return null;
}
export function getTurnIdByDisplayIndex(
items: readonly DisplayItem[],
): Array<string | null> {
const turnIds: Array<string | null> = [];
let currentTurnId: string | null = null;
for (const item of items) {
if (item.type === 'message' && isTurnStartMessage(item.message)) {
currentTurnId = item.message.id;
}
turnIds.push(currentTurnId);
}
return turnIds;
}
function timelineIndexForDisplayIndex(
visibleItems: readonly DisplayItem[],
index: number,
entryIndexById: ReadonlyMap<string, number>,
turnIdByDisplayIndex?: readonly (string | null)[],
): number | null {
const turnId =
turnIdByDisplayIndex === undefined
? findTurnIdForIndex(visibleItems, index)
: (turnIdByDisplayIndex[index] ?? null);
if (!turnId) return null;
return entryIndexById.get(turnId) ?? null;
}
export function getSessionTimelineRangeForIndexes(
visibleItems: readonly DisplayItem[],
visibleItemIndexes: readonly number[],
entryIndexById: ReadonlyMap<string, number>,
currentItemIndex?: number | null,
turnIdByDisplayIndex: readonly (string | null)[] = getTurnIdByDisplayIndex(
visibleItems,
),
): SessionTimelineRange | null {
let startIndex = Number.POSITIVE_INFINITY;
let endIndex = -1;
for (const visibleItemIndex of visibleItemIndexes) {
if (visibleItemIndex < 0 || visibleItemIndex >= visibleItems.length) {
continue;
}
const timelineIndex = timelineIndexForDisplayIndex(
visibleItems,
visibleItemIndex,
entryIndexById,
turnIdByDisplayIndex,
);
if (timelineIndex === null) continue;
startIndex = Math.min(startIndex, timelineIndex);
endIndex = Math.max(endIndex, timelineIndex);
}
if (endIndex < 0) return null;
const currentIndex =
currentItemIndex === undefined || currentItemIndex === null
? null
: timelineIndexForDisplayIndex(
visibleItems,
currentItemIndex,
entryIndexById,
turnIdByDisplayIndex,
);
return {
startIndex,
endIndex,
currentIndex:
currentIndex !== null &&
currentIndex >= startIndex &&
currentIndex <= endIndex
? currentIndex
: endIndex,
};
}
/**
* Fold each completed turn down to its prompt and final answer, hiding the
* intermediate steps (thinking, tool calls, mid-turn assistant text) behind a
* toggle attached to the prompt row. A turn spans one user message up to the
* next; its "final answer" is the last assistant row carrying visible content.
* The leading user row of every collapsible turn is tagged with a
* `TurnCollapseHead`; when collapsed, the hidden middle rows are dropped and the
* final answer's own thinking is stripped so only its purple-prefixed content
* remains. Returns the original array untouched when disabled or when there is
* nothing to collapse.
*/
/** Does any tool group / parallel-agents row in [start, end] own `callId`? */
function turnOwnsCallId(
items: DisplayItem[],
start: number,
end: number,
callId: string | null | undefined,
): boolean {
if (!callId) return false;
for (let i = start; i <= end; i++) {
const item = items[i];
if (item.type === 'parallel_agents') {
if (item.agents.some((agent) => toolContainsCallId(agent, callId))) {
return true;
}
} else if (item.type === 'message' && item.message.role === 'tool_group') {
if (item.message.tools.some((tool) => toolContainsCallId(tool, callId))) {
return true;
}
}
}
return false;
}
export function applyTurnCollapse(
items: DisplayItem[],
{
overrides,
isResponding,
activeTurnStartedAt,
pendingApprovalCallId,
enabled,
}: ApplyTurnCollapseOptions,
): DisplayItem[] {
if (!enabled || items.length === 0) return items;
const userIdxs: number[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === 'message' && isTurnStartMessage(item.message)) {
userIdxs.push(i);
}
}
if (userIdxs.length === 0) return items;
const result: DisplayItem[] = [];
// Anything before the first prompt (e.g. a session-restore banner) is not
// part of any turn and passes through verbatim.
for (let i = 0; i < userIdxs[0]; i++) result.push(items[i]);
for (let k = 0; k < userIdxs.length; k++) {
const start = userIdxs[k];
const end = (k + 1 < userIdxs.length ? userIdxs[k + 1] : items.length) - 1;
const head = items[start] as Extract<DisplayItem, { type: 'message' }>;
const turnId = head.message.id;
const promptTs = head.message.timestamp;
const isActiveTurn = k === userIdxs.length - 1 && isResponding;
const hasPendingApproval = turnOwnsCallId(
items,
start,
end,
pendingApprovalCallId,
);
const answerIdx = findFinalAnswerIndex(items, start, end);
let hiddenCount = 0;
let terminalTs: number | undefined;
let assistantTs: number | undefined;
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let toolCallCount = 0;
let thinkingCount = 0;
let hasUsage = false;
let hasTurnError = false;
for (let i = start + 1; i <= end; i++) {
const item = items[i]!;
const isStep = isHideableStep(item, i === answerIdx);
if (isStep) {
hiddenCount++;
}
if (isTurnErrorItem(item)) {
hasTurnError = true;
}
toolCallCount += itemToolCallCount(item);
if (item.type === 'message' && item.message.role === 'thinking') {
thinkingCount++;
}
const terminalTimestamp = terminalTurnTimestamp(item);
if (terminalTimestamp !== undefined) {
terminalTs =
terminalTs === undefined
? terminalTimestamp
: Math.max(terminalTs, terminalTimestamp);
}
const assistantTimestamp = assistantContentTimestamp(item);
if (assistantTimestamp !== undefined) {
assistantTs =
assistantTs === undefined
? assistantTimestamp
: Math.max(assistantTs, assistantTimestamp);
}
const usage = itemAssistantUsage(item);
if (usage) {
inputTokens += usage.inputTokens;
outputTokens += usage.outputTokens;
cachedTokens += usage.cachedTokens ?? 0;
hasUsage = true;
}
}
const liveStartedAt = isActiveTurn
? (activeTurnStartedAt ?? promptTs ?? Date.now())
: undefined;
const lastStepTs = terminalTs ?? assistantTs;
const elapsedMs =
promptTs !== undefined &&
lastStepTs !== undefined &&
lastStepTs >= promptTs
? lastStepTs - promptTs
: undefined;
const hasMetrics =
hasUsage || elapsedMs !== undefined || liveStartedAt !== undefined;
if (hasPendingApproval || (hiddenCount === 0 && !hasMetrics)) {
// Nothing to add: the inline approve/reject UI must stay reachable, or the
// turn has neither foldable steps nor a measured metric. Emit it untouched.
for (let i = start; i <= end; i++) result.push(items[i]);
continue;
}
// A turn with foldable steps gets a chevron and defaults to expanded while
// streaming, when the turn errored, or when there is no final answer;
// otherwise it collapses once complete. A step-less turn (e.g. a plain "hi"
// reply) has nothing to fold, so it stays expanded and shows a chevron-less
// metrics line. An explicit user toggle always wins.
const shouldStayOpen = isActiveTurn || hasTurnError || answerIdx < 0;
const expanded =
hiddenCount === 0
? true
: overrides.has(turnId)
? (overrides.get(turnId) as boolean)
: shouldStayOpen;
const collapsed = !expanded;
let turnContentGroupIndex = 0;
const pushTurnContentGroup = (groupItems: DisplayItem[]) => {
if (groupItems.length === 0) return;
result.push({
type: 'turn_content',
key: `${turnId}-content-${turnContentGroupIndex++}`,
turnId,
collapsed,
items: groupItems,
});
};
// Push the user message
result.push({
type: 'message',
key: head.key,
message: head.message,
});
// Insert standalone turn_collapse item right after user message
// This keeps the toggle at the top of the turn regardless of expand state
const turnCollapseInfo: TurnCollapseHead = {
turnId,
collapsed,
hiddenCount,
...(elapsedMs !== undefined ? { elapsedMs } : {}),
...(hasUsage ? { inputTokens, outputTokens } : {}),
...(cachedTokens > 0 ? { cachedTokens } : {}),
...(toolCallCount > 0 ? { toolCallCount } : {}),
...(thinkingCount > 0 ? { thinkingCount } : {}),
...(liveStartedAt !== undefined ? { liveStartedAt } : {}),
};
result.push({
type: 'turn_collapse',
key: `tc-${turnId}`,
turnCollapse: turnCollapseInfo,
});
if (!collapsed) {
let turnContentItems: DisplayItem[] = [];
for (let i = start + 1; i <= end; i++) {
const item = items[i]!;
// Attach turnCollapse to final answer for metrics display
if (
i === answerIdx &&
item.type === 'message' &&
item.message.role === 'assistant'
) {
pushTurnContentGroup(turnContentItems);
turnContentItems = [];
result.push({
...item,
turnCollapse: turnCollapseInfo,
});
} else {
turnContentItems.push(item);
}
}
pushTurnContentGroup(turnContentItems);
continue;
}
// Collapsed: keep hideable steps mounted in a zero-height content group so
// the fold animation can run. Keep the final answer and non-step rows
// (errors, cancellations, command output) in their original places. On an
// active turn the "answer" is still streaming, so fold it away too rather
// than strand a provisional line.
const collapsedContentItems: DisplayItem[] = [];
const visibleCollapsedItems: DisplayItem[] = [];
for (let i = start + 1; i <= end; i++) {
const item = items[i];
if (i === answerIdx && isActiveTurn) continue;
if (
i === answerIdx &&
item.type === 'message' &&
item.message.role === 'assistant'
) {
visibleCollapsedItems.push({
...item,
turnCollapse: turnCollapseInfo,
});
continue;
}
if (isHideableStep(item, i === answerIdx)) {
collapsedContentItems.push(item);
continue;
}
visibleCollapsedItems.push(item);
}
pushTurnContentGroup(collapsedContentItems);
result.push(...visibleCollapsedItems);
}
return result;
}
/**
* Locate a display item by message id, falling back to the tool call id for
* tool groups that were merged (compact mode) or grouped (parallel agents)
* under another message's id.
*/
export function findDisplayItemIndex(
items: readonly DisplayItem[],
messageId: string,
callId?: string,
): number {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === 'message') {
if (item.message.id === messageId) return i;
if (
callId &&
item.message.role === 'tool_group' &&
item.message.tools.some((tool) => toolContainsCallId(tool, callId))
) {
return i;
}
} else if (
item.type === 'parallel_agents' &&
callId &&
item.agents.some((agent) => toolContainsCallId(agent, callId))
) {
return i;
} else if (
item.type === 'turn_content' &&
findDisplayItemIndex(item.items, messageId, callId) >= 0
) {
return i;
}
}
return -1;
}
function displayItemMatchesLocateTarget(
item: DisplayItem,
target: LocateFlashTarget | null,
): boolean {
if (!target) return false;
const callId = target.callId;
if (item.type === 'message') {
if (item.message.id === target.messageId) return true;
return (
!!callId &&
item.message.role === 'tool_group' &&
item.message.tools.some((tool) => toolContainsCallId(tool, callId))
);
}
if (item.type === 'parallel_agents') {
return (
!!callId && item.agents.some((agent) => toolContainsCallId(agent, callId))
);
}
if (item.type === 'turn_content') {
return item.items.some((child) =>
displayItemMatchesLocateTarget(child, target),
);
}
return false;
}
export interface MessageListHandle {
/**
* Scroll the transcript so the given message is visible and briefly
* highlight it. Returns false when the message is not in the list.
*/
scrollToMessage: (messageId: string, callId?: string) => boolean;
/** Resume bottom-follow mode and scroll to the latest output. */
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
const HEADER_INDEX = 0;
const ESTIMATE_HEADER = 120;
const ESTIMATE_MESSAGE = 80;
const ESTIMATE_TURN_COLLAPSE = 32;
const ESTIMATE_TAIL = 240;
const FOLLOW_BOTTOM_THRESHOLD_PX = 30;
export const VIRTUAL_SCROLL_THRESHOLD = 200;
const SESSION_TIMELINE_MIN_VISIBLE_ENTRIES = 4;
export function shouldUseVirtualScroll(
totalCount: number,
threshold = VIRTUAL_SCROLL_THRESHOLD,
): boolean {
return totalCount > threshold;
}
function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds - minutes * 60;
return `${minutes}m ${seconds}s`;
}
function formatTokenCount(tokens: number): string {
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;
}
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
function durationMetricText(elapsedMs: number | undefined): string {
return elapsedMs !== undefined ? formatDuration(elapsedMs) : '';
}
function tokenMetricText(collapse: TurnCollapseHead, t: Translate): string {
if (
collapse.inputTokens === undefined ||
collapse.outputTokens === undefined
) {
return '';
}
const cachedTokens = collapse.cachedTokens ?? 0;
const cached =
cachedTokens > 0 && collapse.inputTokens > 0
? ` (${formatTokenCount(cachedTokens)} ${t('turn.cached')}, ${Math.round(
(cachedTokens / collapse.inputTokens) * 100,
)}%)`
: '';
return `${formatTokenCount(collapse.inputTokens)}${cached}${formatTokenCount(
collapse.outputTokens,
)}`;
}
function turnMetricsText(collapse: TurnCollapseHead, t: Translate): string {
const parts: string[] = [];
const tokenMetric = tokenMetricText(collapse, t);
if (tokenMetric) parts.push(tokenMetric);
if (collapse.toolCallCount !== undefined && collapse.toolCallCount > 0) {
parts.push(t('turn.toolCalls', { count: collapse.toolCallCount }));
}
if (collapse.thinkingCount !== undefined && collapse.thinkingCount > 0) {
parts.push(t('turn.thinkingCount', { count: collapse.thinkingCount }));
}
return parts.join(' · ');
}
function hasNonDurationMetrics(collapse: TurnCollapseHead): boolean {
return (
(collapse.inputTokens !== undefined &&
collapse.outputTokens !== undefined) ||
(collapse.toolCallCount !== undefined && collapse.toolCallCount > 0)
);
}
interface TurnCollapseRowProps {
turnCollapse: TurnCollapseHead;
onToggleCollapse: (turnId: string, nextExpanded: boolean) => void;
}
const TurnCollapseRow = memo(function TurnCollapseRow({
turnCollapse,
onToggleCollapse,
}: TurnCollapseRowProps) {
const { t } = useI18n();
const hasToggle = turnCollapse.hiddenCount > 0;
const liveStartedAt = turnCollapse.liveStartedAt;
const showMetadataRow =
hasToggle ||
liveStartedAt !== undefined ||
hasNonDurationMetrics(turnCollapse);
const now = useSharedNow(liveStartedAt !== undefined && showMetadataRow);
const elapsedSeenRef = useRef(0);
const previousLiveStartedAtRef = useRef<number | undefined>(liveStartedAt);
if (previousLiveStartedAtRef.current !== liveStartedAt) {
previousLiveStartedAtRef.current = liveStartedAt;
elapsedSeenRef.current = 0;
}
let displayElapsedMs: number | undefined;
if (liveStartedAt !== undefined && showMetadataRow) {
elapsedSeenRef.current = Math.max(
elapsedSeenRef.current,
Math.max(0, now - liveStartedAt),
);
displayElapsedMs = elapsedSeenRef.current;
} else if (showMetadataRow && turnCollapse.elapsedMs !== undefined) {
elapsedSeenRef.current = 0;
displayElapsedMs = turnCollapse.elapsedMs;
} else {
elapsedSeenRef.current = 0;
displayElapsedMs = undefined;
}
const visibleMetrics = durationMetricText(displayElapsedMs);
const hiddenMetrics = turnMetricsText(turnCollapse, t);
const summaryMetrics = turnMetricsText(turnCollapse, t);
const statusLabel =
liveStartedAt !== undefined ? t('turn.processing') : t('turn.processed');
const showVisibleMetrics = !!visibleMetrics && showMetadataRow;
const showHiddenMetrics = !!hiddenMetrics && showMetadataRow;
const showSummaryMetrics = !!summaryMetrics && showMetadataRow;
if (!showMetadataRow) return null;
const toggleExpanded = () => {
if (!hasToggle) return;
const nextExpanded = turnCollapse.collapsed;
onToggleCollapse(turnCollapse.turnId, nextExpanded);
};
return (
<div
className={
hasToggle
? `${turnCollapseStyles.collapseRow} ${turnCollapseStyles.collapseRowClickable}`
: turnCollapseStyles.collapseRow
}
role={hasToggle ? 'button' : undefined}
tabIndex={hasToggle ? 0 : undefined}
aria-expanded={hasToggle ? !turnCollapse.collapsed : undefined}
aria-label={
hasToggle
? turnCollapse.collapsed
? t('turn.expand')
: t('turn.collapse')
: undefined
}
title={
hasToggle
? turnCollapse.collapsed
? t('turn.expand')
: t('turn.collapse')
: undefined
}
onClick={hasToggle ? toggleExpanded : undefined}
onKeyDown={
hasToggle
? (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleExpanded();
}
: undefined
}
>
<span className={turnCollapseStyles.collapseLabel}>
<span className={turnCollapseStyles.processedLabel}>
{statusLabel}
{showVisibleMetrics && (
<span className={turnCollapseStyles.processedMeta}>
{' '}
{visibleMetrics}
</span>
)}
</span>
{showSummaryMetrics && (
<span className={turnCollapseStyles.summaryMetrics}>
{summaryMetrics}
</span>
)}
{showHiddenMetrics && (
<span className={turnCollapseStyles.hiddenMetrics}>
{hiddenMetrics}
</span>
)}
</span>
{hasToggle && (
<span
data-testid={`toggle-${turnCollapse.turnId}`}
className={turnCollapseStyles.collapseIcon}
onClick={(event) => {
event.stopPropagation();
toggleExpanded();
}}
>
<span
className={
turnCollapse.collapsed
? turnCollapseStyles.chevronRight
: turnCollapseStyles.chevronDown
}
aria-hidden="true"
/>
</span>
)}
</div>
);
});
function getChatRowClassName(item: DisplayItem): string | undefined {
if (item.type === 'turn_collapse') return styles.turnStatusRow;
if (item.type === 'turn_content') {
return styles.turnContentRow;
}
if (item.type !== 'message') return undefined;
if (item.turnCollapse) return styles.turnAnswerRow;
return undefined;
}
const TurnContent = memo(function TurnContent({
collapsed,
children,
}: {
collapsed: boolean;
children: ReactNode;
}) {
const className = joinClassNames(
styles.turnContentClip,
collapsed ? styles.turnContentCollapsed : undefined,
);
return (
<div className={className} data-collapsed={collapsed ? 'true' : 'false'}>
<div className={styles.turnContentInner}>{children}</div>
</div>
);
});
const SESSION_TIMELINE_KIND_LABEL: Record<TurnTimelineNodeKind, string> = {
thought: 'thinking',
commentary: 'assistant update',
tool: 'tool calls',
agents: 'parallel agents',
plan: 'plan update',
status: 'status update',
none: 'turn',
};
const SessionTimeline = memo(function SessionTimeline({
entries,
currentTurnId,
currentRange,
hidden,
onSelect,
}: {
entries: readonly SessionTimelineEntry[];
currentTurnId: string | null;
currentRange: SessionTimelineRange | null;
hidden: boolean;
onSelect: (turnId: string) => void;
}) {
if (hidden || entries.length === 0) return null;
return (
<div className={styles.sessionTimelineLayer} aria-hidden="false">
<nav
className={styles.sessionTimelinePanel}
aria-label="Session timeline"
data-testid="session-timeline"
>
<ol className={styles.sessionTimelineList}>
{entries.map((entry, index) => {
const isInCurrentRange =
currentRange !== null &&
index >= currentRange.startIndex &&
index <= currentRange.endIndex;
const isCurrent =
currentRange !== null
? index === currentRange.currentIndex
: entry.id === currentTurnId;
const nodeKinds = entry.nodeKinds.join(',');
const ariaLabel = [
`Turn ${index + 1}: ${entry.label}`,
isCurrent ? 'Current turn' : null,
]
.filter(Boolean)
.join('. ');
return (
<li
key={entry.id}
className={styles.sessionTimelineItem}
data-testid="session-timeline-entry"
data-turn-id={entry.id}
data-node-kinds={nodeKinds}
data-in-current-range={isInCurrentRange ? 'true' : undefined}
>
<button
type="button"
className={joinClassNames(
styles.sessionTimelineButton,
isInCurrentRange
? styles.sessionTimelineButtonInRange
: undefined,
isCurrent ? styles.sessionTimelineButtonCurrent : undefined,
)}
aria-current={isCurrent ? 'step' : undefined}
aria-label={ariaLabel}
onClick={() => onSelect(entry.id)}
>
<span className={styles.sessionTimelineTick} />
<span
className={styles.sessionTimelineDetails}
data-testid="session-timeline-detail"
data-title={entry.label}
data-detail={entry.detail}
data-scheduled-task={
entry.isScheduledTask ? 'true' : undefined
}
aria-hidden="true"
>
<span className={styles.sessionTimelineDetailsTitle}>
{entry.isScheduledTask && <TimelineClockIcon />}
<span className={styles.sessionTimelineDetailsTitleText}>
{entry.label}
</span>
</span>
<span className={styles.sessionTimelineDetailsDetail}>
{entry.detail}
</span>
</span>
</button>
</li>
);
})}
</ol>
</nav>
</div>
);
});
function joinClassNames(
...classNames: Array<string | undefined>
): string | undefined {
const result = classNames.filter(Boolean).join(' ');
return result || undefined;
}
const EMPTY_SESSION_TIMELINE_ENTRIES: SessionTimelineEntry[] = [];
function LoadingTranscriptSkeleton({ label }: { label: string }) {
return (
<>
<div role="status" aria-live="polite" className={styles.srOnly}>
{label}
</div>
<div
className={styles.loadingSkeleton}
data-testid="message-list-loading-skeleton"
aria-hidden="true"
>
<div className={styles.loadingSkeletonUserRow}>
<div className={styles.loadingSkeletonUserBubble}>
<span className={styles.loadingSkeletonLineWide} />
<span className={styles.loadingSkeletonLineShort} />
</div>
</div>
<div className={styles.loadingSkeletonAssistantRow}>
<div className={styles.loadingSkeletonAssistantBlock}>
<span className={styles.loadingSkeletonLineMedium} />
<span className={styles.loadingSkeletonLineWide} />
<span className={styles.loadingSkeletonLineNarrow} />
</div>
</div>
<div className={styles.loadingSkeletonUserRow}>
<div className={styles.loadingSkeletonUserBubbleCompact}>
<span className={styles.loadingSkeletonLineMedium} />
</div>
</div>
<div className={styles.loadingSkeletonAssistantRow}>
<div className={styles.loadingSkeletonAssistantBlock}>
<span className={styles.loadingSkeletonLineWide} />
<span className={styles.loadingSkeletonLineMedium} />
</div>
</div>
</div>
</>
);
}
export const MessageList = memo(
forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
messages,
pendingApproval,
onShowContextDetail,
loadingTranscript,
catchingUp,
isResponding = false,
activeTurnStartedAt,
welcomeHeader,
workspaceCwd,
tailContent,
tailKey = 'tail',
virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD,
autoScrollTailIntoView = false,
hideSessionTimeline = false,
showRetryHint = false,
onRetryClick,
onBranchSession,
onCanScrollToBottomChange,
},
ref,
) {
const { t } = useI18n();
const compactMode = useContext(CompactModeContext);
const mergedMessages = useMemo(
() =>
compactMode
? mergeCompactToolGroups(messages, pendingApproval)
: messages,
[compactMode, messages, pendingApproval],
);
const displayItems = useMemo(
() => groupParallelAgents(mergedMessages),
[mergedMessages],
);
const [isSessionTimelineVisible, setIsSessionTimelineVisible] =
useState(false);
const sessionTimelineCache = useRef<{
signature: string;
entries: SessionTimelineEntry[];
} | null>(null);
// Signature + entries are O(transcript text); only pay for them while the
// rail can actually show (container >= 1160px — never on mobile).
const sessionTimelineEntries = useMemo(() => {
if (!isSessionTimelineVisible) return EMPTY_SESSION_TIMELINE_ENTRIES;
const signature = getSessionTimelineSignature(mergedMessages);
if (sessionTimelineCache.current?.signature !== signature) {
sessionTimelineCache.current = {
signature,
entries: getSessionTimelineEntries(mergedMessages),
};
}
return sessionTimelineCache.current.entries;
}, [isSessionTimelineVisible, mergedMessages]);
const sessionTimelineEntryIndexById = useMemo(
() =>
new Map(
sessionTimelineEntries.map((entry, index) => [entry.id, index]),
),
[sessionTimelineEntries],
);
const fallbackCurrentTimelineTurnId = useMemo(
() => getLastTurnStartMessageId(mergedMessages),
[mergedMessages],
);
const [sessionTimelineRange, setSessionTimelineRange] =
useState<SessionTimelineRange | null>(null);
const currentTimelineTurnId =
sessionTimelineRange !== null
? (sessionTimelineEntries[sessionTimelineRange.currentIndex]?.id ??
fallbackCurrentTimelineTurnId)
: fallbackCurrentTimelineTurnId;
const lastCompletedAssistantId = useMemo(() => {
if (isResponding) return null;
for (let i = mergedMessages.length - 1; i >= 0; i -= 1) {
const message = mergedMessages[i];
if (
message &&
(message.role === 'tool_group' || message.role === 'plan')
) {
return null;
}
if (
message?.role === 'assistant' &&
!message.isStreaming &&
message.content?.trim()
) {
return message.id;
}
}
return null;
}, [isResponding, mergedMessages]);
const finalAssistantIdsByTurn = useMemo(
() => collectFinalAssistantIdsByTurn(displayItems, isResponding),
[displayItems, isResponding],
);
// ── Per-turn collapse ────────────────────────────────────────────────
// Completed turns fold down to their prompt + final answer (toggle on the
// prompt row). `collapseOverrides` records explicit user toggles keyed by
// the turn's user-message id; turns absent from it follow the default
// (collapsed once complete). `displayItems` stays the full, pre-collapse
// list — used only to locate rows hidden inside a collapsed turn — while
// `visibleItems` is what actually renders.
const { collapseCompletedTurns } = useWebShellCustomization();
const collapseEnabled = collapseCompletedTurns ?? true;
const [collapseOverrides, setCollapseOverrides] = useState<
ReadonlyMap<string, boolean>
>(() => new Map());
const shouldFollow = useRef(true);
const lastScrollTop = useRef(0);
const scrollCooldown = useRef(false);
const scrollCooldownCount = useRef(0);
const sessionTimelineFrame = useRef<number | null>(null);
const lastReportedCanScrollToBottom = useRef<boolean | null>(null);
const didTrackLastUserMsgRef = useRef(false);
const prevLastUserMsgId = useRef<string | null>(null);
const pendingNewUserSmoothScroll = useRef(false);
const prevLoadingTranscript = useRef(loadingTranscript);
const prevActiveExecutionKey = useRef<string | null>(null);
const prevCatchingUp: MutableRefObject<boolean | undefined> =
useRef(catchingUp);
const catchingUpRef = useRef(catchingUp);
const prevHasTailContent = useRef(false);
const pendingFollowRecheck = useRef(false);
const pendingFollowRecheckFrame = useRef<number | undefined>(undefined);
const pendingFollowRecheckTimer = useRef<number | undefined>(undefined);
const pendingOverflowFrame = useRef<number | undefined>(undefined);
catchingUpRef.current = catchingUp;
const containerRef = useRef<HTMLDivElement>(null);
const reportCanScrollToBottom = useCallback(() => {
const el = containerRef.current;
const distanceFromBottom = el
? el.scrollHeight - el.scrollTop - el.clientHeight
: 0;
const canScrollToBottom = !shouldFollow.current && distanceFromBottom > 1;
if (lastReportedCanScrollToBottom.current === canScrollToBottom) return;
lastReportedCanScrollToBottom.current = canScrollToBottom;
onCanScrollToBottomChange?.(canScrollToBottom);
}, [onCanScrollToBottomChange]);
const scheduleScrollOverflowReport = useCallback(() => {
if (pendingOverflowFrame.current !== undefined) {
window.cancelAnimationFrame(pendingOverflowFrame.current);
}
pendingOverflowFrame.current = window.requestAnimationFrame(
reportCanScrollToBottom,
);
}, [reportCanScrollToBottom]);
const setShouldFollow = useCallback(
(value: boolean) => {
if (shouldFollow.current === value) return;
shouldFollow.current = value;
scheduleScrollOverflowReport();
},
[scheduleScrollOverflowReport],
);
const visibleItems = useMemo(
() =>
applyTurnCollapse(displayItems, {
overrides: collapseOverrides,
isResponding,
activeTurnStartedAt,
pendingApprovalCallId: pendingApproval?.toolCallId ?? null,
enabled: collapseEnabled,
}),
[
displayItems,
collapseOverrides,
isResponding,
activeTurnStartedAt,
pendingApproval?.toolCallId,
collapseEnabled,
],
);
const visibleTurnIdByDisplayIndex = useMemo(
() => getTurnIdByDisplayIndex(visibleItems),
[visibleItems],
);
const hasEnoughSessionTimelineEntries =
sessionTimelineEntries.length >= SESSION_TIMELINE_MIN_VISIBLE_ENTRIES;
useLayoutEffect(() => {
if (hideSessionTimeline) {
setIsSessionTimelineVisible((prev) => (prev ? false : prev));
return;
}
const el = containerRef.current;
if (!el) return;
const updateVisibility = () => {
const width = el.getBoundingClientRect().width;
const nextVisible = width >= 1160;
setIsSessionTimelineVisible((prev) =>
prev === nextVisible ? prev : nextVisible,
);
};
updateVisibility();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(updateVisibility);
observer.observe(el);
return () => observer.disconnect();
}, [hideSessionTimeline]);
// ── Scroll-follow state ──────────────────────────────────────────────
//
// The scroll behavior follows 6 rules:
//
// 1. Default follow-bottom — while the user is looking at the bottom,
// new content (streaming tokens, tool cards expanding, approval
// cards appearing, any height change) keeps the viewport pinned
// to the latest output.
//
// 2. Scroll-up pauses follow — if the user scrolls up, the page
// assumes they want to read history and stops auto-scrolling.
// Even if the model is still streaming, the viewport stays put.
//
// 3. Scroll-back-to-bottom resumes — when the user scrolls back
// near the bottom (within FOLLOW_BOTTOM_THRESHOLD_PX), follow mode
// re-engages
// and new content resumes sticking.
//
// 4. New message resets follow — after the user sends a message,
// follow mode is forced on so the model's reply scrolls in
// naturally.
//
// 5. Session restore / reconnect — during history replay
// (`catchingUp === true`), all auto-scrolling is suppressed to
// avoid fighting the rapidly replaying transcript. Once replay
// finishes (`catchingUp` flips to falsy), a single scroll-to-
// bottom fires so the user lands at the latest content.
//
// 6. Short content — if the content doesn't overflow the container
// (no scrollbar), scrollToBottom is a no-op. This avoids a
// visual flash when the model just started replying with a
// short first chunk.
//
// Implementation: three refs, three effects, one scroll handler.
//
// - `shouldFollow` — whether auto-scroll is active
// - `lastScrollTop` — previous scrollTop for direction detection
// - `prevLastUserMsgId` — tracks when a new user message appears
// - `prevCatchingUp` — tracks the catchingUp → ready transition
//
// The single auto-scroll driver is a `useLayoutEffect` on
// `totalVirtualSize` (the virtualizer's computed content height).
// Every height change — streaming text, card expand, approval
// appearance — flows through this one effect.
// ─────────────────────────────────────────────────────────────────────
const hasTailContent = tailContent !== undefined && tailContent !== null;
const showLoadingSkeleton = Boolean(loadingTranscript);
const hasHeader = !!welcomeHeader;
const headerOffset = hasHeader ? 1 : 0;
const tailContentIndex = headerOffset + visibleItems.length;
const totalCount = tailContentIndex + (hasTailContent ? 1 : 0);
const useVirtualScroll = shouldUseVirtualScroll(
totalCount,
virtualScrollThreshold,
);
const getScrollElement = useCallback((): HTMLElement | null => {
return containerRef.current;
}, []);
const recheckFollowFromScrollGeometry = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const distanceFromBottom =
el.scrollHeight - el.scrollTop - el.clientHeight;
setShouldFollow(distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX);
scheduleScrollOverflowReport();
}, [scheduleScrollOverflowReport, setShouldFollow]);
const scheduleFollowRecheck = useCallback(() => {
pendingFollowRecheck.current = true;
if (pendingFollowRecheckFrame.current !== undefined) {
window.cancelAnimationFrame(pendingFollowRecheckFrame.current);
}
if (pendingFollowRecheckTimer.current !== undefined) {
window.clearTimeout(pendingFollowRecheckTimer.current);
}
pendingFollowRecheckFrame.current = window.requestAnimationFrame(
recheckFollowFromScrollGeometry,
);
// Turn content uses a 180ms grid transition. The real scrollHeight can
// cross the overflow threshold only after the animation advances, so do a
// final geometry read once the expansion has settled.
pendingFollowRecheckTimer.current = window.setTimeout(() => {
pendingFollowRecheck.current = false;
pendingFollowRecheckFrame.current = undefined;
pendingFollowRecheckTimer.current = undefined;
recheckFollowFromScrollGeometry();
}, 220);
}, [recheckFollowFromScrollGeometry]);
useEffect(
() => () => {
if (pendingFollowRecheckFrame.current !== undefined) {
window.cancelAnimationFrame(pendingFollowRecheckFrame.current);
}
if (pendingFollowRecheckTimer.current !== undefined) {
window.clearTimeout(pendingFollowRecheckTimer.current);
}
if (pendingOverflowFrame.current !== undefined) {
window.cancelAnimationFrame(pendingOverflowFrame.current);
}
},
[],
);
const handleToggleCollapse = useCallback(
(turnId: string, nextExpanded: boolean) => {
// Expanding/collapsing a turn is an explicit reading action. Pause
// follow so streaming output does not yank the viewport back to the
// tail while the user is inspecting history.
const el = containerRef.current;
// If there is no scrollbar yet, there is no meaningful "not at
// bottom" state to report. The toggle may create overflow though, so
// re-check after the expanded/collapsed rows have been laid out.
if (!el || el.scrollHeight > el.clientHeight + 1) {
setShouldFollow(false);
}
scheduleFollowRecheck();
setCollapseOverrides((prev) => {
const next = new Map(prev);
next.set(turnId, nextExpanded);
return next;
});
},
[scheduleFollowRecheck, setShouldFollow],
);
const handleDisclosureClickCapture = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (!target.closest('[aria-expanded]')) return;
setShouldFollow(false);
scheduleFollowRecheck();
},
[scheduleFollowRecheck, setShouldFollow],
);
const getItemKey = useCallback(
(index: number) => {
if (hasHeader && index === HEADER_INDEX) return 'slot:header';
if (hasTailContent && index === tailContentIndex) {
return `slot:tail:${tailKey}`;
}
const item = visibleItems[index - headerOffset];
return item ? getDisplayItemVirtualKey(item) : `slot:row:${index}`;
},
[
hasHeader,
hasTailContent,
tailContentIndex,
tailKey,
visibleItems,
headerOffset,
],
);
// Rule 6: skip if content doesn't overflow (no scrollbar).
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'auto') => {
const el = getScrollElement();
if (!el) return;
if (el.scrollHeight <= el.clientHeight) return;
scrollCooldownCount.current += 1;
const gen = scrollCooldownCount.current;
scrollCooldown.current = true;
if (behavior === 'smooth') {
el.scrollTo({ top: el.scrollHeight, behavior });
} else {
el.scrollTop = el.scrollHeight;
}
scheduleScrollOverflowReport();
lastScrollTop.current = Math.max(0, el.scrollHeight - el.clientHeight);
reportCanScrollToBottom();
const releaseCooldown = () => {
if (scrollCooldownCount.current === gen) {
scrollCooldown.current = false;
}
};
if (behavior === 'smooth') {
setTimeout(releaseCooldown, 350);
} else {
requestAnimationFrame(releaseCooldown);
}
},
[getScrollElement, reportCanScrollToBottom, scheduleScrollOverflowReport],
);
const resumeBottomFollow = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
setShouldFollow(true);
scrollToBottom(behavior);
},
[scrollToBottom, setShouldFollow],
);
const virtualizer = useVirtualizer({
count: totalCount,
enabled: useVirtualScroll,
getScrollElement,
getItemKey,
estimateSize: (index) => {
if (hasHeader && index === HEADER_INDEX) return ESTIMATE_HEADER;
if (hasTailContent && index === tailContentIndex) return ESTIMATE_TAIL;
const item = visibleItems[index - headerOffset];
if (item?.type === 'turn_collapse') return ESTIMATE_TURN_COLLAPSE;
if (item?.type === 'turn_content') {
return Math.max(
ESTIMATE_MESSAGE,
item.items.length * ESTIMATE_MESSAGE,
);
}
return ESTIMATE_MESSAGE;
},
overscan: 20,
useFlushSync: false,
useAnimationFrameWithResizeObserver: true,
});
const virtualItems = virtualizer.getVirtualItems();
const totalVirtualSize = virtualizer.getTotalSize();
const sessionTimelineRangeState = useRef<{
entryIndexById: ReadonlyMap<string, number>;
headerOffset: number;
isVisible: boolean;
turnIdByDisplayIndex: readonly (string | null)[];
visibleItems: readonly DisplayItem[];
}>({
entryIndexById: new Map(),
headerOffset: 0,
isVisible: false,
turnIdByDisplayIndex: [],
visibleItems: [],
});
sessionTimelineRangeState.current = {
entryIndexById: sessionTimelineEntryIndexById,
headerOffset,
isVisible: isSessionTimelineVisible,
turnIdByDisplayIndex: visibleTurnIdByDisplayIndex,
visibleItems,
};
const updateSessionTimelineRange = useCallback(() => {
const el = getScrollElement();
const state = sessionTimelineRangeState.current;
if (!el || !state.isVisible || state.entryIndexById.size === 0) {
setSessionTimelineRange((prev) => (prev === null ? prev : null));
return;
}
const viewportRect = el.getBoundingClientRect();
const viewportTop = viewportRect.top;
const viewportBottom = viewportRect.bottom;
const viewportCenter = viewportTop + (viewportBottom - viewportTop) / 2;
const visibleItemIndexes: number[] = [];
let currentItemIndex: number | null = null;
let closestDistance = Number.POSITIVE_INFINITY;
el.querySelectorAll<HTMLElement>('[data-index]').forEach((row) => {
const rawIndex = row.dataset.index;
if (rawIndex === undefined) return;
const rowIndex = Number(rawIndex);
if (!Number.isFinite(rowIndex)) return;
const visibleItemIndex = rowIndex - state.headerOffset;
if (
visibleItemIndex < 0 ||
visibleItemIndex >= state.visibleItems.length
) {
return;
}
const rowRect = row.getBoundingClientRect();
if (rowRect.bottom < viewportTop || rowRect.top > viewportBottom) {
return;
}
visibleItemIndexes.push(visibleItemIndex);
const rowCenter = rowRect.top + (rowRect.bottom - rowRect.top) / 2;
const distance = Math.abs(rowCenter - viewportCenter);
if (distance < closestDistance) {
closestDistance = distance;
currentItemIndex = visibleItemIndex;
}
});
const next = getSessionTimelineRangeForIndexes(
state.visibleItems,
visibleItemIndexes,
state.entryIndexById,
currentItemIndex,
state.turnIdByDisplayIndex,
);
setSessionTimelineRange((prev) => {
if (
prev?.startIndex === next?.startIndex &&
prev?.endIndex === next?.endIndex &&
prev?.currentIndex === next?.currentIndex
) {
return prev;
}
return next;
});
}, [getScrollElement]);
const scheduleSessionTimelineRangeUpdate = useCallback(() => {
if (sessionTimelineFrame.current !== null) {
cancelAnimationFrame(sessionTimelineFrame.current);
}
sessionTimelineFrame.current = requestAnimationFrame(() => {
sessionTimelineFrame.current = null;
updateSessionTimelineRange();
});
}, [updateSessionTimelineRange]);
useEffect(
() => () => {
if (sessionTimelineFrame.current !== null) {
cancelAnimationFrame(sessionTimelineFrame.current);
sessionTimelineFrame.current = null;
}
},
[],
);
useEffect(() => {
scheduleSessionTimelineRangeUpdate();
}, [
scheduleSessionTimelineRangeUpdate,
totalCount,
totalVirtualSize,
useVirtualScroll,
virtualItems.length,
isSessionTimelineVisible,
]);
// Imperative scroll-to-message (e.g. the floating TodoPanel's "show in
// transcript" button) with a brief highlight on the target message.
const [flashTarget, setFlashTarget] = useState<LocateFlashTarget | null>(
null,
);
useEffect(() => {
if (!flashTarget) return;
const timer = setTimeout(() => setFlashTarget(null), 1600);
return () => clearTimeout(timer);
}, [flashTarget]);
// Scroll a visible row to center and flash the target message inside it.
const performScrollToRow = useCallback(
(rowIndex: number, target: LocateFlashTarget) => {
// Explicit navigation away from the tail — pause follow so the
// auto-scroll driver doesn't yank the viewport straight back down,
// and engage the same cooldown scrollToBottom uses so the scroll
// events this triggers short-circuit handleScroll. Without it, Rule 3
// (near-bottom → resume follow) would re-enable follow whenever the
// target sits near the bottom, and the next streaming height change
// would pull the viewport back to the tail. An instant (non-smooth)
// scroll keeps that cooldown window short and deterministic.
setShouldFollow(false);
scrollCooldownCount.current += 1;
const gen = scrollCooldownCount.current;
scrollCooldown.current = true;
if (useVirtualScroll) {
virtualizer.scrollToIndex(rowIndex, { align: 'center' });
} else {
containerRef.current
?.querySelector(`[data-index="${rowIndex}"]`)
?.scrollIntoView({ block: 'center' });
}
// Release once the scroll has settled (the virtualizer may re-scroll
// a frame or two later after measuring the target row).
setTimeout(() => {
if (scrollCooldownCount.current === gen) {
scrollCooldown.current = false;
scheduleSessionTimelineRangeUpdate();
scheduleScrollOverflowReport();
}
}, 150);
setFlashTarget(null);
requestAnimationFrame(() => setFlashTarget(target));
},
[
useVirtualScroll,
virtualizer,
setShouldFollow,
scheduleSessionTimelineRangeUpdate,
scheduleScrollOverflowReport,
],
);
const scrollToMessageState = useRef<{
visibleItems: readonly DisplayItem[];
displayItems: readonly DisplayItem[];
headerOffset: number;
performScrollToRow: (rowIndex: number, target: LocateFlashTarget) => void;
}>({
visibleItems: [],
displayItems: [],
headerOffset: 0,
performScrollToRow: () => {},
});
scrollToMessageState.current = {
visibleItems,
displayItems,
headerOffset,
performScrollToRow,
};
// A scroll target that currently sits inside a collapsed turn: expand the
// turn, then finish the scroll once its rows materialize in `visibleItems`.
const pendingScrollRef = useRef<LocateFlashTarget | null>(null);
const scrollToMessage = useCallback(
(messageId: string, callId?: string): boolean => {
const { visibleItems, displayItems, headerOffset, performScrollToRow } =
scrollToMessageState.current;
const visibleIndex = findDisplayItemIndex(
visibleItems,
messageId,
callId,
);
if (visibleIndex >= 0) {
const visibleItem = visibleItems[visibleIndex];
if (visibleItem?.type === 'turn_content' && visibleItem.collapsed) {
pendingScrollRef.current = { messageId, callId };
setCollapseOverrides((prev) => {
if (prev.get(visibleItem.turnId) === true) return prev;
const next = new Map(prev);
next.set(visibleItem.turnId, true);
return next;
});
return true;
}
pendingScrollRef.current = null;
performScrollToRow(visibleIndex + headerOffset, {
messageId,
callId,
});
return true;
}
// Not on screen — it may be folded inside a collapsed turn. Locate it
// in the full list, expand that turn, and defer the scroll.
const fullIndex = findDisplayItemIndex(displayItems, messageId, callId);
if (fullIndex < 0) return false;
const turnId = findTurnIdForIndex(displayItems, fullIndex);
if (!turnId) return false;
pendingScrollRef.current = { messageId, callId };
setCollapseOverrides((prev) => {
if (prev.get(turnId) === true) return prev;
const next = new Map(prev);
next.set(turnId, true);
return next;
});
return true;
},
[],
);
useImperativeHandle(
ref,
() => ({ scrollToMessage, scrollToBottom: resumeBottomFollow }),
[scrollToMessage, resumeBottomFollow],
);
// Flush a deferred scroll once the expanded turn's rows are visible.
useEffect(() => {
const pending = pendingScrollRef.current;
if (!pending) return;
const idx = findDisplayItemIndex(
visibleItems,
pending.messageId,
pending.callId,
);
if (idx < 0) return;
pendingScrollRef.current = null;
performScrollToRow(idx + headerOffset, pending);
}, [visibleItems, headerOffset, performScrollToRow]);
// Rules 2 & 3: detect scroll direction to toggle follow mode.
// Runs synchronously in the scroll handler — no rAF needed since
// the browser already coalesces scroll events.
const handleScroll = useCallback(() => {
const el = getScrollElement();
if (!el) return;
if (scrollCooldown.current) {
lastScrollTop.current = el.scrollTop;
return;
}
scheduleSessionTimelineRangeUpdate();
const prev = lastScrollTop.current;
const curr = el.scrollTop;
lastScrollTop.current = curr;
const distanceFromBottom = el.scrollHeight - curr - el.clientHeight;
scheduleScrollOverflowReport();
// Rule 2: scrolling up → pause follow
if (curr < prev - 1) {
// Container resizes can clamp scrollTop downward while the viewport is
// still at the tail. Treat that as follow mode, not a manual scroll-up.
setShouldFollow(distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX);
return;
}
// Rule 3: near bottom → resume follow
// Run only after non-upward scrolls. Otherwise a tiny wheel-up near the
// tail would pause follow and immediately re-enable it in the same event.
if (distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX) {
setShouldFollow(true);
}
}, [
getScrollElement,
scheduleScrollOverflowReport,
scheduleSessionTimelineRangeUpdate,
setShouldFollow,
]);
useEffect(() => {
const el = getScrollElement();
if (!el) return;
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [getScrollElement, handleScroll]);
useEffect(() => {
const el = getScrollElement();
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(scheduleScrollOverflowReport);
observer.observe(el);
for (const child of Array.from(el.children)) {
observer.observe(child);
}
const mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
if (node instanceof HTMLElement) observer.observe(node);
}
for (const node of Array.from(mutation.removedNodes)) {
if (node instanceof HTMLElement) observer.unobserve(node);
}
}
scheduleScrollOverflowReport();
});
mutationObserver.observe(el, { childList: true });
scheduleScrollOverflowReport();
return () => {
observer.disconnect();
mutationObserver.disconnect();
};
}, [getScrollElement, scheduleScrollOverflowReport]);
// Clear screen (e.g. /clear) → reset to follow mode, drop stale per-turn
// collapse overrides, and disarm any deferred scroll so it can't fire
// against the next session.
useEffect(() => {
if (messages.length === 0) {
setShouldFollow(true);
pendingScrollRef.current = null;
setCollapseOverrides((prev) => (prev.size ? new Map() : prev));
}
}, [messages.length, setShouldFollow]);
// Container-resize guard: when floating panels (e.g. TodoPanel)
// appear or disappear the scroll container's clientHeight changes.
// Snap back to bottom so the user doesn't lose their place while
// follow mode is active.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
scheduleSessionTimelineRangeUpdate();
if (catchingUpRef.current) return;
if (!shouldFollow.current) return;
requestAnimationFrame(() => {
if (!catchingUpRef.current && shouldFollow.current) {
scrollToBottom();
}
});
});
observer.observe(el);
return () => observer.disconnect();
}, [scrollToBottom, scheduleSessionTimelineRangeUpdate]);
// Rule 4: new user message → force follow on so the model's reply
// scrolls into view as it streams in.
useLayoutEffect(() => {
const lastId = getLastUserMessageId(messages);
if (catchingUp || loadingTranscript || prevLoadingTranscript.current) {
prevLastUserMsgId.current = lastId;
didTrackLastUserMsgRef.current = true;
pendingNewUserSmoothScroll.current = false;
prevLoadingTranscript.current = loadingTranscript;
return;
}
prevLoadingTranscript.current = loadingTranscript;
if (!didTrackLastUserMsgRef.current) {
prevLastUserMsgId.current = lastId;
didTrackLastUserMsgRef.current = true;
pendingNewUserSmoothScroll.current = false;
return;
}
const lastMessage = getLastMessage(messages);
if (
lastId &&
lastMessage?.role === 'user' &&
lastId !== prevLastUserMsgId.current
) {
setShouldFollow(true);
// A new prompt supersedes any pending "Show in transcript" scroll.
pendingScrollRef.current = null;
pendingNewUserSmoothScroll.current = true;
} else {
pendingNewUserSmoothScroll.current = false;
}
prevLastUserMsgId.current = lastId;
}, [messages, catchingUp, loadingTranscript, setShouldFollow]);
// Rule 5: session restore — when catchingUp flips from true → falsy,
// replay just finished. Scroll to bottom once so the user sees the
// latest content without the viewport fighting the replay.
useLayoutEffect(() => {
if (prevCatchingUp.current && !catchingUp) {
setShouldFollow(true);
scrollToBottom('auto');
}
prevCatchingUp.current = catchingUp;
}, [catchingUp, scrollToBottom, setShouldFollow]);
const runningExecutionKey = useMemo(
() => latestActiveExecutionKey(visibleItems),
[visibleItems],
);
// Tool summaries and parallel-agent boxes can grow after their first
// render, which used to leave the row clipped behind the fixed composer.
// Instead of observing every row resize (too noisy while streaming), scroll
// once when a new execution row starts, and only while the user is already
// following the bottom.
useLayoutEffect(() => {
if (catchingUp) return;
if (!runningExecutionKey) {
prevActiveExecutionKey.current = null;
return;
}
if (runningExecutionKey === prevActiveExecutionKey.current) return;
prevActiveExecutionKey.current = runningExecutionKey;
if (shouldFollow.current) {
requestAnimationFrame(() => {
if (shouldFollow.current) {
scrollToBottom();
}
});
}
}, [catchingUp, runningExecutionKey, scrollToBottom]);
// Rule 6: an inline picker/dialog (tailContent) just appeared. It renders
// at the very bottom of the virtualized list, so if the user had scrolled
// up it would open below the fold and the action would look like a no-op.
// Only opt-in callers (autoScrollTailIntoView) force-follow it into view, so
// unrelated tail panels keep the reader's scroll position.
useEffect(() => {
if (
autoScrollTailIntoView &&
hasTailContent &&
!prevHasTailContent.current
) {
setShouldFollow(true);
// Re-check follow inside the frame: if the user scrolls up in the gap
// before it fires (Rule 2 clears the flag), don't fight them.
requestAnimationFrame(() => {
if (shouldFollow.current) scrollToBottom();
});
}
prevHasTailContent.current = hasTailContent;
}, [
autoScrollTailIntoView,
hasTailContent,
scrollToBottom,
setShouldFollow,
]);
const renderVirtualItem = useCallback(
(index: number) => {
const renderDisplayItem = (
displayItem: DisplayItem,
isLatest: boolean,
): ReactNode => {
if (displayItem.type === 'parallel_agents') {
return (
<MessageTimestamp timestamp={displayItem.timestamp}>
<div
className={
displayItemMatchesLocateTarget(displayItem, flashTarget)
? flashStyles.flash
: undefined
}
>
<ParallelAgentsGroup
agents={displayItem.agents}
pendingApproval={pendingApproval}
/>
</div>
</MessageTimestamp>
);
}
if (displayItem.type === 'turn_collapse') {
return (
<TurnCollapseRow
turnCollapse={displayItem.turnCollapse}
onToggleCollapse={handleToggleCollapse}
/>
);
}
if (displayItem.type === 'turn_content') {
return (
<TurnContent collapsed={displayItem.collapsed}>
{displayItem.items.map((child) => (
<div
key={getDisplayItemVirtualKey(child)}
className={getChatRowClassName(child)}
>
{renderDisplayItem(child, false)}
</div>
))}
</TurnContent>
);
}
return (
<MessageItem
message={displayItem.message}
pendingApproval={pendingApproval}
onShowContextDetail={onShowContextDetail}
workspaceCwd={workspaceCwd}
isLatest={isLatest}
showRetryHint={showRetryHint}
onRetryClick={onRetryClick}
onBranchSession={onBranchSession}
showAssistantActions={
displayItem.message.role === 'assistant' &&
finalAssistantIdsByTurn.has(displayItem.message.id)
}
showAssistantBranch={
displayItem.message.role === 'assistant' &&
displayItem.message.id === lastCompletedAssistantId
}
isLocateFlashing={displayItemMatchesLocateTarget(
displayItem,
flashTarget,
)}
/>
);
};
if (hasHeader && index === HEADER_INDEX) {
return welcomeHeader;
}
if (hasTailContent && index === tailContentIndex) {
return tailContent;
}
const itemIndex = index - headerOffset;
const item = visibleItems[itemIndex];
if (!item) return null;
return renderDisplayItem(item, itemIndex === visibleItems.length - 1);
},
[
hasHeader,
welcomeHeader,
hasTailContent,
tailContent,
tailContentIndex,
pendingApproval,
onShowContextDetail,
headerOffset,
visibleItems,
flashTarget,
finalAssistantIdsByTurn,
lastCompletedAssistantId,
workspaceCwd,
showRetryHint,
onRetryClick,
onBranchSession,
handleToggleCollapse,
],
);
const getRowClassName = useCallback(
(item?: DisplayItem): string | undefined =>
item ? getChatRowClassName(item) : undefined,
[],
);
// ── Single auto-scroll driver (rules 1, 5, 6) ──────────────────────
// Fires whenever the virtualizer's total content height changes —
// this captures every scenario: streaming tokens appending, tool
// cards expanding/collapsing, approval cards appearing, etc.
//
// Rule 5: during replay (catchingUp) → skip, avoid fighting rapid
// transcript replay. The catchingUp→ready transition effect
// above handles the final scroll.
// Rule 1: when shouldFollow is true → scroll to bottom.
// Rule 6: scrollToBottom itself checks scrollHeight <= clientHeight
// and is a no-op when there's no overflow.
useLayoutEffect(() => {
if (catchingUp) return;
const isNewUserMessage = pendingNewUserSmoothScroll.current;
if (scrollCooldown.current && !isNewUserMessage) return;
// Preserve the new-prompt scroll even if a previous disclosure resize is
// still settling; it targets the latest virtualizer size from this render.
if (pendingFollowRecheck.current && !isNewUserMessage) return;
if (shouldFollow.current || isNewUserMessage) {
scrollToBottom(isNewUserMessage ? 'smooth' : 'auto');
pendingNewUserSmoothScroll.current = false;
}
}, [totalVirtualSize, messages, totalCount, catchingUp, scrollToBottom]);
useLayoutEffect(() => {
scheduleScrollOverflowReport();
}, [messages, scheduleScrollOverflowReport, totalCount, totalVirtualSize]);
return (
<div
ref={containerRef}
className={styles.list}
onClickCapture={handleDisclosureClickCapture}
>
{showLoadingSkeleton && (
<LoadingTranscriptSkeleton label={t('editor.sessionLoading')} />
)}
<SessionTimeline
entries={sessionTimelineEntries}
currentTurnId={currentTimelineTurnId}
currentRange={sessionTimelineRange}
hidden={!isSessionTimelineVisible || !hasEnoughSessionTimelineEntries}
onSelect={scrollToMessage}
/>
{useVirtualScroll ? (
<div
className={styles.virtualSizer}
style={{
height: totalVirtualSize,
}}
>
{virtualItems.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={joinClassNames(
styles.virtualRow,
getRowClassName(
visibleItems[virtualRow.index - headerOffset],
),
)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderVirtualItem(virtualRow.index)}
</div>
))}
</div>
) : (
Array.from({ length: totalCount }, (_, index) => {
const key = getItemKey(index);
const item = visibleItems[index - headerOffset];
return (
<div
key={key}
data-index={index}
className={getRowClassName(item)}
>
{renderVirtualItem(index)}
</div>
);
})
)}
</div>
);
}),
);