feat(app): virtualize session timeline
Render SessionDetail through measured dynamic-height virtual rows while preserving disclosure state, UUID navigation, reader anchoring, and tail-follow across live updates. Add focused state/reconciliation tests plus a production Electron harness covering long-session DOM bounds, scrolling performance, offscreen navigation, and live viewport stability.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// Production renderer integration test for the dynamic SessionDetail timeline.
|
||||
// Run: npm run test:electron:timeline
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = join(here, '..');
|
||||
const sessionId = 'test-session';
|
||||
const channels = [
|
||||
'db:getSessions',
|
||||
'db:getSessionMessages',
|
||||
'db:getSessionToolCalls',
|
||||
'db:getSessionToolResults',
|
||||
'db:getSessionSubagents',
|
||||
'db:getSessionWorkflows',
|
||||
'db:getSessionSummaries',
|
||||
'db:getMemories',
|
||||
'db:getProjects',
|
||||
'db:getStats',
|
||||
'settings:get',
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
let firstSessionListRead = true;
|
||||
const messages = Array.from({ length: 2000 }, (_, index) => ({
|
||||
uuid: `message-${index}`,
|
||||
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||
timestamp: new Date(Date.UTC(2026, 6, 14, 0, 0, index)).toISOString(),
|
||||
text: index === 1
|
||||
? ''
|
||||
: `Message ${index} ${'dynamic-height content '.repeat((index % 7) + 1)}`,
|
||||
content_type: index === 1 ? 'tool_use' : 'text',
|
||||
is_meta: 0,
|
||||
}));
|
||||
const toolCalls = [{
|
||||
id: 'call-1',
|
||||
message_uuid: 'message-1',
|
||||
name: 'Bash',
|
||||
input_json: JSON.stringify({ command: 'printf virtualized' }),
|
||||
}];
|
||||
const toolResults = [{
|
||||
tool_use_id: 'call-1',
|
||||
content: `${'virtualized output\n'.repeat(80)}`,
|
||||
is_error: 0,
|
||||
}];
|
||||
|
||||
function sessionSummary() {
|
||||
return {
|
||||
id: sessionId,
|
||||
title: 'Virtualized timeline integration',
|
||||
project: 'quiet-zero',
|
||||
project_path: '/tmp/quiet-zero',
|
||||
source: 'claude',
|
||||
started_at: '2026-07-14T00:00:00.000Z',
|
||||
ended_at: '2026-07-14T01:00:00.000Z',
|
||||
message_count: messages.length,
|
||||
git_branch: 'main',
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) console.log(`PASS: ${message}`);
|
||||
else {
|
||||
failures++;
|
||||
console.error(`FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(webContents, expression, message, timeoutMs = 8000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await webContents.executeJavaScript(`Boolean(${expression})`, true)) return;
|
||||
await delay(40);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${message}`);
|
||||
}
|
||||
|
||||
function registerHandlers() {
|
||||
ipcMain.handle('db:getSessions', async () => {
|
||||
if (firstSessionListRead) {
|
||||
firstSessionListRead = false;
|
||||
await delay(120);
|
||||
}
|
||||
return [sessionSummary()];
|
||||
});
|
||||
ipcMain.handle('db:getSessionMessages', () => messages);
|
||||
ipcMain.handle('db:getSessionToolCalls', () => toolCalls);
|
||||
ipcMain.handle('db:getSessionToolResults', () => toolResults);
|
||||
ipcMain.handle('db:getSessionSubagents', () => []);
|
||||
ipcMain.handle('db:getSessionWorkflows', () => []);
|
||||
ipcMain.handle('db:getSessionSummaries', () => []);
|
||||
ipcMain.handle('db:getMemories', () => []);
|
||||
ipcMain.handle('db:getProjects', () => [{ project: 'quiet-zero', count: 1 }]);
|
||||
ipcMain.handle('db:getStats', () => ({}));
|
||||
ipcMain.handle('settings:get', () => ({}));
|
||||
}
|
||||
|
||||
function appendMessage(win, index) {
|
||||
messages.push({
|
||||
uuid: `message-${index}`,
|
||||
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||
timestamp: new Date().toISOString(),
|
||||
text: `Live message ${index}`,
|
||||
content_type: 'text',
|
||||
is_meta: 0,
|
||||
});
|
||||
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||
}
|
||||
|
||||
async function run() {
|
||||
registerHandlers();
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
preload: join(appRoot, 'out', 'preload', 'index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), {
|
||||
hash: `/sessions/${sessionId}`,
|
||||
});
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2000'`,
|
||||
'the cold-start session snapshot',
|
||||
);
|
||||
|
||||
const initial = await win.webContents.executeJavaScript(`(() => ({
|
||||
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
|
||||
rows: document.querySelectorAll('.virtual-timeline-row').length,
|
||||
roots: document.querySelectorAll('.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]').length,
|
||||
scrollTop: document.querySelector('.detail-wrap')?.scrollTop,
|
||||
scrollHeight: document.querySelector('.detail-wrap')?.scrollHeight,
|
||||
}))()`, true);
|
||||
assert(initial.scrollTop < 2 && initial.current < 100, `cold start stays at the beginning (scrollTop ${initial.scrollTop}, item ${initial.current})`);
|
||||
assert(initial.total === 2000, `timeline exposes all 2000 items (got ${initial.total})`);
|
||||
assert(initial.rows < 60 && initial.roots === initial.rows, `only ${initial.rows} virtual rows are mounted`);
|
||||
|
||||
const disclosure = await win.webContents.executeJavaScript(`(async () => {
|
||||
const toggle = document.querySelector('.toolcall-toggle');
|
||||
const row = toggle?.closest('.virtual-timeline-row');
|
||||
const before = row?.getBoundingClientRect().height || 0;
|
||||
toggle?.click();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const after = row?.getBoundingClientRect().height || 0;
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
wrap.scrollTop = wrap.scrollHeight * 0.55;
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
const unmounted = !document.querySelector('[data-view-key="tool:call-1"]');
|
||||
document.querySelector('button[title="First"]')?.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
return {
|
||||
before,
|
||||
after,
|
||||
unmounted,
|
||||
restored: Boolean(document.querySelector('[data-view-key="tool:call-1"].open')),
|
||||
};
|
||||
})()`, true);
|
||||
assert(disclosure.after > disclosure.before, `expanded tool row remeasures from ${disclosure.before}px to ${disclosure.after}px`);
|
||||
assert(disclosure.unmounted, 'the expanded tool row unmounts outside overscan');
|
||||
assert(disclosure.restored, 'disclosure state survives unmount and remount');
|
||||
|
||||
await win.webContents.executeJavaScript(`window.location.hash = '#/sessions'`, true);
|
||||
await waitFor(win.webContents, `!document.querySelector('.virtual-timeline')`, 'session detail deactivation');
|
||||
await win.webContents.executeJavaScript(
|
||||
`window.location.hash = '#/sessions/${sessionId}?focus=message-1500'`,
|
||||
true,
|
||||
);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('[data-uuid="message-1500"].is-focused')`,
|
||||
'offscreen UUID focus',
|
||||
);
|
||||
const focusState = await win.webContents.executeJavaScript(`(() => {
|
||||
const target = document.querySelector('[data-uuid="message-1500"].is-focused');
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
return {
|
||||
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||
visible: targetRect.bottom > wrapRect.top && targetRect.top < wrapRect.bottom,
|
||||
};
|
||||
})()`, true);
|
||||
assert(focusState.visible, `UUID navigation mounts and reveals message-1500 (viewport ends at item ${focusState.current})`);
|
||||
|
||||
setTimeout(() => appendMessage(win, 2000), 250);
|
||||
const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
const gaps = [];
|
||||
const startedAt = performance.now();
|
||||
let previous = startedAt;
|
||||
function frame(now) {
|
||||
gaps.push(now - previous);
|
||||
previous = now;
|
||||
wrap.scrollTop += 70;
|
||||
if (now - startedAt < 1200) requestAnimationFrame(frame);
|
||||
else {
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
const anchorRow = [...document.querySelectorAll('.virtual-timeline-row')]
|
||||
.find(row => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom;
|
||||
});
|
||||
const anchorElement = anchorRow?.querySelector('[data-uuid]');
|
||||
resolve({
|
||||
maxFrameGap: Math.max(...gaps),
|
||||
frames: gaps.length,
|
||||
rows: document.querySelectorAll('.virtual-timeline-row').length,
|
||||
distanceFromTail: wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop,
|
||||
anchor: anchorElement && {
|
||||
uuid: anchorElement.getAttribute('data-uuid'),
|
||||
offset: anchorRow.getBoundingClientRect().top - wrapRect.top,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
})`, true);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2001'`,
|
||||
'reader-position live update',
|
||||
);
|
||||
const readerState = await win.webContents.executeJavaScript(`(() => {
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
const anchorElement = document.querySelector(
|
||||
${JSON.stringify(`[data-uuid="${scrollProbe.anchor?.uuid}"]`)},
|
||||
);
|
||||
const anchorRow = anchorElement?.closest('.virtual-timeline-row');
|
||||
return {
|
||||
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||
anchor: anchorElement && {
|
||||
uuid: anchorElement.getAttribute('data-uuid'),
|
||||
offset: anchorRow.getBoundingClientRect().top - wrap.getBoundingClientRect().top,
|
||||
},
|
||||
};
|
||||
})()`, true);
|
||||
assert(scrollProbe.rows < 60, `live scrolling keeps mounted rows bounded (${scrollProbe.rows})`);
|
||||
assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit');
|
||||
assert(
|
||||
scrollProbe.distanceFromTail > 1000
|
||||
&& readerState.current < 2001
|
||||
&& readerState.anchor?.uuid === scrollProbe.anchor?.uuid
|
||||
&& Math.abs(readerState.anchor.offset - scrollProbe.anchor.offset) < 2,
|
||||
`live append preserves reader anchor ${scrollProbe.anchor?.uuid} (${scrollProbe.anchor?.offset}px -> ${readerState.anchor?.offset}px)`,
|
||||
);
|
||||
assert(scrollProbe.maxFrameGap < 250, `live scroll has no catastrophic long frame (${scrollProbe.maxFrameGap.toFixed(1)}ms)`);
|
||||
|
||||
await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.msg-nav-current')?.textContent === '2001'`,
|
||||
'last-item navigation',
|
||||
);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`,
|
||||
'last-item scroll settlement',
|
||||
);
|
||||
appendMessage(win, 2001);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2002'`,
|
||||
'tail-follow total update',
|
||||
);
|
||||
await delay(1000);
|
||||
const tailState = await win.webContents.executeJavaScript(`(() => {
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
return {
|
||||
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
|
||||
scrollTop: wrap.scrollTop,
|
||||
maxScrollTop: wrap.scrollHeight - wrap.clientHeight,
|
||||
distanceFromTail: wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop,
|
||||
};
|
||||
})()`, true);
|
||||
assert(
|
||||
tailState.current === 2002 && tailState.distanceFromTail < 2,
|
||||
`tail follow reaches item 2002 (${JSON.stringify(tailState)})`,
|
||||
);
|
||||
|
||||
const reduction = (1 - initial.rows / initial.total) * 100;
|
||||
console.log(`PERF: ${initial.total} timeline items -> ${initial.rows} mounted rows (${reduction.toFixed(2)}% fewer roots)`);
|
||||
console.log(`PERF: ${scrollProbe.frames} frames, max frame gap ${scrollProbe.maxFrameGap.toFixed(1)}ms during live scroll`);
|
||||
win.destroy();
|
||||
}
|
||||
|
||||
app.whenReady()
|
||||
.then(run)
|
||||
.catch(error => {
|
||||
failures++;
|
||||
console.error(error.stack || error);
|
||||
})
|
||||
.finally(() => {
|
||||
for (const channel of channels) ipcMain.removeHandler(channel);
|
||||
app.exit(failures ? 1 : 0);
|
||||
});
|
||||
Reference in New Issue
Block a user