diff --git a/app/package.json b/app/package.json
index 66aef48..0706f25 100644
--- a/app/package.json
+++ b/app/package.json
@@ -20,6 +20,7 @@
"generate:session-share": "node scripts/generate-session-share.mjs",
"verify:icons": "node scripts/verify-icons.mjs",
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs",
+ "test:electron:images": "electron-vite build && electron --no-sandbox tests/electron-session-images.mjs",
"test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs",
"test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs"
},
diff --git a/app/tests/electron-session-images.mjs b/app/tests/electron-session-images.mjs
new file mode 100644
index 0000000..49b259e
--- /dev/null
+++ b/app/tests/electron-session-images.mjs
@@ -0,0 +1,290 @@
+import { app, BrowserWindow, ipcMain } from 'electron';
+import { createServer } from 'node:http';
+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 = 'session-image-test';
+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 messages = [];
+
+function assert(condition, message) {
+ if (condition) console.log(`PASS: ${message}`);
+ else {
+ failures++;
+ console.error(`FAIL: ${message}`);
+ }
+}
+
+async function waitFor(webContents, expression, message, timeoutMs = 8_000) {
+ 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 startImageServer() {
+ const wideSvg = '';
+ const smallSvg = '';
+ const server = createServer((request, response) => {
+ const svg = request.url === '/wide.svg'
+ ? wideSvg
+ : request.url === '/small.svg'
+ ? smallSvg
+ : null;
+ if (!svg) {
+ 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();
+ });
+ return new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address();
+ resolve({
+ server,
+ baseUrl: `http://127.0.0.1:${address.port}`,
+ });
+ });
+ });
+}
+
+function sessionSummary() {
+ return {
+ id: sessionId,
+ title: 'Session image rendering',
+ project: 'image-fixture',
+ project_path: '/tmp/image-fixture',
+ source: 'codex',
+ started_at: '2026-07-30T00:00:00.000Z',
+ ended_at: '2026-07-30T00:05:00.000Z',
+ message_count: messages.length,
+ git_branch: 'main',
+ };
+}
+
+function registerHandlers() {
+ ipcMain.handle('db:getSessions', () => [sessionSummary()]);
+ ipcMain.handle('db:getSessionMessages', () => messages);
+ ipcMain.handle('db:getSessionToolCalls', () => []);
+ ipcMain.handle('db:getSessionToolResults', () => []);
+ ipcMain.handle('db:getSessionSubagents', () => []);
+ ipcMain.handle('db:getSessionWorkflows', () => []);
+ ipcMain.handle('db:getSessionSummaries', () => []);
+ ipcMain.handle('db:getMemories', () => []);
+ ipcMain.handle('db:getProjects', () => [{ project: 'image-fixture', count: 1 }]);
+ ipcMain.handle('db:getStats', () => ({}));
+ ipcMain.handle('settings:get', () => ({}));
+}
+
+async function run() {
+ const { server, baseUrl } = await startImageServer();
+ let win = null;
+ try {
+ messages = [
+ {
+ uuid: 'message-0',
+ type: 'user',
+ timestamp: '2026-07-30T00:00:00.000Z',
+ text: 'Image rendering fixtures',
+ content_type: 'text',
+ is_meta: 0,
+ },
+ {
+ uuid: 'message-1',
+ type: 'assistant',
+ timestamp: '2026-07-30T00:01:00.000Z',
+ text: `Wide image\n\n`,
+ content_type: 'text',
+ is_meta: 0,
+ },
+ {
+ uuid: 'message-2',
+ type: 'assistant',
+ timestamp: '2026-07-30T00:02:00.000Z',
+ text: `Small image\n\n`,
+ content_type: 'text',
+ is_meta: 0,
+ },
+ {
+ uuid: 'message-3',
+ type: 'assistant',
+ timestamp: '2026-07-30T00:03:00.000Z',
+ text: 'Broken image\n\n',
+ content_type: 'text',
+ is_meta: 0,
+ },
+ {
+ uuid: 'message-4',
+ type: 'user',
+ timestamp: '2026-07-30T00:04:00.000Z',
+ text: 'Following message',
+ content_type: 'text',
+ is_meta: 0,
+ },
+ ];
+ registerHandlers();
+ 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',
+ });
+ await waitFor(
+ win.webContents,
+ `document.body.textContent.includes('Session image rendering')`,
+ 'session list',
+ );
+ await win.webContents.executeJavaScript(`(() => {
+ window.__wideImageLayout = new Promise(resolve => {
+ const observer = new MutationObserver(() => {
+ const message = document.querySelector('[data-uuid="message-1"]');
+ const host = message?.querySelector('obelisk-session-image');
+ const image = host?.shadowRoot?.querySelector('img');
+ const row = message?.closest('.virtual-timeline-row');
+ if (!image || !row) return;
+ observer.disconnect();
+ const before = row.getBoundingClientRect().height;
+ image.addEventListener('load', () => {
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ resolve({
+ before,
+ after: row.getBoundingClientRect().height,
+ });
+ }));
+ }, { once: true });
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ });
+ window.location.hash = ${JSON.stringify(`/sessions/${sessionId}`)};
+ })()`, true);
+
+ await waitFor(
+ win.webContents,
+ `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messages.length}'`,
+ 'image fixture timeline',
+ );
+ const resize = await win.webContents.executeJavaScript('window.__wideImageLayout', true);
+ await waitFor(
+ win.webContents,
+ `document.querySelector('[data-uuid="message-2"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
+ 'small image load',
+ );
+ await waitFor(
+ win.webContents,
+ `document.querySelector('[data-uuid="message-3"] obelisk-session-image')?.shadowRoot?.querySelector('.is-error')`,
+ 'failed image state',
+ );
+ await delay(100);
+
+ const layout = await win.webContents.executeJavaScript(`(() => {
+ const wrap = document.querySelector('.detail-wrap');
+ const wideMessage = document.querySelector('[data-uuid="message-1"]');
+ const wideRow = wideMessage.closest('.virtual-timeline-row');
+ const nextRow = document.querySelector('[data-uuid="message-2"]').closest('.virtual-timeline-row');
+ const wideHost = wideMessage.querySelector('obelisk-session-image');
+ const wideImage = wideHost.shadowRoot.querySelector('img');
+ const smallHost = document.querySelector('[data-uuid="message-2"] obelisk-session-image');
+ const smallImage = smallHost.shadowRoot.querySelector('img');
+ const brokenHost = document.querySelector('[data-uuid="message-3"] obelisk-session-image');
+ const wideHostRect = wideHost.getBoundingClientRect();
+ const wideImageRect = wideImage.getBoundingClientRect();
+ const smallImageRect = smallImage.getBoundingClientRect();
+ const wideRowRect = wideRow.getBoundingClientRect();
+ const nextRowRect = nextRow.getBoundingClientRect();
+ return {
+ wrapClientWidth: wrap.clientWidth,
+ wrapScrollWidth: wrap.scrollWidth,
+ wideHostWidth: wideHostRect.width,
+ wideImageWidth: wideImageRect.width,
+ wideImageHeight: wideImageRect.height,
+ wideNaturalWidth: wideImage.naturalWidth,
+ wideNaturalHeight: wideImage.naturalHeight,
+ smallImageWidth: smallImageRect.width,
+ smallNaturalWidth: smallImage.naturalWidth,
+ rowHeight: wideRowRect.height,
+ rowGap: nextRowRect.top - wideRowRect.bottom,
+ brokenText: brokenHost.shadowRoot.querySelector('figcaption')?.textContent || '',
+ };
+ })()`, true);
+
+ assert(
+ resize.after > resize.before + 100,
+ `image load grows and remeasures its virtual row (${JSON.stringify(resize)})`,
+ );
+ assert(
+ layout.wrapScrollWidth <= layout.wrapClientWidth + 1,
+ `wide images do not add session-level horizontal overflow (${JSON.stringify(layout)})`,
+ );
+ assert(
+ layout.wideImageWidth <= layout.wideHostWidth + 1
+ && Math.abs(
+ layout.wideImageWidth / layout.wideImageHeight
+ - layout.wideNaturalWidth / layout.wideNaturalHeight
+ ) < 0.01,
+ `wide images fit their host and preserve aspect ratio (${JSON.stringify(layout)})`,
+ );
+ assert(
+ layout.smallImageWidth <= layout.smallNaturalWidth + 1,
+ `small images keep their intrinsic width (${JSON.stringify(layout)})`,
+ );
+ assert(
+ layout.rowHeight > layout.wideImageHeight && layout.rowGap >= 13,
+ `measured image rows do not overlap the following row (${JSON.stringify(layout)})`,
+ );
+ assert(
+ layout.brokenText.includes('Image unavailable')
+ && layout.brokenText.includes('Missing timeline fixture'),
+ `failed images remain contained with fallback text (${JSON.stringify(layout)})`,
+ );
+ } finally {
+ win?.destroy();
+ await new Promise(resolve => server.close(resolve));
+ }
+}
+
+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);
+ });