fix(app): isolate live session updates from scrolling
Defer and coalesce global catalogue refreshes while SessionDetail is active, carry session metadata through incremental patches, and gate in-flight catalogue commits by route. Harden the virtual timeline cold-open and user-scroll lifecycle, and add Electron frame, continuity, metadata, and overlap regressions.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createGlobalDataRefreshCoordinator } from '../app/src/renderer/src/session-global-refresh.mjs';
|
||||
|
||||
test('conversation detail defers and coalesces global catalogue invalidations until route exit', async () => {
|
||||
let conversationDetailActive = true;
|
||||
let loads = 0;
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => conversationDetailActive,
|
||||
load: async () => ++loads,
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
await coordinator.invalidate();
|
||||
await coordinator.invalidate();
|
||||
await coordinator.invalidate();
|
||||
assert.equal(loads, 0, 'detail updates never start a global catalogue IPC');
|
||||
|
||||
conversationDetailActive = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1, 'route exit loads the latest invalidation exactly once');
|
||||
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1, 'an idle route flush is a no-op');
|
||||
});
|
||||
|
||||
test('initial catalogue load is allowed on a cold conversation route', async () => {
|
||||
let loads = 0;
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => true,
|
||||
load: async () => ++loads,
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
await coordinator.initialize();
|
||||
assert.equal(loads, 1, 'deep links still receive the catalogue needed to resolve the session');
|
||||
|
||||
await coordinator.invalidate();
|
||||
assert.equal(loads, 1, 'later daemon invalidations remain deferred');
|
||||
});
|
||||
|
||||
test('an invalidation arriving during a load is retained without overlapping loads', async () => {
|
||||
let deferred = false;
|
||||
let loads = 0;
|
||||
let activeLoads = 0;
|
||||
let maxActiveLoads = 0;
|
||||
let releaseFirstLoad;
|
||||
const firstLoadGate = new Promise(resolve => { releaseFirstLoad = resolve; });
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => deferred,
|
||||
load: async () => {
|
||||
loads++;
|
||||
activeLoads++;
|
||||
maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
|
||||
if (loads === 1) await firstLoadGate;
|
||||
activeLoads--;
|
||||
return loads;
|
||||
},
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
const first = coordinator.invalidate();
|
||||
const second = coordinator.invalidate();
|
||||
deferred = true;
|
||||
releaseFirstLoad();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
assert.equal(loads, 1, 'detail activation prevents the queued reload');
|
||||
assert.equal(maxActiveLoads, 1, 'global snapshots never overlap');
|
||||
|
||||
deferred = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 2, 'route exit catches up with the retained invalidation');
|
||||
});
|
||||
|
||||
test('a failed catalogue load remains dirty for the next flush', async () => {
|
||||
let loads = 0;
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => false,
|
||||
load: async () => {
|
||||
loads++;
|
||||
if (loads === 1) throw new Error('temporary IPC failure');
|
||||
return loads;
|
||||
},
|
||||
commit: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(coordinator.invalidate(), /temporary IPC failure/);
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 2, 'the failed invalidation is retried instead of being lost');
|
||||
});
|
||||
|
||||
test('a catalogue fetched before navigation never commits inside conversation detail', async () => {
|
||||
let deferred = false;
|
||||
let loads = 0;
|
||||
let releaseLoad;
|
||||
const loadGate = new Promise(resolve => { releaseLoad = resolve; });
|
||||
const commits = [];
|
||||
const coordinator = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => deferred,
|
||||
load: async () => {
|
||||
loads++;
|
||||
await loadGate;
|
||||
return 'catalogue-snapshot';
|
||||
},
|
||||
commit: snapshot => { commits.push(snapshot); },
|
||||
});
|
||||
|
||||
const request = coordinator.invalidate();
|
||||
deferred = true;
|
||||
releaseLoad();
|
||||
await request;
|
||||
|
||||
assert.equal(loads, 1);
|
||||
assert.deepEqual(commits, [], 'route activation gates the reactive commit after IPC resolves');
|
||||
|
||||
deferred = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 1, 'the already-fetched snapshot is reused');
|
||||
assert.deepEqual(commits, ['catalogue-snapshot']);
|
||||
});
|
||||
@@ -4,14 +4,15 @@ import assert from 'node:assert/strict';
|
||||
import { createSessionLiveReloadCoordinator } from '../app/src/renderer/src/session-live-reload.mjs';
|
||||
import { state } from '../app/src/renderer/src/store.js';
|
||||
import {
|
||||
fetchSessionDetailPatch,
|
||||
getCachedSessionDetail,
|
||||
loadSessionDetail,
|
||||
loadSessionDetailPatch,
|
||||
materializeSessionDetailPatch,
|
||||
} from '../app/src/renderer/src/data.js';
|
||||
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
|
||||
import { createSessionPatch } from '../app/src/shared/session-patch.mjs';
|
||||
|
||||
test('live snapshots keep loading while scrolling and commit only the latest after scroll end', async () => {
|
||||
test('live updates coalesce while scrolling and load only the latest after scroll end', async () => {
|
||||
let scrolling = true;
|
||||
let loads = 0;
|
||||
const commits = [];
|
||||
@@ -24,16 +25,16 @@ test('live snapshots keep loading while scrolling and commit only the latest aft
|
||||
await coordinator.request();
|
||||
await coordinator.request();
|
||||
await coordinator.request();
|
||||
assert.equal(loads, 3, 'patches are loaded into the pending snapshot while the timeline is frozen');
|
||||
assert.equal(loads, 0, 'patch preparation stays off the scrolling renderer task budget');
|
||||
assert.deepEqual(commits, []);
|
||||
|
||||
scrolling = false;
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 3, 'scroll end reuses the freshest pending snapshot');
|
||||
assert.deepEqual(commits, [3]);
|
||||
assert.equal(loads, 1, 'scroll end loads the latest coalesced state once');
|
||||
assert.deepEqual(commits, [1]);
|
||||
|
||||
await coordinator.flush();
|
||||
assert.equal(loads, 3, 'an idle flush without another update is a no-op');
|
||||
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 () => {
|
||||
@@ -101,6 +102,7 @@ test('a skipped live patch does not advance the visible patch baseline', async t
|
||||
const previousSessions = state.sessions;
|
||||
t.after(() => {
|
||||
state.sessions = previousSessions;
|
||||
state.sessionTitleOverrides.delete(sessionId);
|
||||
delete globalThis.window;
|
||||
});
|
||||
let rows = [
|
||||
@@ -133,21 +135,30 @@ test('a skipped live patch does not advance the visible patch baseline', async t
|
||||
firstPatchStarted();
|
||||
await firstPatchGate;
|
||||
}
|
||||
return createSessionPatch(snapshotAtCall, cursor);
|
||||
return {
|
||||
...createSessionPatch(snapshotAtCall, cursor),
|
||||
session: {
|
||||
id: sessionId,
|
||||
title: 'Live session title',
|
||||
message_count: rows.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
state.sessions = [{ id: sessionId, messages: [] }];
|
||||
state.sessions = [{ id: sessionId, title: 'Initial title', message_count: 1, messages: [] }];
|
||||
await loadSessionDetail(sessionId);
|
||||
|
||||
const commits = [];
|
||||
const coordinator = createSessionLiveReloadCoordinator({
|
||||
isScrolling: () => false,
|
||||
load: () => loadSessionDetailPatch(sessionId),
|
||||
load: async () => materializeSessionDetailPatch(await fetchSessionDetailPatch(sessionId)),
|
||||
commit: async latest => {
|
||||
commits.push({
|
||||
messages: latest.messages.map(message => message.uuid),
|
||||
changedIds: latest.messagePatch.changedIds,
|
||||
title: latest.title,
|
||||
messageCount: latest.message_count,
|
||||
});
|
||||
latest.acceptMessagePatch?.();
|
||||
},
|
||||
@@ -164,17 +175,32 @@ test('a skipped live patch does not advance the visible patch baseline', async t
|
||||
assert.deepEqual(commits, [{
|
||||
messages: ['message-1', 'message-2', 'message-3'],
|
||||
changedIds: ['message-2', 'message-3'],
|
||||
title: 'Live session title',
|
||||
messageCount: 3,
|
||||
}]);
|
||||
assert.deepEqual(
|
||||
getCachedSessionDetail(sessionId).messages.map(message => message.uuid),
|
||||
['message-1', 'message-2', 'message-3'],
|
||||
'accepted patches become the reusable session-detail snapshot',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
title: getCachedSessionDetail(sessionId).title,
|
||||
messageCount: getCachedSessionDetail(sessionId).message_count,
|
||||
},
|
||||
{ title: 'Live session title', messageCount: 3 },
|
||||
'accepted patches retain the current session metadata without a global catalogue reload',
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.sessions.find(session => session.id === sessionId).messages,
|
||||
[],
|
||||
'the stale full-snapshot copy is invalidated after patch acceptance',
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.sessionTitleOverrides.get(sessionId),
|
||||
'Live session title',
|
||||
'accepted title changes update the shared breadcrumb/window-title overlay after the visible commit',
|
||||
);
|
||||
|
||||
const evictionSessionIds = ['eviction-session-1', 'eviction-session-2', 'eviction-session-3'];
|
||||
state.sessions.push(...evictionSessionIds.map(id => ({ id, messages: [] })));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createViewportRangeExtractor } from '../app/src/renderer/src/session-timeline-viewport.mjs';
|
||||
|
||||
const sessionDetail = readFileSync(
|
||||
new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url),
|
||||
@@ -42,6 +43,7 @@ test('timeline viewport owns measurement and anchoring while SessionDetail alone
|
||||
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
|
||||
assert.match(viewportModule, /useVirtualizer/);
|
||||
assert.match(viewportModule, /overscan/);
|
||||
assert.match(viewportModule, /rangeExtractor/);
|
||||
assert.match(viewportModule, /anchorTo:\s*'end'/);
|
||||
assert.match(viewportModule, /followOnAppend:\s*false/);
|
||||
assert.match(viewportModule, /resetForInitialSnapshot/);
|
||||
@@ -62,6 +64,26 @@ test('timeline viewport owns measurement and anchoring while SessionDetail alone
|
||||
);
|
||||
});
|
||||
|
||||
test('timeline viewport buffers by rendered pixels instead of a fixed row count', () => {
|
||||
const rangeExtractor = createViewportRangeExtractor({
|
||||
getScrollElement: () => ({ clientHeight: 700, scrollTop: 5000 }),
|
||||
getVirtualizer: () => ({
|
||||
getVirtualItemForOffset: offset => ({ index: Math.floor(offset / 50) }),
|
||||
}),
|
||||
});
|
||||
|
||||
const indexes = rangeExtractor({
|
||||
startIndex: 100,
|
||||
endIndex: 113,
|
||||
overscan: 6,
|
||||
count: 1000,
|
||||
});
|
||||
|
||||
assert.equal(indexes[0], 44);
|
||||
assert.equal(indexes.at(-1), 170);
|
||||
assert.equal(indexes.length, 127);
|
||||
});
|
||||
|
||||
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(timelineRow, /disclosures\.isOpen/);
|
||||
@@ -90,9 +112,10 @@ test('live patch state advances only after the visible snapshot commit is accept
|
||||
const commitLiveSnapshot = sessionDetail.match(/async function commitLiveSnapshot\(snapshot\) \{([\s\S]*?)\n\}/)?.[1] || '';
|
||||
|
||||
assert.doesNotMatch(loadLiveSnapshot, /clearSessionDirty|acceptMessagePatch/);
|
||||
assert.match(loadLiveSnapshot, /fetchSessionDetailPatch\(sessionId\)/);
|
||||
assert.match(
|
||||
commitLiveSnapshot,
|
||||
/await commitSessionSnapshot\(snapshot\.latest\);[\s\S]*acceptMessagePatch[\s\S]*clearSessionDirty/,
|
||||
/materializeSessionDetailPatch\(snapshot\.patchRequest\);[\s\S]*await commitSessionSnapshot\(latest\);[\s\S]*acceptMessagePatch[\s\S]*clearSessionDirty/,
|
||||
);
|
||||
assert.match(commitLiveSnapshot, /markSessionDirty\(snapshot\.sessionId\)/);
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ function dispatch(target, type, properties = {}) {
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', () => {
|
||||
test('native scrollend ends a user scroll after a short wheel-burst grace period', () => {
|
||||
const scheduler = createScheduler();
|
||||
const target = new EventTarget();
|
||||
target.scrollTop = 100;
|
||||
@@ -46,6 +46,7 @@ test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', ()
|
||||
let ended = 0;
|
||||
const userScroll = createSessionUserScroll({
|
||||
quietMs: 450,
|
||||
scrollEndGraceMs: 100,
|
||||
setTimeout: scheduler.setTimeout,
|
||||
clearTimeout: scheduler.clearTimeout,
|
||||
onEnd: () => { ended++; },
|
||||
@@ -61,6 +62,9 @@ test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', ()
|
||||
assert.equal(ended, 0);
|
||||
|
||||
dispatch(target, 'scrollend');
|
||||
scheduler.advance(99);
|
||||
assert.equal(userScroll.isActive(), true, 'a following wheel packet can retain scroll ownership');
|
||||
scheduler.advance(1);
|
||||
assert.equal(userScroll.isActive(), false);
|
||||
assert.equal(ended, 1);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user