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:
tommy0103
2026-07-16 23:22:47 +08:00
parent 4d8f201d22
commit d9f31bbe8a
12 changed files with 672 additions and 84 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"dist:win": "electron-vite build && electron-builder --win", "dist:win": "electron-vite build && electron-builder --win",
"dist:linux": "electron-vite build && electron-builder --linux", "dist:linux": "electron-vite build && electron-builder --linux",
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs", "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": { "build": {
"appId": "com.obelisk.app", "appId": "com.obelisk.app",
+4 -6
View File
@@ -198,9 +198,6 @@ onUnmounted(() => {
clearTimeout(searchTimer); clearTimeout(searchTimer);
}); });
// --- Keep-alive includes ---
const keepAliveIncludes = ['SessionDetail'];
const isExportRoute = computed(() => route.name === 'RecapExport'); const isExportRoute = computed(() => route.name === 'RecapExport');
// --- Source health dots --- // --- Source health dots ---
@@ -583,9 +580,10 @@ provide('recapGenerateOpen', recapGenerateOpen);
</div> </div>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive :include="['SessionDetail']"> <component
<component :is="Component" /> :is="Component"
</keep-alive> :key="route.name === 'SessionDetail' ? `session:${route.params.id}` : undefined"
/>
</router-view> </router-view>
</main> </main>
</div> </div>
+1 -2
View File
@@ -24,8 +24,7 @@ const routes = [
path: '/sessions/:id', path: '/sessions/:id',
name: 'SessionDetail', name: 'SessionDetail',
component: SessionDetail, component: SessionDetail,
props: true, props: true
meta: { keepAlive: true }
}, },
{ {
path: '/sessions/:id/agent/:agentId', path: '/sessions/:id/agent/:agentId',
@@ -1,5 +1,23 @@
import { reactive } from 'vue'; 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() { export function createSessionDisclosureState() {
const entries = reactive(new Map()); const entries = reactive(new Map());
@@ -28,5 +46,18 @@ export function createSessionDisclosureState() {
if (!messageUuids.has(entry.messageUuid)) entries.delete(key); 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({ export function useSessionTimelineViewport({
items, items,
scrollElement, scrollElement,
@@ -115,9 +129,55 @@ export function useSessionTimelineViewport({
function runWithMeasurementRetry(scroll) { function runWithMeasurementRetry(scroll) {
scroll(); scroll();
const targetWindow = scrollElement.value?.ownerDocument?.defaultView; const targetWindow = scrollElement.value?.ownerDocument?.defaultView;
targetWindow?.requestAnimationFrame(() => { if (!targetWindow) return Promise.resolve();
targetWindow.requestAnimationFrame(scroll); 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 = {}) { function scrollToIndex(index, options = {}) {
@@ -129,7 +189,7 @@ export function useSessionTimelineViewport({
// A far jump starts from estimates. Re-align after mounted rows have been // A far jump starts from estimates. Re-align after mounted rows have been
// measured so the requested item does not remain only in overscan. // measured so the requested item does not remain only in overscan.
runWithMeasurementRetry(scroll); return runWithMeasurementRetry(scroll);
} }
async function scrollToEnd() { async function scrollToEnd() {
@@ -159,13 +219,6 @@ export function useSessionTimelineViewport({
return virtualizer.value.isAtEnd(50); return virtualizer.value.isAtEnd(50);
} }
function resetForInitialSnapshot() {
tailFollowReady.value = false;
scrollPolicy.runExplicit(() => {
virtualizer.value.scrollToOffset(0, { behavior: 'auto' });
});
}
function completeInitialSnapshot() { function completeInitialSnapshot() {
tailFollowReady.value = true; tailFollowReady.value = true;
} }
@@ -198,8 +251,9 @@ export function useSessionTimelineViewport({
indexAtViewportEnd, indexAtViewportEnd,
scrollToIndex, scrollToIndex,
scrollToEnd, scrollToEnd,
captureReaderPosition,
restoreReaderPosition,
isFollowingTail, isFollowingTail,
resetForInitialSnapshot,
completeInitialSnapshot, completeInitialSnapshot,
waitForStableLayout, waitForStableLayout,
}; };
-1
View File
@@ -10,7 +10,6 @@ export const state = reactive({
projects: [], projects: [],
stats: {}, stats: {},
view: 'active', // 'active' | 'archived' view: 'active', // 'active' | 'archived'
pendingFocusUuid: null,
query: '', query: '',
projectFilter: 'all', projectFilter: 'all',
sourceFilter: 'all', sourceFilter: 'all',
+61 -60
View File
@@ -1,5 +1,5 @@
<script setup> <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 { useRouter, useRoute } from 'vue-router';
import { state, FOLDER_SVG, getSessionSummary } from '../store.js'; import { state, FOLDER_SVG, getSessionSummary } from '../store.js';
import { import {
@@ -16,6 +16,7 @@ import { createSessionDisclosureState } from '../session-disclosures.mjs';
import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs'; import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs';
import { createSessionUserScroll } from '../session-user-scroll.mjs'; import { createSessionUserScroll } from '../session-user-scroll.mjs';
import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs'; import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs';
import { sessionReaderStateCache } from '../session-reader-state.mjs';
import FlapNumber from '../components/FlapNumber.vue'; import FlapNumber from '../components/FlapNumber.vue';
import SessionTimelineRow from '../components/SessionTimelineRow.vue'; import SessionTimelineRow from '../components/SessionTimelineRow.vue';
import { import {
@@ -41,13 +42,17 @@ const timelineReady = ref(false);
const progressPct = ref(0); const progressPct = ref(0);
const active = ref(false); const active = ref(false);
const focusedItemKey = ref(null); const focusedItemKey = ref(null);
const pendingFocusUuid = ref(
typeof route.query.focus === 'string' ? route.query.focus : null,
);
const expandedMessageText = reactive(new Map()); const expandedMessageText = reactive(new Map());
const fullTextLoading = reactive(new Set()); const fullTextLoading = reactive(new Set());
let removeSessionUpdated = null; let removeSessionUpdated = null;
let keydownAttached = false; let keydownAttached = false;
let focusTimer = null; let focusTimer = null;
let loadRevision = 0; let loadRevision = 0;
let initialMountComplete = false; let pendingReaderState = sessionReaderStateCache.get(props.id);
let readerStatePrepared = false;
// DOM refs // DOM refs
const wrapRef = ref(null); const wrapRef = ref(null);
@@ -89,6 +94,37 @@ function observeSessionHeader() {
headerResizeObserver.observe(headerRef.value); 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 --- // --- Load session on mount or when id changes ---
const FONT_SIZE_KEY = 'obelisk:session-font-size'; const FONT_SIZE_KEY = 'obelisk:session-font-size';
const FONT_SIZES = [12, 13, 14, 15, 16, 18]; const FONT_SIZES = [12, 13, 14, 15, 16, 18];
@@ -140,9 +176,6 @@ onMounted(async () => {
active.value = true; active.value = true;
userScroll.attach(wrapRef.value); userScroll.attach(wrapRef.value);
attachKeydown(); attachKeydown();
if (route.query.focus) {
state.pendingFocusUuid = route.query.focus;
}
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => { removeSessionUpdated = window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
if (!active.value || !props.id || sessionId !== props.id) return; if (!active.value || !props.id || sessionId !== props.id) return;
void liveReloadCoordinator.request(); void liveReloadCoordinator.request();
@@ -152,41 +185,14 @@ onMounted(async () => {
localStorage.setItem(HINT_KEY, '1'); localStorage.setItem(HINT_KEY, '1');
setTimeout(() => { showFontHint.value = false; }, 4000); setTimeout(() => { showFontHint.value = false; }, 4000);
} }
try {
await loadMessages({ force: consumeGlobalSessionDirty(props.id) }); await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
await nextTick(); await nextTick();
syncTimelineScrollMargin(); syncTimelineScrollMargin();
observeSessionHeader(); observeSessionHeader();
} finally {
initialMountComplete = true;
}
}); });
onActivated(async () => { onBeforeUnmount(() => {
active.value = true; saveReaderState();
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 nextTick();
syncTimelineScrollMargin();
observeSessionHeader();
});
onDeactivated(() => {
active.value = false;
userScroll.detach();
detachKeydown();
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -205,30 +211,22 @@ onUnmounted(() => {
removeSessionUpdated = null; 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 => { watch(() => session.value?.id, async sessionId => {
if (sessionId === props.id && messages.value.length === 0) { if (sessionId === props.id && messages.value.length === 0) {
await loadMessages({ force: true }); 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 } = {}) { async function loadMessages({ force = false } = {}) {
const requestedSessionId = props.id; const requestedSessionId = props.id;
if (!requestedSessionId) return; if (!requestedSessionId) return;
@@ -257,6 +255,8 @@ async function revealColdTimeline(revision, sessionId) {
if (revision !== loadRevision || sessionId !== props.id) return; if (revision !== loadRevision || sessionId !== props.id) return;
syncTimelineScrollMargin(); syncTimelineScrollMargin();
if (timelineItems.value.length === 0) { if (timelineItems.value.length === 0) {
pendingReaderState = null;
readerStatePrepared = false;
timelineReady.value = true; timelineReady.value = true;
return; return;
} }
@@ -265,6 +265,8 @@ async function revealColdTimeline(revision, sessionId) {
isCurrent: () => revision === loadRevision && sessionId === props.id, isCurrent: () => revision === loadRevision && sessionId === props.id,
}); });
if (revision !== loadRevision || sessionId !== props.id) return; if (revision !== loadRevision || sessionId !== props.id) return;
await restoreReaderStateAfterLayout();
if (revision !== loadRevision || sessionId !== props.id) return;
timelineReady.value = true; timelineReady.value = true;
} }
@@ -340,11 +342,12 @@ async function commitSessionSnapshot(latest) {
for (const uuid of expandedMessageText.keys()) { for (const uuid of expandedMessageText.keys()) {
if (!retainedMessageUuids.has(uuid)) expandedMessageText.delete(uuid); if (!retainedMessageUuids.has(uuid)) expandedMessageText.delete(uuid);
} }
await prepareReaderState(retainedMessageUuids);
} }
} }
if (!reconciliation.changed) { if (!reconciliation.changed) {
if (state.pendingFocusUuid) await focusPendingMessage(); if (timelineReady.value && pendingFocusUuid.value) await focusPendingMessage();
timelineViewport.completeInitialSnapshot(); timelineViewport.completeInitialSnapshot();
return; return;
} }
@@ -353,18 +356,16 @@ async function commitSessionSnapshot(latest) {
timelineViewport.completeInitialSnapshot(); timelineViewport.completeInitialSnapshot();
if (restoreTail) await timelineViewport.scrollToEnd(); if (restoreTail) await timelineViewport.scrollToEnd();
syncTimelineScrollMargin(); syncTimelineScrollMargin();
if (!state.pendingFocusUuid) onScroll(); if (timelineReady.value) {
if (!pendingFocusUuid.value) onScroll();
// Focus pending uuid if any else await focusPendingMessage();
if (state.pendingFocusUuid) {
await focusPendingMessage();
} }
} }
async function focusPendingMessage() { async function focusPendingMessage() {
const targetUuid = state.pendingFocusUuid; const targetUuid = pendingFocusUuid.value;
if (!targetUuid) return; if (!targetUuid) return;
state.pendingFocusUuid = null; pendingFocusUuid.value = null;
const targetIndex = timelineItems.value.findIndex(item => ( const targetIndex = timelineItems.value.findIndex(item => (
item.anchorUuid === targetUuid || item.messageUuid === targetUuid item.anchorUuid === targetUuid || item.messageUuid === targetUuid
)); ));
+342
View File
@@ -0,0 +1,342 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
import { createSessionPatch } from '../src/shared/session-patch.mjs';
import { assembleSessionMessages } from '../src/shared/session-detail-assembly.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = join(here, '..');
const sessionA = 'reader-session-a';
const sessionB = 'reader-session-b';
const focusUuid = 'a-message-180';
const expandedTextSentinel = 'RESTORED FULL TEXT SENTINEL';
const channels = [
'db:getSessions',
'db:getSessionMessages',
'db:getSessionToolCalls',
'db:getSessionToolResults',
'db:getSessionPatch',
'db:getSessionSubagents',
'db:getSessionWorkflows',
'db:getSessionSummaries',
'db:getMessageFullText',
'db:getMemories',
'db:getProjects',
'db:getStats',
'settings:get',
];
let failures = 0;
let nextFullTextDelayMs = 0;
function makeMessages(prefix, count) {
return Array.from({ length: count }, (_, index) => ({
uuid: `${prefix}-message-${index}`,
type: index % 2 === 0 ? 'user' : 'assistant',
timestamp: new Date(Date.UTC(2026, 6, 16, 0, 0, index)).toISOString(),
text: `Session ${prefix.toUpperCase()} message ${index} ${'dynamic reader content '.repeat((index % 5) + 1)}`,
content_type: 'text',
is_meta: 0,
}));
}
const fixtures = {
[sessionA]: {
title: 'Reader state A',
messages: makeMessages('a', 240),
toolCalls: [{
id: 'a-tool-call',
message_uuid: 'a-message-1',
name: 'Bash',
input_json: JSON.stringify({ command: 'printf reader-state-a' }),
}],
toolResults: [{
tool_use_id: 'a-tool-call',
content: 'reader state output',
is_error: 0,
}],
},
[sessionB]: {
title: 'Reader state B',
messages: makeMessages('b', 160),
toolCalls: [],
toolResults: [],
},
};
fixtures[sessionA].messages[2].text = `Truncated preview ${'indexed content '.repeat(700)}`;
function summary(sessionId) {
const fixture = fixtures[sessionId];
return {
id: sessionId,
title: fixture.title,
project: 'quiet-zero',
project_path: '/tmp/quiet-zero',
source: 'claude',
started_at: '2026-07-16T00:00:00.000Z',
ended_at: '2026-07-16T01:00:00.000Z',
message_count: fixture.messages.length,
git_branch: 'main',
};
}
function assert(condition, message) {
if (condition) console.log(`PASS: ${message}`);
else {
failures++;
console.error(`FAIL: ${message}`);
}
}
async function waitFor(webContents, expression, message, timeoutMs = 8_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await webContents.executeJavaScript(`Boolean(${expression})`, true)) return;
await delay(40);
}
throw new Error(`Timed out waiting for ${message}`);
}
function registerHandlers() {
ipcMain.handle('db:getSessions', () => [summary(sessionA), summary(sessionB)]);
ipcMain.handle('db:getSessionMessages', (_event, sessionId) => fixtures[sessionId]?.messages || []);
ipcMain.handle('db:getSessionToolCalls', (_event, sessionId) => fixtures[sessionId]?.toolCalls || []);
ipcMain.handle('db:getSessionToolResults', (_event, sessionId) => fixtures[sessionId]?.toolResults || []);
ipcMain.handle('db:getSessionPatch', (_event, sessionId, cursor) => {
const fixture = fixtures[sessionId];
const patch = createSessionPatch({
messages: assembleSessionMessages({
messages: fixture.messages,
toolCalls: fixture.toolCalls,
toolResults: fixture.toolResults,
subagents: [],
workflows: [],
}),
workflows: [],
}, cursor);
return { ...patch, session: summary(sessionId) };
});
ipcMain.handle('db:getSessionSubagents', () => []);
ipcMain.handle('db:getSessionWorkflows', () => []);
ipcMain.handle('db:getSessionSummaries', () => []);
ipcMain.handle('db:getMessageFullText', async (_event, messageUuid) => {
const delayMs = nextFullTextDelayMs;
nextFullTextDelayMs = 0;
if (delayMs > 0) await delay(delayMs);
return messageUuid === 'a-message-2' ? `${expandedTextSentinel} complete message` : null;
});
ipcMain.handle('db:getMemories', () => []);
ipcMain.handle('db:getProjects', () => [{ project: 'quiet-zero', count: 2 }]);
ipcMain.handle('db:getStats', () => ({}));
ipcMain.handle('settings:get', () => ({}));
}
async function navigate(win, sessionId, query = '') {
await win.webContents.executeJavaScript(
`window.location.hash = ${JSON.stringify(`#/sessions/${sessionId}${query}`)}`,
true,
);
await waitFor(
win.webContents,
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${fixtures[sessionId].messages.length}'`,
`${sessionId} timeline`,
);
await delay(650);
}
async function scrollState(win, fraction = null) {
return win.webContents.executeJavaScript(`(async () => {
const wrap = document.querySelector('.detail-wrap');
${fraction === null ? '' : `wrap.scrollTop = (wrap.scrollHeight - wrap.clientHeight) * ${fraction};`}
await new Promise(resolve => setTimeout(resolve, 500));
const wrapRect = wrap.getBoundingClientRect();
const anchorRow = [...document.querySelectorAll('.virtual-timeline-row')]
.find(row => row.getBoundingClientRect().bottom > wrapRect.top);
const anchorRect = anchorRow?.getBoundingClientRect();
return {
current: Number(document.querySelector('.msg-nav-current')?.textContent),
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
scrollTop: wrap.scrollTop,
anchorUuid: anchorRow?.querySelector('[data-message-uuid]')?.dataset.messageUuid || null,
anchorOffset: anchorRect ? anchorRect.top - wrapRect.top : null,
};
})()`, true);
}
async function run() {
registerHandlers();
const win = new BrowserWindow({
show: false,
width: 1200,
height: 800,
webPreferences: {
preload: join(appRoot, 'out', 'preload', 'index.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), { hash: '/sessions' });
await waitFor(win.webContents, `document.body.textContent.includes('Reader state A')`, 'session list');
await navigate(win, sessionA);
await win.webContents.executeJavaScript(
`document.querySelector('[data-view-key="tool:a-tool-call"] .toolcall-toggle')?.click()`,
true,
);
await win.webContents.executeJavaScript(
`document.querySelector('[data-uuid="a-message-2"] .truncated-btn')?.click()`,
true,
);
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="a-message-2"]')?.textContent.includes('${expandedTextSentinel}')`,
'expanded full message text in session A',
);
const aPosition = await scrollState(win, 0.46);
assert(aPosition.current > 60, `session A reaches a mid-session reader position (${JSON.stringify(aPosition)})`);
await navigate(win, sessionB);
const bInitial = await scrollState(win);
assert(bInitial.current < 10, `session B starts with its own progress (${JSON.stringify(bInitial)})`);
const bPosition = await scrollState(win, 0.68);
assert(bPosition.current > 60, `session B records an independent reader position (${JSON.stringify(bPosition)})`);
fixtures[sessionA].messages.push({
uuid: 'a-message-live',
type: 'assistant',
timestamp: new Date().toISOString(),
text: 'Hidden-session live update',
content_type: 'text',
is_meta: 0,
});
win.webContents.send('obelisk:session-updated', { sessionId: sessionA });
await navigate(win, sessionA);
const restoredA = await scrollState(win);
assert(
restoredA.anchorUuid === aPosition.anchorUuid
&& Math.abs(restoredA.anchorOffset - aPosition.anchorOffset) <= 2
&& Math.abs(restoredA.current - aPosition.current) <= 3,
`session A restores its semantic reader anchor after a hidden live update (${JSON.stringify({ aPosition, restoredA })})`,
);
await win.webContents.executeJavaScript(`document.querySelector('button[title="First"]')?.click()`, true);
await delay(450);
const disclosureRestored = await win.webContents.executeJavaScript(
`Boolean(document.querySelector('[data-view-key="tool:a-tool-call"].open'))`,
true,
);
assert(disclosureRestored, 'session A restores its expanded tool disclosure');
const expandedTextRestored = await win.webContents.executeJavaScript(
`Boolean(document.querySelector('[data-uuid="a-message-2"]')?.textContent.includes('${expandedTextSentinel}'))`,
true,
);
assert(expandedTextRestored, 'session A restores expanded full-message state without caching its text');
await navigate(win, sessionB);
const restoredB = await scrollState(win);
assert(
restoredB.anchorUuid === bPosition.anchorUuid
&& Math.abs(restoredB.anchorOffset - bPosition.anchorOffset) <= 2
&& Math.abs(restoredB.current - bPosition.current) <= 3,
`session B restores its own semantic reader anchor (${JSON.stringify({ bPosition, restoredB })})`,
);
await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true);
await waitFor(
win.webContents,
`document.querySelector('.msg-nav-current')?.textContent === '${fixtures[sessionB].messages.length}'`,
'session B tail position',
);
await navigate(win, sessionA);
fixtures[sessionB].messages.push({
uuid: 'b-message-live',
type: 'assistant',
timestamp: new Date().toISOString(),
text: 'Hidden tail update',
content_type: 'text',
is_meta: 0,
});
win.webContents.send('obelisk:session-updated', { sessionId: sessionB });
await navigate(win, sessionB);
const restoredTail = await win.webContents.executeJavaScript(`(() => {
const wrap = document.querySelector('.detail-wrap');
return {
current: Number(document.querySelector('.msg-nav-current')?.textContent),
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
distanceFromTail: wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop,
};
})()`, true);
assert(
restoredTail.current === restoredTail.total && restoredTail.distanceFromTail < 2,
`session B tail mode follows a hidden-session append (${JSON.stringify(restoredTail)})`,
);
await navigate(win, sessionA, `?focus=${focusUuid}`);
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="${focusUuid}"].is-focused')`,
'explicit focus target',
);
const focused = await scrollState(win);
assert(focused.current > 150, `explicit UUID focus overrides cached reader state (${JSON.stringify(focused)})`);
await win.webContents.executeJavaScript(
`window.location.hash = '#/sessions/${sessionA}?focus=a-message-20'`,
true,
);
await waitFor(
win.webContents,
`document.querySelector('[data-uuid="a-message-20"].is-focused')`,
'same-session focus target',
);
const sameSessionFocus = await scrollState(win);
assert(
sameSessionFocus.current < 50,
`same-session query focus is observed without a remount (${JSON.stringify(sameSessionFocus)})`,
);
await navigate(win, sessionB);
nextFullTextDelayMs = 400;
await win.webContents.executeJavaScript(
`window.location.hash = '#/sessions/${sessionA}'`,
true,
);
await waitFor(
win.webContents,
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${fixtures[sessionA].messages.length}'`,
'session A preparing a cached restore',
);
await win.webContents.executeJavaScript(
`window.location.hash = '#/sessions/${sessionB}'`,
true,
);
await waitFor(
win.webContents,
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${fixtures[sessionB].messages.length}'`,
'session B after interrupting restore',
);
await delay(450);
await navigate(win, sessionA);
const afterInterruptedRestore = await scrollState(win);
assert(
afterInterruptedRestore.anchorUuid === sameSessionFocus.anchorUuid
&& Math.abs(afterInterruptedRestore.anchorOffset - sameSessionFocus.anchorOffset) <= 2,
`leaving during restore does not overwrite the cached reader anchor (${JSON.stringify({ sameSessionFocus, afterInterruptedRestore })})`,
);
win.destroy();
}
app.whenReady()
.then(run)
.catch(error => {
failures++;
console.error(error.stack || error);
})
.finally(() => {
for (const channel of channels) ipcMain.removeHandler(channel);
app.exit(failures ? 1 : 0);
});
+17
View File
@@ -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-1'), false);
assert.equal(disclosures.isOpen('tool:call-2'), true); 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 },
]);
});
+88
View File
@@ -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, /rangeExtractor/);
assert.match(viewportModule, /anchorTo:\s*'end'/); assert.match(viewportModule, /anchorTo:\s*'end'/);
assert.match(viewportModule, /followOnAppend:\s*false/); assert.match(viewportModule, /followOnAppend:\s*false/);
assert.match(viewportModule, /resetForInitialSnapshot/);
assert.match(viewportModule, /completeInitialSnapshot/); assert.match(viewportModule, /completeInitialSnapshot/);
assert.match(viewportModule, /scrollToFn:\s*scrollPolicy\.scrollToFn/); assert.match(viewportModule, /scrollToFn:\s*scrollPolicy\.scrollToFn/);
assert.match(viewportModule, /useScrollendEvent:\s*true/); assert.match(viewportModule, /useScrollendEvent:\s*true/);