fix(app): preserve session reader state across navigation
Cache semantic timeline anchors and disclosures per session instead of relying on KeepAlive. Restore state after virtualized layout stabilization while preserving explicit focus and tail-follow behavior.
This commit is contained in:
@@ -29,3 +29,20 @@ test('disclosure state forgets entries owned by removed messages', () => {
|
||||
assert.equal(disclosures.isOpen('tool:call-1'), false);
|
||||
assert.equal(disclosures.isOpen('tool:call-2'), true);
|
||||
});
|
||||
|
||||
test('disclosure state restores a serializable snapshot for retained messages', () => {
|
||||
const source = createSessionDisclosureState();
|
||||
source.toggleOpen('tool:call-1', 'message-1');
|
||||
source.toggleRaw('tool:call-1', 'message-1');
|
||||
source.toggleOpen('tool:call-2', 'message-2');
|
||||
|
||||
const restored = createSessionDisclosureState();
|
||||
restored.restore(source.snapshot(), new Set(['message-1']));
|
||||
|
||||
assert.equal(restored.isOpen('tool:call-1'), true);
|
||||
assert.equal(restored.isRaw('tool:call-1'), true);
|
||||
assert.equal(restored.isOpen('tool:call-2'), false);
|
||||
assert.deepEqual(restored.snapshot(), [
|
||||
{ key: 'tool:call-1', messageUuid: 'message-1', open: true, raw: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createSessionReaderStateCache } from '../app/src/renderer/src/session-reader-state.mjs';
|
||||
import { resolveReaderAnchorIndex } from '../app/src/renderer/src/session-timeline-viewport.mjs';
|
||||
|
||||
test('reader state cache stores only semantic state in independent session snapshots', () => {
|
||||
const cache = createSessionReaderStateCache({ maxEntries: 4 });
|
||||
|
||||
cache.set('session-a', {
|
||||
mode: 'anchor',
|
||||
anchor: {
|
||||
itemKey: 'message:a-120',
|
||||
messageUuid: 'a-120',
|
||||
offset: 18,
|
||||
fallbackIndex: 120,
|
||||
},
|
||||
disclosures: [{ key: 'tool:a-call', messageUuid: 'a-120', open: true, raw: false }],
|
||||
expandedMessageIds: ['a-120'],
|
||||
scrollTop: 98_000,
|
||||
currentMsgIdx: 120,
|
||||
});
|
||||
|
||||
const restored = cache.get('session-a');
|
||||
assert.deepEqual(restored, {
|
||||
mode: 'anchor',
|
||||
anchor: {
|
||||
itemKey: 'message:a-120',
|
||||
messageUuid: 'a-120',
|
||||
offset: 18,
|
||||
fallbackIndex: 120,
|
||||
},
|
||||
disclosures: [{ key: 'tool:a-call', messageUuid: 'a-120', open: true, raw: false }],
|
||||
expandedMessageIds: ['a-120'],
|
||||
});
|
||||
assert.equal(cache.get('session-b'), null);
|
||||
|
||||
restored.anchor.offset = 999;
|
||||
restored.disclosures[0].open = false;
|
||||
restored.expandedMessageIds.push('mutated');
|
||||
assert.equal(cache.get('session-a').anchor.offset, 18);
|
||||
assert.equal(cache.get('session-a').disclosures[0].open, true);
|
||||
assert.deepEqual(cache.get('session-a').expandedMessageIds, ['a-120']);
|
||||
});
|
||||
|
||||
test('reader state cache evicts the least recently used session', () => {
|
||||
const cache = createSessionReaderStateCache({ maxEntries: 2 });
|
||||
const state = messageUuid => ({
|
||||
mode: 'anchor',
|
||||
anchor: { itemKey: `message:${messageUuid}`, messageUuid, offset: 0, fallbackIndex: 0 },
|
||||
});
|
||||
|
||||
cache.set('session-a', state('a'));
|
||||
cache.set('session-b', state('b'));
|
||||
cache.get('session-a');
|
||||
cache.set('session-c', state('c'));
|
||||
|
||||
assert.equal(cache.get('session-b'), null);
|
||||
assert.equal(cache.get('session-a').anchor.messageUuid, 'a');
|
||||
assert.equal(cache.get('session-c').anchor.messageUuid, 'c');
|
||||
});
|
||||
|
||||
test('reader anchors resolve by stable identity before falling back to position', () => {
|
||||
const items = [
|
||||
{ key: 'message:first', messageUuid: 'first' },
|
||||
{ key: 'workflow:shared', messageUuid: 'shared' },
|
||||
{ key: 'message:shared', messageUuid: 'shared' },
|
||||
{ key: 'message:last', messageUuid: 'last' },
|
||||
];
|
||||
|
||||
assert.equal(resolveReaderAnchorIndex({
|
||||
itemKey: 'message:shared',
|
||||
messageUuid: 'shared',
|
||||
fallbackIndex: 0,
|
||||
}, items), 2);
|
||||
assert.equal(resolveReaderAnchorIndex({
|
||||
itemKey: 'missing',
|
||||
messageUuid: 'shared',
|
||||
fallbackIndex: 0,
|
||||
}, items), 1);
|
||||
assert.equal(resolveReaderAnchorIndex({
|
||||
itemKey: 'missing',
|
||||
messageUuid: 'missing',
|
||||
fallbackIndex: 99,
|
||||
}, items), 3);
|
||||
assert.equal(resolveReaderAnchorIndex(null, items), 0);
|
||||
assert.equal(resolveReaderAnchorIndex(null, []), null);
|
||||
});
|
||||
@@ -46,7 +46,6 @@ test('timeline viewport owns measurement and anchoring while SessionDetail alone
|
||||
assert.match(viewportModule, /rangeExtractor/);
|
||||
assert.match(viewportModule, /anchorTo:\s*'end'/);
|
||||
assert.match(viewportModule, /followOnAppend:\s*false/);
|
||||
assert.match(viewportModule, /resetForInitialSnapshot/);
|
||||
assert.match(viewportModule, /completeInitialSnapshot/);
|
||||
assert.match(viewportModule, /scrollToFn:\s*scrollPolicy\.scrollToFn/);
|
||||
assert.match(viewportModule, /useScrollendEvent:\s*true/);
|
||||
|
||||
Reference in New Issue
Block a user