diff --git a/app/package.json b/app/package.json
index 1d4bf70..8926bef 100644
--- a/app/package.json
+++ b/app/package.json
@@ -20,9 +20,11 @@
"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",
- "test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs"
+ "test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs",
+ "test:electron:all": "electron-vite build && node tests/run-electron-suites.mjs"
},
"build": {
"appId": "com.obelisk.app",
diff --git a/app/src/renderer/src/components/SessionImage.ce.vue b/app/src/renderer/src/components/SessionImage.ce.vue
new file mode 100644
index 0000000..d918048
--- /dev/null
+++ b/app/src/renderer/src/components/SessionImage.ce.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+ Image unavailable
+ {{ accessibleLabel }}
+
+
+
+
+
diff --git a/app/src/renderer/src/main.js b/app/src/renderer/src/main.js
index 6584fbb..d468d0d 100644
--- a/app/src/renderer/src/main.js
+++ b/app/src/renderer/src/main.js
@@ -6,6 +6,8 @@ import router from './router.js';
import { commitInitialData, fetchInitialData } from './data.js';
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
+import { registerSessionImageElement } from './session-image-element.js';
+import { configureMarkdownImages } from './markdown-image-renderer.js';
import { installFileReferenceHandler } from './file-references.mjs';
// Import shared renderer CSS globally
@@ -15,6 +17,10 @@ import '../styles/toolbar.css';
import '../styles/list.css';
import '../styles/detail.css';
+registerSessionImageElement();
+// marked is loaded globally via CDN in index.html, ahead of this module.
+configureMarkdownImages(window.marked);
+
const app = createApp(App);
app.use(router);
diff --git a/app/src/renderer/src/markdown-image-renderer.js b/app/src/renderer/src/markdown-image-renderer.js
new file mode 100644
index 0000000..e0ae6e6
--- /dev/null
+++ b/app/src/renderer/src/markdown-image-renderer.js
@@ -0,0 +1,90 @@
+import { SESSION_IMAGE_TAG } from './session-image-contract.js';
+
+const SAFE_IMAGE_PROTOCOLS = new Set(['blob:', 'file:', 'http:', 'https:']);
+const NAMED_ENTITIES = {
+ amp: '&',
+ lt: '<',
+ gt: '>',
+ quot: '"',
+ apos: "'",
+ nbsp: ' ',
+};
+let configuredMarked = null;
+
+// marked escapes the alt text and title it hands to the renderer, and the
+// attributes are re-escaped on the way back out, so they have to be decoded
+// once in between. Doing that through a detached element's innerHTML would
+// mean parsing untrusted markup, so decode the entities marked actually emits
+// in a single pass instead -- one pass, so `<` stays `<`.
+function decodeMarkedAttribute(value) {
+ return String(value ?? '').replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, entity) => {
+ if (entity[0] === '#') {
+ const codePoint = entity[1] === 'x' || entity[1] === 'X'
+ ? Number.parseInt(entity.slice(2), 16)
+ : Number.parseInt(entity.slice(1), 10);
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return whole;
+ try {
+ return String.fromCodePoint(codePoint);
+ } catch {
+ return whole;
+ }
+ }
+ return NAMED_ENTITIES[entity.toLowerCase()] ?? whole;
+ });
+}
+
+function escapeAttribute(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+function isSafeImageSource(source) {
+ try {
+ const url = new URL(source, globalThis.document?.baseURI ?? 'file:///');
+ return SAFE_IMAGE_PROTOCOLS.has(url.protocol)
+ || (url.protocol === 'data:' && /^data:image\//i.test(source));
+ } catch {
+ return false;
+ }
+}
+
+// marked <= 14 calls renderer.image(href, title, text); marked >= 15 passes the
+// image token instead. Accepting both keeps an upgrade from silently turning
+// every session image into fallback text.
+export function normalizeMarkdownImageToken(hrefOrToken, title, text) {
+ if (hrefOrToken && typeof hrefOrToken === 'object') {
+ return {
+ href: hrefOrToken.href ?? '',
+ title: hrefOrToken.title ?? '',
+ text: hrefOrToken.text ?? '',
+ };
+ }
+ return { href: hrefOrToken ?? '', title: title ?? '', text: text ?? '' };
+}
+
+// A source the app will not load is rendered through the same element with no
+// src, so a blocked image and an image that fails to load look the same to the
+// reader instead of being two different pieces of UI.
+export function renderSessionMarkdownImage(hrefOrToken, title, text) {
+ const token = normalizeMarkdownImageToken(hrefOrToken, title, text);
+ const source = decodeMarkedAttribute(token.href).trim();
+ const alt = decodeMarkedAttribute(token.text);
+ const accessibleTitle = decodeMarkedAttribute(token.title);
+ const attributes = [`alt="${escapeAttribute(alt)}"`];
+ if (source && isSafeImageSource(source)) attributes.unshift(`src="${escapeAttribute(source)}"`);
+ if (accessibleTitle) attributes.push(`title="${escapeAttribute(accessibleTitle)}"`);
+ return `<${SESSION_IMAGE_TAG} ${attributes.join(' ')}>${SESSION_IMAGE_TAG}>`;
+}
+
+export function configureMarkdownImages(marked) {
+ if (!marked || configuredMarked === marked) return;
+ marked.use({
+ renderer: {
+ image: renderSessionMarkdownImage,
+ },
+ });
+ configuredMarked = marked;
+}
diff --git a/app/src/renderer/src/session-image-contract.js b/app/src/renderer/src/session-image-contract.js
new file mode 100644
index 0000000..ea1d67e
--- /dev/null
+++ b/app/src/renderer/src/session-image-contract.js
@@ -0,0 +1,16 @@
+// Shared constants for the session image element. Kept free of the component
+// import so the Markdown renderer (and its tests) do not have to pull a .vue
+// module into scope just to know the tag name.
+
+export const SESSION_IMAGE_TAG = 'obelisk-session-image';
+
+// Fired from inside the session image element (composed, so they cross the
+// shadow boundary). The virtualized timeline uses them to tell real media
+// growth apart from an estimate correction.
+//
+// Pending is announced when the element mounts with a source, not when the
+// image loads: a decoder sizes an image from its header and grows the row well
+// before the load event, so a row is only known to be settled once loading has
+// actually finished.
+export const SESSION_IMAGE_PENDING_EVENT = 'obelisk-session-image-pending';
+export const SESSION_IMAGE_SETTLED_EVENT = 'obelisk-session-image-settled';
diff --git a/app/src/renderer/src/session-image-element.js b/app/src/renderer/src/session-image-element.js
new file mode 100644
index 0000000..abaea24
--- /dev/null
+++ b/app/src/renderer/src/session-image-element.js
@@ -0,0 +1,8 @@
+import { defineCustomElement } from 'vue';
+import SessionImage from './components/SessionImage.ce.vue';
+import { SESSION_IMAGE_TAG } from './session-image-contract.js';
+
+export function registerSessionImageElement() {
+ if (customElements.get(SESSION_IMAGE_TAG)) return;
+ customElements.define(SESSION_IMAGE_TAG, defineCustomElement(SessionImage));
+}
diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs
index 3bedb69..4df377a 100644
--- a/app/src/renderer/src/session-timeline-viewport.mjs
+++ b/app/src/renderer/src/session-timeline-viewport.mjs
@@ -1,4 +1,4 @@
-import { computed, nextTick, ref } from 'vue';
+import { computed, nextTick, onScopeDispose, ref } from 'vue';
import {
defaultRangeExtractor,
elementScroll,
@@ -6,6 +6,15 @@ import {
useVirtualizer,
} from '@tanstack/vue-virtual';
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
+import {
+ SESSION_IMAGE_PENDING_EVENT,
+ SESSION_IMAGE_SETTLED_EVENT,
+} from './session-image-contract.js';
+
+// How long after an image settles its row still counts as "grew because media
+// finished" rather than "grew because the estimate was wrong". Covers the
+// remeasurement that follows the final state change.
+const MEDIA_SETTLE_WINDOW_MS = 1_000;
function estimatedTextHeight(text = '') {
return Math.min(560, Math.ceil(String(text).length / 72) * 20);
@@ -126,6 +135,82 @@ export function useSessionTimelineViewport({
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
const totalSize = computed(() => virtualizer.value.getTotalSize());
+ // virtual-core deliberately skips scroll compensation when an already-measured
+ // row above the viewport is re-measured during an upward scroll, because that
+ // is normally an estimate correction and compensating it makes rows jump while
+ // the reader scrolls back. Media growth is not an estimate correction: the row
+ // really did get taller, so leaving it uncompensated pushes everything the
+ // reader is looking at down the screen.
+ //
+ // A row counts as settling from the moment an image mounts until loading
+ // finishes, because a decoder grows the row from the image header long before
+ // the load event -- keying off load alone would miss the first and largest
+ // growth. Counted, not flagged, so a message with several images stays marked
+ // until the last of them is done.
+ const mediaPending = new Map();
+ const mediaSettledAt = new Map();
+
+ function rowKeyFor(event) {
+ const row = event.target?.closest?.('.virtual-timeline-row');
+ if (!row) return null;
+ const timeline = timelineElement?.value;
+ if (timeline && !timeline.contains(row)) return null;
+ const index = Number(row.dataset?.index);
+ if (!Number.isInteger(index)) return null;
+ return items.value[index]?.key ?? index;
+ }
+
+ function noteMediaPending(event) {
+ const key = rowKeyFor(event);
+ if (key === null) return;
+ mediaPending.set(key, (mediaPending.get(key) ?? 0) + 1);
+ mediaSettledAt.delete(key);
+ }
+
+ function noteMediaSettled(event) {
+ const key = rowKeyFor(event);
+ if (key === null) return;
+ const outstanding = (mediaPending.get(key) ?? 0) - 1;
+ if (outstanding > 0) mediaPending.set(key, outstanding);
+ else mediaPending.delete(key);
+ const now = performance.now();
+ for (const [settledKey, at] of mediaSettledAt) {
+ if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(settledKey);
+ }
+ mediaSettledAt.set(key, now);
+ }
+
+ function isMediaSettling(key) {
+ if (mediaPending.has(key)) return true;
+ const at = mediaSettledAt.get(key);
+ if (at === undefined) return false;
+ if (performance.now() - at > MEDIA_SETTLE_WINDOW_MS) {
+ mediaSettledAt.delete(key);
+ return false;
+ }
+ return true;
+ }
+
+ // Bound to the document rather than to the timeline element, because rows
+ // announce their images while mounting -- before a ref-driven listener would
+ // be in place to hear the first one.
+ const eventTarget = globalThis.document ?? null;
+ eventTarget?.addEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending);
+ eventTarget?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
+ onScopeDispose(() => {
+ eventTarget?.removeEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending);
+ eventTarget?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
+ });
+
+ virtualizer.value.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
+ if (item.start >= instance.getScrollOffset() + instance.scrollAdjustments) return false;
+ // Never measured before: the estimate is what placed the reader, so the
+ // estimate-to-actual difference always has to be taken out.
+ if (!instance.itemSizeCache.has(item.key)) return true;
+ if (isMediaSettling(item.key)) return true;
+ return instance.scrollDirection !== 'backward';
+ };
+
function resolveTimelineElement(instance = virtualizer?.value) {
return timelineElement?.value
|| [...(instance?.elementsCache?.values?.() || [])]
diff --git a/app/src/renderer/src/utils.js b/app/src/renderer/src/utils.js
index 6998a1d..d4690fc 100644
--- a/app/src/renderer/src/utils.js
+++ b/app/src/renderer/src/utils.js
@@ -95,7 +95,7 @@ export function highlightTextNodes(rootEl, query) {
export function renderMarkdown(text, opts = {}) {
if (text == null) return '';
- // marked is loaded globally via CDN in index.html
+ // marked is loaded globally via CDN in index.html and configured at startup.
const html = sanitizeMarkdown(window.marked.parse(text));
const cls = opts.variant === 'msg' ? 'markdown-msg'
: opts.variant === 'compact' ? 'markdown-compact'
diff --git a/app/src/renderer/styles/detail.css b/app/src/renderer/styles/detail.css
index dea8388..fcf6bd0 100644
--- a/app/src/renderer/styles/detail.css
+++ b/app/src/renderer/styles/detail.css
@@ -364,6 +364,11 @@
.markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; }
.markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
+/* Session images cap themselves against the reading column. Compact Markdown
+ renders in much smaller surfaces (subagent panes, memory rows, tool results),
+ where a viewport-sized image would swamp the block it belongs to. */
+.markdown-compact { --session-image-max-block: 240px; }
+
.detail-section-divider {
display: flex; align-items: center; gap: 10px;
margin: 32px 0 14px; color: var(--muted);
diff --git a/app/tests/electron-session-images.mjs b/app/tests/electron-session-images.mjs
new file mode 100644
index 0000000..b5b1e3b
--- /dev/null
+++ b/app/tests/electron-session-images.mjs
@@ -0,0 +1,633 @@
+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';
+import { deflateSync } from 'node:zlib';
+
+// A real PNG. The fixture writes it in full and then holds the response open,
+// so Blink sizes and lays the image out from the buffered bytes while the load
+// event waits for the response to complete. An SVG served in one shot lays out
+// and fires load together, which hides that gap entirely.
+function crc32(buffer) {
+ let crc = ~0;
+ for (const byte of buffer) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+ }
+ return ~crc >>> 0;
+}
+
+function pngChunk(type, data) {
+ const length = Buffer.alloc(4);
+ length.writeUInt32BE(data.length);
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
+ const checksum = Buffer.alloc(4);
+ checksum.writeUInt32BE(crc32(body));
+ return Buffer.concat([length, body, checksum]);
+}
+
+function buildPng(width, height) {
+ const header = Buffer.alloc(13);
+ header.writeUInt32BE(width, 0);
+ header.writeUInt32BE(height, 4);
+ header[8] = 8; // bit depth
+ header[9] = 0; // greyscale
+ const scanlines = Buffer.alloc(height * (width + 1), 0x40);
+ for (let row = 0; row < height; row++) scanlines[row * (width + 1)] = 0; // filter: none
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ pngChunk('IHDR', header),
+ pngChunk('IDAT', deflateSync(scanlines)),
+ pngChunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+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 = [];
+const FILLER_COUNT = 40;
+
+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}`);
+}
+
+// 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();
+ // The bytes go out in one write and the response stays open, so the row grows
+ // from the decoded header while the load event waits for completion.
+ const progressivePng = buildPng(900, 1200);
+ let progressiveResponses = [];
+ 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 (request.url === '/held-progressive.png') {
+ response.writeHead(200, {
+ 'Content-Type': 'image/png',
+ 'Cache-Control': 'no-store',
+ });
+ progressiveResponses.push(response);
+ return;
+ }
+ 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'
+ ? smallSvg
+ : null;
+ if (!svg) {
+ response.writeHead(404).end();
+ return;
+ }
+ if (request.url === '/wide.svg') setTimeout(() => sendSvg(response, wideSvg), 300);
+ else sendSvg(response, smallSvg);
+ });
+ 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}`,
+ 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);
+ },
+ progressiveRequestCount: () => progressiveResponses.length,
+ // Delivers the whole image now and completes the response completeMs
+ // later, reproducing the gap a real image opens between the row growing
+ // and the load event firing.
+ releaseProgressiveImage(completeMs) {
+ const pending = progressiveResponses;
+ progressiveResponses = [];
+ for (const response of pending) {
+ response.write(progressivePng);
+ setTimeout(() => response.end(), completeMs);
+ }
+ },
+ });
+ });
+ });
+}
+
+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, heldRequestCount, releaseHeldImage,
+ progressiveRequestCount, releaseProgressiveImage,
+ } = 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,
+ },
+ {
+ 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,
+ },
+ {
+ uuid: 'message-7',
+ type: 'assistant',
+ timestamp: '2026-07-30T00:07:00.000Z',
+ text: `Progressive image\n\n`,
+ content_type: 'text',
+ is_meta: 0,
+ },
+ ...Array.from({ length: FILLER_COUNT }, (_, offset) => ({
+ uuid: `message-${8 + 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({
+ 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, 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');
+ 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('error', () => fail('wide image failed to load'), { once: true });
+ image.addEventListener('load', () => {
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ settle({
+ 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 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')`,
+ '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();
+ // Row spacing is applied by the virtualizer, not by CSS on the row, so
+ // calibrate against the spacing the rest of this timeline is using rather
+ // than hard-coding the current value.
+ const mountedRows = [...document.querySelectorAll('.virtual-timeline-row')]
+ .map(row => ({ index: Number(row.dataset.index), rect: row.getBoundingClientRect() }))
+ .sort((left, right) => left.index - right.index);
+ const gaps = mountedRows
+ .slice(1)
+ .map((row, offset) => row.rect.top - mountedRows[offset].rect.bottom)
+ .sort((left, right) => left - right);
+ 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,
+ typicalRowGap: gaps[Math.floor(gaps.length / 2)] ?? null,
+ 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.typicalRowGap !== null
+ && Math.abs(layout.rowGap - layout.typicalRowGap) <= 1,
+ `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)})`,
+ );
+
+ // --- 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 = (uuid, distance) => win.webContents.executeJavaScript(`(() => {
+ const wrap = document.querySelector('.detail-wrap');
+ const wrapRect = wrap.getBoundingClientRect();
+ const row = document.querySelector('[data-uuid="${uuid}"]').closest('.virtual-timeline-row');
+ const rowRect = row.getBoundingClientRect();
+ wrap.scrollTop += (rowRect.bottom - wrapRect.top) + ${distance};
+ return wrap.scrollTop;
+ })()`, true);
+
+ // Scrolls backward and reports the largest movement of a visible row that
+ // scroll input does not account for. A row growing above the viewport
+ // without compensation shows up here as a residual the size of the growth;
+ // a compensated one leaves the residual at zero.
+ const backwardScrollProbe = ({ durationMs = 1_500, stepPx = 12 } = {}) =>
+ 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 -= ${stepPx};
+ 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 < ${durationMs}) {
+ requestAnimationFrame(frame);
+ return;
+ }
+ wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
+ wrap.dispatchEvent(new Event('scrollend'));
+ resolve({ maxResidual, example });
+ }
+ requestAnimationFrame(frame);
+ })`, 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('message-6', 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('message-6', 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 = backwardScrollProbe();
+ 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)})`,
+ );
+
+ // A progressively decoded image lays out from its header, so the row grows
+ // long before the load event. Waiting for load to mark the row would leave
+ // that first, largest growth uncompensated.
+ await parkAbove('message-7', 2_000);
+ await delay(700);
+ assert(
+ progressiveRequestCount() > 0,
+ `the progressive fixture was requested (${progressiveRequestCount()})`,
+ );
+ await win.webContents.executeJavaScript(`(() => {
+ const host = document.querySelector('[data-uuid="message-7"] obelisk-session-image');
+ const row = host.closest('.virtual-timeline-row');
+ const image = host.shadowRoot.querySelector('img');
+ const timing = {
+ start: performance.now(),
+ placeholderHeight: row.getBoundingClientRect().height,
+ grewAt: null,
+ loadedAt: null,
+ height: 0,
+ };
+ // ResizeObserver reports the current size straight away; only a later,
+ // larger measurement is the image arriving.
+ timing.height = timing.placeholderHeight;
+ const since = () => Math.round(performance.now() - timing.start);
+ new ResizeObserver(entries => {
+ for (const entry of entries) {
+ if (entry.contentRect.height <= timing.height + 100) continue;
+ timing.height = entry.contentRect.height;
+ if (timing.grewAt === null) timing.grewAt = since();
+ }
+ }).observe(row);
+ image.addEventListener('load', () => { timing.loadedAt = since(); }, { once: true });
+ window.__progressiveTiming = timing;
+ })()`, true);
+ const progressiveProbe = backwardScrollProbe({ durationMs: 3_400, stepPx: 6 });
+ await delay(400);
+ // Blink holds partial image data back for about a second before flushing it
+ // to the decoder, so the response has to stay open well past that for the
+ // row to grow before the load event.
+ releaseProgressiveImage(2_000);
+ const progressive = await withDeadline(
+ progressiveProbe,
+ 'progressive scroll residual probe',
+ 15_000,
+ );
+ const timing = await win.webContents.executeJavaScript('window.__progressiveTiming', true);
+ assert(
+ timing.grewAt !== null && timing.loadedAt !== null && timing.grewAt < timing.loadedAt - 100,
+ `the fixture grows its row before the load event, as a chunked image does (${JSON.stringify(timing)})`,
+ );
+ assert(
+ Math.abs(progressive.maxResidual) <= 2,
+ 'a progressively decoded image does not move visible rows before it finishes'
+ + ` (${JSON.stringify(progressive)})`,
+ );
+ } finally {
+ win?.destroy();
+ await new Promise(resolve => server.close(resolve));
+ }
+}
+
+app.whenReady()
+ .then(() => withDeadline(run(), 'the session image suite to finish', 180_000))
+ .catch(error => {
+ failures++;
+ console.error(error.stack || error);
+ })
+ .finally(() => {
+ for (const channel of channels) ipcMain.removeHandler(channel);
+ app.exit(failures ? 1 : 0);
+ });
diff --git a/app/tests/run-electron-suites.mjs b/app/tests/run-electron-suites.mjs
new file mode 100644
index 0000000..2e1ac61
--- /dev/null
+++ b/app/tests/run-electron-suites.mjs
@@ -0,0 +1,32 @@
+// Runs every Electron regression suite against one build and reports a single
+// summary. Each suite already prints its own PASS/FAIL lines; this exists so a
+// new suite is covered by one command instead of only being run by whoever
+// remembers it is there.
+
+import { spawnSync } from 'node:child_process'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import electron from 'electron'
+
+const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const suites = [
+ 'electron-concurrency.mjs',
+ 'electron-session-images.mjs',
+ 'electron-session-virtualization.mjs',
+ 'electron-session-reader-state.mjs',
+ 'electron-file-references.mjs',
+]
+
+const failed = []
+for (const suite of suites) {
+ console.log(`\n=== ${suite} ===`)
+ const result = spawnSync(electron, ['--no-sandbox', path.join('tests', suite)], {
+ cwd: appRoot,
+ stdio: 'inherit',
+ })
+ if (result.status !== 0) failed.push(`${suite} (exit ${result.status ?? 'signal'})`)
+}
+
+console.log(`\n${suites.length - failed.length}/${suites.length} Electron suites passed`)
+for (const failure of failed) console.error(`FAILED: ${failure}`)
+process.exit(failed.length ? 1 : 0)
diff --git a/tests/markdown-image-renderer.test.mjs b/tests/markdown-image-renderer.test.mjs
new file mode 100644
index 0000000..84c992b
--- /dev/null
+++ b/tests/markdown-image-renderer.test.mjs
@@ -0,0 +1,92 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ normalizeMarkdownImageToken,
+ renderSessionMarkdownImage,
+} from '../app/src/renderer/src/markdown-image-renderer.js';
+
+// marked <= 14 calls renderer.image(href, title, text); marked >= 15 passes the
+// token. Getting this wrong degrades silently: every session image turns into
+// fallback text because the href is no longer a string.
+test('accepts the positional renderer signature', () => {
+ assert.deepEqual(
+ normalizeMarkdownImageToken('http://example.test/a.png', 'A title', 'Alt text'),
+ { href: 'http://example.test/a.png', title: 'A title', text: 'Alt text' },
+ );
+});
+
+test('accepts the token renderer signature', () => {
+ assert.deepEqual(
+ normalizeMarkdownImageToken({
+ type: 'image',
+ href: 'http://example.test/a.png',
+ title: 'A title',
+ text: 'Alt text',
+ }),
+ { href: 'http://example.test/a.png', title: 'A title', text: 'Alt text' },
+ );
+});
+
+test('fills in the fields marked leaves null', () => {
+ assert.deepEqual(
+ normalizeMarkdownImageToken({ href: 'http://example.test/a.png', title: null, text: '' }),
+ { href: 'http://example.test/a.png', title: '', text: '' },
+ );
+ assert.deepEqual(
+ normalizeMarkdownImageToken('http://example.test/a.png', null, null),
+ { href: 'http://example.test/a.png', title: '', text: '' },
+ );
+});
+
+test('renders an allowed source as the session image element', () => {
+ assert.equal(
+ renderSessionMarkdownImage('http://example.test/a.png', '', 'Alt text'),
+ '',
+ );
+ assert.equal(
+ renderSessionMarkdownImage('data:image/png;base64,AAAA', '', ''),
+ '',
+ );
+});
+
+test('carries the title through when marked supplies one', () => {
+ assert.equal(
+ renderSessionMarkdownImage('file:///shots/a.png', 'A title', 'Alt'),
+ '',
+ );
+});
+
+test('drops a source the app will not load, keeping one unavailable state', () => {
+ for (const href of ['javascript:alert(1)', 'data:text/html,x', 'ftp://example.test/a.png', '']) {
+ assert.equal(
+ renderSessionMarkdownImage(href, '', 'Alt text'),
+ '',
+ `expected ${href || '(empty)'} to render without a src`,
+ );
+ }
+});
+
+test('decodes what marked escaped exactly once, then re-escapes it', () => {
+ assert.equal(
+ renderSessionMarkdownImage('http://example.test/a.png?x=1&y=2', '', '"quoted" & 'single''),
+ '',
+ );
+ // A single decoding pass, so text that was literally `<` in the source
+ // does not decay into a real angle bracket.
+ assert.equal(
+ renderSessionMarkdownImage('http://example.test/a.png', '', '<script>'),
+ '',
+ );
+});
+
+test('never lets alt text break out of the attribute', () => {
+ const html = renderSessionMarkdownImage('http://example.test/a.png', '', '">
');
+ assert.equal(html.includes('onerror=alert(1)>'), false);
+ assert.equal(
+ html,
+ '',
+ );
+});