diff --git a/app/tests/electron-session-images.mjs b/app/tests/electron-session-images.mjs
index 49b259e..514c2ec 100644
--- a/app/tests/electron-session-images.mjs
+++ b/app/tests/electron-session-images.mjs
@@ -23,6 +23,7 @@ const channels = [
let failures = 0;
let messages = [];
+const FILLER_COUNT = 40;
function assert(condition, message) {
if (condition) console.log(`PASS: ${message}`);
@@ -41,10 +42,45 @@ async function waitFor(webContents, expression, message, timeoutMs = 8_000) {
throw new Error(`Timed out waiting for ${message}`);
}
+// Renderer-side probes resolve from event handlers that a regression can keep
+// from ever firing. Racing every one of them against a deadline keeps a broken
+// build reporting a failure instead of hanging the run.
+async function withDeadline(promise, message, timeoutMs = 10_000) {
+ let timer = null;
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`Timed out waiting for ${message}`)), timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
function startImageServer() {
const wideSvg = '';
const smallSvg = '';
+ // Held until the test releases them, so an above-viewport image can be made
+ // to finish loading at a moment the test controls.
+ const heldSvg = '';
+ const heldPaths = ['/held-rest.svg', '/held-scroll.svg'];
+ const heldResponses = new Map(heldPaths.map(path => [path, []]));
+ const releasedPaths = new Set();
+ const sendSvg = (response, svg) => {
+ response.writeHead(200, {
+ 'Content-Type': 'image/svg+xml',
+ 'Cache-Control': 'no-store',
+ });
+ response.end(svg);
+ };
const server = createServer((request, response) => {
+ if (heldResponses.has(request.url)) {
+ if (releasedPaths.has(request.url)) sendSvg(response, heldSvg);
+ else heldResponses.get(request.url).push(response);
+ return;
+ }
const svg = request.url === '/wide.svg'
? wideSvg
: request.url === '/small.svg'
@@ -54,15 +90,8 @@ function startImageServer() {
response.writeHead(404).end();
return;
}
- const send = () => {
- response.writeHead(200, {
- 'Content-Type': 'image/svg+xml',
- 'Cache-Control': 'no-store',
- });
- response.end(svg);
- };
- if (request.url === '/wide.svg') setTimeout(send, 300);
- else send();
+ if (request.url === '/wide.svg') setTimeout(() => sendSvg(response, wideSvg), 300);
+ else sendSvg(response, smallSvg);
});
return new Promise((resolve, reject) => {
server.once('error', reject);
@@ -71,6 +100,13 @@ function startImageServer() {
resolve({
server,
baseUrl: `http://127.0.0.1:${address.port}`,
+ heldRequestCount: path => heldResponses.get(path)?.length ?? 0,
+ releaseHeldImage(path) {
+ releasedPaths.add(path);
+ const pending = heldResponses.get(path) ?? [];
+ heldResponses.set(path, []);
+ for (const response of pending) sendSvg(response, heldSvg);
+ },
});
});
});
@@ -105,7 +141,7 @@ function registerHandlers() {
}
async function run() {
- const { server, baseUrl } = await startImageServer();
+ const { server, baseUrl, heldRequestCount, releaseHeldImage } = await startImageServer();
let win = null;
try {
messages = [
@@ -149,6 +185,30 @@ async function run() {
content_type: 'text',
is_meta: 0,
},
+ {
+ uuid: 'message-5',
+ type: 'assistant',
+ timestamp: '2026-07-30T00:05:00.000Z',
+ text: `Held image (at rest)\n\n`,
+ content_type: 'text',
+ is_meta: 0,
+ },
+ {
+ uuid: 'message-6',
+ type: 'assistant',
+ timestamp: '2026-07-30T00:06:00.000Z',
+ text: `Held image (during scroll)\n\n`,
+ content_type: 'text',
+ is_meta: 0,
+ },
+ ...Array.from({ length: FILLER_COUNT }, (_, offset) => ({
+ uuid: `message-${7 + offset}`,
+ type: offset % 2 === 0 ? 'user' : 'assistant',
+ timestamp: new Date(Date.UTC(2026, 6, 30, 1, offset)).toISOString(),
+ text: `Filler ${offset}. ${'Timeline body copy that gives the row a realistic height. '.repeat(6)}`,
+ content_type: 'text',
+ is_meta: 0,
+ })),
];
registerHandlers();
win = new BrowserWindow({
@@ -171,7 +231,16 @@ async function run() {
'session list',
);
await win.webContents.executeJavaScript(`(() => {
- window.__wideImageLayout = new Promise(resolve => {
+ window.__wideImageLayout = new Promise((resolve, reject) => {
+ const fail = reason => {
+ observer.disconnect();
+ reject(new Error(reason));
+ };
+ const timer = setTimeout(() => fail('wide image never reported a layout'), 8000);
+ const settle = value => {
+ clearTimeout(timer);
+ resolve(value);
+ };
const observer = new MutationObserver(() => {
const message = document.querySelector('[data-uuid="message-1"]');
const host = message?.querySelector('obelisk-session-image');
@@ -180,9 +249,10 @@ async function run() {
if (!image || !row) return;
observer.disconnect();
const before = row.getBoundingClientRect().height;
+ image.addEventListener('error', () => fail('wide image failed to load'), { once: true });
image.addEventListener('load', () => {
requestAnimationFrame(() => requestAnimationFrame(() => {
- resolve({
+ settle({
before,
after: row.getBoundingClientRect().height,
});
@@ -199,7 +269,10 @@ async function run() {
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messages.length}'`,
'image fixture timeline',
);
- const resize = await win.webContents.executeJavaScript('window.__wideImageLayout', true);
+ const resize = await withDeadline(
+ win.webContents.executeJavaScript('window.__wideImageLayout', true),
+ 'wide image row remeasurement',
+ );
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="message-2"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
@@ -272,6 +345,126 @@ async function run() {
&& layout.brokenText.includes('Missing timeline fixture'),
`failed images remain contained with fallback text (${JSON.stringify(layout)})`,
);
+
+ // --- Reader position must survive an above-viewport image finishing. ---
+ // Both fixtures sit above the viewport for the rest of the run, so this also
+ // confirms a mounted row fetches its image without being on screen.
+ assert(
+ heldRequestCount('/held-rest.svg') > 0 && heldRequestCount('/held-scroll.svg') > 0,
+ 'mounted rows request their images while off screen'
+ + ` (${heldRequestCount('/held-rest.svg')}, ${heldRequestCount('/held-scroll.svg')})`,
+ );
+
+ const parkAbove = distance => win.webContents.executeJavaScript(`(() => {
+ const wrap = document.querySelector('.detail-wrap');
+ const wrapRect = wrap.getBoundingClientRect();
+ const row = document.querySelector('[data-uuid="message-6"]').closest('.virtual-timeline-row');
+ const rowRect = row.getBoundingClientRect();
+ wrap.scrollTop += (rowRect.bottom - wrapRect.top) + ${distance};
+ return wrap.scrollTop;
+ })()`, true);
+
+ const captureGeometry = () => win.webContents.executeJavaScript(`(() => {
+ const wrap = document.querySelector('.detail-wrap');
+ const wrapRect = wrap.getBoundingClientRect();
+ const rows = [...document.querySelectorAll('.virtual-timeline-row')]
+ .map(row => ({
+ uuid: row.querySelector('[data-uuid]')?.getAttribute('data-uuid'),
+ rect: row.getBoundingClientRect(),
+ }))
+ .filter(row => row.uuid && row.rect.bottom > wrapRect.top && row.rect.top < wrapRect.bottom)
+ .sort((left, right) => left.rect.top - right.rect.top);
+ return {
+ scrollTop: wrap.scrollTop,
+ firstVisible: rows[0]?.uuid || null,
+ tops: Object.fromEntries(rows.map(row => [row.uuid, row.rect.top - wrapRect.top])),
+ };
+ })()`, true);
+
+ await parkAbove(420);
+ // Longer than isScrollingResetDelay so the virtualizer is genuinely at rest.
+ await delay(700);
+ await waitFor(
+ win.webContents,
+ `document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`,
+ 'held rest image still pending above the viewport',
+ );
+ const restBefore = await captureGeometry();
+ releaseHeldImage('/held-rest.svg');
+ await waitFor(
+ win.webContents,
+ `document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
+ 'held rest image load',
+ );
+ await delay(250);
+ const restAfter = await captureGeometry();
+ const restAnchor = restBefore.firstVisible;
+ const restDrift = restAnchor !== null && restAfter.tops[restAnchor] !== undefined
+ ? restAfter.tops[restAnchor] - restBefore.tops[restAnchor]
+ : Number.NaN;
+ assert(
+ Math.abs(restDrift) <= 1,
+ 'an image loading above the viewport does not move the reader position'
+ + ` (${JSON.stringify({ anchor: restAnchor, drift: restDrift, scrollTop: [restBefore.scrollTop, restAfter.scrollTop] })})`,
+ );
+
+ // Same guarantee mid-gesture: scrolling back through history is when rows
+ // above the viewport are most likely to still be settling their media.
+ await parkAbove(2_000);
+ await delay(700);
+ await waitFor(
+ win.webContents,
+ `document.querySelector('[data-uuid="message-6"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`,
+ 'held scroll image still pending above the viewport',
+ );
+ const scrollProbe = win.webContents.executeJavaScript(`new Promise(resolve => {
+ const wrap = document.querySelector('.detail-wrap');
+ const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
+ wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
+ wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -70, bubbles: true }));
+ let previous = null;
+ let maxResidual = 0;
+ let example = null;
+ const startedAt = performance.now();
+ function frame(now) {
+ wrap.scrollTop -= 12;
+ const wrapRect = wrap.getBoundingClientRect();
+ const scrollTop = wrap.scrollTop;
+ const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')]
+ .map(row => [
+ row.querySelector('[data-uuid]')?.getAttribute('data-uuid'),
+ row.getBoundingClientRect().top - wrapRect.top,
+ ])
+ .filter(([uuid, top]) => uuid && top > -200 && top < wrapRect.height));
+ if (previous) {
+ for (const [uuid, top] of rows) {
+ if (!previous.rows.has(uuid)) continue;
+ const residual = (top - previous.rows.get(uuid)) + (scrollTop - previous.scrollTop);
+ if (Math.abs(residual) > Math.abs(maxResidual)) {
+ maxResidual = residual;
+ example = { uuid, residual, scrollTop };
+ }
+ }
+ }
+ previous = { rows, scrollTop };
+ if (now - startedAt < 1500) {
+ requestAnimationFrame(frame);
+ return;
+ }
+ wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
+ wrap.dispatchEvent(new Event('scrollend'));
+ resolve({ maxResidual, example });
+ }
+ requestAnimationFrame(frame);
+ })`, true);
+ await delay(500);
+ releaseHeldImage('/held-scroll.svg');
+ const scrolling = await withDeadline(scrollProbe, 'backward scroll residual probe');
+ assert(
+ Math.abs(scrolling.maxResidual) <= 2,
+ 'an image loading above the viewport does not move visible rows mid-scroll'
+ + ` (${JSON.stringify(scrolling)})`,
+ );
} finally {
win?.destroy();
await new Promise(resolve => server.close(resolve));
@@ -279,7 +472,7 @@ async function run() {
}
app.whenReady()
- .then(run)
+ .then(() => withDeadline(run(), 'the session image suite to finish', 180_000))
.catch(error => {
failures++;
console.error(error.stack || error);