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:
tommy0103
2026-07-15 22:12:40 +08:00
parent 5294d76f07
commit 1e4e5ec2d8
16 changed files with 906 additions and 72 deletions
+122
View File
@@ -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']);
});
+35 -9
View File
@@ -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: [] })));
+24 -1
View File
@@ -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\)/);
});
+5 -1
View File
@@ -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);