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:
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user