From ff6386c6da244736f4cc20731df67ead16471de8 Mon Sep 17 00:00:00 2001
From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Date: Thu, 30 Jul 2026 00:24:10 +0800
Subject: [PATCH 01/10] feat(renderer): add bounded session image element
---
.../src/components/SessionImage.ce.vue | 94 +++++++++++++++++++
app/src/renderer/src/main.js | 3 +
app/src/renderer/src/session-image-element.js | 9 ++
3 files changed, 106 insertions(+)
create mode 100644 app/src/renderer/src/components/SessionImage.ce.vue
create mode 100644 app/src/renderer/src/session-image-element.js
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..ea54e17
--- /dev/null
+++ b/app/src/renderer/src/components/SessionImage.ce.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+ Image unavailable
+ {{ accessibleLabel }}
+
+
+
+
+
diff --git a/app/src/renderer/src/main.js b/app/src/renderer/src/main.js
index 51b05a1..a564358 100644
--- a/app/src/renderer/src/main.js
+++ b/app/src/renderer/src/main.js
@@ -6,6 +6,7 @@ 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 shared renderer CSS globally
import '../styles/base.css';
@@ -14,6 +15,8 @@ import '../styles/toolbar.css';
import '../styles/list.css';
import '../styles/detail.css';
+registerSessionImageElement();
+
const app = createApp(App);
app.use(router);
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..f365952
--- /dev/null
+++ b/app/src/renderer/src/session-image-element.js
@@ -0,0 +1,9 @@
+import { defineCustomElement } from 'vue';
+import SessionImage from './components/SessionImage.ce.vue';
+
+export const SESSION_IMAGE_TAG = 'obelisk-session-image';
+
+export function registerSessionImageElement() {
+ if (customElements.get(SESSION_IMAGE_TAG)) return;
+ customElements.define(SESSION_IMAGE_TAG, defineCustomElement(SessionImage));
+}
From c079c503768f4806a77aa29c1b95673797483926 Mon Sep 17 00:00:00 2001
From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Date: Thu, 30 Jul 2026 00:25:58 +0800
Subject: [PATCH 02/10] fix(renderer): route markdown images through media
element
---
.../renderer/src/markdown-image-renderer.js | 50 +++++++++++++++++++
app/src/renderer/src/utils.js | 2 +
app/src/renderer/styles/detail.css | 14 ++++++
3 files changed, 66 insertions(+)
create mode 100644 app/src/renderer/src/markdown-image-renderer.js
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..84c11e0
--- /dev/null
+++ b/app/src/renderer/src/markdown-image-renderer.js
@@ -0,0 +1,50 @@
+import { SESSION_IMAGE_TAG } from './session-image-element.js';
+
+const SAFE_IMAGE_PROTOCOLS = new Set(['blob:', 'file:', 'http:', 'https:']);
+let configuredMarked = null;
+
+function decodeMarkedAttribute(value) {
+ const decoder = document.createElement('textarea');
+ decoder.innerHTML = String(value ?? '');
+ return decoder.value;
+}
+
+function isSafeImageSource(source) {
+ try {
+ const url = new URL(source, document.baseURI);
+ return SAFE_IMAGE_PROTOCOLS.has(url.protocol)
+ || (url.protocol === 'data:' && /^data:image\//i.test(source));
+ } catch {
+ return false;
+ }
+}
+
+function imageFallback(alt) {
+ const fallback = document.createElement('span');
+ fallback.className = 'session-image-fallback';
+ fallback.textContent = alt || 'Image unavailable';
+ return fallback.outerHTML;
+}
+
+export function renderSessionMarkdownImage(href, title, text) {
+ const source = decodeMarkedAttribute(href).trim();
+ const alt = decodeMarkedAttribute(text);
+ const accessibleTitle = decodeMarkedAttribute(title);
+ if (!source || !isSafeImageSource(source)) return imageFallback(alt);
+
+ const image = document.createElement(SESSION_IMAGE_TAG);
+ image.setAttribute('src', source);
+ image.setAttribute('alt', alt);
+ if (accessibleTitle) image.setAttribute('title', accessibleTitle);
+ return image.outerHTML;
+}
+
+export function configureMarkdownImages(marked) {
+ if (!marked || configuredMarked === marked) return;
+ marked.use({
+ renderer: {
+ image: renderSessionMarkdownImage,
+ },
+ });
+ configuredMarked = marked;
+}
diff --git a/app/src/renderer/src/utils.js b/app/src/renderer/src/utils.js
index 743b58c..1efcba4 100644
--- a/app/src/renderer/src/utils.js
+++ b/app/src/renderer/src/utils.js
@@ -2,6 +2,7 @@
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
import { state } from './store.js';
+import { configureMarkdownImages } from './markdown-image-renderer.js';
// --- Time / formatting ---
@@ -95,6 +96,7 @@ export function highlightTextNodes(rootEl, query) {
export function renderMarkdown(text, opts = {}) {
if (text == null) return '';
// marked is loaded globally via CDN in index.html
+ configureMarkdownImages(window.marked);
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 98813e1..a45080d 100644
--- a/app/src/renderer/styles/detail.css
+++ b/app/src/renderer/styles/detail.css
@@ -363,6 +363,20 @@
.markdown-msg th, .markdown-msg td { border: 1px solid var(--hairline); padding: 5px 9px; text-align: left; }
.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-image-fallback {
+ display: block;
+ max-width: 100%;
+ margin: 0.7em 0;
+ padding: 10px 12px;
+ overflow: hidden;
+ border: 1px solid var(--hairline-strong);
+ border-radius: 6px;
+ color: var(--muted);
+ background: rgba(0,0,0,0.22);
+ font: 12px/1.5 var(--font-mono);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.detail-section-divider {
display: flex; align-items: center; gap: 10px;
From 3b93ac2082471397f8b21ff5d6c9b56267b179c0 Mon Sep 17 00:00:00 2001
From: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Date: Thu, 30 Jul 2026 00:30:34 +0800
Subject: [PATCH 03/10] test(renderer): cover session image layout
---
app/package.json | 1 +
app/tests/electron-session-images.mjs | 290 ++++++++++++++++++++++++++
2 files changed, 291 insertions(+)
create mode 100644 app/tests/electron-session-images.mjs
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);
+ });
From 34bd3aed834d9e9ec8f97231ceb97eb0028fc212 Mon Sep 17 00:00:00 2001
From: tommy0103
Date: Mon, 3 Aug 2026 01:28:45 +0800
Subject: [PATCH 04/10] fix(renderer): accept both marked image renderer
signatures
marked <= 14 calls renderer.image(href, title, text); marked >= 15 passes
the token instead. With only the positional form handled, an upgrade of the
pinned CDN build would turn every session image into fallback text without
any error, so normalise both shapes and cover them with a unit test.
The tag name moves into session-image-contract.js so the Markdown renderer
no longer reaches it through the module that imports the .vue component,
which is what kept it out of Node's test runner.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../renderer/src/markdown-image-renderer.js | 25 ++++++++++---
.../renderer/src/session-image-contract.js | 10 ++++++
app/src/renderer/src/session-image-element.js | 3 +-
tests/markdown-image-renderer.test.mjs | 36 +++++++++++++++++++
4 files changed, 67 insertions(+), 7 deletions(-)
create mode 100644 app/src/renderer/src/session-image-contract.js
create mode 100644 tests/markdown-image-renderer.test.mjs
diff --git a/app/src/renderer/src/markdown-image-renderer.js b/app/src/renderer/src/markdown-image-renderer.js
index 84c11e0..f48abdd 100644
--- a/app/src/renderer/src/markdown-image-renderer.js
+++ b/app/src/renderer/src/markdown-image-renderer.js
@@ -1,4 +1,4 @@
-import { SESSION_IMAGE_TAG } from './session-image-element.js';
+import { SESSION_IMAGE_TAG } from './session-image-contract.js';
const SAFE_IMAGE_PROTOCOLS = new Set(['blob:', 'file:', 'http:', 'https:']);
let configuredMarked = null;
@@ -26,10 +26,25 @@ function imageFallback(alt) {
return fallback.outerHTML;
}
-export function renderSessionMarkdownImage(href, title, text) {
- const source = decodeMarkedAttribute(href).trim();
- const alt = decodeMarkedAttribute(text);
- const accessibleTitle = decodeMarkedAttribute(title);
+// 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 ?? '' };
+}
+
+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);
if (!source || !isSafeImageSource(source)) return imageFallback(alt);
const image = document.createElement(SESSION_IMAGE_TAG);
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..3e6da82
--- /dev/null
+++ b/app/src/renderer/src/session-image-contract.js
@@ -0,0 +1,10 @@
+// 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 it crosses the
+// shadow boundary) once the image has either decoded or failed. The virtualized
+// timeline uses it to tell real media growth apart from an estimate correction.
+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
index f365952..abaea24 100644
--- a/app/src/renderer/src/session-image-element.js
+++ b/app/src/renderer/src/session-image-element.js
@@ -1,7 +1,6 @@
import { defineCustomElement } from 'vue';
import SessionImage from './components/SessionImage.ce.vue';
-
-export const SESSION_IMAGE_TAG = 'obelisk-session-image';
+import { SESSION_IMAGE_TAG } from './session-image-contract.js';
export function registerSessionImageElement() {
if (customElements.get(SESSION_IMAGE_TAG)) return;
diff --git a/tests/markdown-image-renderer.test.mjs b/tests/markdown-image-renderer.test.mjs
new file mode 100644
index 0000000..cd65fe0
--- /dev/null
+++ b/tests/markdown-image-renderer.test.mjs
@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { normalizeMarkdownImageToken } 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: '' },
+ );
+});
From dde076153aa614348b8c09f7a16b19348def8d78 Mon Sep 17 00:00:00 2001
From: tommy0103
Date: Mon, 3 Aug 2026 01:29:02 +0800
Subject: [PATCH 05/10] fix(renderer): keep the reader anchored when a session
image settles
virtual-core skips scroll compensation when an already-measured row above
the viewport is re-measured during an upward scroll: that is normally an
estimate correction, and compensating it makes rows jump while the reader
scrolls back through history. An image finishing loading is not an estimate
correction -- the row really did get taller -- so skipping compensation
pushed everything on screen down by the full image height. Scrolling back
through a session with screenshots moved the reader by ~490px per image.
The element now announces load and error through a composed event, and the
timeline compensates size changes for rows that just settled media while
leaving virtual-core's guard in place for every other re-measurement.
loading="lazy" comes off the image at the same time. Rows are only mounted
within a few viewports, so the attribute bought almost nothing while making
load timing depend on Chromium's connection heuristics.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/components/SessionImage.ce.vue | 20 +++++--
.../src/session-timeline-viewport.mjs | 55 ++++++++++++++++++-
2 files changed, 70 insertions(+), 5 deletions(-)
diff --git a/app/src/renderer/src/components/SessionImage.ce.vue b/app/src/renderer/src/components/SessionImage.ce.vue
index ea54e17..0fd9303 100644
--- a/app/src/renderer/src/components/SessionImage.ce.vue
+++ b/app/src/renderer/src/components/SessionImage.ce.vue
@@ -1,5 +1,6 @@
@@ -32,7 +45,6 @@ function handleError() {
:src="src"
:alt="alt"
:title="title || undefined"
- loading="lazy"
decoding="async"
@load="handleLoad"
@error="handleError"
diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs
index 3bedb69..726c752 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, ref, watch } from 'vue';
import {
defaultRangeExtractor,
elementScroll,
@@ -6,6 +6,11 @@ import {
useVirtualizer,
} from '@tanstack/vue-virtual';
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
+import { 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".
+const MEDIA_SETTLE_WINDOW_MS = 1_000;
function estimatedTextHeight(text = '') {
return Math.min(560, Math.ceil(String(text).length / 72) * 20);
@@ -126,6 +131,54 @@ 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. An image finishing 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. Track which rows just settled
+ // media and compensate only those.
+ const mediaSettledAt = new Map();
+
+ function noteMediaSettled(event) {
+ const row = event.target?.closest?.('.virtual-timeline-row');
+ const index = Number(row?.dataset?.index);
+ if (!Number.isInteger(index)) return;
+ const now = performance.now();
+ for (const [key, at] of mediaSettledAt) {
+ if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(key);
+ }
+ mediaSettledAt.set(items.value[index]?.key ?? index, now);
+ }
+
+ function isMediaSettling(key) {
+ 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;
+ }
+
+ watch(
+ () => timelineElement?.value,
+ (element, previous) => {
+ previous?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
+ element?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
+ },
+ { immediate: true },
+ );
+
+ 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?.() || [])]
From a9e6d1bab8b3e425064c0ca9a804c4e45f84e474 Mon Sep 17 00:00:00 2001
From: tommy0103
Date: Mon, 3 Aug 2026 01:29:02 +0800
Subject: [PATCH 06/10] test(renderer): cover image reader anchoring, and fail
instead of hang
The wide-image probe resolved only from a load handler that a regression
could keep from ever firing, and it was awaited bare, so a broken build
hung the run instead of reporting a failure. Every renderer probe now runs
against a deadline, the probe rejects on image error, and the suite as a
whole is bounded.
Adds a held image endpoint so an above-viewport image can be made to finish
at a moment the test controls, and asserts the reader does not move -- both
at rest and mid-gesture, which is where it regressed.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/tests/electron-session-images.mjs | 221 ++++++++++++++++++++++++--
1 file changed, 207 insertions(+), 14 deletions(-)
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);
From 1124758b6b4918e5c7d66eebdf3519d10e1459c1 Mon Sep 17 00:00:00 2001
From: tommy0103
Date: Mon, 3 Aug 2026 02:10:23 +0800
Subject: [PATCH 07/10] refactor(renderer): give session images one unavailable
state, off the DOM
A source the Markdown renderer refuses now goes through the same element
with no src rather than a separate light-DOM span, so a blocked source and
a source that fails to load are one piece of UI instead of two. That drops
the span's stylesheet rule and the only reason the renderer built elements
just to serialise them.
Decoding what marked escaped no longer runs untrusted markup through a
detached element's innerHTML; the entities marked actually emits are
decoded in one pass, and attributes are escaped on the way out. With the
DOM dependency gone the renderer is directly unit-testable, so the escaping
and the protocol allowlist now have coverage that does not need Electron.
The element's shadow styles also stop hard-coding values that already exist
as tokens -- custom properties cross the shadow boundary, so --muted and
--hairline-strong are now the single source of truth -- and the height cap
becomes --session-image-max-block, which compact Markdown surfaces (subagent
panes, memory rows, tool results) lower from a viewport fraction to 240px.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../src/components/SessionImage.ce.vue | 29 ++++++---
.../renderer/src/markdown-image-renderer.js | 61 +++++++++++++------
app/src/renderer/styles/detail.css | 19 ++----
tests/markdown-image-renderer.test.mjs | 58 +++++++++++++++++-
4 files changed, 125 insertions(+), 42 deletions(-)
diff --git a/app/src/renderer/src/components/SessionImage.ce.vue b/app/src/renderer/src/components/SessionImage.ce.vue
index 0fd9303..b219188 100644
--- a/app/src/renderer/src/components/SessionImage.ce.vue
+++ b/app/src/renderer/src/components/SessionImage.ce.vue
@@ -1,18 +1,25 @@
0) mediaPending.set(key, outstanding);
+ else mediaPending.delete(key);
const now = performance.now();
- for (const [key, at] of mediaSettledAt) {
- if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(key);
+ for (const [settledKey, at] of mediaSettledAt) {
+ if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(settledKey);
}
- mediaSettledAt.set(items.value[index]?.key ?? index, now);
+ 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) {
@@ -161,14 +191,16 @@ export function useSessionTimelineViewport({
return true;
}
- watch(
- () => timelineElement?.value,
- (element, previous) => {
- previous?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
- element?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
- },
- { immediate: 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;
diff --git a/app/tests/electron-session-images.mjs b/app/tests/electron-session-images.mjs
index e342edf..b5b1e3b 100644
--- a/app/tests/electron-session-images.mjs
+++ b/app/tests/electron-session-images.mjs
@@ -3,6 +3,45 @@ 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, '..');
@@ -68,6 +107,10 @@ function startImageServer() {
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',
@@ -76,6 +119,14 @@ function startImageServer() {
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);
@@ -107,6 +158,18 @@ function startImageServer() {
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);
+ }
+ },
});
});
});
@@ -141,7 +204,10 @@ function registerHandlers() {
}
async function run() {
- const { server, baseUrl, heldRequestCount, releaseHeldImage } = await startImageServer();
+ const {
+ server, baseUrl, heldRequestCount, releaseHeldImage,
+ progressiveRequestCount, releaseProgressiveImage,
+ } = await startImageServer();
let win = null;
try {
messages = [
@@ -201,8 +267,16 @@ async function run() {
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-${7 + 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)}`,
@@ -368,15 +442,61 @@ async function run() {
+ ` (${heldRequestCount('/held-rest.svg')}, ${heldRequestCount('/held-scroll.svg')})`,
);
- const parkAbove = distance => win.webContents.executeJavaScript(`(() => {
+ const parkAbove = (uuid, 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 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();
@@ -394,7 +514,7 @@ async function run() {
};
})()`, true);
- await parkAbove(420);
+ await parkAbove('message-6', 420);
// Longer than isScrollingResetDelay so the virtualizer is genuinely at rest.
await delay(700);
await waitFor(
@@ -423,53 +543,14 @@ async function run() {
// 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 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 = 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);
+ const scrollProbe = backwardScrollProbe();
await delay(500);
releaseHeldImage('/held-scroll.svg');
const scrolling = await withDeadline(scrollProbe, 'backward scroll residual probe');
@@ -478,6 +559,62 @@ async function run() {
'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));