From d9f31bbe8aad04942fe5942d0ee09d6835db865c Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Thu, 16 Jul 2026 23:08:10 +0800 Subject: [PATCH] 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. --- app/package.json | 3 +- app/src/renderer/src/App.vue | 10 +- app/src/renderer/src/router.js | 3 +- app/src/renderer/src/session-disclosures.mjs | 31 ++ app/src/renderer/src/session-reader-state.mjs | 59 +++ .../src/session-timeline-viewport.mjs | 78 +++- app/src/renderer/src/store.js | 1 - app/src/renderer/src/views/SessionDetail.vue | 123 +++---- app/tests/electron-session-reader-state.mjs | 342 ++++++++++++++++++ tests/session-disclosures.test.mjs | 17 + tests/session-reader-state.test.mjs | 88 +++++ .../session-timeline-virtualization.test.mjs | 1 - 12 files changed, 672 insertions(+), 84 deletions(-) create mode 100644 app/src/renderer/src/session-reader-state.mjs create mode 100644 app/tests/electron-session-reader-state.mjs create mode 100644 tests/session-reader-state.test.mjs diff --git a/app/package.json b/app/package.json index 994c627..7b7c4a3 100644 --- a/app/package.json +++ b/app/package.json @@ -13,7 +13,8 @@ "dist:win": "electron-vite build && electron-builder --win", "dist:linux": "electron-vite build && electron-builder --linux", "test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs", - "test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs" + "test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs", + "test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs" }, "build": { "appId": "com.obelisk.app", diff --git a/app/src/renderer/src/App.vue b/app/src/renderer/src/App.vue index 29e8c05..a6689fd 100644 --- a/app/src/renderer/src/App.vue +++ b/app/src/renderer/src/App.vue @@ -198,9 +198,6 @@ onUnmounted(() => { clearTimeout(searchTimer); }); -// --- Keep-alive includes --- -const keepAliveIncludes = ['SessionDetail']; - const isExportRoute = computed(() => route.name === 'RecapExport'); // --- Source health dots --- @@ -583,9 +580,10 @@ provide('recapGenerateOpen', recapGenerateOpen); - - - + diff --git a/app/src/renderer/src/router.js b/app/src/renderer/src/router.js index 6455d76..8adc31e 100644 --- a/app/src/renderer/src/router.js +++ b/app/src/renderer/src/router.js @@ -24,8 +24,7 @@ const routes = [ path: '/sessions/:id', name: 'SessionDetail', component: SessionDetail, - props: true, - meta: { keepAlive: true } + props: true }, { path: '/sessions/:id/agent/:agentId', diff --git a/app/src/renderer/src/session-disclosures.mjs b/app/src/renderer/src/session-disclosures.mjs index d34bdfe..a3ce2a3 100644 --- a/app/src/renderer/src/session-disclosures.mjs +++ b/app/src/renderer/src/session-disclosures.mjs @@ -1,5 +1,23 @@ import { reactive } from 'vue'; +export function normalizeSessionDisclosureSnapshot(snapshot, messageUuids = null) { + if (!Array.isArray(snapshot)) return []; + return snapshot + .filter(entry => ( + entry + && typeof entry.key === 'string' + && typeof entry.messageUuid === 'string' + && (!messageUuids || messageUuids.has(entry.messageUuid)) + )) + .map(entry => ({ + key: entry.key, + messageUuid: entry.messageUuid, + open: entry.open === true, + raw: entry.raw === true, + })) + .filter(entry => entry.open || entry.raw); +} + export function createSessionDisclosureState() { const entries = reactive(new Map()); @@ -28,5 +46,18 @@ export function createSessionDisclosureState() { if (!messageUuids.has(entry.messageUuid)) entries.delete(key); } }, + snapshot() { + return [...entries].map(([key, entry]) => ({ key, ...entry })); + }, + restore(snapshot, messageUuids = null) { + entries.clear(); + for (const { key, ...entry } of normalizeSessionDisclosureSnapshot(snapshot, messageUuids)) { + entries.set(key, { + messageUuid: entry.messageUuid, + open: entry.open, + raw: entry.raw, + }); + } + }, }; } diff --git a/app/src/renderer/src/session-reader-state.mjs b/app/src/renderer/src/session-reader-state.mjs new file mode 100644 index 0000000..f0c2e64 --- /dev/null +++ b/app/src/renderer/src/session-reader-state.mjs @@ -0,0 +1,59 @@ +import { normalizeSessionDisclosureSnapshot } from './session-disclosures.mjs'; + +function normalizeAnchor(anchor) { + if (!anchor || typeof anchor !== 'object') return null; + return { + itemKey: typeof anchor.itemKey === 'string' ? anchor.itemKey : null, + messageUuid: typeof anchor.messageUuid === 'string' ? anchor.messageUuid : null, + offset: Number.isFinite(anchor.offset) ? anchor.offset : 0, + fallbackIndex: Number.isInteger(anchor.fallbackIndex) ? anchor.fallbackIndex : 0, + }; +} + +function normalizeReaderState(state) { + const mode = state?.mode === 'tail' ? 'tail' : 'anchor'; + return { + mode, + anchor: mode === 'anchor' ? normalizeAnchor(state?.anchor) : null, + disclosures: normalizeSessionDisclosureSnapshot(state?.disclosures), + expandedMessageIds: Array.isArray(state?.expandedMessageIds) + ? [...new Set(state.expandedMessageIds.filter(id => typeof id === 'string'))] + : [], + }; +} + +function cloneReaderState(state) { + return { + mode: state.mode, + anchor: state.anchor ? { ...state.anchor } : null, + disclosures: state.disclosures.map(entry => ({ ...entry })), + expandedMessageIds: [...state.expandedMessageIds], + }; +} + +export function createSessionReaderStateCache({ maxEntries = 12 } = {}) { + if (!Number.isInteger(maxEntries) || maxEntries < 1) { + throw new Error('Session reader state cache requires maxEntries >= 1'); + } + const entries = new Map(); + + return { + get(sessionId) { + if (!entries.has(sessionId)) return null; + const state = entries.get(sessionId); + entries.delete(sessionId); + entries.set(sessionId, state); + return cloneReaderState(state); + }, + set(sessionId, state) { + if (!sessionId) return; + entries.delete(sessionId); + entries.set(sessionId, normalizeReaderState(state)); + while (entries.size > maxEntries) { + entries.delete(entries.keys().next().value); + } + }, + }; +} + +export const sessionReaderStateCache = createSessionReaderStateCache(); diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs index fc152f7..0b2b423 100644 --- a/app/src/renderer/src/session-timeline-viewport.mjs +++ b/app/src/renderer/src/session-timeline-viewport.mjs @@ -57,6 +57,20 @@ export function createViewportRangeExtractor({ }; } +export function resolveReaderAnchorIndex(anchor, items = []) { + if (!items.length) return null; + if (anchor?.itemKey) { + const itemIndex = items.findIndex(item => item?.key === anchor.itemKey); + if (itemIndex >= 0) return itemIndex; + } + if (anchor?.messageUuid) { + const messageIndex = items.findIndex(item => item?.messageUuid === anchor.messageUuid); + if (messageIndex >= 0) return messageIndex; + } + const fallbackIndex = Number.isInteger(anchor?.fallbackIndex) ? anchor.fallbackIndex : 0; + return Math.max(0, Math.min(items.length - 1, fallbackIndex)); +} + export function useSessionTimelineViewport({ items, scrollElement, @@ -115,9 +129,55 @@ export function useSessionTimelineViewport({ function runWithMeasurementRetry(scroll) { scroll(); const targetWindow = scrollElement.value?.ownerDocument?.defaultView; - targetWindow?.requestAnimationFrame(() => { - targetWindow.requestAnimationFrame(scroll); - }); + if (!targetWindow) return Promise.resolve(); + return new Promise(resolve => targetWindow.requestAnimationFrame(() => { + targetWindow.requestAnimationFrame(() => { + scroll(); + resolve(); + }); + })); + } + + function captureReaderPosition() { + if (isFollowingTail()) return { mode: 'tail', anchor: null }; + const itemIndex = resolveReaderAnchorIndex(null, items.value); + if (itemIndex === null) return { mode: 'anchor', anchor: null }; + + const instance = virtualizer.value; + const scrollOffset = instance.scrollOffset ?? scrollElement.value?.scrollTop ?? 0; + const measurement = instance.getVirtualItemForOffset(scrollOffset) + || instance.getMeasurements?.()[itemIndex]; + const index = measurement?.index ?? itemIndex; + const item = items.value[index]; + return { + mode: 'anchor', + anchor: { + itemKey: item?.key || null, + messageUuid: item?.messageUuid || null, + offset: scrollOffset - (measurement?.start ?? scrollOffset), + fallbackIndex: index, + }, + }; + } + + async function restoreReaderPosition(position) { + if (position?.mode === 'tail') { + await scrollToEnd(); + return; + } + const index = resolveReaderAnchorIndex(position?.anchor, items.value); + if (index === null) return; + const offsetWithinItem = Number.isFinite(position?.anchor?.offset) + ? position.anchor.offset + : 0; + const scroll = () => { + const measurement = virtualizer.value.getMeasurements?.()[index]; + const targetOffset = Math.max(0, (measurement?.start || 0) + offsetWithinItem); + scrollPolicy.runExplicit(() => { + virtualizer.value.scrollToOffset(targetOffset, { behavior: 'auto' }); + }); + }; + await runWithMeasurementRetry(scroll); } function scrollToIndex(index, options = {}) { @@ -129,7 +189,7 @@ export function useSessionTimelineViewport({ // A far jump starts from estimates. Re-align after mounted rows have been // measured so the requested item does not remain only in overscan. - runWithMeasurementRetry(scroll); + return runWithMeasurementRetry(scroll); } async function scrollToEnd() { @@ -159,13 +219,6 @@ export function useSessionTimelineViewport({ return virtualizer.value.isAtEnd(50); } - function resetForInitialSnapshot() { - tailFollowReady.value = false; - scrollPolicy.runExplicit(() => { - virtualizer.value.scrollToOffset(0, { behavior: 'auto' }); - }); - } - function completeInitialSnapshot() { tailFollowReady.value = true; } @@ -198,8 +251,9 @@ export function useSessionTimelineViewport({ indexAtViewportEnd, scrollToIndex, scrollToEnd, + captureReaderPosition, + restoreReaderPosition, isFollowingTail, - resetForInitialSnapshot, completeInitialSnapshot, waitForStableLayout, }; diff --git a/app/src/renderer/src/store.js b/app/src/renderer/src/store.js index 2de99ec..6b5951b 100644 --- a/app/src/renderer/src/store.js +++ b/app/src/renderer/src/store.js @@ -10,7 +10,6 @@ export const state = reactive({ projects: [], stats: {}, view: 'active', // 'active' | 'archived' - pendingFocusUuid: null, query: '', projectFilter: 'all', sourceFilter: 'all', diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index 65a704d..24dd8fe 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -1,5 +1,5 @@