qwen-code/packages/web-shell/client/components/MessageList.dom.test.tsx
ytahdn 8a44b1b9f7
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
fix(web-shell): render task notifications as system messages (#7822)
* fix(web-shell): render background notifications as system messages

* fix(web-shell): use basic table rendering by default

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-07-27 09:15:18 +00:00

2119 lines
65 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, createRef, type RefObject } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message } from '../adapters/types';
import {
WebShellCustomizationProvider,
type WebShellAssistantTurnFooterRenderInfo,
type WebShellCustomization,
} from '../customization';
import { I18nProvider } from '../i18n';
import { WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS } from '../constants/sessions';
import flashStyles from './MessageLocateFlash.module.css';
import styles from './MessageList.module.css';
// Mock the App context and the heavy row children so this test exercises only
// MessageList's own collapse + deferred-scroll logic, not the whole render tree.
vi.mock('../App', async () => {
const { createContext } = await import('react');
return { CompactModeContext: createContext(false) };
});
vi.mock('./MessageItem', async () => {
const React = await import('react');
const { useWebShellCustomization } = await import('../customization');
return {
MessageItem: ({
message,
showAssistantActions,
isLocateFlashing,
assistantTurnFooterInfo,
}: {
message: Message;
showAssistantActions?: boolean;
isLocateFlashing?: boolean;
assistantTurnFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
}) => {
const { renderAssistantTurnFooter } = useWebShellCustomization();
const assistantTurnFooter = assistantTurnFooterInfo
? renderAssistantTurnFooter?.(assistantTurnFooterInfo)
: undefined;
return React.createElement(
'div',
{
'data-testid': `msg-${message.id}`,
'data-assistant-actions': String(Boolean(showAssistantActions)),
'data-locate-flashing': isLocateFlashing ? 'true' : undefined,
},
message.role === 'thinking'
? React.createElement('button', {
'aria-expanded': 'false',
'data-testid': `disclosure-${message.id}`,
})
: null,
assistantTurnFooter,
);
},
};
});
vi.mock('./messages/tools/ParallelAgentsGroup', async () => {
const React = await import('react');
return {
ParallelAgentsGroup: () =>
React.createElement('div', { 'data-testid': 'parallel-agents' }),
};
});
vi.mock('./messages/ToolApproval', () => ({ ToolApproval: () => null }));
vi.mock('./messages/AskUserQuestion', () => ({ AskUserQuestion: () => null }));
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({
count,
enabled,
getItemKey,
}: {
count: number;
enabled: boolean;
getItemKey: (index: number) => string | number;
}) => {
const virtualItems = enabled
? Array.from({ length: Math.min(count, 5) }, (_, index) => ({
key: getItemKey(index),
index,
start: index * 80,
}))
: [];
return {
getVirtualItems: () => virtualItems,
getTotalSize: () => (enabled ? count * 80 : 0),
measureElement: () => {},
scrollToIndex: () => {},
};
},
}));
const { MessageList } = await import('./MessageList');
type MessageListHandle = import('./MessageList').MessageListHandle;
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
// jsdom provides neither ResizeObserver (MessageList's resize guard) nor a real
// scrollIntoView (the non-virtual scroll path) — stub both.
const resizeObserverCallbacks: ResizeObserverCallback[] = [];
class ResizeObserverStub {
constructor(private readonly callback: ResizeObserverCallback) {
resizeObserverCallbacks.push(callback);
}
observe() {
this.callback([], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
}
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
ResizeObserverStub;
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
function triggerResizeObservers() {
for (const callback of resizeObserverCallbacks) {
callback([], {} as ResizeObserver);
}
}
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
resizeObserverCallbacks.length = 0;
vi.useRealTimers();
});
type UserMessage = Extract<Message, { role: 'user' }>;
type ToolGroupMessage = Extract<Message, { role: 'tool_group' }>;
type AssistantMessage = Extract<Message, { role: 'assistant' }>;
type SystemMessage = Extract<Message, { role: 'system' }>;
type ThinkingMessage = Extract<Message, { role: 'thinking' }>;
type PlanMessage = Extract<Message, { role: 'plan' }>;
const userMsg = (id: string): UserMessage => ({
id,
role: 'user',
content: 'q',
});
const userShellMsg = (
id: string,
): Extract<Message, { role: 'user_shell' }> => ({
id,
role: 'user_shell',
command: 'npm test',
output: '',
});
const toolMsg = (id: string): ToolGroupMessage => ({
id,
role: 'tool_group',
tools: [{ callId: `call-${id}`, toolName: 'Read', status: 'completed' }],
});
const agentMsg = (id: string): ToolGroupMessage => ({
id,
role: 'tool_group',
tools: [
{
callId: `call-${id}`,
toolName: 'Task',
status: 'completed',
args: { subagent_type: 'explore' },
},
],
});
const asstMsg = (id: string): AssistantMessage => ({
id,
role: 'assistant',
content: 'answer',
});
const systemMsg = (id: string): SystemMessage => ({
id,
role: 'system',
content: 'cancelled',
variant: 'warning',
source: 'prompt_cancelled',
});
const backgroundNotificationMsg = (id: string): SystemMessage => ({
id,
role: 'system',
content: 'background task completed',
variant: 'info',
source: 'background_notification',
});
const thinkingMsg = (id: string): ThinkingMessage => ({
id,
role: 'thinking',
content: 'thinking',
});
const planMsg = (id: string): PlanMessage => ({
id,
role: 'plan',
todos: [{ id: 'todo-1', content: 'step one', status: 'pending' }],
});
function mount(
messages: Message[],
ref?: RefObject<MessageListHandle | null>,
opts: {
hideSessionTimeline?: boolean;
loadingTranscript?: boolean;
catchingUp?: boolean;
hasOlderHistory?: boolean;
loadingOlderHistory?: boolean;
historyCapacityReached?: boolean;
historyPaginationError?: boolean;
onLoadOlderHistory?: () => Promise<void>;
transcriptBlockCount?: number;
transcriptActivity?: {
getSnapshot(): {
lastEventId?: number;
blocks?: { readonly length: number };
};
subscribe(listener: () => void): () => void;
};
onReloadTranscript?: (signal: AbortSignal) => Promise<void>;
isResponding?: boolean;
hideFirstUserMessage?: boolean;
firstTurnMetrics?: {
durationMs?: number;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
};
includeSubagentToolUsageInMetrics?: boolean;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
customization?: WebShellCustomization;
} = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<WebShellCustomizationProvider value={opts.customization ?? {}}>
<MessageList
ref={ref}
messages={messages}
pendingApproval={null}
hideSessionTimeline={opts.hideSessionTimeline}
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
hasOlderHistory={opts.hasOlderHistory}
loadingOlderHistory={opts.loadingOlderHistory}
historyCapacityReached={opts.historyCapacityReached}
historyPaginationError={opts.historyPaginationError}
onLoadOlderHistory={opts.onLoadOlderHistory}
transcriptBlockCount={opts.transcriptBlockCount}
transcriptActivity={opts.transcriptActivity}
onReloadTranscript={opts.onReloadTranscript}
isResponding={opts.isResponding}
hideFirstUserMessage={opts.hideFirstUserMessage}
firstTurnMetrics={opts.firstTurnMetrics}
includeSubagentToolUsageInMetrics={
opts.includeSubagentToolUsageInMetrics
}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</WebShellCustomizationProvider>
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}
function renderInto(
root: Root,
messages: Message[],
ref?: RefObject<MessageListHandle | null>,
opts: {
loadingTranscript?: boolean;
catchingUp?: boolean;
isResponding?: boolean;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
} = {},
) {
act(() => {
root.render(
<I18nProvider language="en">
<MessageList
ref={ref}
messages={messages}
pendingApproval={null}
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</I18nProvider>,
);
});
}
const has = (c: HTMLElement, id: string) =>
c.querySelector(`[data-testid="msg-${id}"]`) !== null;
const assistantActions = (c: HTMLElement, id: string) =>
c
.querySelector(`[data-testid="msg-${id}"]`)
?.getAttribute('data-assistant-actions');
const isCollapsed = (c: HTMLElement, id: string) =>
c.querySelector(`[data-testid="msg-${id}"]`) === null;
const queryToggle = (c: HTMLElement, turnId: string) =>
c.querySelector(`[data-testid="toggle-${turnId}"]`) as HTMLElement | null;
const toggle = (c: HTMLElement, turnId: string) =>
queryToggle(c, turnId) as HTMLElement;
const disclosure = (c: HTMLElement, id: string) =>
c.querySelector(`[data-testid="disclosure-${id}"]`) as HTMLElement;
const toggleRow = (c: HTMLElement, turnId: string) =>
toggle(c, turnId).closest('[role="button"]') as HTMLElement;
const click = (el: Element) =>
act(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true })));
const focusIn = (el: Element) =>
act(() => el.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
const focusOut = (el: Element) =>
act(() => el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
const nextFrame = () =>
act(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
const mockMessageListWidth = (width: number) =>
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width,
height: 600,
top: 0,
right: width,
bottom: 600,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
const simpleTurns = (count: number): Message[] =>
Array.from({ length: count }, (_, index) => {
const turn = index + 1;
return [userMsg(`u${turn}`), asstMsg(`a${turn}`)] as Message[];
}).flat();
describe('MessageList — turn collapse (DOM)', () => {
it('reloads an oversized transcript after 120 quiet seconds at the tail', async () => {
vi.useFakeTimers();
const onReloadTranscript = vi.fn().mockResolvedValue(undefined);
let lastEventId = 10;
let notifyActivity = () => undefined;
mount([userMsg('u1'), asstMsg('a1')], undefined, {
transcriptBlockCount: WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 1,
transcriptActivity: {
getSnapshot: () => ({ lastEventId }),
subscribe: (listener) => {
notifyActivity = listener;
return () => undefined;
},
},
onReloadTranscript,
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
lastEventId++;
notifyActivity();
await vi.advanceTimersByTimeAsync(60_000);
});
expect(onReloadTranscript).not.toHaveBeenCalled();
await act(async () => vi.advanceTimersByTimeAsync(60_000));
expect(onReloadTranscript).toHaveBeenCalledOnce();
await act(async () => vi.advanceTimersByTimeAsync(120_000));
expect(onReloadTranscript).toHaveBeenCalledOnce();
lastEventId++;
notifyActivity();
await act(async () => vi.advanceTimersByTimeAsync(120_000));
expect(onReloadTranscript).toHaveBeenCalledTimes(2);
});
it('aborts an in-flight transcript reload when the reader leaves the tail', async () => {
vi.useFakeTimers();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 600,
writable: true,
});
let resolveReload = () => undefined;
let reloadSignal: AbortSignal | undefined;
const onReloadTranscript = vi.fn((signal: AbortSignal) => {
reloadSignal = signal;
return new Promise<void>((resolve) => {
resolveReload = resolve;
});
});
const container = mount([userMsg('u1'), asstMsg('a1')], undefined, {
transcriptBlockCount: WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS + 1,
onReloadTranscript,
});
await act(async () => vi.advanceTimersByTimeAsync(120_000));
expect(reloadSignal?.aborted).toBe(false);
const list = container.firstElementChild as HTMLElement;
list.scrollTop = 400;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
expect(reloadSignal?.aborted).toBe(true);
await act(async () => resolveReload());
});
it('hides only the first user message and overrides first-turn metrics', () => {
const c = mount(
[
{ ...userMsg('u1'), content: 'first prompt' },
toolMsg('g1'),
asstMsg('a1'),
{ ...userMsg('u2'), content: 'second prompt' },
toolMsg('g2'),
asstMsg('a2'),
],
undefined,
{
hideFirstUserMessage: true,
firstTurnMetrics: {
durationMs: 9_000,
inputTokens: 1_200,
outputTokens: 45,
cachedTokens: 800,
},
},
);
expect(has(c, 'u1')).toBe(false);
expect(has(c, 'u2')).toBe(true);
expect(c.textContent).toContain('9s');
expect(c.textContent).toContain('↑1.2k (800 cached, 67%) ↓45');
});
it('collapses a completed turn: hides the step, keeps prompt + answer, shows the toggle', () => {
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')]);
expect(has(c, 'u1')).toBe(true);
expect(has(c, 'a1')).toBe(true);
expect(isCollapsed(c, 'g1')).toBe(true);
expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false');
});
it('collapses a background notification before the final assistant content', () => {
const c = mount([
userMsg('u1'),
backgroundNotificationMsg('bg1'),
asstMsg('a1'),
]);
expect(has(c, 'bg1')).toBe(false);
expect(has(c, 'a1')).toBe(true);
click(toggle(c, 'u1'));
expect(has(c, 'bg1')).toBe(true);
});
it('keeps a background notification when it is the final content', () => {
const c = mount([
userMsg('u1'),
asstMsg('a1'),
backgroundNotificationMsg('bg1'),
]);
expect(has(c, 'a1')).toBe(false);
expect(has(c, 'bg1')).toBe(true);
click(toggle(c, 'u1'));
expect(has(c, 'a1')).toBe(true);
});
it('renders collapse metrics in the standalone turn row', () => {
const c = mount([
{ ...userMsg('u1'), timestamp: 1_000 },
{ ...toolMsg('g1'), timestamp: 2_000 },
{
id: 't1',
role: 'thinking',
content: 'checking the tool result',
timestamp: 2_500,
},
{
...asstMsg('a1'),
timestamp: 13_400,
usage: { inputTokens: 3100, outputTokens: 5100, cachedTokens: 2800 },
},
]);
const text = c.textContent ?? '';
expect(text).toContain('Processed');
expect(text).toContain('13s');
expect(text).toContain('↑3.1k (2.8k cached, 90%) ↓5.1k');
expect(text).toContain('1 tool call');
expect(text).toContain('1 thought');
expect(text).not.toContain('1 step');
expect(text.indexOf('↓5.1k')).toBeLessThan(text.indexOf('1 tool call'));
});
it('does not add tool summary usage when full transcript usage includes it', () => {
const agent = agentMsg('nested');
agent.tools[0]!.rawOutput = {
executionSummary: { inputTokens: 100, outputTokens: 20 },
};
const c = mount(
[
userMsg('u1'),
agent,
{
...asstMsg('a1'),
usage: { inputTokens: 100, outputTokens: 20 },
},
],
undefined,
{ includeSubagentToolUsageInMetrics: false },
);
expect(c.textContent).toContain('↑100 ↓20');
expect(c.textContent).not.toContain('↑200 ↓40');
});
it('renders step-less metrics without a toggle', () => {
const c = mount([
{ ...userMsg('u1'), timestamp: 1_000 },
{
...asstMsg('a1'),
timestamp: 1_900,
usage: { inputTokens: 1200, outputTokens: 45 },
},
]);
const text = c.textContent ?? '';
expect(queryToggle(c, 'u1')).toBeNull();
expect(text).toContain('Processed 1s');
expect(text).toContain('↑1.2k ↓45');
expect(text).not.toContain('step');
});
it('omits elapsed-only completed metrics when there is no toggle', () => {
const c = mount([
{ ...userMsg('u1'), timestamp: 1_000 },
{ ...asstMsg('a1'), timestamp: 13_400 },
]);
const text = c.textContent ?? '';
expect(queryToggle(c, 'u1')).toBeNull();
expect(text).not.toContain('Processed');
expect(text).not.toContain('13s');
});
it('renders custom footer on the completed turn final assistant message', () => {
const renderAssistantTurnFooter = vi.fn(({ turnId, message }) => (
<span data-testid="assistant-turn-footer">
{turnId}:{message.id}:{message.content}
</span>
));
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')], undefined, {
customization: { renderAssistantTurnFooter },
});
expect(renderAssistantTurnFooter).toHaveBeenCalledWith({
turnId: 'u1',
message: {
id: 'a1',
content: 'answer',
isStreaming: undefined,
timestamp: undefined,
},
});
expect(
c.querySelector('[data-testid="assistant-turn-footer"]')?.textContent,
).toBe('u1:a1:answer');
});
it('maps each completed turn footer to its own turn id', () => {
const renderAssistantTurnFooter = vi.fn(({ turnId, message }) => (
<span data-testid={`assistant-turn-footer-${message.id}`}>
{turnId}:{message.id}
</span>
));
const c = mount(
[userMsg('u1'), asstMsg('a1'), userMsg('u2'), asstMsg('a2')],
undefined,
{
customization: { renderAssistantTurnFooter },
},
);
expect(renderAssistantTurnFooter).toHaveBeenCalledTimes(2);
expect(renderAssistantTurnFooter.mock.calls.map(([info]) => info)).toEqual([
{
turnId: 'u1',
message: {
id: 'a1',
content: 'answer',
isStreaming: undefined,
timestamp: undefined,
},
},
{
turnId: 'u2',
message: {
id: 'a2',
content: 'answer',
isStreaming: undefined,
timestamp: undefined,
},
},
]);
expect(
c.querySelector('[data-testid="assistant-turn-footer-a1"]')?.textContent,
).toBe('u1:a1');
expect(
c.querySelector('[data-testid="assistant-turn-footer-a2"]')?.textContent,
).toBe('u2:a2');
});
it('does not render the custom assistant footer for the active streaming turn', () => {
const renderAssistantTurnFooter = vi.fn(() => (
<span data-testid="assistant-turn-footer">footer</span>
));
const c = mount(
[userMsg('u1'), { ...asstMsg('a1'), isStreaming: true }],
undefined,
{
isResponding: true,
customization: { renderAssistantTurnFooter },
},
);
expect(renderAssistantTurnFooter).not.toHaveBeenCalled();
expect(c.querySelector('[data-testid="assistant-turn-footer"]')).toBeNull();
});
it('does not render the custom assistant footer when a turn has no final assistant message', () => {
const renderAssistantTurnFooter = vi.fn(() => (
<span data-testid="assistant-turn-footer">footer</span>
));
const c = mount([userMsg('u1'), systemMsg('s1')], undefined, {
customization: { renderAssistantTurnFooter },
});
expect(renderAssistantTurnFooter).not.toHaveBeenCalled();
expect(c.querySelector('[data-testid="assistant-turn-footer"]')).toBeNull();
});
it('shows live elapsed time for a running step-less turn', () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
const c = mount([{ ...userMsg('u1'), timestamp: 7_600 }], undefined, {
isResponding: true,
});
expect(queryToggle(c, 'u1')).toBeNull();
expect(c.textContent).toContain('Processing 3s');
});
it('toggle round-trip reveals then re-hides the step', () => {
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')]);
click(toggle(c, 'u1'));
expect(has(c, 'g1')).toBe(true);
expect(isCollapsed(c, 'g1')).toBe(false);
expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true');
click(toggle(c, 'u1'));
expect(isCollapsed(c, 'g1')).toBe(true);
});
it('renders virtual scroll rows with sizer and row width classes', () => {
const c = mount(simpleTurns(110));
expect(c.querySelector(`.${styles.virtualSizer}`)).not.toBeNull();
expect(c.querySelectorAll(`.${styles.virtualRow}`).length).toBeGreaterThan(
0,
);
});
it('renders the session timeline in the left gutter without expanding turns', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount([
userMsg('u1'),
thinkingMsg('think1'),
asstMsg('mid1'),
toolMsg('g1'),
planMsg('plan1'),
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
userMsg('u3'),
asstMsg('a3'),
userMsg('u4'),
asstMsg('a4'),
]);
await nextFrame();
const timeline = c.querySelector('[data-testid="session-timeline"]');
expect(timeline).not.toBeNull();
const entries = Array.from(
c.querySelectorAll('[data-testid="session-timeline-entry"]'),
);
expect(entries.map((entry) => entry.getAttribute('data-turn-id'))).toEqual([
'u1',
'u2',
'u3',
'u4',
]);
expect(entries[0]?.getAttribute('data-node-kinds')).toBe(
'thought,commentary,tool,plan',
);
expect(
document.querySelectorAll('[data-testid="session-timeline-detail"]'),
).toHaveLength(0);
const buttons = Array.from(
c.querySelectorAll<HTMLButtonElement>(
'[data-testid="session-timeline-entry"] button',
),
);
expect(buttons[0]?.getAttribute('aria-label')).toBe(
'Turn 1: q. Current turn',
);
expect(buttons[0]?.hasAttribute('title')).toBe(false);
expect(entries[0]?.getAttribute('data-in-current-range')).toBe('true');
expect(entries[1]?.getAttribute('data-in-current-range')).toBe('true');
expect(
c.querySelector('[data-testid="session-timeline-range"]'),
).toBeNull();
expect(isCollapsed(c, 'g1')).toBe(true);
expect(c.querySelector('[data-testid="turn-timeline-row"]')).toBeNull();
rectSpy.mockRestore();
});
it('keeps a long session timeline scrollable and preserves first-entry selection', async () => {
const rectSpy = mockMessageListWidth(1200);
const offsetTopSpy = vi
.spyOn(HTMLElement.prototype, 'offsetTop', 'get')
.mockImplementation(function (this: HTMLElement) {
const index = this.getAttribute('data-timeline-index');
return index === null ? 0 : 240 + Number(index) * 60;
});
const offsetHeightSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-timeline-index') ? 3 : 0;
});
const clientHeightSpy = vi
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 220
: 0;
});
const scrollHeightSpy = vi
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 5200
: 0;
});
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
try {
const c = mount(simpleTurns(80));
await nextFrame();
const viewport = c.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(viewport).not.toBeNull();
expect(viewport!.scrollTop).toBeGreaterThan(0);
const entries = Array.from(
c.querySelectorAll('[data-testid="session-timeline-entry"]'),
);
expect(entries).toHaveLength(80);
expect(entries[0]?.getAttribute('data-turn-id')).toBe('u1');
expect(entries[0]?.getAttribute('data-timeline-index')).toBe('0');
expect(entries[79]?.getAttribute('data-turn-id')).toBe('u80');
expect(entries[79]?.getAttribute('data-timeline-index')).toBe('79');
expect(
entries[0]?.closest('[data-testid="session-timeline-viewport"]'),
).toBe(viewport);
click(entries[0]!.querySelector('button')!);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
} finally {
scrollIntoView.mockRestore();
scrollHeightSpy.mockRestore();
clientHeightSpy.mockRestore();
offsetHeightSpy.mockRestore();
offsetTopSpy.mockRestore();
rectSpy.mockRestore();
}
});
it('renders timeline details as one body-level tooltip outside the timeline stack', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4));
await nextFrame();
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
expect(firstEntryButton).not.toBeNull();
focusIn(firstEntryButton!);
const detail = document.querySelector(
'[data-testid="session-timeline-detail"]',
);
expect(detail).not.toBeNull();
expect(detail?.getAttribute('data-detail')).toBe('answer');
expect(
detail?.closest('[data-testid="session-timeline-viewport"]'),
).toBeNull();
expect(detail?.closest('[data-testid="session-timeline"]')).toBeNull();
expect(detail?.parentElement).toBe(document.body);
expect(c.contains(detail!)).toBe(false);
expect(detail?.id).toBe('session-timeline-detail-tooltip');
expect(firstEntryButton?.getAttribute('aria-describedby')).toBe(
'session-timeline-detail-tooltip',
);
focusOut(firstEntryButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
expect(firstEntryButton?.hasAttribute('aria-describedby')).toBe(false);
rectSpy.mockRestore();
});
it('clamps timeline details to the viewport edge', async () => {
const originalInnerHeight = window.innerHeight;
const rect = (
width: number,
height: number,
top: number,
left = 0,
): DOMRect => ({
width,
height,
top,
right: left + width,
bottom: top + height,
left,
x: left,
y: top,
toJSON: () => ({}),
});
let detailRect = rect(240, 50, -5, 80);
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: HTMLElement) {
if (this.getAttribute('data-testid') === 'session-timeline-detail') {
return detailRect;
}
const item = this.closest('[data-testid="session-timeline-entry"]');
if (item) return rect(58, 16, 20, 12);
return rect(1200, 600, 0);
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 100,
});
const c = mount(simpleTurns(4));
await nextFrame();
try {
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
expect(firstEntryButton).not.toBeNull();
focusIn(firstEntryButton!);
let detail = document.querySelector<HTMLElement>(
'[data-testid="session-timeline-detail"]',
);
expect(detail?.style.top).toBe('45px');
focusOut(firstEntryButton!);
detailRect = rect(240, 100, 30, 80);
focusIn(firstEntryButton!);
detail = document.querySelector<HTMLElement>(
'[data-testid="session-timeline-detail"]',
);
expect(detail?.style.top).toBe('-14px');
} finally {
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: originalInnerHeight,
});
rectSpy.mockRestore();
}
});
it('keeps timeline details during current-turn centering but hides them on user scroll', async () => {
const rectSpy = mockMessageListWidth(1200);
const offsetTopSpy = vi
.spyOn(HTMLElement.prototype, 'offsetTop', 'get')
.mockImplementation(function (this: HTMLElement) {
const index = this.getAttribute('data-timeline-index');
return index === null ? 0 : 240 + Number(index) * 60;
});
const offsetHeightSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-timeline-index') ? 3 : 0;
});
const clientHeightSpy = vi
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 220
: 0;
});
const scrollHeightSpy = vi
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 1200
: 0;
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
try {
renderInto(root, simpleTurns(3));
await nextFrame();
renderInto(root, simpleTurns(80));
const viewport = container.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(viewport).not.toBeNull();
expect(viewport!.scrollTop).toBeGreaterThan(0);
const currentButton = container.querySelector<HTMLButtonElement>(
'[data-turn-id="u80"] button',
);
expect(currentButton).not.toBeNull();
focusIn(currentButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
act(() =>
viewport!.dispatchEvent(new Event('scroll', { bubbles: true })),
);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
await nextFrame();
act(() =>
viewport!.dispatchEvent(new Event('scroll', { bubbles: true })),
);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
} finally {
scrollHeightSpy.mockRestore();
clientHeightSpy.mockRestore();
offsetHeightSpy.mockRestore();
offsetTopSpy.mockRestore();
rectSpy.mockRestore();
}
});
it('hides timeline details when the focused marker moves out of view', async () => {
let markerOffset = 0;
const rect = (
width: number,
height: number,
top: number,
left = 0,
): DOMRect => ({
width,
height,
top,
right: left + width,
bottom: top + height,
left,
x: left,
y: top,
toJSON: () => ({}),
});
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: HTMLElement) {
if (this.getAttribute('data-testid') === 'session-timeline-viewport') {
return rect(70, 220, 0);
}
const item = this.closest('[data-testid="session-timeline-entry"]');
if (item) {
const index = Number(item.getAttribute('data-timeline-index'));
return rect(58, 16, 40 + index * 60 - markerOffset);
}
return rect(1200, 600, 0);
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
try {
renderInto(root, simpleTurns(4));
await nextFrame();
const focusedButton = container.querySelector<HTMLButtonElement>(
'[data-turn-id="u2"] button',
);
expect(focusedButton).not.toBeNull();
focusIn(focusedButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
markerOffset = 700;
act(() => window.dispatchEvent(new Event('resize')));
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
expect(
container
.querySelector<HTMLButtonElement>('[data-turn-id="u2"] button')
?.hasAttribute('aria-describedby'),
).toBe(false);
} finally {
rectSpy.mockRestore();
}
});
it('keeps timeline details when focus scrolls the timeline viewport', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4));
await nextFrame();
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
const viewport = c.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(firstEntryButton).not.toBeNull();
expect(viewport).not.toBeNull();
focusIn(firstEntryButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
act(() => viewport!.dispatchEvent(new Event('scroll', { bubbles: true })));
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
expect(firstEntryButton?.hasAttribute('aria-describedby')).toBe(true);
rectSpy.mockRestore();
});
it('hides timeline details when the user scrolls the timeline viewport', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4));
await nextFrame();
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
const viewport = c.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(firstEntryButton).not.toBeNull();
expect(viewport).not.toBeNull();
focusIn(firstEntryButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
await nextFrame();
act(() => viewport!.dispatchEvent(new Event('scroll', { bubbles: true })));
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
expect(firstEntryButton?.hasAttribute('aria-describedby')).toBe(false);
rectSpy.mockRestore();
});
it('renders scheduled task marker when source is present', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount([
// Source propagation is owned by the metadata adapter PR; this test covers
// the timeline rendering contract once that source is present.
{ ...userMsg('u1'), source: 'cron', content: 'scheduled tracking task' },
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
userMsg('u3'),
asstMsg('a3'),
userMsg('u4'),
asstMsg('a4'),
]);
await nextFrame();
const scheduledButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
expect(scheduledButton).not.toBeNull();
focusIn(scheduledButton!);
const scheduledDetail = document.querySelector(
'[data-testid="session-timeline-detail"]',
);
expect(scheduledDetail?.getAttribute('data-scheduled-task')).toBe('true');
expect(
scheduledDetail?.querySelector(`.${styles.sessionTimelineDetailsIcon}`),
).not.toBeNull();
expect(scheduledDetail?.textContent).toContain('scheduled tracking task');
rectSpy.mockRestore();
});
it('hides the session timeline until there are at least four turns', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(3));
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('clicks a session timeline entry to jump to its turn', async () => {
const rectSpy = mockMessageListWidth(1200);
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
const c = mount(simpleTurns(4));
await nextFrame();
const secondEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u2"] button',
);
expect(secondEntryButton).not.toBeNull();
act(() => {
secondEntryButton?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
});
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
await nextFrame();
const targetMessage = c.querySelector('[data-testid="msg-u2"]');
expect(targetMessage?.getAttribute('data-locate-flashing')).toBe('true');
expect(targetMessage?.closest('[data-index]')?.className).not.toMatch(
/flash/i,
);
scrollIntoView.mockRestore();
rectSpy.mockRestore();
});
it('flashes grouped parallel agents inside the row when locating a tool', async () => {
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
const ref = createRef<MessageListHandle>();
const c = mount(
[userMsg('u1'), agentMsg('g1'), agentMsg('g2'), asstMsg('a1')],
ref,
);
let found = false;
act(() => {
found = ref.current!.scrollToMessage('g1', 'call-g1');
});
await nextFrame();
expect(found).toBe(true);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
const parallelAgents = c.querySelector('[data-testid="parallel-agents"]');
expect(parallelAgents?.parentElement?.className).toContain(
flashStyles.flash,
);
expect(parallelAgents?.closest('[data-index]')?.className).not.toMatch(
/flash/i,
);
scrollIntoView.mockRestore();
});
it('hides the session timeline when the message list is narrow', async () => {
const rectSpy = mockMessageListWidth(1000);
const c = mount(simpleTurns(4));
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('hides the session timeline when the caller disables it', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4), undefined, {
hideSessionTimeline: true,
});
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('hides the session timeline when the message list has no width', async () => {
const rectSpy = mockMessageListWidth(0);
const c = mount(simpleTurns(4));
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('scrollToMessage auto-expands the collapsed turn that holds the target', () => {
const ref = createRef<MessageListHandle>();
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')], ref);
expect(isCollapsed(c, 'g1')).toBe(true);
let found = false;
act(() => {
found = ref.current!.scrollToMessage('g1', 'call-g1');
});
expect(found).toBe(true);
expect(has(c, 'g1')).toBe(true);
expect(isCollapsed(c, 'g1')).toBe(false);
});
it('smooth-scrolls the page when a new chat prompt appears', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
renderInto(root, [userMsg('u1'), asstMsg('a1'), userMsg('u2')]);
await nextFrame();
expect(scrollTo).toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll when initial history already contains a user prompt', () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
mount([userMsg('u1'), asstMsg('a1')]);
expect(scrollTo).not.toHaveBeenCalled();
});
it('shows a transcript skeleton while loading transcript', () => {
const c = mount([], undefined, { loadingTranscript: true });
expect(
c.querySelector('[data-testid="message-list-loading-skeleton"]'),
).not.toBeNull();
expect(c.querySelector('[role="status"]')?.textContent).toBe(
'Session is still loading. Try again in a moment.',
);
});
it('shows the transcript skeleton while loading transcript with existing messages', () => {
const c = mount([userMsg('u1')], undefined, {
loadingTranscript: true,
});
expect(
c.querySelector('[data-testid="message-list-loading-skeleton"]'),
).not.toBeNull();
});
it('does not show the transcript skeleton outside transcript loading', () => {
const idle = mount([]);
expect(
idle.querySelector('[data-testid="message-list-loading-skeleton"]'),
).toBeNull();
});
it('loads earlier history once when the transcript reaches the top', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined);
const c = mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
onLoadOlderHistory,
});
const list = c.querySelector('[data-web-shell-message-list]');
Object.defineProperty(list, 'scrollTop', {
configurable: true,
writable: true,
value: 0,
});
await act(async () => {
list?.dispatchEvent(new Event('scroll'));
list?.dispatchEvent(new Event('scroll'));
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
});
it('preserves the scroll anchor after prepending earlier history', async () => {
let scrollHeight = 1200;
let scrollTop = 40;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
const onLoadOlderHistory = vi.fn(async () => {
scrollHeight = 1800;
});
const c = mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
onLoadOlderHistory,
});
const list = c.querySelector(
'[data-web-shell-message-list]',
) as HTMLElement;
Object.defineProperty(list, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
scrollTop = 40;
await act(async () => {
list.dispatchEvent(new Event('scroll'));
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
expect(scrollTop).toBe(640);
});
it('loads earlier history when the transcript does not overflow', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 300,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined);
mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
onLoadOlderHistory,
});
await act(async () => {
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
});
it('does not auto-load again when an underfill page adds no content', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 300,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
const render = (loadingOlderHistory: boolean) => {
root.render(
<I18nProvider language="en">
<MessageList
messages={[userMsg('u1')]}
pendingApproval={null}
hasOlderHistory
loadingOlderHistory={loadingOlderHistory}
onLoadOlderHistory={onLoadOlderHistory}
/>
</I18nProvider>,
);
};
await act(async () => {
render(false);
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
await act(async () => {
render(true);
await Promise.resolve();
});
await act(async () => {
render(false);
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
const list = container.querySelector(
'[data-web-shell-message-list]',
) as HTMLElement;
Object.defineProperty(list, 'scrollTop', {
configurable: true,
writable: true,
value: 0,
});
await act(async () => {
list.dispatchEvent(new WheelEvent('wheel', { deltaY: -1 }));
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(2);
});
it('waits for another upward scroll intent before retrying a failed underfill load', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 300,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onLoadOlderHistory = vi
.fn()
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValueOnce(undefined);
const c = mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
onLoadOlderHistory,
});
const list = c.querySelector(
'[data-web-shell-message-list]',
) as HTMLElement;
Object.defineProperty(list, 'scrollTop', {
configurable: true,
writable: true,
value: 0,
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
await act(async () => {
list.dispatchEvent(new WheelEvent('wheel', { deltaY: -1 }));
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(2);
});
it('loads earlier history when a resize removes the overflow', async () => {
let clientHeight = 600;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get: () => clientHeight,
});
const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined);
const c = mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
onLoadOlderHistory,
});
const list = c.querySelector(
'[data-web-shell-message-list]',
) as HTMLElement;
expect(onLoadOlderHistory).not.toHaveBeenCalled();
expect(list.scrollHeight).toBe(1200);
expect(list.clientHeight).toBe(600);
clientHeight = 1200;
expect(list.clientHeight).toBe(1200);
await act(async () => {
triggerResizeObservers();
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
});
it('shows a status while loading earlier history', () => {
const c = mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
loadingOlderHistory: true,
});
expect(c.querySelector('[role="status"]')?.textContent).toBe(
'Loading earlier messages…',
);
expect(c.querySelector('button')).toBeNull();
});
it('suppresses the loading status during automatic pagination', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 300,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
let resolveLoad!: () => void;
const onLoadOlderHistory = vi.fn(
() => new Promise<void>((resolve) => (resolveLoad = resolve)),
);
const c = mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
onLoadOlderHistory,
});
await act(async () => {
await Promise.resolve();
});
expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
expect(c.querySelector('[role="status"]')).toBeNull();
await act(async () => {
resolveLoad();
await Promise.resolve();
});
});
it('shows when the history display limit is reached', () => {
const c = mount([userMsg('u1')], undefined, {
historyCapacityReached: true,
});
expect(c.querySelector('[role="status"]')?.textContent).toBe(
'History display limit reached. Earlier messages remain saved.',
);
});
it('shows a persistent error when history pagination fails', () => {
const c = mount([userMsg('u1')], undefined, {
historyPaginationError: true,
});
expect(c.querySelector('[role="status"]')?.textContent).toBe(
'Earlier history could not be loaded.',
);
});
it('does not auto-load older history when a pagination error is present', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 300,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined);
// historyPaginationError is true, hasOlderHistory is true
const c = mount([userMsg('u1')], undefined, {
hasOlderHistory: true,
historyPaginationError: true,
onLoadOlderHistory,
});
const list = c.querySelector(
'[data-web-shell-message-list]',
) as HTMLElement;
Object.defineProperty(list, 'scrollTop', {
configurable: true,
writable: true,
value: 0,
});
await act(async () => {
list.dispatchEvent(new Event('scroll'));
await Promise.resolve();
});
// It should NOT call loadMore because paginationError blocks it
expect(onLoadOlderHistory).not.toHaveBeenCalled();
});
it('does not smooth-scroll when existing session history loads after an empty render', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, []);
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalled();
});
it('smooth-scrolls the first new prompt after an empty render', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, []);
renderInto(root, [userMsg('u1')]);
await nextFrame();
expect(scrollTo).toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll restored history that ends with a user prompt', async () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [], undefined, { loadingTranscript: true });
renderInto(root, [userMsg('u1')], undefined, {
loadingTranscript: false,
});
await nextFrame();
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll when a user prompt is already followed by an assistant row', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
renderInto(root, [
userMsg('u1'),
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
]);
await nextFrame();
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('snaps to bottom without smooth scrolling when catch-up completes', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const messages = [userMsg('u1'), asstMsg('a1')];
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, messages, undefined, { catchingUp: true });
expect(scrollTop).toBe(0);
renderInto(root, messages, undefined, { catchingUp: false });
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalled();
});
it('does not treat a user_shell row as a new chat prompt', () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
mount([userShellMsg('shell')]);
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('shows assistant actions on the final answer of a user_shell turn', () => {
const c = mount([
userShellMsg('shell'),
asstMsg('mid'),
toolMsg('tool'),
asstMsg('a1'),
]);
expect(has(c, 'mid')).toBe(false);
expect(assistantActions(c, 'a1')).toBe('true');
});
it('reports when the user has scrolled away from the bottom', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 600,
writable: true,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
});
const onCanScrollToBottomChange = vi.fn();
const container = mount([asstMsg('a1')], undefined, {
onCanScrollToBottomChange,
});
await nextFrame();
const list = container.firstElementChild as HTMLElement;
list.scrollTop = 600;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
list.scrollTop = 500;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
list.scrollTop = 600;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports no scroll-to-bottom affordance when the list has no scrollbar', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onCanScrollToBottomChange = vi.fn();
mount([userMsg('u1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports no scroll-to-bottom affordance when already at the bottom', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 600,
writable: true,
});
const onCanScrollToBottomChange = vi.fn();
mount([userMsg('u1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('keeps the scroll-to-bottom affordance hidden when followed content grows', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
mount([asstMsg('a1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
scrollHeight = 1200;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports scroll-to-bottom affordance when a clicked disclosure grows during streaming', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([thinkingMsg('t1'), asstMsg('a1')], undefined, {
isResponding: true,
onCanScrollToBottomChange,
});
await nextFrame();
click(disclosure(c, 't1'));
scrollHeight = 1200;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
});
it('keeps the scroll-to-bottom affordance hidden when disclosure growth stays near bottom', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([thinkingMsg('t1'), asstMsg('a1')], undefined, {
isResponding: true,
onCanScrollToBottomChange,
});
await nextFrame();
click(disclosure(c, 't1'));
scrollHeight = 620;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('clears the scroll-to-bottom affordance immediately after scrolling to bottom', async () => {
let scrollTop = 600;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const ref = createRef<MessageListHandle>();
const c = mount([asstMsg('a1')], ref, { onCanScrollToBottomChange });
await nextFrame();
await nextFrame();
const list = c.firstElementChild as HTMLElement;
scrollTop = 0;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
act(() => ref.current?.scrollToBottom('auto'));
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports scroll-to-bottom affordance when expanding content creates overflow', async () => {
let scrollHeight = 600;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 0,
writable: true,
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')], undefined, {
onCanScrollToBottomChange,
});
await nextFrame();
click(toggle(c, 'u1'));
scrollHeight = 1200;
await nextFrame();
await nextFrame();
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 230)));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
});
});