Merge pull request #25 from tommy0103/fix/session-image-review

[Sessions] Render Markdown images within message bounds
This commit is contained in:
tommy0103
2026-08-03 05:10:09 +08:00
committed by GitHub
12 changed files with 1099 additions and 3 deletions
+3 -1
View File
@@ -20,9 +20,11 @@
"generate:session-share": "node scripts/generate-session-share.mjs",
"verify:icons": "node scripts/verify-icons.mjs",
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs",
"test:electron:images": "electron-vite build && electron --no-sandbox tests/electron-session-images.mjs",
"test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs",
"test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs",
"test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs"
"test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs",
"test:electron:all": "electron-vite build && node tests/run-electron-suites.mjs"
},
"build": {
"appId": "com.obelisk.app",
@@ -0,0 +1,127 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import {
SESSION_IMAGE_PENDING_EVENT,
SESSION_IMAGE_SETTLED_EVENT,
} from '../session-image-contract.js';
defineOptions({ name: 'SessionImage' });
const props = defineProps({
// Absent when the Markdown renderer refused the source. The element still
// renders, in its error state, so a blocked source and a source that fails
// to load are one piece of UI rather than two.
src: { type: String, default: '' },
alt: { type: String, default: '' },
title: { type: String, default: '' },
});
const figure = ref(null);
const status = ref(props.src ? 'loading' : 'error');
const accessibleLabel = computed(() => props.alt || props.title || 'Session image');
// The events are composed so they leave the shadow root, and dispatched
// synchronously so the timeline knows the row's size is about to change for a
// reason the reader did not cause -- before the resize observation lands.
function announce(target, type) {
target?.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true }));
}
// Announced from mount rather than from load: the row grows as soon as the
// decoder knows the intrinsic size, which for anything bigger than a trivial
// image is long before loading finishes.
onMounted(() => {
if (props.src) announce(figure.value, SESSION_IMAGE_PENDING_EVENT);
});
watch(() => props.src, source => {
status.value = source ? 'loading' : 'error';
if (source) announce(figure.value, SESSION_IMAGE_PENDING_EVENT);
});
function handleLoad(event) {
status.value = 'loaded';
announce(event.target, SESSION_IMAGE_SETTLED_EVENT);
}
function handleError(event) {
status.value = 'error';
announce(event.target, SESSION_IMAGE_SETTLED_EVENT);
}
</script>
<template>
<figure
ref="figure"
class="session-image"
:class="`is-${status}`"
:aria-busy="status === 'loading' ? 'true' : undefined"
>
<img
v-if="status !== 'error'"
:src="src"
:alt="alt"
:title="title || undefined"
decoding="async"
@load="handleLoad"
@error="handleError"
>
<figcaption v-else role="status">
<span>Image unavailable</span>
<span v-if="accessibleLabel" class="image-label">{{ accessibleLabel }}</span>
</figcaption>
</figure>
</template>
<style>
/* Custom properties cross the shadow boundary, so the app's tokens are the
single source of truth for these colours. --session-image-max-block lets a
host context (a compact Markdown block, say) cap the image lower than the
session timeline does. */
:host {
display: block;
max-inline-size: 100%;
min-inline-size: 0;
margin: 0.7em 0;
contain: inline-size;
}
.session-image {
max-inline-size: 100%;
min-inline-size: 0;
margin: 0;
overflow: clip;
border: 1px solid var(--hairline-strong);
border-radius: 6px;
background: var(--session-image-backdrop, rgba(0, 0, 0, 0.22));
}
img {
display: block;
inline-size: auto;
max-inline-size: 100%;
block-size: auto;
max-block-size: var(--session-image-max-block, min(70vh, 720px));
object-fit: contain;
color: var(--muted);
}
.is-loading {
min-block-size: 48px;
}
figcaption {
display: flex;
min-block-size: 48px;
align-items: center;
gap: 6px;
padding: 10px 12px;
color: var(--muted);
font: var(--text-sm, 12px)/1.5 var(--font-mono);
}
.image-label::before {
content: '·';
margin-inline-end: 6px;
}
</style>
+6
View File
@@ -6,6 +6,8 @@ import router from './router.js';
import { commitInitialData, fetchInitialData } from './data.js';
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
import { registerSessionImageElement } from './session-image-element.js';
import { configureMarkdownImages } from './markdown-image-renderer.js';
import { installFileReferenceHandler } from './file-references.mjs';
// Import shared renderer CSS globally
@@ -15,6 +17,10 @@ import '../styles/toolbar.css';
import '../styles/list.css';
import '../styles/detail.css';
registerSessionImageElement();
// marked is loaded globally via CDN in index.html, ahead of this module.
configureMarkdownImages(window.marked);
const app = createApp(App);
app.use(router);
@@ -0,0 +1,90 @@
import { SESSION_IMAGE_TAG } from './session-image-contract.js';
const SAFE_IMAGE_PROTOCOLS = new Set(['blob:', 'file:', 'http:', 'https:']);
const NAMED_ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
let configuredMarked = null;
// marked escapes the alt text and title it hands to the renderer, and the
// attributes are re-escaped on the way back out, so they have to be decoded
// once in between. Doing that through a detached element's innerHTML would
// mean parsing untrusted markup, so decode the entities marked actually emits
// in a single pass instead -- one pass, so `&amp;lt;` stays `&lt;`.
function decodeMarkedAttribute(value) {
return String(value ?? '').replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, entity) => {
if (entity[0] === '#') {
const codePoint = entity[1] === 'x' || entity[1] === 'X'
? Number.parseInt(entity.slice(2), 16)
: Number.parseInt(entity.slice(1), 10);
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return whole;
try {
return String.fromCodePoint(codePoint);
} catch {
return whole;
}
}
return NAMED_ENTITIES[entity.toLowerCase()] ?? whole;
});
}
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function isSafeImageSource(source) {
try {
const url = new URL(source, globalThis.document?.baseURI ?? 'file:///');
return SAFE_IMAGE_PROTOCOLS.has(url.protocol)
|| (url.protocol === 'data:' && /^data:image\//i.test(source));
} catch {
return false;
}
}
// marked <= 14 calls renderer.image(href, title, text); marked >= 15 passes the
// image token instead. Accepting both keeps an upgrade from silently turning
// every session image into fallback text.
export function normalizeMarkdownImageToken(hrefOrToken, title, text) {
if (hrefOrToken && typeof hrefOrToken === 'object') {
return {
href: hrefOrToken.href ?? '',
title: hrefOrToken.title ?? '',
text: hrefOrToken.text ?? '',
};
}
return { href: hrefOrToken ?? '', title: title ?? '', text: text ?? '' };
}
// A source the app will not load is rendered through the same element with no
// src, so a blocked image and an image that fails to load look the same to the
// reader instead of being two different pieces of UI.
export function renderSessionMarkdownImage(hrefOrToken, title, text) {
const token = normalizeMarkdownImageToken(hrefOrToken, title, text);
const source = decodeMarkedAttribute(token.href).trim();
const alt = decodeMarkedAttribute(token.text);
const accessibleTitle = decodeMarkedAttribute(token.title);
const attributes = [`alt="${escapeAttribute(alt)}"`];
if (source && isSafeImageSource(source)) attributes.unshift(`src="${escapeAttribute(source)}"`);
if (accessibleTitle) attributes.push(`title="${escapeAttribute(accessibleTitle)}"`);
return `<${SESSION_IMAGE_TAG} ${attributes.join(' ')}></${SESSION_IMAGE_TAG}>`;
}
export function configureMarkdownImages(marked) {
if (!marked || configuredMarked === marked) return;
marked.use({
renderer: {
image: renderSessionMarkdownImage,
},
});
configuredMarked = marked;
}
@@ -0,0 +1,16 @@
// Shared constants for the session image element. Kept free of the component
// import so the Markdown renderer (and its tests) do not have to pull a .vue
// module into scope just to know the tag name.
export const SESSION_IMAGE_TAG = 'obelisk-session-image';
// Fired from inside the session image element (composed, so they cross the
// shadow boundary). The virtualized timeline uses them to tell real media
// growth apart from an estimate correction.
//
// Pending is announced when the element mounts with a source, not when the
// image loads: a decoder sizes an image from its header and grows the row well
// before the load event, so a row is only known to be settled once loading has
// actually finished.
export const SESSION_IMAGE_PENDING_EVENT = 'obelisk-session-image-pending';
export const SESSION_IMAGE_SETTLED_EVENT = 'obelisk-session-image-settled';
@@ -0,0 +1,8 @@
import { defineCustomElement } from 'vue';
import SessionImage from './components/SessionImage.ce.vue';
import { SESSION_IMAGE_TAG } from './session-image-contract.js';
export function registerSessionImageElement() {
if (customElements.get(SESSION_IMAGE_TAG)) return;
customElements.define(SESSION_IMAGE_TAG, defineCustomElement(SessionImage));
}
@@ -1,4 +1,4 @@
import { computed, nextTick, ref } from 'vue';
import { computed, nextTick, onScopeDispose, ref } from 'vue';
import {
defaultRangeExtractor,
elementScroll,
@@ -6,6 +6,15 @@ import {
useVirtualizer,
} from '@tanstack/vue-virtual';
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
import {
SESSION_IMAGE_PENDING_EVENT,
SESSION_IMAGE_SETTLED_EVENT,
} from './session-image-contract.js';
// How long after an image settles its row still counts as "grew because media
// finished" rather than "grew because the estimate was wrong". Covers the
// remeasurement that follows the final state change.
const MEDIA_SETTLE_WINDOW_MS = 1_000;
function estimatedTextHeight(text = '') {
return Math.min(560, Math.ceil(String(text).length / 72) * 20);
@@ -126,6 +135,82 @@ export function useSessionTimelineViewport({
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
const totalSize = computed(() => virtualizer.value.getTotalSize());
// virtual-core deliberately skips scroll compensation when an already-measured
// row above the viewport is re-measured during an upward scroll, because that
// is normally an estimate correction and compensating it makes rows jump while
// the reader scrolls back. Media growth is not an estimate correction: the row
// really did get taller, so leaving it uncompensated pushes everything the
// reader is looking at down the screen.
//
// A row counts as settling from the moment an image mounts until loading
// finishes, because a decoder grows the row from the image header long before
// the load event -- keying off load alone would miss the first and largest
// growth. Counted, not flagged, so a message with several images stays marked
// until the last of them is done.
const mediaPending = new Map();
const mediaSettledAt = new Map();
function rowKeyFor(event) {
const row = event.target?.closest?.('.virtual-timeline-row');
if (!row) return null;
const timeline = timelineElement?.value;
if (timeline && !timeline.contains(row)) return null;
const index = Number(row.dataset?.index);
if (!Number.isInteger(index)) return null;
return items.value[index]?.key ?? index;
}
function noteMediaPending(event) {
const key = rowKeyFor(event);
if (key === null) return;
mediaPending.set(key, (mediaPending.get(key) ?? 0) + 1);
mediaSettledAt.delete(key);
}
function noteMediaSettled(event) {
const key = rowKeyFor(event);
if (key === null) return;
const outstanding = (mediaPending.get(key) ?? 0) - 1;
if (outstanding > 0) mediaPending.set(key, outstanding);
else mediaPending.delete(key);
const now = performance.now();
for (const [settledKey, at] of mediaSettledAt) {
if (now - at > MEDIA_SETTLE_WINDOW_MS) mediaSettledAt.delete(settledKey);
}
mediaSettledAt.set(key, now);
}
function isMediaSettling(key) {
if (mediaPending.has(key)) return true;
const at = mediaSettledAt.get(key);
if (at === undefined) return false;
if (performance.now() - at > MEDIA_SETTLE_WINDOW_MS) {
mediaSettledAt.delete(key);
return false;
}
return true;
}
// Bound to the document rather than to the timeline element, because rows
// announce their images while mounting -- before a ref-driven listener would
// be in place to hear the first one.
const eventTarget = globalThis.document ?? null;
eventTarget?.addEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending);
eventTarget?.addEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
onScopeDispose(() => {
eventTarget?.removeEventListener(SESSION_IMAGE_PENDING_EVENT, noteMediaPending);
eventTarget?.removeEventListener(SESSION_IMAGE_SETTLED_EVENT, noteMediaSettled);
});
virtualizer.value.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (item.start >= instance.getScrollOffset() + instance.scrollAdjustments) return false;
// Never measured before: the estimate is what placed the reader, so the
// estimate-to-actual difference always has to be taken out.
if (!instance.itemSizeCache.has(item.key)) return true;
if (isMediaSettling(item.key)) return true;
return instance.scrollDirection !== 'backward';
};
function resolveTimelineElement(instance = virtualizer?.value) {
return timelineElement?.value
|| [...(instance?.elementsCache?.values?.() || [])]
+1 -1
View File
@@ -95,7 +95,7 @@ export function highlightTextNodes(rootEl, query) {
export function renderMarkdown(text, opts = {}) {
if (text == null) return '';
// marked is loaded globally via CDN in index.html
// marked is loaded globally via CDN in index.html and configured at startup.
const html = sanitizeMarkdown(window.marked.parse(text));
const cls = opts.variant === 'msg' ? 'markdown-msg'
: opts.variant === 'compact' ? 'markdown-compact'
+5
View File
@@ -364,6 +364,11 @@
.markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; }
.markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
/* Session images cap themselves against the reading column. Compact Markdown
renders in much smaller surfaces (subagent panes, memory rows, tool results),
where a viewport-sized image would swamp the block it belongs to. */
.markdown-compact { --session-image-max-block: 240px; }
.detail-section-divider {
display: flex; align-items: center; gap: 10px;
margin: 32px 0 14px; color: var(--muted);
+633
View File
@@ -0,0 +1,633 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
import { deflateSync } from 'node:zlib';
// A real PNG. The fixture writes it in full and then holds the response open,
// so Blink sizes and lays the image out from the buffered bytes while the load
// event waits for the response to complete. An SVG served in one shot lays out
// and fires load together, which hides that gap entirely.
function crc32(buffer) {
let crc = ~0;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
return ~crc >>> 0;
}
function pngChunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, checksum]);
}
function buildPng(width, height) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8; // bit depth
header[9] = 0; // greyscale
const scanlines = Buffer.alloc(height * (width + 1), 0x40);
for (let row = 0; row < height; row++) scanlines[row * (width + 1)] = 0; // filter: none
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk('IHDR', header),
pngChunk('IDAT', deflateSync(scanlines)),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = join(here, '..');
const sessionId = 'session-image-test';
const channels = [
'db:getSessions',
'db:getSessionMessages',
'db:getSessionToolCalls',
'db:getSessionToolResults',
'db:getSessionSubagents',
'db:getSessionWorkflows',
'db:getSessionSummaries',
'db:getMemories',
'db:getProjects',
'db:getStats',
'settings:get',
];
let failures = 0;
let messages = [];
const FILLER_COUNT = 40;
function assert(condition, message) {
if (condition) console.log(`PASS: ${message}`);
else {
failures++;
console.error(`FAIL: ${message}`);
}
}
async function waitFor(webContents, expression, message, timeoutMs = 8_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await webContents.executeJavaScript(`Boolean(${expression})`, true)) return;
await delay(40);
}
throw new Error(`Timed out waiting for ${message}`);
}
// Renderer-side probes resolve from event handlers that a regression can keep
// from ever firing. Racing every one of them against a deadline keeps a broken
// build reporting a failure instead of hanging the run.
async function withDeadline(promise, message, timeoutMs = 10_000) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out waiting for ${message}`)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function startImageServer() {
const wideSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="1176" height="768" viewBox="0 0 1176 768"><rect width="1176" height="768" fill="#7c3aed"/></svg>';
const smallSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80"><rect width="120" height="80" fill="#22c55e"/></svg>';
// 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 = '<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1200" viewBox="0 0 900 1200"><rect width="900" height="1200" fill="#0ea5e9"/></svg>';
const heldPaths = ['/held-rest.svg', '/held-scroll.svg'];
const heldResponses = new Map(heldPaths.map(path => [path, []]));
const releasedPaths = new Set();
// The bytes go out in one write and the response stays open, so the row grows
// from the decoded header while the load event waits for completion.
const progressivePng = buildPng(900, 1200);
let progressiveResponses = [];
const sendSvg = (response, svg) => {
response.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-store',
});
response.end(svg);
};
const server = createServer((request, response) => {
if (request.url === '/held-progressive.png') {
response.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
});
progressiveResponses.push(response);
return;
}
if (heldResponses.has(request.url)) {
if (releasedPaths.has(request.url)) sendSvg(response, heldSvg);
else heldResponses.get(request.url).push(response);
return;
}
const svg = request.url === '/wide.svg'
? wideSvg
: request.url === '/small.svg'
? smallSvg
: null;
if (!svg) {
response.writeHead(404).end();
return;
}
if (request.url === '/wide.svg') setTimeout(() => sendSvg(response, wideSvg), 300);
else sendSvg(response, smallSvg);
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve({
server,
baseUrl: `http://127.0.0.1:${address.port}`,
heldRequestCount: path => heldResponses.get(path)?.length ?? 0,
releaseHeldImage(path) {
releasedPaths.add(path);
const pending = heldResponses.get(path) ?? [];
heldResponses.set(path, []);
for (const response of pending) sendSvg(response, heldSvg);
},
progressiveRequestCount: () => progressiveResponses.length,
// Delivers the whole image now and completes the response completeMs
// later, reproducing the gap a real image opens between the row growing
// and the load event firing.
releaseProgressiveImage(completeMs) {
const pending = progressiveResponses;
progressiveResponses = [];
for (const response of pending) {
response.write(progressivePng);
setTimeout(() => response.end(), completeMs);
}
},
});
});
});
}
function sessionSummary() {
return {
id: sessionId,
title: 'Session image rendering',
project: 'image-fixture',
project_path: '/tmp/image-fixture',
source: 'codex',
started_at: '2026-07-30T00:00:00.000Z',
ended_at: '2026-07-30T00:05:00.000Z',
message_count: messages.length,
git_branch: 'main',
};
}
function registerHandlers() {
ipcMain.handle('db:getSessions', () => [sessionSummary()]);
ipcMain.handle('db:getSessionMessages', () => messages);
ipcMain.handle('db:getSessionToolCalls', () => []);
ipcMain.handle('db:getSessionToolResults', () => []);
ipcMain.handle('db:getSessionSubagents', () => []);
ipcMain.handle('db:getSessionWorkflows', () => []);
ipcMain.handle('db:getSessionSummaries', () => []);
ipcMain.handle('db:getMemories', () => []);
ipcMain.handle('db:getProjects', () => [{ project: 'image-fixture', count: 1 }]);
ipcMain.handle('db:getStats', () => ({}));
ipcMain.handle('settings:get', () => ({}));
}
async function run() {
const {
server, baseUrl, heldRequestCount, releaseHeldImage,
progressiveRequestCount, releaseProgressiveImage,
} = await startImageServer();
let win = null;
try {
messages = [
{
uuid: 'message-0',
type: 'user',
timestamp: '2026-07-30T00:00:00.000Z',
text: 'Image rendering fixtures',
content_type: 'text',
is_meta: 0,
},
{
uuid: 'message-1',
type: 'assistant',
timestamp: '2026-07-30T00:01:00.000Z',
text: `Wide image\n\n![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,
},
{
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,
},
{
uuid: 'message-7',
type: 'assistant',
timestamp: '2026-07-30T00:07:00.000Z',
text: `Progressive image\n\n![Progressive fixture](${baseUrl}/held-progressive.png)`,
content_type: 'text',
is_meta: 0,
},
...Array.from({ length: FILLER_COUNT }, (_, offset) => ({
uuid: `message-${8 + offset}`,
type: offset % 2 === 0 ? 'user' : 'assistant',
timestamp: new Date(Date.UTC(2026, 6, 30, 1, offset)).toISOString(),
text: `Filler ${offset}. ${'Timeline body copy that gives the row a realistic height. '.repeat(6)}`,
content_type: 'text',
is_meta: 0,
})),
];
registerHandlers();
win = new BrowserWindow({
show: false,
width: 1200,
height: 800,
webPreferences: {
preload: join(appRoot, 'out', 'preload', 'index.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), {
hash: '/sessions',
});
await waitFor(
win.webContents,
`document.body.textContent.includes('Session image rendering')`,
'session list',
);
await win.webContents.executeJavaScript(`(() => {
window.__wideImageLayout = new Promise((resolve, reject) => {
const fail = reason => {
observer.disconnect();
reject(new Error(reason));
};
const timer = setTimeout(() => fail('wide image never reported a layout'), 8000);
const settle = value => {
clearTimeout(timer);
resolve(value);
};
const observer = new MutationObserver(() => {
const message = document.querySelector('[data-uuid="message-1"]');
const host = message?.querySelector('obelisk-session-image');
const image = host?.shadowRoot?.querySelector('img');
const row = message?.closest('.virtual-timeline-row');
if (!image || !row) return;
observer.disconnect();
const before = row.getBoundingClientRect().height;
image.addEventListener('error', () => fail('wide image failed to load'), { once: true });
image.addEventListener('load', () => {
requestAnimationFrame(() => requestAnimationFrame(() => {
settle({
before,
after: row.getBoundingClientRect().height,
});
}));
}, { once: true });
});
observer.observe(document.body, { childList: true, subtree: true });
});
window.location.hash = ${JSON.stringify(`/sessions/${sessionId}`)};
})()`, true);
await waitFor(
win.webContents,
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messages.length}'`,
'image fixture timeline',
);
const resize = await withDeadline(
win.webContents.executeJavaScript('window.__wideImageLayout', true),
'wide image row remeasurement',
);
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="message-2"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
'small image load',
);
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="message-3"] obelisk-session-image')?.shadowRoot?.querySelector('.is-error')`,
'failed image state',
);
await delay(100);
const layout = await win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap');
const wideMessage = document.querySelector('[data-uuid="message-1"]');
const wideRow = wideMessage.closest('.virtual-timeline-row');
const nextRow = document.querySelector('[data-uuid="message-2"]').closest('.virtual-timeline-row');
const wideHost = wideMessage.querySelector('obelisk-session-image');
const wideImage = wideHost.shadowRoot.querySelector('img');
const smallHost = document.querySelector('[data-uuid="message-2"] obelisk-session-image');
const smallImage = smallHost.shadowRoot.querySelector('img');
const brokenHost = document.querySelector('[data-uuid="message-3"] obelisk-session-image');
const wideHostRect = wideHost.getBoundingClientRect();
const wideImageRect = wideImage.getBoundingClientRect();
const smallImageRect = smallImage.getBoundingClientRect();
const wideRowRect = wideRow.getBoundingClientRect();
const nextRowRect = nextRow.getBoundingClientRect();
// Row spacing is applied by the virtualizer, not by CSS on the row, so
// calibrate against the spacing the rest of this timeline is using rather
// than hard-coding the current value.
const mountedRows = [...document.querySelectorAll('.virtual-timeline-row')]
.map(row => ({ index: Number(row.dataset.index), rect: row.getBoundingClientRect() }))
.sort((left, right) => left.index - right.index);
const gaps = mountedRows
.slice(1)
.map((row, offset) => row.rect.top - mountedRows[offset].rect.bottom)
.sort((left, right) => left - right);
return {
wrapClientWidth: wrap.clientWidth,
wrapScrollWidth: wrap.scrollWidth,
wideHostWidth: wideHostRect.width,
wideImageWidth: wideImageRect.width,
wideImageHeight: wideImageRect.height,
wideNaturalWidth: wideImage.naturalWidth,
wideNaturalHeight: wideImage.naturalHeight,
smallImageWidth: smallImageRect.width,
smallNaturalWidth: smallImage.naturalWidth,
rowHeight: wideRowRect.height,
rowGap: nextRowRect.top - wideRowRect.bottom,
typicalRowGap: gaps[Math.floor(gaps.length / 2)] ?? null,
brokenText: brokenHost.shadowRoot.querySelector('figcaption')?.textContent || '',
};
})()`, true);
assert(
resize.after > resize.before + 100,
`image load grows and remeasures its virtual row (${JSON.stringify(resize)})`,
);
assert(
layout.wrapScrollWidth <= layout.wrapClientWidth + 1,
`wide images do not add session-level horizontal overflow (${JSON.stringify(layout)})`,
);
assert(
layout.wideImageWidth <= layout.wideHostWidth + 1
&& Math.abs(
layout.wideImageWidth / layout.wideImageHeight
- layout.wideNaturalWidth / layout.wideNaturalHeight
) < 0.01,
`wide images fit their host and preserve aspect ratio (${JSON.stringify(layout)})`,
);
assert(
layout.smallImageWidth <= layout.smallNaturalWidth + 1,
`small images keep their intrinsic width (${JSON.stringify(layout)})`,
);
assert(
layout.rowHeight > layout.wideImageHeight
&& layout.typicalRowGap !== null
&& Math.abs(layout.rowGap - layout.typicalRowGap) <= 1,
`measured image rows do not overlap the following row (${JSON.stringify(layout)})`,
);
assert(
layout.brokenText.includes('Image unavailable')
&& layout.brokenText.includes('Missing timeline fixture'),
`failed images remain contained with fallback text (${JSON.stringify(layout)})`,
);
// --- Reader position must survive an above-viewport image finishing. ---
// Both fixtures sit above the viewport for the rest of the run, so this also
// confirms a mounted row fetches its image without being on screen.
assert(
heldRequestCount('/held-rest.svg') > 0 && heldRequestCount('/held-scroll.svg') > 0,
'mounted rows request their images while off screen'
+ ` (${heldRequestCount('/held-rest.svg')}, ${heldRequestCount('/held-scroll.svg')})`,
);
const parkAbove = (uuid, distance) => win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap');
const wrapRect = wrap.getBoundingClientRect();
const row = document.querySelector('[data-uuid="${uuid}"]').closest('.virtual-timeline-row');
const rowRect = row.getBoundingClientRect();
wrap.scrollTop += (rowRect.bottom - wrapRect.top) + ${distance};
return wrap.scrollTop;
})()`, true);
// Scrolls backward and reports the largest movement of a visible row that
// scroll input does not account for. A row growing above the viewport
// without compensation shows up here as a residual the size of the growth;
// a compensated one leaves the residual at zero.
const backwardScrollProbe = ({ durationMs = 1_500, stepPx = 12 } = {}) =>
win.webContents.executeJavaScript(`new Promise(resolve => {
const wrap = document.querySelector('.detail-wrap');
const blockAutomaticScrollEnd = event => event.stopImmediatePropagation();
wrap.addEventListener('scrollend', blockAutomaticScrollEnd, true);
wrap.dispatchEvent(new WheelEvent('wheel', { deltaY: -70, bubbles: true }));
let previous = null;
let maxResidual = 0;
let example = null;
const startedAt = performance.now();
function frame(now) {
wrap.scrollTop -= ${stepPx};
const wrapRect = wrap.getBoundingClientRect();
const scrollTop = wrap.scrollTop;
const rows = new Map([...document.querySelectorAll('.virtual-timeline-row')]
.map(row => [
row.querySelector('[data-uuid]')?.getAttribute('data-uuid'),
row.getBoundingClientRect().top - wrapRect.top,
])
.filter(([uuid, top]) => uuid && top > -200 && top < wrapRect.height));
if (previous) {
for (const [uuid, top] of rows) {
if (!previous.rows.has(uuid)) continue;
const residual = (top - previous.rows.get(uuid)) + (scrollTop - previous.scrollTop);
if (Math.abs(residual) > Math.abs(maxResidual)) {
maxResidual = residual;
example = { uuid, residual, scrollTop };
}
}
}
previous = { rows, scrollTop };
if (now - startedAt < ${durationMs}) {
requestAnimationFrame(frame);
return;
}
wrap.removeEventListener('scrollend', blockAutomaticScrollEnd, true);
wrap.dispatchEvent(new Event('scrollend'));
resolve({ maxResidual, example });
}
requestAnimationFrame(frame);
})`, true);
const captureGeometry = () => win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap');
const wrapRect = wrap.getBoundingClientRect();
const rows = [...document.querySelectorAll('.virtual-timeline-row')]
.map(row => ({
uuid: row.querySelector('[data-uuid]')?.getAttribute('data-uuid'),
rect: row.getBoundingClientRect(),
}))
.filter(row => row.uuid && row.rect.bottom > wrapRect.top && row.rect.top < wrapRect.bottom)
.sort((left, right) => left.rect.top - right.rect.top);
return {
scrollTop: wrap.scrollTop,
firstVisible: rows[0]?.uuid || null,
tops: Object.fromEntries(rows.map(row => [row.uuid, row.rect.top - wrapRect.top])),
};
})()`, true);
await parkAbove('message-6', 420);
// Longer than isScrollingResetDelay so the virtualizer is genuinely at rest.
await delay(700);
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`,
'held rest image still pending above the viewport',
);
const restBefore = await captureGeometry();
releaseHeldImage('/held-rest.svg');
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="message-5"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loaded')`,
'held rest image load',
);
await delay(250);
const restAfter = await captureGeometry();
const restAnchor = restBefore.firstVisible;
const restDrift = restAnchor !== null && restAfter.tops[restAnchor] !== undefined
? restAfter.tops[restAnchor] - restBefore.tops[restAnchor]
: Number.NaN;
assert(
Math.abs(restDrift) <= 1,
'an image loading above the viewport does not move the reader position'
+ ` (${JSON.stringify({ anchor: restAnchor, drift: restDrift, scrollTop: [restBefore.scrollTop, restAfter.scrollTop] })})`,
);
// Same guarantee mid-gesture: scrolling back through history is when rows
// above the viewport are most likely to still be settling their media.
await parkAbove('message-6', 2_000);
await delay(700);
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="message-6"] obelisk-session-image')?.shadowRoot?.querySelector('.is-loading')`,
'held scroll image still pending above the viewport',
);
const scrollProbe = backwardScrollProbe();
await delay(500);
releaseHeldImage('/held-scroll.svg');
const scrolling = await withDeadline(scrollProbe, 'backward scroll residual probe');
assert(
Math.abs(scrolling.maxResidual) <= 2,
'an image loading above the viewport does not move visible rows mid-scroll'
+ ` (${JSON.stringify(scrolling)})`,
);
// A progressively decoded image lays out from its header, so the row grows
// long before the load event. Waiting for load to mark the row would leave
// that first, largest growth uncompensated.
await parkAbove('message-7', 2_000);
await delay(700);
assert(
progressiveRequestCount() > 0,
`the progressive fixture was requested (${progressiveRequestCount()})`,
);
await win.webContents.executeJavaScript(`(() => {
const host = document.querySelector('[data-uuid="message-7"] obelisk-session-image');
const row = host.closest('.virtual-timeline-row');
const image = host.shadowRoot.querySelector('img');
const timing = {
start: performance.now(),
placeholderHeight: row.getBoundingClientRect().height,
grewAt: null,
loadedAt: null,
height: 0,
};
// ResizeObserver reports the current size straight away; only a later,
// larger measurement is the image arriving.
timing.height = timing.placeholderHeight;
const since = () => Math.round(performance.now() - timing.start);
new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.contentRect.height <= timing.height + 100) continue;
timing.height = entry.contentRect.height;
if (timing.grewAt === null) timing.grewAt = since();
}
}).observe(row);
image.addEventListener('load', () => { timing.loadedAt = since(); }, { once: true });
window.__progressiveTiming = timing;
})()`, true);
const progressiveProbe = backwardScrollProbe({ durationMs: 3_400, stepPx: 6 });
await delay(400);
// Blink holds partial image data back for about a second before flushing it
// to the decoder, so the response has to stay open well past that for the
// row to grow before the load event.
releaseProgressiveImage(2_000);
const progressive = await withDeadline(
progressiveProbe,
'progressive scroll residual probe',
15_000,
);
const timing = await win.webContents.executeJavaScript('window.__progressiveTiming', true);
assert(
timing.grewAt !== null && timing.loadedAt !== null && timing.grewAt < timing.loadedAt - 100,
`the fixture grows its row before the load event, as a chunked image does (${JSON.stringify(timing)})`,
);
assert(
Math.abs(progressive.maxResidual) <= 2,
'a progressively decoded image does not move visible rows before it finishes'
+ ` (${JSON.stringify(progressive)})`,
);
} finally {
win?.destroy();
await new Promise(resolve => server.close(resolve));
}
}
app.whenReady()
.then(() => withDeadline(run(), 'the session image suite to finish', 180_000))
.catch(error => {
failures++;
console.error(error.stack || error);
})
.finally(() => {
for (const channel of channels) ipcMain.removeHandler(channel);
app.exit(failures ? 1 : 0);
});
+32
View File
@@ -0,0 +1,32 @@
// Runs every Electron regression suite against one build and reports a single
// summary. Each suite already prints its own PASS/FAIL lines; this exists so a
// new suite is covered by one command instead of only being run by whoever
// remembers it is there.
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import electron from 'electron'
const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const suites = [
'electron-concurrency.mjs',
'electron-session-images.mjs',
'electron-session-virtualization.mjs',
'electron-session-reader-state.mjs',
'electron-file-references.mjs',
]
const failed = []
for (const suite of suites) {
console.log(`\n=== ${suite} ===`)
const result = spawnSync(electron, ['--no-sandbox', path.join('tests', suite)], {
cwd: appRoot,
stdio: 'inherit',
})
if (result.status !== 0) failed.push(`${suite} (exit ${result.status ?? 'signal'})`)
}
console.log(`\n${suites.length - failed.length}/${suites.length} Electron suites passed`)
for (const failure of failed) console.error(`FAILED: ${failure}`)
process.exit(failed.length ? 1 : 0)
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeMarkdownImageToken,
renderSessionMarkdownImage,
} from '../app/src/renderer/src/markdown-image-renderer.js';
// marked <= 14 calls renderer.image(href, title, text); marked >= 15 passes the
// token. Getting this wrong degrades silently: every session image turns into
// fallback text because the href is no longer a string.
test('accepts the positional renderer signature', () => {
assert.deepEqual(
normalizeMarkdownImageToken('http://example.test/a.png', 'A title', 'Alt text'),
{ href: 'http://example.test/a.png', title: 'A title', text: 'Alt text' },
);
});
test('accepts the token renderer signature', () => {
assert.deepEqual(
normalizeMarkdownImageToken({
type: 'image',
href: 'http://example.test/a.png',
title: 'A title',
text: 'Alt text',
}),
{ href: 'http://example.test/a.png', title: 'A title', text: 'Alt text' },
);
});
test('fills in the fields marked leaves null', () => {
assert.deepEqual(
normalizeMarkdownImageToken({ href: 'http://example.test/a.png', title: null, text: '' }),
{ href: 'http://example.test/a.png', title: '', text: '' },
);
assert.deepEqual(
normalizeMarkdownImageToken('http://example.test/a.png', null, null),
{ href: 'http://example.test/a.png', title: '', text: '' },
);
});
test('renders an allowed source as the session image element', () => {
assert.equal(
renderSessionMarkdownImage('http://example.test/a.png', '', 'Alt text'),
'<obelisk-session-image src="http://example.test/a.png" alt="Alt text"></obelisk-session-image>',
);
assert.equal(
renderSessionMarkdownImage('data:image/png;base64,AAAA', '', ''),
'<obelisk-session-image src="data:image/png;base64,AAAA" alt=""></obelisk-session-image>',
);
});
test('carries the title through when marked supplies one', () => {
assert.equal(
renderSessionMarkdownImage('file:///shots/a.png', 'A title', 'Alt'),
'<obelisk-session-image src="file:///shots/a.png" alt="Alt" title="A title"></obelisk-session-image>',
);
});
test('drops a source the app will not load, keeping one unavailable state', () => {
for (const href of ['javascript:alert(1)', 'data:text/html,<b>x</b>', 'ftp://example.test/a.png', '']) {
assert.equal(
renderSessionMarkdownImage(href, '', 'Alt text'),
'<obelisk-session-image alt="Alt text"></obelisk-session-image>',
`expected ${href || '(empty)'} to render without a src`,
);
}
});
test('decodes what marked escaped exactly once, then re-escapes it', () => {
assert.equal(
renderSessionMarkdownImage('http://example.test/a.png?x=1&amp;y=2', '', '&quot;quoted&quot; &amp; &#39;single&#39;'),
'<obelisk-session-image src="http://example.test/a.png?x=1&amp;y=2"'
+ ' alt="&quot;quoted&quot; &amp; \'single\'"></obelisk-session-image>',
);
// A single decoding pass, so text that was literally `&lt;` in the source
// does not decay into a real angle bracket.
assert.equal(
renderSessionMarkdownImage('http://example.test/a.png', '', '&amp;lt;script&amp;gt;'),
'<obelisk-session-image src="http://example.test/a.png"'
+ ' alt="&amp;lt;script&amp;gt;"></obelisk-session-image>',
);
});
test('never lets alt text break out of the attribute', () => {
const html = renderSessionMarkdownImage('http://example.test/a.png', '', '"><img src=x onerror=alert(1)>');
assert.equal(html.includes('onerror=alert(1)>'), false);
assert.equal(
html,
'<obelisk-session-image src="http://example.test/a.png"'
+ ' alt="&quot;&gt;&lt;img src=x onerror=alert(1)&gt;"></obelisk-session-image>',
);
});