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:
+2
-1
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
|
||||||
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 nextTick();
|
await nextTick();
|
||||||
syncTimelineScrollMargin();
|
syncTimelineScrollMargin();
|
||||||
observeSessionHeader();
|
observeSessionHeader();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDeactivated(() => {
|
onBeforeUnmount(() => {
|
||||||
active.value = false;
|
saveReaderState();
|
||||||
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
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user