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:
@@ -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);
|
||||
</div>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="['SessionDetail']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.name === 'SessionDetail' ? `session:${route.params.id}` : undefined"
|
||||
/>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ export const state = reactive({
|
||||
projects: [],
|
||||
stats: {},
|
||||
view: 'active', // 'active' | 'archived'
|
||||
pendingFocusUuid: null,
|
||||
query: '',
|
||||
projectFilter: 'all',
|
||||
sourceFilter: 'all',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, shallowRef, computed, reactive, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
|
||||
import { ref, shallowRef, computed, reactive, onMounted, onBeforeUnmount, onUnmounted, nextTick, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { state, FOLDER_SVG, getSessionSummary } from '../store.js';
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import { createSessionDisclosureState } from '../session-disclosures.mjs';
|
||||
import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs';
|
||||
import { createSessionUserScroll } from '../session-user-scroll.mjs';
|
||||
import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs';
|
||||
import { sessionReaderStateCache } from '../session-reader-state.mjs';
|
||||
import FlapNumber from '../components/FlapNumber.vue';
|
||||
import SessionTimelineRow from '../components/SessionTimelineRow.vue';
|
||||
import {
|
||||
@@ -41,13 +42,17 @@ const timelineReady = ref(false);
|
||||
const progressPct = ref(0);
|
||||
const active = ref(false);
|
||||
const focusedItemKey = ref(null);
|
||||
const pendingFocusUuid = ref(
|
||||
typeof route.query.focus === 'string' ? route.query.focus : null,
|
||||
);
|
||||
const expandedMessageText = reactive(new Map());
|
||||
const fullTextLoading = reactive(new Set());
|
||||
let removeSessionUpdated = null;
|
||||
let keydownAttached = false;
|
||||
let focusTimer = null;
|
||||
let loadRevision = 0;
|
||||
let initialMountComplete = false;
|
||||
let pendingReaderState = sessionReaderStateCache.get(props.id);
|
||||
let readerStatePrepared = false;
|
||||
|
||||
// DOM refs
|
||||
const wrapRef = ref(null);
|
||||
@@ -89,6 +94,37 @@ function observeSessionHeader() {
|
||||
headerResizeObserver.observe(headerRef.value);
|
||||
}
|
||||
|
||||
function saveReaderState(sessionId = props.id) {
|
||||
if (!timelineReady.value || !sessionId || timelineItems.value.length === 0) return;
|
||||
sessionReaderStateCache.set(sessionId, {
|
||||
...timelineViewport.captureReaderPosition(),
|
||||
disclosures: disclosures.snapshot(),
|
||||
expandedMessageIds: [...expandedMessageText.keys()],
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareReaderState(messageUuids) {
|
||||
if (readerStatePrepared || !pendingReaderState) return;
|
||||
disclosures.restore(pendingReaderState.disclosures, messageUuids);
|
||||
const expandedIds = pendingReaderState.expandedMessageIds
|
||||
.filter(messageUuid => messageUuids.has(messageUuid));
|
||||
await Promise.all(expandedIds.map(messageUuid => handleLoadFullText(messageUuid)));
|
||||
readerStatePrepared = true;
|
||||
}
|
||||
|
||||
async function restoreReaderStateAfterLayout() {
|
||||
const explicitFocus = Boolean(pendingFocusUuid.value);
|
||||
if (explicitFocus) {
|
||||
await focusPendingMessage();
|
||||
} else if (pendingReaderState) {
|
||||
userScroll.clearUpwardIntent();
|
||||
await timelineViewport.restoreReaderPosition(pendingReaderState);
|
||||
}
|
||||
updateScrollProgress();
|
||||
pendingReaderState = null;
|
||||
readerStatePrepared = false;
|
||||
}
|
||||
|
||||
// --- Load session on mount or when id changes ---
|
||||
const FONT_SIZE_KEY = 'obelisk:session-font-size';
|
||||
const FONT_SIZES = [12, 13, 14, 15, 16, 18];
|
||||
@@ -140,9 +176,6 @@ onMounted(async () => {
|
||||
active.value = true;
|
||||
userScroll.attach(wrapRef.value);
|
||||
attachKeydown();
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
}
|
||||
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
|
||||
if (!active.value || !props.id || sessionId !== props.id) return;
|
||||
void liveReloadCoordinator.request();
|
||||
@@ -152,41 +185,14 @@ onMounted(async () => {
|
||||
localStorage.setItem(HINT_KEY, '1');
|
||||
setTimeout(() => { showFontHint.value = false; }, 4000);
|
||||
}
|
||||
try {
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
||||
await nextTick();
|
||||
syncTimelineScrollMargin();
|
||||
observeSessionHeader();
|
||||
} finally {
|
||||
initialMountComplete = true;
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
active.value = true;
|
||||
userScroll.attach(wrapRef.value);
|
||||
attachKeydown();
|
||||
// KeepAlive invokes onActivated during the initial mount as well. The
|
||||
// onMounted path already owns that first load and layout reveal.
|
||||
if (!initialMountComplete) return;
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
}
|
||||
if (props.id && (messages.value.length === 0 || consumeGlobalSessionDirty(props.id))) {
|
||||
await loadMessages({ force: true });
|
||||
} else if (state.pendingFocusUuid) {
|
||||
await focusPendingMessage();
|
||||
}
|
||||
await liveReloadCoordinator.flush();
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
||||
await nextTick();
|
||||
syncTimelineScrollMargin();
|
||||
observeSessionHeader();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
active.value = false;
|
||||
userScroll.detach();
|
||||
detachKeydown();
|
||||
onBeforeUnmount(() => {
|
||||
saveReaderState();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -205,30 +211,22 @@ onUnmounted(() => {
|
||||
removeSessionUpdated = null;
|
||||
});
|
||||
|
||||
watch(() => props.id, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadRevision++;
|
||||
userScroll.clearUpwardIntent();
|
||||
timelineViewport.resetForInitialSnapshot();
|
||||
liveSessionMetadata.value = null;
|
||||
messages.value = [];
|
||||
timelineItems.value = [];
|
||||
timelineReady.value = false;
|
||||
disclosures.retainMessages(new Set());
|
||||
expandedMessageText.clear();
|
||||
fullTextLoading.clear();
|
||||
progressPct.value = 0;
|
||||
currentMsgIdx.value = 0;
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(newId) });
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => session.value?.id, async sessionId => {
|
||||
if (sessionId === props.id && messages.value.length === 0) {
|
||||
await loadMessages({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => route.query.focus, async focus => {
|
||||
pendingFocusUuid.value = typeof focus === 'string' ? focus : null;
|
||||
if (
|
||||
!pendingFocusUuid.value
|
||||
|| String(route.params.id || '') !== props.id
|
||||
|| !timelineReady.value
|
||||
) return;
|
||||
await focusPendingMessage();
|
||||
});
|
||||
|
||||
async function loadMessages({ force = false } = {}) {
|
||||
const requestedSessionId = props.id;
|
||||
if (!requestedSessionId) return;
|
||||
@@ -257,6 +255,8 @@ async function revealColdTimeline(revision, sessionId) {
|
||||
if (revision !== loadRevision || sessionId !== props.id) return;
|
||||
syncTimelineScrollMargin();
|
||||
if (timelineItems.value.length === 0) {
|
||||
pendingReaderState = null;
|
||||
readerStatePrepared = false;
|
||||
timelineReady.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -265,6 +265,8 @@ async function revealColdTimeline(revision, sessionId) {
|
||||
isCurrent: () => revision === loadRevision && sessionId === props.id,
|
||||
});
|
||||
if (revision !== loadRevision || sessionId !== props.id) return;
|
||||
await restoreReaderStateAfterLayout();
|
||||
if (revision !== loadRevision || sessionId !== props.id) return;
|
||||
timelineReady.value = true;
|
||||
}
|
||||
|
||||
@@ -340,11 +342,12 @@ async function commitSessionSnapshot(latest) {
|
||||
for (const uuid of expandedMessageText.keys()) {
|
||||
if (!retainedMessageUuids.has(uuid)) expandedMessageText.delete(uuid);
|
||||
}
|
||||
await prepareReaderState(retainedMessageUuids);
|
||||
}
|
||||
}
|
||||
|
||||
if (!reconciliation.changed) {
|
||||
if (state.pendingFocusUuid) await focusPendingMessage();
|
||||
if (timelineReady.value && pendingFocusUuid.value) await focusPendingMessage();
|
||||
timelineViewport.completeInitialSnapshot();
|
||||
return;
|
||||
}
|
||||
@@ -353,18 +356,16 @@ async function commitSessionSnapshot(latest) {
|
||||
timelineViewport.completeInitialSnapshot();
|
||||
if (restoreTail) await timelineViewport.scrollToEnd();
|
||||
syncTimelineScrollMargin();
|
||||
if (!state.pendingFocusUuid) onScroll();
|
||||
|
||||
// Focus pending uuid if any
|
||||
if (state.pendingFocusUuid) {
|
||||
await focusPendingMessage();
|
||||
if (timelineReady.value) {
|
||||
if (!pendingFocusUuid.value) onScroll();
|
||||
else await focusPendingMessage();
|
||||
}
|
||||
}
|
||||
|
||||
async function focusPendingMessage() {
|
||||
const targetUuid = state.pendingFocusUuid;
|
||||
const targetUuid = pendingFocusUuid.value;
|
||||
if (!targetUuid) return;
|
||||
state.pendingFocusUuid = null;
|
||||
pendingFocusUuid.value = null;
|
||||
const targetIndex = timelineItems.value.findIndex(item => (
|
||||
item.anchorUuid === targetUuid || item.messageUuid === targetUuid
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user