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 @@ + + + + + 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![Wide timeline fixture](${baseUrl}/wide.svg "Wide fixture")`, + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-2', + type: 'assistant', + timestamp: '2026-07-30T00:02:00.000Z', + text: `Small image\n\n![Small timeline fixture](${baseUrl}/small.svg)`, + content_type: 'text', + is_meta: 0, + }, + { + uuid: 'message-3', + type: 'assistant', + timestamp: '2026-07-30T00:03:00.000Z', + text: 'Broken image\n\n![Missing timeline fixture](file:///obelisk-fixtures/missing-session-image.png)', + 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![Held rest fixture](${baseUrl}/held-rest.svg)`, + 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![Held scroll fixture](${baseUrl}/held-scroll.svg)`, + 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 @@