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