Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 249 additions & 7 deletions packages/web-shell/client/components/MessageList.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ 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(
() =>
Expand Down Expand Up @@ -397,11 +401,9 @@ describe('MessageList — turn collapse (DOM)', () => {
expect(entries[0]?.getAttribute('data-node-kinds')).toBe(
'thought,commentary,tool,plan',
);
const details = Array.from(
c.querySelectorAll('[data-testid="session-timeline-detail"]'),
);
expect(details).toHaveLength(4);
expect(details[0]?.getAttribute('data-detail')).toBe('answer');
expect(
document.querySelectorAll('[data-testid="session-timeline-detail"]'),
).toHaveLength(0);
const buttons = Array.from(
c.querySelectorAll<HTMLButtonElement>(
'[data-testid="session-timeline-entry"] button',
Expand All @@ -421,6 +423,240 @@ describe('MessageList — turn collapse (DOM)', () => {
rectSpy.mockRestore();
});

it('keeps a long session timeline scrollable and preserves first-entry selection', async () => {
const rectSpy = mockMessageListWidth(1200);
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
const c = mount(simpleTurns(80));
await nextFrame();

const viewport = c.querySelector(
'[data-testid="session-timeline-viewport"]',
);
expect(viewport).not.toBeNull();
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' });
scrollIntoView.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('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('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();

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([
Expand All @@ -437,8 +673,14 @@ describe('MessageList — turn collapse (DOM)', () => {
]);
await nextFrame();

const scheduledDetail = c.querySelector(
'[data-turn-id="u1"] [data-testid="session-timeline-detail"]',
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(
Expand Down
Loading
Loading