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 @@