qwen-code/packages/web-shell/client/components/MessageList.tsx
Shaojin Wen 06ead8f4ff
fix(core): unblock history pagination on oversized transcript turns (#8335)
* fix(core): ride indivisible transcript pages over the read budget

Backward history pagination dead-ended with HTTP 413 whenever a single
turn exceeded the 4 MiB page budget: a turn cannot be split across
pages, so the reader threw SessionTranscriptPageTooLargeError and the
Web Shell latched a permanent pagination error banner. Take at least
one indivisible unit per page (one aggregate record forward, one turn
backward) so pagination always makes progress; the 32 MiB response
serialization cap remains the hard ceiling.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(web-shell): add retry button for history pagination error

A non-retryable transcript page failure (4xx, partial replay) latched
paginationError with no in-UI recovery short of reloading the session.
The banner now offers a retry that force-clears the latch and refetches
the same page, whose cursor was never advanced by the failed attempt.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-01 18:39:05 +00:00

3925 lines
129 KiB
TypeScript

import {
forwardRef,
memo,
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useCallback,
useMemo,
useState,
useTransition,
type CSSProperties,
type ReactNode,
type FocusEvent as ReactFocusEvent,
type MouseEvent as ReactMouseEvent,
type MutableRefObject,
} from 'react';
import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
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,
type WebShellAssistantTurnFooterRenderInfo,
} from '../customization';
import { useI18n } from '../i18n';
import { useWebShellPortalRoot } from '../portalRoot';
import { MessageItem } from './MessageItem';
import type { SessionContentGenerator } from './messages/AssistantMessage';
import { MessageTimestamp } from './MessageTimestamp';
import {
TurnOutputs,
type TurnOutputFileChange,
type TurnOutputOpenRequest,
type TurnOutputScheduledTask,
} from './artifacts/TurnOutputs';
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';
import { WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS } from '../constants/sessions';
const noopTurnOutputAction = () => undefined;
const RELOAD_TRANSCRIPT_DELAY_MS = 120_000;
const TURN_LAYOUT_ANIMATION_MS = 180;
interface MessageListProps {
messages: Message[];
pendingApproval: PermissionRequest | null;
/** Run /context detail, exactly like typing it (context-usage panels). */
onShowContextDetail?: () => void;
loadingTranscript?: boolean;
catchingUp?: boolean;
hasOlderHistory?: boolean;
loadingOlderHistory?: boolean;
historyCapacityReached?: boolean;
historyPaginationError?: boolean;
onLoadOlderHistory?: (options?: { force?: boolean }) => Promise<void>;
transcriptBlockCount?: number;
transcriptActivity?: {
getSnapshot(): {
lastEventId?: number;
blocks?: { readonly length: number };
};
subscribe(listener: () => void): () => void;
};
onReloadTranscript?: (signal: AbortSignal) => Promise<void>;
/**
* 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;
centerWelcomeHeader?: boolean;
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;
/**
* Height reserved for app-level floating UI below the transcript, such as the
* bottom todo/status panel. When it changes while the transcript is following
* the bottom, perform one more bottom alignment after layout settles.
*/
bottomOverlayInset?: number;
hideSessionTimeline?: boolean;
hideFirstUserMessage?: boolean;
firstTurnMetrics?: {
durationMs?: number;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
};
includeSubagentToolUsageInMetrics?: boolean;
showRetryHint?: boolean;
onRetryClick?: () => void;
failedPromptMessageId?: string;
onRetryFailedPrompt?: () => void;
onBranchSession?: () => void;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
turnFileChanges?: ReadonlyMap<string, readonly TurnOutputFileChange[]>;
turnArtifacts?: ReadonlyMap<string, readonly DaemonSessionArtifact[]>;
turnScheduledTasks?: ReadonlyMap<string, readonly TurnOutputScheduledTask[]>;
onReviewChanges?: (
changes: readonly TurnOutputFileChange[],
selectedPath?: string,
) => void;
onOpenArtifact?: (artifactId: string, previewContent?: string) => void;
onOpenScheduledTask?: (task: TurnOutputScheduledTask) => void;
onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
onError?: (error: unknown, fallback: string) => void;
generateContent?: SessionContentGenerator;
}
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: 'parallel_agents';
key: string;
turnId: 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;
}
| {
type: 'turn_outputs';
key: string;
turnId: string;
changes: readonly TurnOutputFileChange[];
artifacts: readonly DaemonSessionArtifact[];
scheduledTasks: readonly TurnOutputScheduledTask[];
};
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}`,
turnId: 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}`,
turnId: 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_outputs') return `outputs:${item.key}`;
if (item.type === 'turn_collapse') {
const liveKey = item.turnCollapse.liveStartedAt;
return liveKey === undefined
? `tc:${item.key}`
: `tc:${item.key}:${liveKey}`;
}
return `msg:${item.key}`;
}
export function attachTurnOutputs(
items: DisplayItem[],
isResponding: boolean,
turnFileChanges?: ReadonlyMap<string, readonly TurnOutputFileChange[]>,
turnArtifacts?: ReadonlyMap<string, readonly DaemonSessionArtifact[]>,
turnScheduledTasks?: ReadonlyMap<string, readonly TurnOutputScheduledTask[]>,
): DisplayItem[] {
if (
(!turnFileChanges || turnFileChanges.size === 0) &&
(!turnArtifacts || turnArtifacts.size === 0) &&
(!turnScheduledTasks || turnScheduledTasks.size === 0)
) {
return items;
}
const result: DisplayItem[] = [];
let currentTurnId: string | null = null;
const pushTurnOutputs = (turnId: string | null, isFinalTurn: boolean) => {
if (isFinalTurn && isResponding) return;
if (!turnId) return;
const changes = turnFileChanges?.get(turnId) ?? [];
const artifacts = turnArtifacts?.get(turnId) ?? [];
const scheduledTasks = turnScheduledTasks?.get(turnId) ?? [];
if (
changes.length === 0 &&
artifacts.length === 0 &&
scheduledTasks.length === 0
) {
return;
}
result.push({
type: 'turn_outputs',
key: turnId,
turnId,
changes,
artifacts,
scheduledTasks,
});
};
for (const item of items) {
if (item.type === 'message' && isTurnStartMessage(item.message)) {
pushTurnOutputs(currentTurnId, false);
currentTurnId = item.message.id;
} else if (!currentTurnId && item.type === 'message') {
currentTurnId = item.message.id;
} else if (!currentTurnId && item.type === 'parallel_agents') {
currentTurnId = item.turnId;
}
result.push(item);
}
pushTurnOutputs(currentTurnId, true);
return result;
}
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;
includeSubagentToolUsageInMetrics?: boolean;
/** Master switch; when false the items pass through untouched. */
enabled: boolean;
}
function isFinalContentCandidate(
item: DisplayItem,
includeBackgroundNotifications: boolean,
): boolean {
return (
item.type === 'message' &&
(item.message.role === 'assistant' ||
(includeBackgroundNotifications &&
item.message.role === 'system' &&
item.message.source === 'background_notification')) &&
// `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,
includeBackgroundNotifications = true,
): 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 (isFinalContentCandidate(items[i]!, includeBackgroundNotifications)) {
return i;
}
}
return -1;
}
function collectFinalAssistantTurnIds(
items: readonly DisplayItem[],
isResponding: boolean,
): ReadonlyMap<string, 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 turnIdByAssistantId = new Map<string, 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 turnHead = items[start];
const answerIdx = findFinalAnswerIndex(items, start, end, false);
if (answerIdx < 0) continue;
const item = items[answerIdx];
if (
turnHead?.type === 'message' &&
item.type === 'message' &&
item.message.role === 'assistant'
) {
turnIdByAssistantId.set(item.message.id, turnHead.message.id);
}
}
return turnIdByAssistantId;
}
/**
* A turn's hideable "steps": tool activity, plans, mid-turn assistant text,
* and non-final background notifications. The final content and any other
* 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_outputs') return false;
if (item.type === 'turn_collapse') return false;
switch (item.message.role) {
case 'tool_group':
case 'plan':
return true;
case 'assistant':
return !isFinalAnswer;
case 'thinking':
return true;
case 'system':
if (item.message.source === 'background_notification') {
return !isFinalAnswer;
}
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.
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,
t?: (key: string, vars?: Record<string, string | number>) => string,
): TurnTimelineNode {
if (item.type === 'parallel_agents') {
return {
kind: 'agents',
timestamp: item.timestamp,
label: t ? t('timeline.parallelAgents') : 'Parallel agents',
};
}
if (item.type === 'turn_outputs') return { kind: 'none' };
if (item.type !== 'message') return { kind: 'none' };
const { message } = item;
switch (message.role) {
case 'thinking':
return {
kind: 'thought',
timestamp: message.timestamp,
label: t ? t('timeline.thinking') : '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: t ? t('timeline.assistantUpdate') : 'Assistant update',
};
case 'tool_group': {
const count = message.tools.length;
return {
kind: 'tool',
timestamp: message.timestamp,
label: t
? t('timeline.toolCalls', { count })
: `${count} tool call${count === 1 ? '' : 's'}`,
};
}
case 'plan':
return {
kind: 'plan',
timestamp: message.timestamp,
label: t ? t('timeline.planUpdate') : 'Plan update',
};
case 'system':
return isMidTurnInjectedDebugMessage(message)
? {
kind: 'status',
timestamp: message.timestamp,
label: t ? t('timeline.statusUpdate') : '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: {
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,
t?: (key: string, vars?: Record<string, string | number>) => string,
): string {
const raw =
message.role === 'user'
? message.content
: message.role === 'user_shell'
? message.command
: '';
const compact = compactTimelineText(raw, 32);
if (!compact) return t ? t('timeline.userTurn') : '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,
t?: (key: string, vars?: Record<string, string | number>) => string,
): string {
switch (message.role) {
case 'thinking':
// Thinking content may include private model reasoning; keep details label-only.
return t ? t('timeline.kind.thought') : 'thinking';
case 'assistant':
return compactTimelineText(message.content, 120, { stripMarkdown: true });
case 'tool_group': {
const count = message.tools.length;
return t
? t('timeline.toolCalls', { count })
: `${count} tool call${count === 1 ? '' : 's'}`;
}
case 'plan':
return t ? t('timeline.planDetail') : '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: {
const _exhaustive: never = message;
return '';
}
}
}
function timelineDetailSnippetForItem(
item: DisplayItem,
t?: (key: string, vars?: Record<string, string | number>) => string,
): string {
if (item.type === 'parallel_agents') {
const count = item.agents.length;
return t
? t('timeline.parallelAgentsDetail', { count })
: `${count} parallel agent${count === 1 ? '' : 's'}`;
}
if (item.type === 'turn_outputs') return '';
if (item.type !== 'message') return '';
return timelineDetailSnippetForMessage(item.message, t);
}
function getKindLabel(
kind: TurnTimelineNodeKind,
t?: (key: string, vars?: Record<string, string | number>) => string,
): string {
if (!t) return SESSION_TIMELINE_KIND_LABEL[kind];
return t(`timeline.kind.${kind}`);
}
function timelineDetailForTurn(
turnItems: readonly DisplayItem[],
finalAssistantId: string | null,
nodeKinds: readonly TurnTimelineNodeKind[],
t?: (key: string, vars?: Record<string, string | number>) => string,
): 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, t);
if (snippet) snippets.push(snippet);
}
const detail = compactTimelineText(snippets.join(' · '), 180);
if (detail) return detail;
if (nodeKinds.length > 0) {
return nodeKinds.map((kind) => getKindLabel(kind, t)).join(' · ');
}
return t ? t('timeline.noActivity') : 'No activity';
}
export function getSessionTimelineEntries(
messages: readonly Message[],
t?: (key: string, vars?: Record<string, string | number>) => string,
): 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,
false,
);
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, t);
if (node.kind !== 'none' && !nodeKinds.includes(node.kind)) {
nodeKinds.push(node.kind);
}
}
entries.push({
id: turnStart.id,
label: timelineLabelForTurn(turnStart, t),
detail: timelineDetailForTurn(
timelineItems,
finalAssistantId,
nodeKinds,
t,
),
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: {
const _exhaustive: never = message;
return base;
}
}
})
.join('|');
}
function isExecutionWorkStep(item: DisplayItem): boolean {
if (item.type === 'parallel_agents') return true;
if (item.type === 'turn_outputs') return false;
if (item.type === 'turn_collapse') return false;
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_outputs') 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;
}
/**
* Main-agent token usage contribution of a row. Subagent usage is carried by
* the root agent tool's execution summary and is added separately below.
*/
function itemAssistantUsage(item: DisplayItem):
| {
inputTokens: number;
outputTokens: number;
cachedTokens?: number;
}
| undefined {
return item.type === 'message' && item.message.role === 'assistant'
? item.message.usage
: undefined;
}
interface TurnTokenUsage {
inputTokens: number;
outputTokens: number;
cachedTokens?: number;
}
function finiteTokenCount(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? value
: undefined;
}
function subagentUsage(
tool: ACPToolCall,
): { callId: string; usage: TurnTokenUsage } | undefined {
if (tool.parentToolCallId || !isSubAgentToolCall(tool)) return undefined;
const raw =
tool.rawOutput && typeof tool.rawOutput === 'object'
? (tool.rawOutput as Record<string, unknown>)
: undefined;
const summary =
raw?.['executionSummary'] && typeof raw['executionSummary'] === 'object'
? (raw['executionSummary'] as Record<string, unknown>)
: undefined;
const inputTokens = finiteTokenCount(summary?.['inputTokens']);
const outputTokens = finiteTokenCount(summary?.['outputTokens']);
if (inputTokens === undefined && outputTokens === undefined) return undefined;
const cachedTokens = finiteTokenCount(summary?.['cachedTokens']);
return {
callId: tool.callId,
usage: {
inputTokens: inputTokens ?? 0,
outputTokens: outputTokens ?? 0,
...(cachedTokens !== undefined ? { cachedTokens } : {}),
},
};
}
function itemSubagentUsages(
item: DisplayItem,
): Array<{ callId: string; usage: TurnTokenUsage }> {
if (item.type === 'parallel_agents') {
return item.agents.flatMap((agent) => subagentUsage(agent) ?? []);
}
if (item.type !== 'message' || item.message.role !== 'tool_group') return [];
return item.message.tools.flatMap((tool) => subagentUsage(tool) ?? []);
}
function itemToolCallCount(item: DisplayItem): number {
if (item.type === 'parallel_agents') return item.agents.length;
if (item.type === 'turn_outputs') return 0;
if (item.type === 'turn_collapse') return 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,
includeSubagentToolUsageInMetrics = true,
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;
const countedSubagents = new Set<string>();
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;
}
if (includeSubagentToolUsageInMetrics) {
for (const subagent of itemSubagentUsages(item)) {
if (countedSubagents.has(subagent.callId)) continue;
countedSubagents.add(subagent.callId);
inputTokens += subagent.usage.inputTokens;
outputTokens += subagent.usage.outputTokens;
cachedTokens += subagent.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;
// 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) {
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'
) {
result.push({
...item,
turnCollapse: turnCollapseInfo,
});
} else {
result.push(item);
}
}
continue;
}
// Collapsed: omit hideable rows so their DOM and layout work disappear.
// Keep the final answer and non-step rows (errors, cancellations, command
// output) in their original places. Expanded rows remain individual
// virtualizer entries instead of one oversized turn wrapper.
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'
) {
result.push({
...item,
turnCollapse: turnCollapseInfo,
});
continue;
}
if (!isHideableStep(item, i === answerIdx)) result.push(item);
}
}
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_outputs') {
continue;
}
}
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_outputs') return false;
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;
const LOAD_OLDER_HISTORY_THRESHOLD_PX = 48;
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 && elapsedMs > 0
? 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_outputs') return styles.turnContentRow;
if (item.type !== 'message') return undefined;
if (item.turnCollapse) return styles.turnAnswerRow;
return undefined;
}
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',
};
type SessionTimelineTooltip = {
entry: SessionTimelineEntry;
top: number;
left: number;
clamped: boolean;
themeVars: CSSProperties;
};
const SESSION_TIMELINE_TOOLTIP_THEME_VARS = [
'--background',
'--foreground',
'--muted-foreground',
'--border',
'--font-sans',
];
const SESSION_TIMELINE_TOOLTIP_ID = 'session-timeline-detail-tooltip';
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;
}) {
const portalRoot = useWebShellPortalRoot();
const { t } = useI18n();
const panelRef = useRef<HTMLElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const programmaticScrollRef = useRef(false);
const focusScrollGuardRef = useRef(false);
const focusScrollGuardFrameRef = useRef<number | null>(null);
const focusScrollGuardFallbackRef = useRef<number | null>(null);
const [tooltip, setTooltip] = useState<SessionTimelineTooltip | null>(null);
const currentIndex =
currentRange !== null
? currentRange.currentIndex
: entries.findIndex((entry) => entry.id === currentTurnId);
const hideTooltip = useCallback(() => setTooltip(null), []);
const handleViewportScroll = useCallback(() => {
if (programmaticScrollRef.current || focusScrollGuardRef.current) return;
hideTooltip();
}, [hideTooltip]);
const buildTooltip = useCallback(
(entry: SessionTimelineEntry, el: HTMLElement) => {
const panel = panelRef.current;
if (!panel) return null;
const computedStyle = getComputedStyle(panel);
const rect = el.getBoundingClientRect();
return {
entry,
top: rect.top + rect.height / 2,
left: rect.right + 8,
clamped: false,
themeVars: Object.fromEntries(
SESSION_TIMELINE_TOOLTIP_THEME_VARS.map((name) => [
name,
computedStyle.getPropertyValue(name),
]),
) as CSSProperties,
};
},
[],
);
const findTooltipAnchor = useCallback((entry: SessionTimelineEntry) => {
const viewport = viewportRef.current;
if (!viewport) return null;
const item = Array.from(
viewport.querySelectorAll<HTMLElement>(
'[data-testid="session-timeline-entry"]',
),
).find((node) => node.getAttribute('data-turn-id') === entry.id);
return item?.querySelector<HTMLButtonElement>('button') ?? null;
}, []);
const isTooltipAnchorVisible = useCallback((anchor: HTMLElement) => {
const viewport = viewportRef.current;
if (!viewport) return false;
const viewportRect = viewport.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
return (
anchorRect.bottom >= viewportRect.top &&
anchorRect.top <= viewportRect.bottom
);
}, []);
const syncTooltip = useCallback(() => {
setTooltip((current) => {
if (!current) return null;
const anchor = findTooltipAnchor(current.entry);
if (!anchor || !isTooltipAnchorVisible(anchor)) return null;
return buildTooltip(current.entry, anchor);
});
}, [buildTooltip, findTooltipAnchor, isTooltipAnchorVisible]);
const showTooltip = useCallback(
(entry: SessionTimelineEntry, el: HTMLElement) => {
setTooltip(buildTooltip(entry, el));
},
[buildTooltip],
);
const guardFocusScroll = useCallback(() => {
if (typeof window === 'undefined') return;
if (focusScrollGuardFrameRef.current !== null) {
window.cancelAnimationFrame(focusScrollGuardFrameRef.current);
}
if (focusScrollGuardFallbackRef.current !== null) {
window.clearTimeout(focusScrollGuardFallbackRef.current);
}
focusScrollGuardRef.current = true;
focusScrollGuardFrameRef.current = window.requestAnimationFrame(() => {
focusScrollGuardFrameRef.current = null;
focusScrollGuardRef.current = false;
syncTooltip();
});
focusScrollGuardFallbackRef.current = window.setTimeout(() => {
focusScrollGuardFallbackRef.current = null;
focusScrollGuardRef.current = false;
}, 100);
}, [syncTooltip]);
useLayoutEffect(() => {
if (hidden) return;
const viewport = viewportRef.current;
if (!viewport || currentIndex < 0) return;
const item = viewport.querySelector<HTMLElement>(
`[data-timeline-index="${currentIndex}"]`,
);
if (!item) return;
const itemCenter = item.offsetTop + item.offsetHeight / 2;
const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
const nextScrollTop = Math.max(
0,
Math.min(itemCenter - viewport.clientHeight / 2, maxScrollTop),
);
if (viewport.scrollTop === nextScrollTop) return;
programmaticScrollRef.current = true;
viewport.scrollTop = nextScrollTop;
const frame = window.requestAnimationFrame(() => {
programmaticScrollRef.current = false;
syncTooltip();
});
const fallback = window.setTimeout(() => {
programmaticScrollRef.current = false;
}, 100);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(fallback);
programmaticScrollRef.current = false;
};
}, [currentIndex, hidden, syncTooltip]);
useLayoutEffect(() => {
if (!tooltip || typeof window === 'undefined') return;
window.addEventListener('resize', syncTooltip);
return () => {
window.removeEventListener('resize', syncTooltip);
};
}, [syncTooltip, tooltip]);
useLayoutEffect(
() => () => {
if (focusScrollGuardFrameRef.current !== null) {
window.cancelAnimationFrame(focusScrollGuardFrameRef.current);
}
if (focusScrollGuardFallbackRef.current !== null) {
window.clearTimeout(focusScrollGuardFallbackRef.current);
}
focusScrollGuardRef.current = false;
},
[],
);
useLayoutEffect(() => {
if (!tooltip || tooltip.clamped) return;
const panel = panelRef.current;
const tooltipEl = tooltipRef.current;
if (!panel || !tooltipEl || typeof window === 'undefined') return;
const rect = tooltipEl.getBoundingClientRect();
const margin = 12;
let nextTop = tooltip.top;
if (rect.top < margin) {
nextTop += margin - rect.top;
} else if (rect.bottom > window.innerHeight - margin) {
nextTop -= rect.bottom - (window.innerHeight - margin);
}
if (nextTop === tooltip.top) return;
setTooltip((current) =>
current?.entry.id === tooltip.entry.id
? { ...current, top: nextTop, clamped: true }
: current,
);
}, [tooltip]);
if (hidden || entries.length === 0) return null;
return (
<div className={styles.sessionTimelineLayer} aria-hidden="false">
<nav
ref={panelRef}
className={styles.sessionTimelinePanel}
aria-label={t('timeline.sessionTimeline')}
data-testid="session-timeline"
onMouseLeave={hideTooltip}
>
<div
ref={viewportRef}
className={styles.sessionTimelineViewport}
data-testid="session-timeline-viewport"
onScroll={handleViewportScroll}
>
<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 = [
`${t('timeline.turnPrefix', { index: index + 1 })}: ${entry.label}`,
isCurrent ? t('timeline.currentTurn') : null,
]
.filter(Boolean)
.join('. ');
const revealTooltip = (
event:
| ReactMouseEvent<HTMLButtonElement>
| ReactFocusEvent<HTMLButtonElement>,
) => showTooltip(entry, event.currentTarget);
const revealFocusedTooltip = (
event: ReactFocusEvent<HTMLButtonElement>,
) => {
guardFocusScroll();
showTooltip(entry, event.currentTarget);
};
const describedByTooltip = tooltip?.entry.id === entry.id;
return (
<li
key={entry.id}
className={styles.sessionTimelineItem}
data-testid="session-timeline-entry"
data-turn-id={entry.id}
data-timeline-index={index}
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-describedby={
describedByTooltip
? SESSION_TIMELINE_TOOLTIP_ID
: undefined
}
aria-label={ariaLabel}
onClick={() => onSelect(entry.id)}
onFocus={revealFocusedTooltip}
onBlur={hideTooltip}
onMouseEnter={revealTooltip}
onMouseLeave={hideTooltip}
>
<span className={styles.sessionTimelineTick} />
</button>
</li>
);
})}
</ol>
</div>
{tooltip &&
typeof document !== 'undefined' &&
createPortal(
<div
ref={tooltipRef}
className={styles.sessionTimelineDetails}
id={SESSION_TIMELINE_TOOLTIP_ID}
data-testid="session-timeline-detail"
data-title={tooltip.entry.label}
data-detail={tooltip.entry.detail}
data-scheduled-task={
tooltip.entry.isScheduledTask ? 'true' : undefined
}
role="tooltip"
style={{
...tooltip.themeVars,
top: tooltip.top,
left: tooltip.left,
}}
>
<span className={styles.sessionTimelineDetailsTitle}>
{tooltip.entry.isScheduledTask && <TimelineClockIcon />}
<span className={styles.sessionTimelineDetailsTitleText}>
{tooltip.entry.label}
</span>
</span>
<span className={styles.sessionTimelineDetailsDetail}>
{tooltip.entry.detail}
</span>
</div>,
portalRoot ?? document.body,
)}
</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,
hasOlderHistory = false,
loadingOlderHistory = false,
historyCapacityReached = false,
historyPaginationError = false,
onLoadOlderHistory,
transcriptBlockCount = 0,
transcriptActivity,
onReloadTranscript,
isResponding = false,
activeTurnStartedAt,
welcomeHeader,
centerWelcomeHeader = false,
workspaceCwd,
tailContent,
tailKey = 'tail',
virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD,
autoScrollTailIntoView = false,
bottomOverlayInset = 0,
hideSessionTimeline = false,
hideFirstUserMessage = false,
firstTurnMetrics,
includeSubagentToolUsageInMetrics = true,
showRetryHint = false,
onRetryClick,
failedPromptMessageId,
onRetryFailedPrompt,
onBranchSession,
onCanScrollToBottomChange,
turnFileChanges,
turnArtifacts,
turnScheduledTasks,
onReviewChanges,
onOpenArtifact,
onOpenScheduledTask,
onTurnOutputOpen,
onError,
generateContent,
},
ref,
) {
const { t } = useI18n();
const compactMode = useContext(CompactModeContext);
const mergedMessages = useMemo(
() =>
compactMode
? mergeCompactToolGroups(messages, pendingApproval)
: messages,
[compactMode, messages, pendingApproval],
);
const displayItems = useMemo(
() =>
attachTurnOutputs(
groupParallelAgents(mergedMessages),
isResponding,
turnFileChanges,
turnArtifacts,
turnScheduledTasks,
),
[
mergedMessages,
isResponding,
turnFileChanges,
turnArtifacts,
turnScheduledTasks,
],
);
const [isSessionTimelineVisible, setIsSessionTimelineVisible] =
useState(false);
const sessionTimelineCache = useRef<{
signature: string;
t: typeof t;
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?.t !== t
) {
sessionTimelineCache.current = {
signature,
t,
entries: getSessionTimelineEntries(mergedMessages, t),
};
}
return sessionTimelineCache.current.entries;
}, [isSessionTimelineVisible, mergedMessages, t]);
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 finalAssistantTurnIdByAssistantId = useMemo(
() => collectFinalAssistantTurnIds(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 [turnLayoutPending, startTurnLayoutTransition] = useTransition();
const turnLayoutTransitionStarted = useRef(false);
const turnLayoutRowTops = useRef(new Map<string, number>());
const turnLayoutAnimationTimer = useRef<number | undefined>(undefined);
const turnLayoutAnimations = useRef<Animation[]>([]);
const shouldFollow = useRef(true);
const followPausedByUserRef = useRef(false);
const userScrollIntentUntil = useRef(0);
const lastScrollTop = useRef(0);
const olderHistoryLoadInFlight = useRef(false);
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 pendingTranscriptBottomScroll = useRef(Boolean(loadingTranscript));
const transcriptBottomScrollFrame = useRef<number | undefined>(undefined);
const transcriptBottomScrollSettleFrame = useRef<number | undefined>(
undefined,
);
const prevBottomOverlayInset = useRef(bottomOverlayInset);
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 pendingOverflowFrame = useRef<number | undefined>(undefined);
catchingUpRef.current = catchingUp;
const containerRef = useRef<HTMLDivElement>(null);
const olderHistoryRetryBlocked = useRef(false);
const reloadTranscriptTimer = useRef<number | undefined>(undefined);
const reloadTranscriptAbort = useRef<AbortController | undefined>(
undefined,
);
const transcriptReloadBaseline = useRef<
| {
lastEventId?: number;
blockCount: number;
}
| undefined
>(undefined);
const transcriptBlockCountRef = useRef(transcriptBlockCount);
const isRespondingRef = useRef(isResponding);
transcriptBlockCountRef.current = transcriptBlockCount;
isRespondingRef.current = isResponding;
const lastUnderfillAutoLoad = useRef<{
loader: typeof onLoadOlderHistory;
totalVirtualSize: number;
} | null>(null);
const [olderHistoryAnchor, setOlderHistoryAnchor] = useState<{
scrollHeight: number;
scrollTop: number;
} | null>(null);
const [
suppressOlderHistoryLoadingStatus,
setSuppressOlderHistoryLoadingStatus,
] = useState(false);
useLayoutEffect(() => {
if (!olderHistoryAnchor) return;
const current = containerRef.current;
if (current) {
current.scrollTop =
olderHistoryAnchor.scrollTop +
Math.max(0, current.scrollHeight - olderHistoryAnchor.scrollHeight);
}
olderHistoryLoadInFlight.current = false;
setOlderHistoryAnchor(null);
}, [olderHistoryAnchor]);
useEffect(() => {
if (!hasOlderHistory) {
olderHistoryRetryBlocked.current = false;
lastUnderfillAutoLoad.current = null;
}
}, [hasOlderHistory]);
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(() => {
const collapsedItems = applyTurnCollapse(displayItems, {
overrides: collapseOverrides,
isResponding,
activeTurnStartedAt,
pendingApprovalCallId: pendingApproval?.toolCallId ?? null,
includeSubagentToolUsageInMetrics,
enabled: collapseEnabled,
});
let metricsApplied = false;
const itemsWithMetrics = firstTurnMetrics
? collapsedItems.map((item) => {
if (metricsApplied || item.type !== 'turn_collapse') return item;
metricsApplied = true;
return {
...item,
turnCollapse: {
...item.turnCollapse,
...(firstTurnMetrics.durationMs !== undefined &&
firstTurnMetrics.durationMs > 0
? { elapsedMs: firstTurnMetrics.durationMs }
: {}),
...(firstTurnMetrics.inputTokens !== undefined
? { inputTokens: firstTurnMetrics.inputTokens }
: {}),
...(firstTurnMetrics.outputTokens !== undefined
? { outputTokens: firstTurnMetrics.outputTokens }
: {}),
...(firstTurnMetrics.cachedTokens !== undefined
? { cachedTokens: firstTurnMetrics.cachedTokens }
: {}),
},
};
})
: collapsedItems;
if (!hideFirstUserMessage) return itemsWithMetrics;
const firstUserId = mergedMessages.find(
(message) => message.role === 'user',
)?.id;
return firstUserId
? itemsWithMetrics.filter(
(item) =>
item.type !== 'message' || item.message.id !== firstUserId,
)
: itemsWithMetrics;
}, [
displayItems,
collapseOverrides,
isResponding,
activeTurnStartedAt,
pendingApproval?.toolCallId,
collapseEnabled,
hideFirstUserMessage,
firstTurnMetrics,
includeSubagentToolUsageInMetrics,
mergedMessages,
]);
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 uncollapsedTotalCount =
headerOffset + displayItems.length + (hasTailContent ? 1 : 0);
const useVirtualScroll = shouldUseVirtualScroll(
uncollapsedTotalCount,
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;
const isNearBottom = distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX;
followPausedByUserRef.current = !isNearBottom;
setShouldFollow(isNearBottom);
scheduleScrollOverflowReport();
}, [scheduleScrollOverflowReport, setShouldFollow]);
const markUserScrollIntent = useCallback(() => {
userScrollIntentUntil.current = Date.now() + 1000;
}, []);
const cancelTranscriptReloadTimer = useCallback(() => {
if (reloadTranscriptTimer.current !== undefined) {
window.clearTimeout(reloadTranscriptTimer.current);
reloadTranscriptTimer.current = undefined;
}
}, []);
const cancelTranscriptReload = useCallback(() => {
cancelTranscriptReloadTimer();
reloadTranscriptAbort.current?.abort();
reloadTranscriptAbort.current = undefined;
}, [cancelTranscriptReloadTimer]);
const scheduleTranscriptReload = useCallback(() => {
cancelTranscriptReloadTimer();
const baseline = transcriptReloadBaseline.current;
if (baseline) {
const lastEventId = transcriptActivity?.getSnapshot().lastEventId;
if (
lastEventId === baseline.lastEventId &&
transcriptBlockCountRef.current <= baseline.blockCount
) {
return;
}
transcriptReloadBaseline.current = undefined;
}
if (
!onReloadTranscript ||
reloadTranscriptAbort.current !== undefined ||
followPausedByUserRef.current ||
isRespondingRef.current ||
transcriptBlockCountRef.current <= WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS
) {
return;
}
reloadTranscriptTimer.current = window.setTimeout(() => {
reloadTranscriptTimer.current = undefined;
const el = containerRef.current;
if (!el) return;
const distanceFromBottom =
el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom >= FOLLOW_BOTTOM_THRESHOLD_PX) return;
const controller = new AbortController();
reloadTranscriptAbort.current = controller;
void onReloadTranscript(controller.signal)
.then(() => {
if (controller.signal.aborted) return;
const snapshot = transcriptActivity?.getSnapshot();
transcriptReloadBaseline.current = {
...(snapshot?.lastEventId !== undefined
? { lastEventId: snapshot.lastEventId }
: {}),
blockCount:
snapshot?.blocks?.length ?? transcriptBlockCountRef.current,
};
})
.catch((error: unknown) => {
if (!(error instanceof Error && error.name === 'AbortError')) {
console.warn('[MessageList] transcript reload failed:', error);
}
})
.finally(() => {
if (reloadTranscriptAbort.current === controller) {
reloadTranscriptAbort.current = undefined;
}
});
}, RELOAD_TRANSCRIPT_DELAY_MS);
}, [cancelTranscriptReloadTimer, onReloadTranscript, transcriptActivity]);
useEffect(() => {
transcriptReloadBaseline.current = undefined;
}, [transcriptActivity]);
useEffect(() => {
if (!transcriptActivity) return cancelTranscriptReload;
let lastEventId = transcriptActivity.getSnapshot().lastEventId;
const unsubscribe = transcriptActivity.subscribe(() => {
const nextLastEventId = transcriptActivity.getSnapshot().lastEventId;
if (nextLastEventId === lastEventId) return;
lastEventId = nextLastEventId;
scheduleTranscriptReload();
});
return () => {
unsubscribe();
cancelTranscriptReload();
};
}, [transcriptActivity, scheduleTranscriptReload, cancelTranscriptReload]);
useEffect(() => {
scheduleTranscriptReload();
}, [isResponding, scheduleTranscriptReload, transcriptBlockCount]);
const scheduleFollowRecheck = useCallback(() => {
pendingFollowRecheck.current = true;
if (pendingFollowRecheckFrame.current !== undefined) {
window.cancelAnimationFrame(pendingFollowRecheckFrame.current);
}
pendingFollowRecheckFrame.current = window.requestAnimationFrame(() => {
pendingFollowRecheck.current = false;
pendingFollowRecheckFrame.current = undefined;
recheckFollowFromScrollGeometry();
});
}, [recheckFollowFromScrollGeometry]);
useEffect(
() => () => {
if (pendingFollowRecheckFrame.current !== undefined) {
window.cancelAnimationFrame(pendingFollowRecheckFrame.current);
}
if (pendingOverflowFrame.current !== undefined) {
window.cancelAnimationFrame(pendingOverflowFrame.current);
}
cancelTranscriptReload();
if (transcriptBottomScrollFrame.current !== undefined) {
window.cancelAnimationFrame(transcriptBottomScrollFrame.current);
}
if (transcriptBottomScrollSettleFrame.current !== undefined) {
window.cancelAnimationFrame(
transcriptBottomScrollSettleFrame.current,
);
}
},
[cancelTranscriptReload],
);
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) {
followPausedByUserRef.current = true;
setShouldFollow(false);
}
scheduleFollowRecheck();
if (turnLayoutAnimationTimer.current !== undefined) {
window.clearTimeout(turnLayoutAnimationTimer.current);
}
turnLayoutRowTops.current.clear();
containerRef.current
?.querySelectorAll<HTMLElement>('[data-message-row-key]')
.forEach((row) => {
const key = row.dataset.messageRowKey;
if (key) {
turnLayoutRowTops.current.set(
key,
row.getBoundingClientRect().top,
);
}
});
turnLayoutTransitionStarted.current = true;
startTurnLayoutTransition(() => {
setCollapseOverrides((prev) => {
const next = new Map(prev);
next.set(turnId, nextExpanded);
return next;
});
});
},
[scheduleFollowRecheck, setShouldFollow],
);
useLayoutEffect(() => {
if (turnLayoutPending || !turnLayoutTransitionStarted.current) return;
turnLayoutTransitionStarted.current = false;
for (const animation of turnLayoutAnimations.current) {
animation.cancel();
}
turnLayoutAnimations.current = [];
const reduceMotion =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduceMotion) {
containerRef.current
?.querySelectorAll<HTMLElement>('[data-message-row-key]')
.forEach((row) => {
if (typeof row.animate !== 'function') return;
const key = row.dataset.messageRowKey;
const previousTop = key
? turnLayoutRowTops.current.get(key)
: undefined;
if (previousTop === undefined) {
turnLayoutAnimations.current.push(
row.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: TURN_LAYOUT_ANIMATION_MS,
easing: 'ease-out',
}),
);
return;
}
const delta = previousTop - row.getBoundingClientRect().top;
if (Math.abs(delta) < 1) return;
turnLayoutAnimations.current.push(
row.animate(
[{ translate: `0 ${delta}px` }, { translate: '0 0' }],
{
duration: TURN_LAYOUT_ANIMATION_MS,
easing: 'ease-out',
},
),
);
});
}
turnLayoutRowTops.current.clear();
turnLayoutAnimationTimer.current = window.setTimeout(
() => {
turnLayoutAnimationTimer.current = undefined;
scheduleFollowRecheck();
},
reduceMotion ? 0 : TURN_LAYOUT_ANIMATION_MS,
);
}, [scheduleFollowRecheck, turnLayoutPending]);
useEffect(
() => () => {
if (turnLayoutAnimationTimer.current !== undefined) {
window.clearTimeout(turnLayoutAnimationTimer.current);
}
for (const animation of turnLayoutAnimations.current) {
animation.cancel();
}
turnLayoutAnimations.current = [];
},
[],
);
const handleDisclosureClickCapture = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (!target.closest('[aria-expanded]')) return;
followPausedByUserRef.current = true;
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') => {
followPausedByUserRef.current = false;
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;
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.
followPausedByUserRef.current = true;
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) {
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]);
const loadOlderHistory = useCallback(
async (allowRetry = false, force = false) => {
const el = containerRef.current;
if (
!el ||
!onLoadOlderHistory ||
loadingOlderHistory ||
olderHistoryLoadInFlight.current ||
(historyPaginationError && !force) ||
(olderHistoryRetryBlocked.current && !allowRetry)
) {
return;
}
olderHistoryRetryBlocked.current = false;
olderHistoryLoadInFlight.current = true;
setSuppressOlderHistoryLoadingStatus(!allowRetry);
const previousHeight = el.scrollHeight;
const previousTop = el.scrollTop;
followPausedByUserRef.current = true;
try {
await onLoadOlderHistory(force ? { force: true } : undefined);
setOlderHistoryAnchor({
scrollHeight: previousHeight,
scrollTop: previousTop,
});
} catch {
olderHistoryRetryBlocked.current = true;
olderHistoryLoadInFlight.current = false;
} finally {
setSuppressOlderHistoryLoadingStatus(false);
}
},
[loadingOlderHistory, onLoadOlderHistory, historyPaginationError],
);
const retryOlderHistory = useCallback(() => {
void loadOlderHistory(true, true);
}, [loadOlderHistory]);
// 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;
const curr = el.scrollTop;
if (hasOlderHistory && curr <= LOAD_OLDER_HISTORY_THRESHOLD_PX) {
void loadOlderHistory(true);
}
if (scrollCooldown.current) {
lastScrollTop.current = curr;
return;
}
scheduleSessionTimelineRangeUpdate();
const prev = lastScrollTop.current;
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.
const isNearBottom = distanceFromBottom < FOLLOW_BOTTOM_THRESHOLD_PX;
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil.current;
if (isNearBottom) {
followPausedByUserRef.current = false;
setShouldFollow(true);
} else if (hasUserScrollIntent) {
cancelTranscriptReload();
followPausedByUserRef.current = true;
setShouldFollow(false);
} else if (!followPausedByUserRef.current) {
cancelTranscriptReload();
setShouldFollow(false);
}
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) {
followPausedByUserRef.current = false;
setShouldFollow(true);
scheduleTranscriptReload();
} else {
cancelTranscriptReload();
}
}, [
getScrollElement,
hasOlderHistory,
loadOlderHistory,
scheduleScrollOverflowReport,
scheduleSessionTimelineRangeUpdate,
scheduleTranscriptReload,
cancelTranscriptReload,
setShouldFollow,
]);
useEffect(() => {
const el = getScrollElement();
if (!el) return;
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [getScrollElement, handleScroll]);
const loadOlderHistoryIfUnderfilled = useCallback(() => {
if (
!hasOlderHistory ||
loadingOlderHistory ||
catchingUp ||
showLoadingSkeleton
) {
return;
}
const el = getScrollElement();
if (!el || el.scrollHeight > el.clientHeight + 1) return;
const previousLoad = lastUnderfillAutoLoad.current;
if (
previousLoad !== null &&
previousLoad.loader === onLoadOlderHistory &&
previousLoad.totalVirtualSize === totalVirtualSize
) {
olderHistoryRetryBlocked.current = true;
return;
}
lastUnderfillAutoLoad.current = {
loader: onLoadOlderHistory,
totalVirtualSize,
};
void loadOlderHistory();
}, [
catchingUp,
getScrollElement,
hasOlderHistory,
loadOlderHistory,
loadingOlderHistory,
onLoadOlderHistory,
showLoadingSkeleton,
totalVirtualSize,
]);
useEffect(() => {
loadOlderHistoryIfUnderfilled();
}, [loadOlderHistoryIfUnderfilled, totalVirtualSize]);
useEffect(() => {
const el = getScrollElement();
if (!el) return;
const retryOlderHistoryAtTop = () => {
if (
olderHistoryRetryBlocked.current &&
el.scrollTop <= LOAD_OLDER_HISTORY_THRESHOLD_PX
) {
void loadOlderHistory(true);
}
};
const markFromWheel = (event: WheelEvent) => {
markUserScrollIntent();
if (event.deltaY < 0) retryOlderHistoryAtTop();
};
const markFromTouch = () => {
markUserScrollIntent();
retryOlderHistoryAtTop();
};
const markFromPointer = (event: PointerEvent) => {
const rect = el.getBoundingClientRect();
const scrollbarEdge = 20;
if (
event.clientX >= rect.right - scrollbarEdge ||
event.clientY >= rect.bottom - scrollbarEdge
) {
markUserScrollIntent();
}
};
const markFromKey = (event: KeyboardEvent) => {
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'PageUp' ||
event.key === 'PageDown' ||
event.key === 'Home' ||
event.key === 'End' ||
event.key === ' '
) {
markUserScrollIntent();
if (
event.key === 'ArrowUp' ||
event.key === 'PageUp' ||
event.key === 'Home'
) {
retryOlderHistoryAtTop();
}
}
};
el.addEventListener('wheel', markFromWheel, { passive: true });
el.addEventListener('touchstart', markFromTouch, {
passive: true,
});
el.addEventListener('pointerdown', markFromPointer, { passive: true });
el.addEventListener('keydown', markFromKey, { passive: true });
return () => {
el.removeEventListener('wheel', markFromWheel);
el.removeEventListener('touchstart', markFromTouch);
el.removeEventListener('pointerdown', markFromPointer);
el.removeEventListener('keydown', markFromKey);
};
}, [getScrollElement, loadOlderHistory, markUserScrollIntent]);
useEffect(() => {
const el = getScrollElement();
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
scheduleScrollOverflowReport();
loadOlderHistoryIfUnderfilled();
});
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,
loadOlderHistoryIfUnderfilled,
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) {
followPausedByUserRef.current = false;
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 (followPausedByUserRef.current) return;
setShouldFollow(true);
requestAnimationFrame(() => {
if (!catchingUpRef.current && !followPausedByUserRef.current) {
scrollToBottom();
}
});
});
observer.observe(el);
return () => observer.disconnect();
}, [scrollToBottom, scheduleSessionTimelineRangeUpdate, setShouldFollow]);
// 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
) {
followPausedByUserRef.current = false;
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) {
followPausedByUserRef.current = false;
setShouldFollow(true);
scrollToBottom('auto');
}
prevCatchingUp.current = catchingUp;
}, [catchingUp, scrollToBottom, setShouldFollow]);
useLayoutEffect(() => {
if (loadingTranscript) {
pendingTranscriptBottomScroll.current = true;
return;
}
if (!pendingTranscriptBottomScroll.current) return;
if (catchingUp || messages.length === 0) return;
pendingTranscriptBottomScroll.current = false;
followPausedByUserRef.current = false;
setShouldFollow(true);
pendingScrollRef.current = null;
if (transcriptBottomScrollFrame.current !== undefined) {
window.cancelAnimationFrame(transcriptBottomScrollFrame.current);
}
if (transcriptBottomScrollSettleFrame.current !== undefined) {
window.cancelAnimationFrame(transcriptBottomScrollSettleFrame.current);
}
const scrollIfStillFollowing = () => {
if (catchingUpRef.current || followPausedByUserRef.current) return;
setShouldFollow(true);
scrollToBottom('auto');
};
transcriptBottomScrollFrame.current = window.requestAnimationFrame(() => {
transcriptBottomScrollFrame.current = undefined;
scrollIfStillFollowing();
transcriptBottomScrollSettleFrame.current =
window.requestAnimationFrame(() => {
transcriptBottomScrollSettleFrame.current = undefined;
scrollIfStillFollowing();
});
});
}, [
catchingUp,
loadingTranscript,
messages.length,
scrollToBottom,
setShouldFollow,
]);
useLayoutEffect(() => {
const insetChanged =
prevBottomOverlayInset.current !== bottomOverlayInset;
prevBottomOverlayInset.current = bottomOverlayInset;
if (!insetChanged) return;
if (catchingUp) return;
if (followPausedByUserRef.current) return;
setShouldFollow(true);
requestAnimationFrame(() => {
if (!catchingUpRef.current && !followPausedByUserRef.current) {
scrollToBottom('auto');
}
});
}, [bottomOverlayInset, 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 || !followPausedByUserRef.current) {
requestAnimationFrame(() => {
if (!followPausedByUserRef.current) {
setShouldFollow(true);
scrollToBottom();
}
});
}
}, [catchingUp, runningExecutionKey, scrollToBottom, setShouldFollow]);
// 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
) {
followPausedByUserRef.current = false;
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 (!followPausedByUserRef.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_outputs') {
return (
<TurnOutputs
changes={displayItem.changes}
turnId={displayItem.turnId}
artifacts={displayItem.artifacts}
scheduledTasks={displayItem.scheduledTasks}
workspaceCwd={workspaceCwd}
onOpenRequest={onTurnOutputOpen}
onReviewChanges={onReviewChanges ?? noopTurnOutputAction}
onOpenArtifact={onOpenArtifact ?? noopTurnOutputAction}
onOpenScheduledTask={
onOpenScheduledTask ?? noopTurnOutputAction
}
onError={onError}
/>
);
}
if (displayItem.type === 'turn_collapse') {
return (
<TurnCollapseRow
turnCollapse={displayItem.turnCollapse}
onToggleCollapse={handleToggleCollapse}
/>
);
}
const finalAssistantTurnId =
displayItem.message.role === 'assistant'
? finalAssistantTurnIdByAssistantId.get(displayItem.message.id)
: undefined;
let assistantTurnFooterInfo:
| WebShellAssistantTurnFooterRenderInfo
| undefined;
if (
displayItem.message.role === 'assistant' &&
finalAssistantTurnId
) {
assistantTurnFooterInfo = {
turnId: finalAssistantTurnId,
message: {
id: displayItem.message.id,
content: displayItem.message.content,
isStreaming: displayItem.message.isStreaming,
timestamp: displayItem.message.timestamp,
},
};
}
return (
<MessageItem
message={displayItem.message}
pendingApproval={pendingApproval}
onShowContextDetail={onShowContextDetail}
workspaceCwd={workspaceCwd}
isLatest={isLatest}
showRetryHint={showRetryHint}
onRetryClick={onRetryClick}
sendFailed={
displayItem.message.role === 'user' &&
displayItem.message.id === failedPromptMessageId
}
onRetrySend={onRetryFailedPrompt}
onBranchSession={onBranchSession}
showAssistantActions={
displayItem.message.role === 'assistant' &&
finalAssistantTurnIdByAssistantId.has(displayItem.message.id)
}
showAssistantBranch={
displayItem.message.role === 'assistant' &&
displayItem.message.id === lastCompletedAssistantId
}
isLocateFlashing={displayItemMatchesLocateTarget(
displayItem,
flashTarget,
)}
assistantTurnFooterInfo={assistantTurnFooterInfo}
generateContent={generateContent}
/>
);
};
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,
generateContent,
headerOffset,
visibleItems,
flashTarget,
finalAssistantTurnIdByAssistantId,
lastCompletedAssistantId,
workspaceCwd,
showRetryHint,
onRetryClick,
failedPromptMessageId,
onRetryFailedPrompt,
onBranchSession,
handleToggleCollapse,
onOpenArtifact,
onOpenScheduledTask,
onReviewChanges,
onTurnOutputOpen,
onError,
],
);
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 ||
!followPausedByUserRef.current
) {
if (!followPausedByUserRef.current) {
setShouldFollow(true);
}
scrollToBottom(isNewUserMessage ? 'smooth' : 'auto');
pendingNewUserSmoothScroll.current = false;
}
}, [
totalVirtualSize,
messages,
totalCount,
catchingUp,
scrollToBottom,
setShouldFollow,
]);
useLayoutEffect(() => {
scheduleScrollOverflowReport();
}, [messages, scheduleScrollOverflowReport, totalCount, totalVirtualSize]);
return (
<div
ref={containerRef}
className={joinClassNames(
styles.list,
hasHeader && centerWelcomeHeader
? styles.listWithWelcomeHeader
: undefined,
)}
data-web-shell-message-list
onClickCapture={handleDisclosureClickCapture}
>
{showLoadingSkeleton && (
<LoadingTranscriptSkeleton label={t('editor.sessionLoading')} />
)}
{loadingOlderHistory &&
!showLoadingSkeleton &&
!suppressOlderHistoryLoadingStatus && (
<div className={styles.historyStatus} role="status">
{t('history.loadingEarlier')}
</div>
)}
{historyCapacityReached && !showLoadingSkeleton && (
<div className={styles.historyStatus} role="status">
{t('history.capacityReached')}
</div>
)}
{historyPaginationError &&
!showLoadingSkeleton &&
!historyCapacityReached && (
<div className={styles.historyStatus}>
<span role="status">{t('history.paginationError')}</span>
{onLoadOlderHistory && (
<button
type="button"
className={styles.historyRetryButton}
onClick={retryOlderHistory}
>
{t('history.retry')}
</button>
)}
</div>
)}
<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],
),
)}
data-message-row-key={String(getItemKey(virtualRow.index))}
data-web-shell-message-row
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)}
data-message-row-key={String(key)}
data-web-shell-message-row
>
{renderVirtualItem(index)}
</div>
);
})
)}
</div>
);
}),
);