feat(app): virtualize session timeline

Render SessionDetail through measured dynamic-height virtual rows while preserving disclosure state, UUID navigation, reader anchoring, and tail-follow across live updates.

Add focused state/reconciliation tests plus a production Electron harness covering long-session DOM bounds, scrolling performance, offscreen navigation, and live viewport stability.
This commit is contained in:
tommy0103
2026-07-14 03:45:39 +08:00
parent bbf16d8f9f
commit 2cad3b554d
14 changed files with 1193 additions and 816 deletions
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createSessionDisclosureState } from '../app/src/renderer/src/session-disclosures.mjs';
test('disclosure state survives virtual row unmounts without depending on DOM nodes', () => {
const disclosures = createSessionDisclosureState();
disclosures.toggleOpen('tool:call-1', 'message-1');
disclosures.toggleRaw('tool:call-1', 'message-1');
assert.equal(disclosures.isOpen('tool:call-1'), true);
assert.equal(disclosures.isRaw('tool:call-1'), true);
disclosures.toggleRaw('tool:call-1', 'message-1');
disclosures.toggleOpen('tool:call-1', 'message-1');
assert.equal(disclosures.isOpen('tool:call-1'), false);
assert.equal(disclosures.isRaw('tool:call-1'), false);
});
test('disclosure state forgets entries owned by removed messages', () => {
const disclosures = createSessionDisclosureState();
disclosures.toggleOpen('tool:call-1', 'message-1');
disclosures.toggleOpen('tool:call-2', 'message-2');
disclosures.retainMessages(new Set(['message-2']));
assert.equal(disclosures.isOpen('tool:call-1'), false);
assert.equal(disclosures.isOpen('tool:call-2'), true);
});
+89
View File
@@ -0,0 +1,89 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createSessionLiveReloadCoordinator } from '../app/src/renderer/src/session-live-reload.mjs';
test('live snapshots coalesce while scrolling and commit once after scroll end', async () => {
let scrolling = true;
let loads = 0;
const commits = [];
const coordinator = createSessionLiveReloadCoordinator({
isScrolling: () => scrolling,
load: async () => ++loads,
commit: async snapshot => { commits.push(snapshot); },
});
await coordinator.request();
await coordinator.request();
await coordinator.request();
assert.equal(loads, 0);
assert.deepEqual(commits, []);
scrolling = false;
await coordinator.flush();
assert.equal(loads, 1);
assert.deepEqual(commits, [1]);
await coordinator.flush();
assert.equal(loads, 1, 'an idle flush without another update is a no-op');
});
test('an update arriving during an in-flight load skips the stale snapshot without overlap', async () => {
let releaseFirstLoad;
let activeLoads = 0;
let maxActiveLoads = 0;
let loads = 0;
const commits = [];
const firstLoadGate = new Promise(resolve => { releaseFirstLoad = resolve; });
const coordinator = createSessionLiveReloadCoordinator({
isScrolling: () => false,
load: async () => {
loads++;
activeLoads++;
maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
if (loads === 1) await firstLoadGate;
activeLoads--;
return loads;
},
commit: async snapshot => { commits.push(snapshot); },
});
const first = coordinator.request();
const second = coordinator.request();
releaseFirstLoad();
await Promise.all([first, second]);
assert.equal(loads, 2);
assert.equal(maxActiveLoads, 1, 'snapshot loads remain serialized');
assert.deepEqual(commits, [2], 'only the freshest loaded snapshot is committed');
});
test('scrolling that starts during IPC defers the loaded snapshot commit', async () => {
let scrolling = false;
let releaseLoad;
let loads = 0;
const commits = [];
const loadGate = new Promise(resolve => { releaseLoad = resolve; });
const coordinator = createSessionLiveReloadCoordinator({
isScrolling: () => scrolling,
load: async () => {
loads++;
await loadGate;
return 'loaded-before-scroll-ended';
},
commit: async snapshot => { commits.push(snapshot); },
});
const request = coordinator.request();
scrolling = true;
releaseLoad();
await request;
assert.equal(loads, 1);
assert.deepEqual(commits, []);
scrolling = false;
await coordinator.flush();
assert.equal(loads, 1, 'the already-loaded snapshot is reused');
assert.deepEqual(commits, ['loaded-before-scroll-ended']);
});
+78
View File
@@ -0,0 +1,78 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { reconcileTimelineItems } from '../app/src/renderer/src/session-timeline-items.mjs';
function message(uuid, overrides = {}) {
return {
uuid,
type: 'assistant',
text: 'message',
tool_calls: [],
...overrides,
};
}
test('timeline items preserve the rendered order and identity of every navigable root', () => {
const messages = [
message('meta', { is_meta: 1 }),
message('workflow', {
text: '',
tool_calls: [
{ id: 'workflow-call', name: 'Workflow', workflow: { workflow_name: 'Build' } },
{ id: 'bash-call', name: 'Bash' },
],
}),
message('skill', {
text: '',
tool_calls: [{ id: 'skill-call', name: 'Skill' }],
}),
message('thinking', { content_type: 'thinking' }),
message('normal'),
];
const items = reconcileTimelineItems([], messages);
assert.deepEqual(items.map(item => ({
key: item.key,
kind: item.kind,
anchorUuid: item.anchorUuid,
messageUuid: item.messageUuid,
})), [
{ key: 'meta:meta', kind: 'meta', anchorUuid: 'meta', messageUuid: 'meta' },
{ key: 'workflow:workflow', kind: 'workflow', anchorUuid: 'workflow', messageUuid: 'workflow' },
{ key: 'workflow-tools:workflow', kind: 'workflow-tools', anchorUuid: 'workflow-tools', messageUuid: 'workflow' },
{ key: 'skill:skill', kind: 'skill', anchorUuid: 'skill', messageUuid: 'skill' },
{ key: 'thinking:thinking', kind: 'thinking', anchorUuid: 'thinking', messageUuid: 'thinking' },
{ key: 'message:normal', kind: 'message', anchorUuid: 'normal', messageUuid: 'normal' },
]);
assert.equal(items[1].workflowCall.id, 'workflow-call');
assert.deepEqual(items[2].toolCalls.map(call => call.id), ['bash-call']);
});
test('snapshot reconciliation reuses unchanged timeline items and replaces only updated roots', () => {
const first = message('first');
const second = message('second');
const initial = reconcileTimelineItems([], [first, second]);
const updatedSecond = { ...second, text: 'updated' };
const reconciled = reconcileTimelineItems(initial, [first, updatedSecond]);
assert.equal(reconciled[0], initial[0]);
assert.notEqual(reconciled[1], initial[1]);
assert.equal(reconciled[1].message, updatedSecond);
});
test('tail appends do not rebuild existing timeline items', () => {
const existingMessages = Array.from({ length: 1000 }, (_, index) => message(`message-${index}`));
const initial = reconcileTimelineItems([], existingMessages);
const appended = reconcileTimelineItems(initial, [
...existingMessages,
message('message-1000'),
]);
assert.equal(appended.length, 1001);
assert.equal(appended[0], initial[0]);
assert.equal(appended[999], initial[999]);
assert.equal(appended[1000].key, 'message:message-1000');
});
@@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const sessionDetail = readFileSync(
new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url),
'utf8',
);
const viewportModule = readFileSync(
new URL('../app/src/renderer/src/session-timeline-viewport.mjs', import.meta.url),
'utf8',
);
const appPackage = JSON.parse(readFileSync(
new URL('../app/package.json', import.meta.url),
'utf8',
));
test('SessionDetail renders a measured virtual window instead of the complete timeline DOM', () => {
assert.match(sessionDetail, /useSessionTimelineViewport/);
assert.match(sessionDetail, /v-for="virtualRow in virtualRows"/);
assert.match(sessionDetail, /:data-index="virtualRow\.index"/);
assert.match(sessionDetail, /:ref="measureElement"/);
assert.doesNotMatch(sessionDetail, /querySelectorAll/);
assert.doesNotMatch(sessionDetail, /v-memo/);
assert.doesNotMatch(sessionDetail, /session-view-state/);
assert.doesNotMatch(sessionDetail, /outerHTML/);
assert.doesNotMatch(sessionDetail, /closest\(['"]\.msg/);
});
test('timeline viewport owns dynamic measurement, overscan, anchoring, and tail-follow', () => {
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
assert.match(viewportModule, /useVirtualizer/);
assert.match(viewportModule, /overscan/);
assert.match(viewportModule, /anchorTo:\s*'end'/);
assert.match(viewportModule, /followOnAppend:\s*followOnAppend\.value/);
assert.match(viewportModule, /resetForInitialSnapshot/);
assert.match(viewportModule, /completeInitialSnapshot/);
assert.doesNotMatch(viewportModule, /followOnAppend:\s*true/);
assert.match(viewportModule, /useAnimationFrameWithResizeObserver:\s*true/);
assert.match(viewportModule, /scrollPaddingEnd/);
assert.match(viewportModule, /scrollToIndex/);
assert.match(viewportModule, /if \(!element\) return/);
});
test('timeline count and disclosure classes come from renderer state rather than DOM state', () => {
assert.match(sessionDetail, /const totalMsgs = computed\(\(\) => timelineItems\.value\.length\)/);
assert.match(sessionDetail, /disclosures\.isOpen/);
assert.match(sessionDetail, /disclosures\.isRaw/);
assert.doesNotMatch(sessionDetail, /function toggleDisclosure[\s\S]{0,200}classList/);
assert.doesNotMatch(sessionDetail, /function toggleRaw[\s\S]{0,200}classList/);
assert.doesNotMatch(sessionDetail, /createSessionDisclosureRegistry/);
});
test('cold startup does not enable append-follow before a real session snapshot exists', () => {
assert.match(sessionDetail, /if \(!latest\) return/);
assert.match(sessionDetail, /timelineViewport\.completeInitialSnapshot\(\)/);
});
-413
View File
@@ -1,413 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
captureSessionViewState,
createSessionDisclosureRegistry,
createSessionDomIndex,
findLastMessageAtOrAbove,
isFollowingSessionTail,
restoreSessionTail,
restoreSessionViewState,
} from '../app/src/renderer/src/session-view-state.mjs';
class FakeClassList {
constructor(classes = []) { this.classes = new Set(classes); }
add(...classes) { for (const value of classes) this.classes.add(value); }
contains(value) { return this.classes.has(value); }
}
function disclosure(key, classes = [], { rawOpen = false } = {}) {
const raw = { classList: new FakeClassList(rawOpen ? ['show'] : []) };
const pretty = { classList: new FakeClassList() };
const button = { classList: new FakeClassList() };
return {
dataset: { viewKey: key },
classList: new FakeClassList(classes),
querySelector(selector) {
if (selector === '.toolcall-raw') return raw;
if (selector === '.toolcall-pretty') return pretty;
if (selector === '.raw-toggle') return button;
return null;
},
raw,
pretty,
button,
};
}
function scrollItem(uuid, top, bottom) {
return {
dataset: { uuid },
getBoundingClientRect: () => ({ top, bottom }),
};
}
function detail(disclosures, scrollItems) {
return {
querySelectorAll(selector) {
if (selector === '[data-view-key]') return disclosures;
if (selector === '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]') return scrollItems;
return [];
},
};
}
function domIndex(items) {
return createSessionDomIndex(detail([], items));
}
function wrap({ scrollTop, scrollHeight, clientHeight, top = 0 }) {
return {
scrollTop,
scrollHeight,
clientHeight,
getBoundingClientRect: () => ({ top }),
};
}
function functionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const signatureEnd = source.indexOf(') {', start);
assert.notEqual(signatureEnd, -1, `${name} should have a function body`);
const bodyStart = signatureEnd + 2;
let depth = 0;
for (let index = bodyStart; index < source.length; index++) {
if (source[index] === '{') depth++;
if (source[index] === '}') depth--;
if (depth === 0) return source.slice(start, index + 1);
}
assert.fail(`${name} should have a complete function body`);
}
test('session refresh restores the visible scroll anchor from cached DOM indexes', () => {
const oldWrap = wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({
wrap: oldWrap,
domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
});
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
});
assert.equal(newWrap.scrollTop, 600, '100 px inserted above the anchor is compensated');
});
test('session refresh falls back to the owning message when its rendered root changes', () => {
const oldRoot = scrollItem('message-1-tools', -20, 180);
oldRoot.dataset.messageUuid = 'message-1';
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
domIndex: domIndex([oldRoot]),
});
const newRoot = scrollItem('message-1', 80, 280);
newRoot.dataset.messageUuid = 'message-1';
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
domIndex: domIndex([newRoot]),
});
assert.equal(snapshot.anchor.messageUuid, 'message-1');
assert.equal(newWrap.scrollTop, 600, 'message identity preserves the anchor across render shapes');
});
test('session refresh follows appended content only when already at the tail', () => {
const oldWrap = wrap({ scrollTop: 1390, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({
wrap: oldWrap,
domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
});
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2400, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
});
assert.equal(newWrap.scrollTop, 2400);
});
test('tail-follow detection uses only the scroll container metrics', () => {
assert.equal(isFollowingSessionTail(wrap({
scrollTop: 1360,
scrollHeight: 2000,
clientHeight: 600,
})), true);
assert.equal(isFollowingSessionTail(wrap({
scrollTop: 1200,
scrollHeight: 2000,
clientHeight: 600,
})), false);
});
test('tail append preserves an active reader and follows only without newer scroll input', () => {
const reader = wrap({ scrollTop: 600, scrollHeight: 2400, clientHeight: 600 });
restoreSessionTail({ wrap: reader, followTail: false });
assert.equal(reader.scrollTop, 600);
const userMoved = wrap({ scrollTop: 800, scrollHeight: 2400, clientHeight: 600 });
restoreSessionTail({ wrap: userMoved, followTail: true, restoreScroll: false });
assert.equal(userMoved.scrollTop, 800);
const follower = wrap({ scrollTop: 1400, scrollHeight: 2400, clientHeight: 600 });
restoreSessionTail({ wrap: follower, followTail: true });
assert.equal(follower.scrollTop, 2400);
});
test('session refresh never restores an old anchor over newer user scrolling', () => {
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
});
const userScrolledWrap = wrap({ scrollTop: 800, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: userScrolledWrap,
domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
restoreScroll: false,
});
assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state');
});
test('scroll progress locates the visible message without scanning the full session', () => {
let layoutReads = 0;
const messages = Array.from({ length: 2048 }, (_, index) => ({
getBoundingClientRect() {
layoutReads++;
return { bottom: (index + 1) * 20 };
},
}));
assert.equal(findLastMessageAtOrAbove(messages, 20100), 1004);
assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`);
});
test('view-state capture locates its anchor logarithmically from the DOM index', () => {
let layoutReads = 0;
const items = Array.from({ length: 4096 }, (_, index) => ({
dataset: { uuid: `message-${index}` },
getBoundingClientRect() {
layoutReads++;
return { top: index * 20, bottom: (index + 1) * 20 };
},
}));
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 20000, scrollHeight: 90000, clientHeight: 600, top: 30000 }),
domIndex: {
items,
byUuid: new Map(items.map(item => [item.dataset.uuid, item])),
byMessageUuid: new Map(),
},
});
assert.equal(snapshot.anchor.uuid, 'message-1500');
assert.ok(layoutReads < 20, `expected logarithmic anchor reads, got ${layoutReads}`);
});
test('session DOM index scans the timeline once and groups roots by message UUID', () => {
let queries = 0;
const first = scrollItem('render-1', 0, 20);
first.dataset.messageUuid = 'message-1';
const second = scrollItem('render-2', 20, 40);
second.dataset.messageUuid = 'message-2';
const secondTools = scrollItem('render-2-tools', 40, 60);
secondTools.dataset.messageUuid = 'message-2';
const index = createSessionDomIndex({
querySelectorAll(selector) {
queries++;
assert.equal(selector, '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]');
return [first, second, secondTools];
},
});
assert.equal(queries, 1);
assert.deepEqual(index.items, [first, second, secondTools]);
assert.equal(index.byUuid.get('render-2-tools'), secondTools);
assert.deepEqual(index.byMessageUuid.get('message-2'), [second, secondTools]);
findLastMessageAtOrAbove(index.items, 35);
findLastMessageAtOrAbove(index.items, 55);
assert.equal(queries, 1, 'scroll reads reuse the index instead of querying the DOM');
});
test('disclosure registry restores only message roots replaced by the snapshot', () => {
const previous = disclosure('tool:call-1', ['open'], { rawOpen: true });
previous.closest = selector => selector === '[data-message-uuid]'
? { dataset: { messageUuid: 'message-1' } }
: null;
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
const replacement = disclosure('tool:call-1');
const updatedRoot = {
matches: () => false,
querySelectorAll(selector) {
assert.equal(selector, '[data-view-key]');
return [replacement];
},
};
const untouchedRoot = {
matches: () => false,
querySelectorAll() {
assert.fail('unchanged message roots must not be scanned');
},
};
registry.reconcile({
byMessageUuid: new Map([
['message-1', [updatedRoot]],
['message-2', [untouchedRoot]],
]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('open'), true);
assert.equal(replacement.raw.classList.contains('show'), true);
assert.equal(replacement.pretty.classList.contains('hidden'), true);
assert.equal(replacement.button.classList.contains('active'), true);
});
test('removed messages discard their remembered disclosure state', () => {
const previous = disclosure('tool:call-1', ['open']);
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
registry.reconcile({ byMessageUuid: new Map() }, {
updatedIds: [],
removedIds: ['message-1'],
});
const replacement = disclosure('tool:call-1');
registry.reconcile({
byMessageUuid: new Map([['message-1', [{
matches: () => false,
querySelectorAll: () => [replacement],
}]]]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('open'), false);
});
test('disclosure registry restores root-level skill cards', () => {
const previous = disclosure('skill:message-1', ['skill-md-open']);
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
const replacement = disclosure('skill:message-1');
replacement.matches = selector => selector === '[data-view-key]';
replacement.dataset.messageUuid = 'message-1';
registry.reconcile({
byMessageUuid: new Map([['message-1', [replacement]]]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('skill-md-open'), true);
});
test('view-state capture and restore do not query the full DOM', () => {
const source = readFileSync(new URL('../app/src/renderer/src/session-view-state.mjs', import.meta.url), 'utf8');
const capture = functionSource(source, 'captureSessionViewState');
const restore = functionSource(source, 'restoreSessionViewState');
assert.doesNotMatch(capture, /querySelectorAll|scrollItems\(|\bdetail\b/);
assert.doesNotMatch(restore, /querySelectorAll|scrollItems\(|\bdetail\b/);
});
test('SessionDetail isolates unchanged rows and gives tail appends a scan-free path', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const dataSource = readFileSync(new URL('../app/src/renderer/src/data.js', import.meta.url), 'utf8');
const loadMessages = functionSource(source, 'loadMessages');
const getSkillMd = functionSource(source, 'getSkillMd');
const toggleDisclosure = functionSource(source, 'toggleDisclosure');
assert.match(source, /import\s*\{[^}]*shallowRef[^}]*\}\s*from ['"]vue['"]/s);
assert.match(source, /const messages = shallowRef\(\[\]\)/);
assert.match(source, /applySnapshot/);
assert.match(source, /isFollowingSessionTail/);
assert.match(source, /captureSessionViewState/);
assert.match(source, /createSessionDomIndex/);
assert.match(source, /createSessionDisclosureRegistry/);
assert.match(source, /restoreSessionViewState/);
assert.match(source, /class="timeline"\s+v-memo="\[messages, state\.query\]"/);
assert.match(source, /:key="msg\.uuid"\s+v-memo=/);
assert.match(source, /v-memo="\[msg, state\.query\]"/);
assert.match(source, /loading\.value\s*=\s*!hadContent/);
assert.match(source, /scrollRevision/);
assert.match(source, /restoreScroll:\s*scrollRevision\s*===\s*scrollRevisionBeforePatch/);
assert.match(source, /requestAnimationFrame/);
assert.match(source, /findLastMessageAtOrAbove/);
assert.match(loadMessages, /if \(hadContent && !reconciliation\.tailOnly\)/);
assert.match(loadMessages, /domIndex:\s*sessionDomIndex/);
assert.match(loadMessages, /disclosureRegistry\.reconcile\(sessionDomIndex, reconciliation\)/);
assert.match(loadMessages, /if \(!reconciliation\.changed\)/);
assert.ok(
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('captureSessionViewState('),
'the expensive disclosure and anchor scan happens only after classifying the snapshot',
);
assert.ok(
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('isFollowingSessionTail('),
'tail state is sampled immediately before the patch, after the async snapshot load',
);
assert.ok(
loadMessages.indexOf('isFollowingSessionTail(') < loadMessages.indexOf('messages.value = reconciliation.messages'),
'tail state is sampled before assigning the new timeline',
);
assert.ok(
loadMessages.indexOf('if (!reconciliation.changed)') < loadMessages.indexOf('await nextTick()'),
'a no-op snapshot returns before awaiting a timeline patch',
);
assert.doesNotMatch(getSkillMd, /messages\.value/);
assert.match(source, /getSkillMd\(msg\)/);
assert.match(toggleDisclosure, /disclosureRegistry\.remember\(element\)/);
assert.ok(
(source.match(/:data-message-uuid="msg\.uuid"/g) || []).length >= 6,
'every timeline root identifies its owning message for targeted disclosure restore',
);
assert.match(dataSource, /messages:\s*markRaw\(assembledMessages\)/);
});
test('live totals and scroll position remain isolated across interleaved updates', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const loadMessages = functionSource(source, 'loadMessages');
const syncTimelineDom = functionSource(source, 'syncTimelineDom');
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
const navTo = functionSource(source, 'navTo');
assert.match(loadMessages, /await nextTick\(\);[\s\S]*syncTimelineDom\(\)/);
assert.match(syncTimelineDom, /createSessionDomIndex\(detailRef\.value\)/);
assert.match(syncTimelineDom, /totalMsgs\.value\s*=/);
assert.doesNotMatch(syncTimelineDom, /currentMsgIdx\.value\s*=/);
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /querySelectorAll/);
assert.doesNotMatch(navTo, /querySelectorAll/);
});
test('message navigation keeps the top progress bar aligned with the current position', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const setMessagePosition = functionSource(source, 'setMessagePosition');
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
const navTo = functionSource(source, 'navTo');
assert.match(setMessagePosition, /currentMsgIdx\.value\s*=\s*index/);
assert.match(setMessagePosition, /progressPct\.value\s*=/);
assert.match(updateScrollProgress, /setMessagePosition\(bottomMsgIdx,\s*msgs\.length\)/);
assert.match(navTo, /setMessagePosition\(idx,\s*msgs\.length\)/);
});