perf(app): stabilize live session timeline updates
Virtualize SessionDetail rows behind stable presentation boundaries and apply typed incremental patches only after visible commits. Preserve reader state across live updates, handle coalesced and reordered patches, and verify the 120Hz append path with Electron tracing.
This commit is contained in:
@@ -4,18 +4,29 @@ 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';
|
||||
import { createSessionPatch } from '../src/shared/session-patch.mjs';
|
||||
import { assembleSessionMessages } from '../src/shared/session-detail-assembly.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = join(here, '..');
|
||||
const sessionId = 'test-session';
|
||||
const messageCount = Number(process.env.OBELISK_TIMELINE_MESSAGE_COUNT || 2000);
|
||||
const focusMessageIndex = Math.floor(messageCount * 0.75);
|
||||
const focusMessageUuid = `message-${focusMessageIndex}`;
|
||||
const stationaryAppendRuns = 3;
|
||||
const firstStationaryAppendIndex = messageCount;
|
||||
const scrollingAppendIndex = messageCount + stationaryAppendRuns;
|
||||
const tailAppendIndex = scrollingAppendIndex + 1;
|
||||
const channels = [
|
||||
'db:getSessions',
|
||||
'db:getSessionMessages',
|
||||
'db:getSessionToolCalls',
|
||||
'db:getSessionToolResults',
|
||||
'db:getSessionPatch',
|
||||
'db:getSessionSubagents',
|
||||
'db:getSessionWorkflows',
|
||||
'db:getSessionSummaries',
|
||||
'db:getMessageFullText',
|
||||
'db:getMemories',
|
||||
'db:getProjects',
|
||||
'db:getStats',
|
||||
@@ -24,7 +35,17 @@ const channels = [
|
||||
|
||||
let failures = 0;
|
||||
let firstSessionListRead = true;
|
||||
const messages = Array.from({ length: 2000 }, (_, index) => ({
|
||||
const ipcReads = {
|
||||
messages: 0,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
subagents: 0,
|
||||
workflows: 0,
|
||||
summaries: 0,
|
||||
patches: 0,
|
||||
patchMessageRows: [],
|
||||
};
|
||||
const messages = Array.from({ length: messageCount }, (_, index) => ({
|
||||
uuid: `message-${index}`,
|
||||
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||
timestamp: new Date(Date.UTC(2026, 6, 14, 0, 0, index)).toISOString(),
|
||||
@@ -34,16 +55,33 @@ const messages = Array.from({ length: 2000 }, (_, index) => ({
|
||||
content_type: index === 1 ? 'tool_use' : 'text',
|
||||
is_meta: 0,
|
||||
}));
|
||||
messages[focusMessageIndex].type = 'assistant';
|
||||
messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repeat(700)}`;
|
||||
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
||||
const codexExecSource = 'const result = { ok: true };\nreturn result;';
|
||||
let codexExecOutput = JSON.stringify([{
|
||||
type: 'input_text',
|
||||
text: 'Script completed\nWall time 0.1 seconds\nOutput:\n{"ok":true}',
|
||||
}]);
|
||||
const toolCalls = [{
|
||||
id: 'call-1',
|
||||
message_uuid: 'message-1',
|
||||
name: 'Bash',
|
||||
input_json: JSON.stringify({ command: 'printf virtualized' }),
|
||||
}, {
|
||||
id: 'call-codex-exec',
|
||||
message_uuid: focusMessageUuid,
|
||||
name: 'exec',
|
||||
input_json: JSON.stringify(codexExecSource),
|
||||
}];
|
||||
const toolResults = [{
|
||||
tool_use_id: 'call-1',
|
||||
content: `${'virtualized output\n'.repeat(80)}`,
|
||||
is_error: 0,
|
||||
}, {
|
||||
tool_use_id: 'call-codex-exec',
|
||||
content: codexExecOutput,
|
||||
is_error: 0,
|
||||
}];
|
||||
|
||||
function sessionSummary() {
|
||||
@@ -77,6 +115,99 @@ async function waitFor(webContents, expression, message, timeoutMs = 8000) {
|
||||
throw new Error(`Timed out waiting for ${message}`);
|
||||
}
|
||||
|
||||
async function startRendererTrace(win) {
|
||||
const traceEvents = [];
|
||||
let completeTrace;
|
||||
const traceComplete = new Promise(resolve => { completeTrace = resolve; });
|
||||
const onMessage = (_event, method, params = {}) => {
|
||||
if (method === 'Tracing.dataCollected') traceEvents.push(...(params.value || []));
|
||||
if (method === 'Tracing.tracingComplete') completeTrace();
|
||||
};
|
||||
win.webContents.debugger.attach('1.3');
|
||||
win.webContents.debugger.on('message', onMessage);
|
||||
await win.webContents.debugger.sendCommand('Tracing.start', {
|
||||
categories: 'devtools.timeline,disabled-by-default-devtools.timeline,blink.user_timing,toplevel',
|
||||
options: 'record-as-much-as-possible',
|
||||
transferMode: 'ReportEvents',
|
||||
});
|
||||
return async () => {
|
||||
await win.webContents.debugger.sendCommand('Tracing.end');
|
||||
await traceComplete;
|
||||
win.webContents.debugger.removeListener('message', onMessage);
|
||||
win.webContents.debugger.detach();
|
||||
return traceEvents;
|
||||
};
|
||||
}
|
||||
|
||||
function rendererTaskMetrics(traceEvents, startMark, endMark) {
|
||||
const start = traceEvents.find(event => event.name === startMark);
|
||||
const end = [...traceEvents].reverse().find(event => event.name === endMark);
|
||||
if (!start || !end) throw new Error(`Missing renderer trace marks: ${startMark}, ${endMark}`);
|
||||
const tasks = traceEvents
|
||||
.filter(event => (
|
||||
/RunTask$/.test(event.name || '')
|
||||
&& event.ph === 'X'
|
||||
&& event.pid === start.pid
|
||||
&& event.tid === start.tid
|
||||
&& event.ts >= start.ts
|
||||
&& event.ts <= end.ts
|
||||
));
|
||||
const taskDurations = tasks.map(event => event.dur / 1000);
|
||||
if (taskDurations.length === 0) throw new Error('Renderer trace contained no RunTask events');
|
||||
const slowest = tasks.reduce((best, task) => !best || task.dur > best.dur ? task : best, null);
|
||||
const slowestChildren = slowest
|
||||
? traceEvents
|
||||
.filter(event => (
|
||||
event.ph === 'X'
|
||||
&& event.pid === slowest.pid
|
||||
&& event.tid === slowest.tid
|
||||
&& event !== slowest
|
||||
&& event.ts >= slowest.ts
|
||||
&& event.ts + (event.dur || 0) <= slowest.ts + slowest.dur
|
||||
))
|
||||
.sort((a, b) => (b.dur || 0) - (a.dur || 0))
|
||||
.slice(0, 8)
|
||||
.map(event => ({ name: event.name, durationMs: (event.dur || 0) / 1000 }))
|
||||
: [];
|
||||
return {
|
||||
tasks: taskDurations.length,
|
||||
maxTaskMs: Math.max(0, ...taskDurations),
|
||||
slowestChildren,
|
||||
};
|
||||
}
|
||||
|
||||
async function traceStationaryAppend(win, index, expectedTotal, runIndex) {
|
||||
const startMark = `obelisk-live-commit-${runIndex}-start`;
|
||||
const endMark = `obelisk-live-commit-${runIndex}-end`;
|
||||
const stopRendererTrace = await startRendererTrace(win);
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const expected = ${JSON.stringify(String(expectedTotal))};
|
||||
const counter = document.querySelector('.flap-number');
|
||||
performance.mark(${JSON.stringify(startMark)});
|
||||
window.__obeliskLiveCommitObserved = new Promise(resolve => {
|
||||
const finish = () => requestAnimationFrame(() => {
|
||||
performance.mark(${JSON.stringify(endMark)});
|
||||
resolve(true);
|
||||
});
|
||||
if (counter?.getAttribute('aria-label') === expected) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
if (counter?.getAttribute('aria-label') !== expected) return;
|
||||
observer.disconnect();
|
||||
finish();
|
||||
});
|
||||
observer.observe(counter, { attributes: true, attributeFilter: ['aria-label'] });
|
||||
});
|
||||
return true;
|
||||
})()`, true);
|
||||
appendMessage(win, index);
|
||||
await win.webContents.executeJavaScript('window.__obeliskLiveCommitObserved', true);
|
||||
await win.webContents.executeJavaScript('delete window.__obeliskLiveCommitObserved', true);
|
||||
return rendererTaskMetrics(await stopRendererTrace(), startMark, endMark);
|
||||
}
|
||||
|
||||
function registerHandlers() {
|
||||
ipcMain.handle('db:getSessions', async () => {
|
||||
if (firstSessionListRead) {
|
||||
@@ -85,12 +216,22 @@ function registerHandlers() {
|
||||
}
|
||||
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:getSessionMessages', () => { ipcReads.messages++; return messages; });
|
||||
ipcMain.handle('db:getSessionToolCalls', () => { ipcReads.toolCalls++; return toolCalls; });
|
||||
ipcMain.handle('db:getSessionToolResults', () => { ipcReads.toolResults++; return toolResults; });
|
||||
ipcMain.handle('db:getSessionPatch', (_event, _sessionId, cursor) => {
|
||||
ipcReads.patches++;
|
||||
const patch = createSessionPatch({
|
||||
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents: [], workflows: [] }),
|
||||
workflows: [],
|
||||
}, cursor);
|
||||
ipcReads.patchMessageRows.push(patch.changes.messages.length);
|
||||
return patch;
|
||||
});
|
||||
ipcMain.handle('db:getSessionSubagents', () => { ipcReads.subagents++; return []; });
|
||||
ipcMain.handle('db:getSessionWorkflows', () => { ipcReads.workflows++; return []; });
|
||||
ipcMain.handle('db:getSessionSummaries', () => { ipcReads.summaries++; return []; });
|
||||
ipcMain.handle('db:getMessageFullText', (_event, uuid) => uuid === focusMessageUuid ? fullTextSentinel : null);
|
||||
ipcMain.handle('db:getMemories', () => []);
|
||||
ipcMain.handle('db:getProjects', () => [{ project: 'quiet-zero', count: 1 }]);
|
||||
ipcMain.handle('db:getStats', () => ({}));
|
||||
@@ -109,6 +250,20 @@ function appendMessage(win, index) {
|
||||
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||
}
|
||||
|
||||
function replaceMessageText(win, uuid, text) {
|
||||
const index = messages.findIndex(message => message.uuid === uuid);
|
||||
if (index < 0) throw new Error(`Cannot update missing message ${uuid}`);
|
||||
messages[index] = { ...messages[index], text };
|
||||
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||
}
|
||||
|
||||
function replaceToolResult(win, toolUseId, content) {
|
||||
const index = toolResults.findIndex(result => result.tool_use_id === toolUseId);
|
||||
if (index < 0) throw new Error(`Cannot update missing tool result ${toolUseId}`);
|
||||
toolResults[index] = { ...toolResults[index], content };
|
||||
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||
}
|
||||
|
||||
async function run() {
|
||||
registerHandlers();
|
||||
const win = new BrowserWindow({
|
||||
@@ -127,7 +282,7 @@ async function run() {
|
||||
});
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2000'`,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount}'`,
|
||||
'the cold-start session snapshot',
|
||||
);
|
||||
|
||||
@@ -140,7 +295,7 @@ async function run() {
|
||||
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.total === messageCount, `timeline exposes all ${messageCount} 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 () => {
|
||||
@@ -169,17 +324,23 @@ async function run() {
|
||||
|
||||
await win.webContents.executeJavaScript(`window.location.hash = '#/sessions'`, true);
|
||||
await waitFor(win.webContents, `!document.querySelector('.virtual-timeline')`, 'session detail deactivation');
|
||||
await win.webContents.executeJavaScript(`(async () => {
|
||||
const search = document.querySelector('#search');
|
||||
search.value = 'SENTINEL';
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
})()`, true);
|
||||
await win.webContents.executeJavaScript(
|
||||
`window.location.hash = '#/sessions/${sessionId}?focus=message-1500'`,
|
||||
`window.location.hash = '#/sessions/${sessionId}?focus=${focusMessageUuid}'`,
|
||||
true,
|
||||
);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('[data-uuid="message-1500"].is-focused')`,
|
||||
`document.querySelector('[data-uuid="${focusMessageUuid}"].is-focused')`,
|
||||
'offscreen UUID focus',
|
||||
);
|
||||
const focusState = await win.webContents.executeJavaScript(`(() => {
|
||||
const target = document.querySelector('[data-uuid="message-1500"].is-focused');
|
||||
const target = document.querySelector('[data-uuid="${focusMessageUuid}"].is-focused');
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
@@ -188,9 +349,143 @@ async function run() {
|
||||
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})`);
|
||||
assert(focusState.visible, `UUID navigation mounts and reveals ${focusMessageUuid} (viewport ends at item ${focusState.current})`);
|
||||
const codexDisplayState = await win.webContents.executeJavaScript(`(async () => {
|
||||
const tool = document.querySelector('[data-view-key="tool:call-codex-exec"]');
|
||||
tool?.querySelector('.toolcall-toggle')?.click();
|
||||
tool?.querySelector('.raw-toggle')?.click();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
return {
|
||||
open: tool?.classList.contains('open'),
|
||||
raw: tool?.querySelector('.raw-toggle')?.classList.contains('active'),
|
||||
};
|
||||
})()`, true);
|
||||
assert(codexDisplayState.open && codexDisplayState.raw, 'Codex exec disclosure and Raw state update without rebuilding its presentation');
|
||||
codexExecOutput = JSON.stringify([{
|
||||
type: 'input_text',
|
||||
text: 'Script completed\nWall time 0.1 seconds\nOutput:\n{"ok":true,"revision":2}',
|
||||
}]);
|
||||
replaceToolResult(win, 'call-codex-exec', codexExecOutput);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('[data-view-key="tool:call-codex-exec"] .toolcall-raw')?.textContent.includes('revision')`,
|
||||
'updated Codex exec result',
|
||||
);
|
||||
const updatedCodexDisplayState = await win.webContents.executeJavaScript(`(() => {
|
||||
const tool = document.querySelector('[data-view-key="tool:call-codex-exec"]');
|
||||
return {
|
||||
open: tool?.classList.contains('open'),
|
||||
raw: tool?.querySelector('.raw-toggle')?.classList.contains('active'),
|
||||
};
|
||||
})()`, true);
|
||||
assert(updatedCodexDisplayState.open && updatedCodexDisplayState.raw, 'Codex exec disclosure and Raw state survive a result update');
|
||||
|
||||
setTimeout(() => appendMessage(win, 2000), 250);
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('[data-uuid="${focusMessageUuid}"] .truncated-btn')?.click()`,
|
||||
true,
|
||||
);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('[data-uuid="${focusMessageUuid}"]')?.textContent.includes('FULL TEXT SENTINEL')`,
|
||||
'expanded full message text',
|
||||
);
|
||||
const fullTextSearchState = await win.webContents.executeJavaScript(`(() => {
|
||||
const target = document.querySelector('[data-uuid="${focusMessageUuid}"]');
|
||||
return {
|
||||
highlighted: [...target.querySelectorAll('mark')].some(mark => mark.textContent === 'SENTINEL'),
|
||||
truncatedButtonRemoved: !target.querySelector('.truncated-btn'),
|
||||
};
|
||||
})()`, true);
|
||||
assert(
|
||||
fullTextSearchState.highlighted && fullTextSearchState.truncatedButtonRemoved,
|
||||
'full-text expansion re-renders the row and preserves search highlighting',
|
||||
);
|
||||
await delay(250);
|
||||
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const original = window.marked.parse;
|
||||
const originalJsonParse = JSON.parse;
|
||||
const codexExecOutput = ${JSON.stringify(codexExecOutput)};
|
||||
const trackedPrefixes = [...document.querySelectorAll('.virtual-timeline-row [data-uuid]')]
|
||||
.map(element => element.getAttribute('data-uuid'))
|
||||
.filter(uuid => /^message-\d+$/.test(uuid))
|
||||
.map(uuid => 'Message ' + uuid.slice('message-'.length) + ' ');
|
||||
let calls = 0;
|
||||
let codexExecCalls = 0;
|
||||
window.marked.parse = function timelineMarkdownProbe(...args) {
|
||||
const text = String(args[0] || '');
|
||||
if (trackedPrefixes.some(prefix => text.startsWith(prefix))) calls++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
JSON.parse = function timelineJsonProbe(value, ...args) {
|
||||
if (value === codexExecOutput) codexExecCalls++;
|
||||
return originalJsonParse.call(this, value, ...args);
|
||||
};
|
||||
window.__timelineMarkdownProbe = {
|
||||
calls: () => calls,
|
||||
codexExecCalls: () => codexExecCalls,
|
||||
restore: () => {
|
||||
window.marked.parse = original;
|
||||
JSON.parse = originalJsonParse;
|
||||
},
|
||||
};
|
||||
})()`, true);
|
||||
const stationaryAnchorBefore = await win.webContents.executeJavaScript(`(() => {
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
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]');
|
||||
return anchorElement && {
|
||||
uuid: anchorElement.getAttribute('data-uuid'),
|
||||
offset: anchorRow.getBoundingClientRect().top - wrapRect.top,
|
||||
};
|
||||
})()`, true);
|
||||
const stationaryTraces = [];
|
||||
for (let runIndex = 0; runIndex < stationaryAppendRuns; runIndex++) {
|
||||
stationaryTraces.push(await traceStationaryAppend(
|
||||
win,
|
||||
firstStationaryAppendIndex + runIndex,
|
||||
messageCount + runIndex + 1,
|
||||
runIndex,
|
||||
));
|
||||
await delay(250);
|
||||
}
|
||||
const stationaryAnchorSelector = `[data-uuid="${stationaryAnchorBefore?.uuid}"]`;
|
||||
const stationaryAnchorAfter = await win.webContents.executeJavaScript(`(() => {
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
const target = document.querySelector(${JSON.stringify(stationaryAnchorSelector)});
|
||||
const row = target?.closest('.virtual-timeline-row');
|
||||
return target && {
|
||||
uuid: target.getAttribute('data-uuid'),
|
||||
offset: row.getBoundingClientRect().top - wrap.getBoundingClientRect().top,
|
||||
};
|
||||
})()`, true);
|
||||
const unchangedRowRenderCalls = await win.webContents.executeJavaScript(`(() => {
|
||||
const calls = {
|
||||
markdown: window.__timelineMarkdownProbe.calls(),
|
||||
codexExec: window.__timelineMarkdownProbe.codexExecCalls(),
|
||||
};
|
||||
window.__timelineMarkdownProbe.restore();
|
||||
delete window.__timelineMarkdownProbe;
|
||||
return calls;
|
||||
})()`, true);
|
||||
assert(unchangedRowRenderCalls.markdown === 0, `three tail appends perform zero Markdown formatting calls for unchanged mounted rows (got ${unchangedRowRenderCalls.markdown})`);
|
||||
assert(unchangedRowRenderCalls.codexExec === 0, `three tail appends perform zero Codex exec JSON decodes for an unchanged mounted row (got ${unchangedRowRenderCalls.codexExec})`);
|
||||
assert(
|
||||
stationaryAnchorBefore?.uuid === stationaryAnchorAfter?.uuid
|
||||
&& Math.abs(stationaryAnchorBefore.offset - stationaryAnchorAfter.offset) < 2,
|
||||
`stationary live commits preserve reader anchor ${stationaryAnchorBefore?.uuid}`,
|
||||
);
|
||||
for (const [runIndex, trace] of stationaryTraces.entries()) {
|
||||
if (trace.maxTaskMs >= 8.33) console.log(`SLOWEST RENDERER TASK ${runIndex + 1}: ${JSON.stringify(trace.slowestChildren)}`);
|
||||
assert(trace.maxTaskMs < 8.33, `stationary live commit ${runIndex + 1} stays inside a 120Hz renderer task budget (${trace.maxTaskMs.toFixed(2)}ms across ${trace.tasks} tasks)`);
|
||||
}
|
||||
|
||||
setTimeout(() => appendMessage(win, scrollingAppendIndex), 250);
|
||||
const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||
const wrap = document.querySelector('.detail-wrap');
|
||||
const gaps = [];
|
||||
@@ -225,7 +520,7 @@ async function run() {
|
||||
})`, true);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2001'`,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 1}'`,
|
||||
'reader-position live update',
|
||||
);
|
||||
const readerState = await win.webContents.executeJavaScript(`(() => {
|
||||
@@ -246,17 +541,65 @@ async function run() {
|
||||
assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit');
|
||||
assert(
|
||||
scrollProbe.distanceFromTail > 1000
|
||||
&& readerState.current < 2001
|
||||
&& readerState.current < messageCount + stationaryAppendRuns + 1
|
||||
&& 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)`);
|
||||
|
||||
const updatedReaderText = `Updated ${scrollProbe.anchor.uuid} ${'content identity '.repeat(20)}`;
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const original = window.marked.parse;
|
||||
const targetUuid = ${JSON.stringify(scrollProbe.anchor.uuid)};
|
||||
const targetText = ${JSON.stringify(updatedReaderText)};
|
||||
const unchangedPrefixes = [...document.querySelectorAll('.virtual-timeline-row [data-uuid]')]
|
||||
.map(element => element.getAttribute('data-uuid'))
|
||||
.filter(uuid => uuid !== targetUuid && /^message-\d+$/.test(uuid))
|
||||
.map(uuid => 'Message ' + uuid.slice('message-'.length) + ' ');
|
||||
let targetCalls = 0;
|
||||
let unchangedCalls = 0;
|
||||
window.marked.parse = function timelineContentIdentityProbe(value, ...args) {
|
||||
const text = String(value || '');
|
||||
if (text === targetText) targetCalls++;
|
||||
if (unchangedPrefixes.some(prefix => text.startsWith(prefix))) unchangedCalls++;
|
||||
return original.call(this, value, ...args);
|
||||
};
|
||||
window.__timelineContentIdentityProbe = {
|
||||
calls: () => ({ target: targetCalls, unchanged: unchangedCalls }),
|
||||
restore: () => { window.marked.parse = original; },
|
||||
};
|
||||
})()`, true);
|
||||
replaceMessageText(win, scrollProbe.anchor.uuid, updatedReaderText);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('[data-uuid=${JSON.stringify(scrollProbe.anchor.uuid)}]')?.textContent.includes(${JSON.stringify(updatedReaderText.slice(0, 40))})`,
|
||||
'visible message content update',
|
||||
);
|
||||
const contentIdentityCalls = await win.webContents.executeJavaScript(`(() => {
|
||||
const calls = window.__timelineContentIdentityProbe.calls();
|
||||
window.__timelineContentIdentityProbe.restore();
|
||||
delete window.__timelineContentIdentityProbe;
|
||||
return calls;
|
||||
})()`, true);
|
||||
assert(contentIdentityCalls.target === 1, `updated mounted row recomputes its Markdown once (got ${contentIdentityCalls.target})`);
|
||||
assert(contentIdentityCalls.unchanged === 0, `updated mounted row leaves other mounted Markdown cached (got ${contentIdentityCalls.unchanged})`);
|
||||
assert(
|
||||
ipcReads.messages === 1
|
||||
&& ipcReads.toolCalls === 1
|
||||
&& ipcReads.toolResults === 1
|
||||
&& ipcReads.subagents === 1
|
||||
&& ipcReads.workflows === 1
|
||||
&& ipcReads.summaries === 1
|
||||
&& ipcReads.patches === 6
|
||||
&& ipcReads.patchMessageRows.every(count => count === 1),
|
||||
`live updates use six single-message patches after one full snapshot (${JSON.stringify(ipcReads)})`,
|
||||
);
|
||||
|
||||
await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.msg-nav-current')?.textContent === '2001'`,
|
||||
`document.querySelector('.msg-nav-current')?.textContent === '${messageCount + stationaryAppendRuns + 1}'`,
|
||||
'last-item navigation',
|
||||
);
|
||||
await waitFor(
|
||||
@@ -264,10 +607,10 @@ async function run() {
|
||||
`(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`,
|
||||
'last-item scroll settlement',
|
||||
);
|
||||
appendMessage(win, 2001);
|
||||
appendMessage(win, tailAppendIndex);
|
||||
await waitFor(
|
||||
win.webContents,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2002'`,
|
||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 2}'`,
|
||||
'tail-follow total update',
|
||||
);
|
||||
await delay(1000);
|
||||
@@ -282,8 +625,8 @@ async function run() {
|
||||
};
|
||||
})()`, true);
|
||||
assert(
|
||||
tailState.current === 2002 && tailState.distanceFromTail < 2,
|
||||
`tail follow reaches item 2002 (${JSON.stringify(tailState)})`,
|
||||
tailState.current === messageCount + stationaryAppendRuns + 2 && tailState.distanceFromTail < 2,
|
||||
`tail follow reaches item ${messageCount + stationaryAppendRuns + 2} (${JSON.stringify(tailState)})`,
|
||||
);
|
||||
|
||||
const reduction = (1 - initial.rows / initial.total) * 100;
|
||||
|
||||
Reference in New Issue
Block a user