fix(app): isolate live session updates from scrolling
Defer and coalesce global catalogue refreshes while SessionDetail is active, carry session metadata through incremental patches, and gate in-flight catalogue commits by route. Harden the virtual timeline cold-open and user-scroll lifecycle, and add Electron frame, continuity, metadata, and overlap regressions.
This commit is contained in:
@@ -3,6 +3,7 @@ import { computed, watch, ref, provide, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
state,
|
||||
getSessionSummary,
|
||||
FOLDER_SVG,
|
||||
resetListState,
|
||||
setView,
|
||||
@@ -21,6 +22,10 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
let searchTimer = null;
|
||||
|
||||
const routeSession = computed(() => {
|
||||
return getSessionSummary(route.params.id);
|
||||
});
|
||||
|
||||
// --- Sidebar data ---
|
||||
|
||||
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
|
||||
@@ -84,7 +89,7 @@ const windowTitle = computed(() => {
|
||||
scopeText = 'Settings';
|
||||
} else if (route.name?.startsWith('Session')) {
|
||||
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
|
||||
const s = state.sessions.find(x => x.id === route.params.id);
|
||||
const s = routeSession.value;
|
||||
scopeText = s ? `Sessions · ${s.title}` : 'Sessions';
|
||||
} else {
|
||||
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
||||
@@ -470,13 +475,13 @@ provide('recapGenerateOpen', recapGenerateOpen);
|
||||
<template v-if="route.name === 'SubagentDetail'">
|
||||
<span class="crumb-sep">/</span>
|
||||
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
|
||||
{{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
|
||||
{{ (routeSession?.title || '').slice(0, 30) || route.params.id }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-if="route.name === 'SessionDetail'">
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb terminal">
|
||||
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
|
||||
{{ routeSession?.title || route.params.id }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="route.name === 'SubagentDetail'">
|
||||
|
||||
@@ -20,23 +20,39 @@ function rememberSessionMessageSnapshot(sessionId, entry) {
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateStoredSessionMessages(sessionId) {
|
||||
function sessionMetadata(session) {
|
||||
if (!session) return null;
|
||||
const metadata = { ...session };
|
||||
delete metadata.messages;
|
||||
delete metadata.workflow;
|
||||
return markRaw(metadata);
|
||||
}
|
||||
|
||||
function commitStoredSessionMetadata(sessionId, metadata) {
|
||||
const session = state.sessions.find(candidate => candidate.id === sessionId);
|
||||
if (session?.messages?.length) session.messages = markRaw([]);
|
||||
const visibleTitle = state.sessionTitleOverrides.get(sessionId) ?? session?.title;
|
||||
if (metadata?.title !== undefined && metadata.title !== visibleTitle) {
|
||||
state.sessionTitleOverrides.set(sessionId, metadata.title);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial data from the DB and populate state.memories, state.sessions,
|
||||
* and state.projects.
|
||||
* Fetch the global catalogue without mutating renderer state. Navigation can
|
||||
* then gate a reply that started before SessionDetail became active.
|
||||
*/
|
||||
export async function loadInitialData() {
|
||||
export async function fetchInitialData() {
|
||||
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
|
||||
window.obelisk.getMemories(),
|
||||
window.obelisk.getSessions({ source: 'all', limit: 1000 }),
|
||||
window.obelisk.getStats(),
|
||||
window.obelisk.getProjects()
|
||||
]);
|
||||
return { rawMemories, rawSessions, stats, projects };
|
||||
}
|
||||
|
||||
/** Commit a fetched global catalogue snapshot to shared renderer state. */
|
||||
export function commitInitialData({ rawMemories, rawSessions, stats, projects }) {
|
||||
// Transform memories: DB records -> render-layer shape
|
||||
state.memories = (rawMemories || []).map(m => ({
|
||||
...m,
|
||||
@@ -47,6 +63,9 @@ export async function loadInitialData() {
|
||||
markdown: null // loaded on demand via loadMemoryMarkdown
|
||||
}));
|
||||
|
||||
// The catalogue now owns the latest metadata; route overlays can retire.
|
||||
state.sessionTitleOverrides.clear();
|
||||
|
||||
// Sessions: merge with existing data to preserve already-loaded messages
|
||||
const existingSessions = new Map(state.sessions.map(s => [s.id, s]));
|
||||
state.sessions = (rawSessions || []).map(s => {
|
||||
@@ -81,26 +100,36 @@ export async function loadSessionDetail(sessionId) {
|
||||
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }),
|
||||
workflows,
|
||||
};
|
||||
const metadata = sessionMetadata(state.sessions.find(candidate => candidate.id === sessionId));
|
||||
rememberSessionMessageSnapshot(sessionId, {
|
||||
snapshot,
|
||||
cursor: createSessionPatchCursor(snapshot),
|
||||
session: metadata,
|
||||
});
|
||||
return commitSessionDetail(sessionId, snapshot, { updateStore: true });
|
||||
return commitSessionDetail(sessionId, snapshot, { updateStore: true, metadata });
|
||||
}
|
||||
|
||||
export async function loadSessionDetailPatch(sessionId) {
|
||||
export async function fetchSessionDetailPatch(sessionId) {
|
||||
const current = sessionMessageSnapshots.get(sessionId);
|
||||
if (!current || typeof window.obelisk.getSessionPatch !== 'function') {
|
||||
return loadSessionDetail(sessionId);
|
||||
return { sessionId, current: null, patch: null };
|
||||
}
|
||||
const patch = await window.obelisk.getSessionPatch(sessionId, current.cursor);
|
||||
if (!patch) return loadSessionDetail(sessionId);
|
||||
return { sessionId, current, patch };
|
||||
}
|
||||
|
||||
export async function materializeSessionDetailPatch({ sessionId, current, patch }) {
|
||||
if (!current || !patch) return loadSessionDetail(sessionId);
|
||||
const next = applySessionPatch(current.snapshot, current.cursor, patch);
|
||||
const latest = commitSessionDetail(sessionId, next.snapshot, { updateStore: false });
|
||||
const metadata = sessionMetadata(patch.session) || current.session;
|
||||
const latest = commitSessionDetail(sessionId, next.snapshot, {
|
||||
updateStore: false,
|
||||
metadata,
|
||||
});
|
||||
latest.acceptMessagePatch = () => {
|
||||
if (sessionMessageSnapshots.get(sessionId) !== current) return false;
|
||||
rememberSessionMessageSnapshot(sessionId, next);
|
||||
invalidateStoredSessionMessages(sessionId);
|
||||
rememberSessionMessageSnapshot(sessionId, { ...next, session: metadata });
|
||||
commitStoredSessionMetadata(sessionId, metadata);
|
||||
return true;
|
||||
};
|
||||
latest.messagePatch = {
|
||||
@@ -119,13 +148,17 @@ export async function loadSessionDetailPatch(sessionId) {
|
||||
export function getCachedSessionDetail(sessionId) {
|
||||
const current = sessionMessageSnapshots.get(sessionId);
|
||||
if (!current) return null;
|
||||
return commitSessionDetail(sessionId, current.snapshot, { updateStore: false });
|
||||
return commitSessionDetail(sessionId, current.snapshot, {
|
||||
updateStore: false,
|
||||
metadata: current.session,
|
||||
});
|
||||
}
|
||||
|
||||
function commitSessionDetail(sessionId, { messages, workflows = [] }, { updateStore }) {
|
||||
function commitSessionDetail(sessionId, { messages, workflows = [] }, { updateStore, metadata = null }) {
|
||||
const session = state.sessions.find(candidate => candidate.id === sessionId);
|
||||
const assembled = {
|
||||
...(session || {}),
|
||||
...(metadata || {}),
|
||||
id: sessionId,
|
||||
messages: markRaw(messages),
|
||||
};
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router.js';
|
||||
import { loadInitialData } from './data.js';
|
||||
import { commitInitialData, fetchInitialData } from './data.js';
|
||||
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
|
||||
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
|
||||
|
||||
// Import shared renderer CSS globally
|
||||
import '../styles/base.css';
|
||||
@@ -17,20 +18,39 @@ const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
|
||||
const globalDataRefresh = createGlobalDataRefreshCoordinator({
|
||||
isDeferred: () => {
|
||||
const routeName = router.currentRoute.value.name;
|
||||
return routeName === 'SessionDetail';
|
||||
},
|
||||
load: fetchInitialData,
|
||||
commit: commitInitialData,
|
||||
});
|
||||
|
||||
function reportGlobalRefreshFailure(request) {
|
||||
void request.catch(error => {
|
||||
console.error('Failed to refresh Obelisk catalogues:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Load data on startup
|
||||
router.isReady().then(() => {
|
||||
loadInitialData();
|
||||
reportGlobalRefreshFailure(globalDataRefresh.initialize());
|
||||
});
|
||||
|
||||
router.afterEach(() => {
|
||||
reportGlobalRefreshFailure(globalDataRefresh.flush());
|
||||
});
|
||||
|
||||
// Refresh data when window regains focus
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadInitialData();
|
||||
reportGlobalRefreshFailure(globalDataRefresh.invalidate());
|
||||
}
|
||||
});
|
||||
|
||||
window.obelisk?.onIndexUpdated?.(() => {
|
||||
loadInitialData();
|
||||
reportGlobalRefreshFailure(globalDataRefresh.invalidate());
|
||||
});
|
||||
|
||||
window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Keeps global catalogue snapshots out of the active SessionDetail view.
|
||||
* SessionDetail receives its own incremental stream; the catalogue is an
|
||||
* eventually-consistent projection that can catch up after the route exits.
|
||||
*/
|
||||
export function createGlobalDataRefreshCoordinator({ isDeferred, load, commit }) {
|
||||
let dirty = false;
|
||||
let inFlight = null;
|
||||
let fetchedSnapshot = null;
|
||||
let hasFetchedSnapshot = false;
|
||||
|
||||
async function commitOrRetain(snapshot) {
|
||||
try {
|
||||
await commit(snapshot);
|
||||
} catch (error) {
|
||||
fetchedSnapshot = snapshot;
|
||||
hasFetchedSnapshot = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function process({ allowDeferred = false } = {}) {
|
||||
let mayCommitWhileDeferred = allowDeferred;
|
||||
while (dirty || hasFetchedSnapshot) {
|
||||
if (!mayCommitWhileDeferred && isDeferred()) return;
|
||||
|
||||
let snapshot;
|
||||
if (dirty) {
|
||||
dirty = false;
|
||||
fetchedSnapshot = null;
|
||||
hasFetchedSnapshot = false;
|
||||
try {
|
||||
snapshot = await load();
|
||||
} catch (error) {
|
||||
dirty = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (mayCommitWhileDeferred) {
|
||||
await commitOrRetain(snapshot);
|
||||
mayCommitWhileDeferred = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A newer invalidation supersedes the snapshot that just loaded.
|
||||
if (dirty) continue;
|
||||
if (isDeferred()) {
|
||||
fetchedSnapshot = snapshot;
|
||||
hasFetchedSnapshot = true;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
snapshot = fetchedSnapshot;
|
||||
fetchedSnapshot = null;
|
||||
hasFetchedSnapshot = false;
|
||||
}
|
||||
|
||||
await commitOrRetain(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function drain(options) {
|
||||
if (inFlight) return inFlight;
|
||||
if (!options?.allowDeferred && isDeferred()) return Promise.resolve();
|
||||
if (!dirty && !hasFetchedSnapshot) return Promise.resolve();
|
||||
const operation = process(options);
|
||||
inFlight = operation;
|
||||
return operation.finally(() => {
|
||||
if (inFlight === operation) inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
invalidate() {
|
||||
dirty = true;
|
||||
return drain();
|
||||
},
|
||||
flush() {
|
||||
return drain();
|
||||
},
|
||||
initialize() {
|
||||
dirty = true;
|
||||
return drain({ allowDeferred: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,9 @@ export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }
|
||||
|
||||
async function processPending() {
|
||||
if (stopped || (!pending && !loadedSnapshot)) return inFlight;
|
||||
// Do not start more IPC/deserialization work during an active wheel
|
||||
// gesture. Coalesce notifications and fetch the latest state once.
|
||||
if (isScrolling()) return inFlight;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = drain();
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { elementScroll, useVirtualizer } from '@tanstack/vue-virtual';
|
||||
import { defaultRangeExtractor, elementScroll, useVirtualizer } from '@tanstack/vue-virtual';
|
||||
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
||||
|
||||
function estimatedTextHeight(text = '') {
|
||||
@@ -26,6 +26,37 @@ export function estimateTimelineItemSize(item) {
|
||||
+ (message._thinking ? 34 : 0);
|
||||
}
|
||||
|
||||
export function createViewportRangeExtractor({
|
||||
getScrollElement,
|
||||
getVirtualizer,
|
||||
bufferViewports = 4,
|
||||
}) {
|
||||
return range => {
|
||||
const element = getScrollElement();
|
||||
const instance = getVirtualizer();
|
||||
const viewportSize = element?.clientHeight || 0;
|
||||
if (!instance || viewportSize <= 0) return defaultRangeExtractor(range);
|
||||
|
||||
// The compositor can advance wheel scrolling before the renderer receives
|
||||
// the scroll event. Buffer in pixels so short rows do not collapse a
|
||||
// count-based overscan into less than one trackpad gesture.
|
||||
const bufferSize = viewportSize * bufferViewports;
|
||||
const scrollOffset = element.scrollTop || 0;
|
||||
const first = instance.getVirtualItemForOffset(Math.max(0, scrollOffset - bufferSize));
|
||||
const last = instance.getVirtualItemForOffset(
|
||||
scrollOffset + viewportSize + bufferSize,
|
||||
);
|
||||
if (!first || !last) return defaultRangeExtractor(range);
|
||||
|
||||
const startIndex = Math.max(0, Math.min(first.index, range.startIndex));
|
||||
const endIndex = Math.min(range.count - 1, Math.max(last.index, range.endIndex));
|
||||
return Array.from(
|
||||
{ length: endIndex - startIndex + 1 },
|
||||
(_, offset) => startIndex + offset,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function useSessionTimelineViewport({
|
||||
items,
|
||||
scrollElement,
|
||||
@@ -40,7 +71,12 @@ export function useSessionTimelineViewport({
|
||||
isUserScrolling: () => userScroll?.isActive() ?? false,
|
||||
writeScroll: elementScroll,
|
||||
});
|
||||
const virtualizer = useVirtualizer(computed(() => ({
|
||||
let virtualizer = null;
|
||||
const rangeExtractor = createViewportRangeExtractor({
|
||||
getScrollElement: () => scrollElement.value,
|
||||
getVirtualizer: () => virtualizer?.value,
|
||||
});
|
||||
virtualizer = useVirtualizer(computed(() => ({
|
||||
count: items.value.length,
|
||||
getScrollElement: () => scrollElement.value,
|
||||
estimateSize: index => estimateTimelineItemSize(items.value[index]),
|
||||
@@ -48,6 +84,7 @@ export function useSessionTimelineViewport({
|
||||
scrollMargin: scrollMargin.value,
|
||||
scrollPaddingEnd,
|
||||
overscan,
|
||||
rangeExtractor,
|
||||
gap,
|
||||
anchorTo: 'end',
|
||||
followOnAppend: false,
|
||||
@@ -133,6 +170,27 @@ export function useSessionTimelineViewport({
|
||||
tailFollowReady.value = true;
|
||||
}
|
||||
|
||||
async function waitForStableLayout({ maxFrames = 8, isCurrent = () => true } = {}) {
|
||||
const targetWindow = scrollElement.value?.ownerDocument?.defaultView;
|
||||
if (!targetWindow || items.value.length === 0) return true;
|
||||
for (let frame = 0; frame < maxFrames; frame++) {
|
||||
await new Promise(resolve => targetWindow.requestAnimationFrame(resolve));
|
||||
if (!isCurrent()) return false;
|
||||
const rows = [...virtualizer.value.elementsCache.values()]
|
||||
.filter(element => element.isConnected)
|
||||
.sort((left, right) => (
|
||||
Number(left.dataset.index) - Number(right.dataset.index)
|
||||
))
|
||||
.map(element => element.getBoundingClientRect())
|
||||
.filter(rect => rect.height > 0);
|
||||
const overlaps = rows.some((rect, index) => (
|
||||
index > 0 && rect.top < rows[index - 1].bottom - 1
|
||||
));
|
||||
if (rows.length > 0 && !overlaps) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
virtualRows,
|
||||
totalSize,
|
||||
@@ -143,5 +201,6 @@ export function useSessionTimelineViewport({
|
||||
isFollowingTail,
|
||||
resetForInitialSnapshot,
|
||||
completeInitialSnapshot,
|
||||
waitForStableLayout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export function createSessionUserScroll({
|
||||
quietMs = 450,
|
||||
scrollEndGraceMs = 100,
|
||||
setTimeout: schedule = globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: cancel = globalThis.clearTimeout.bind(globalThis),
|
||||
onEnd = () => {},
|
||||
@@ -22,12 +23,12 @@ export function createSessionUserScroll({
|
||||
if (notify) onEnd();
|
||||
}
|
||||
|
||||
function scheduleFallback() {
|
||||
function scheduleFallback(delay = quietMs) {
|
||||
clearQuietTimer();
|
||||
quietTimer = schedule(() => {
|
||||
quietTimer = null;
|
||||
finish();
|
||||
}, quietMs);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function begin() {
|
||||
@@ -51,7 +52,10 @@ export function createSessionUserScroll({
|
||||
}
|
||||
|
||||
function handleScrollEnd() {
|
||||
finish();
|
||||
// Chromium can emit scrollend between wheel packets even though the user
|
||||
// is still in one physical trackpad gesture. A short grace period lets the
|
||||
// next packet keep ownership without waiting for the full watchdog.
|
||||
scheduleFallback(scrollEndGraceMs);
|
||||
}
|
||||
|
||||
function detach() {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Shared renderer state. Navigation state belongs to Vue Router; this store
|
||||
// holds only data and cross-view UI preferences.
|
||||
|
||||
import { reactive, markRaw } from 'vue';
|
||||
import { reactive, shallowReactive, markRaw } from 'vue';
|
||||
|
||||
export const state = reactive({
|
||||
memories: [],
|
||||
sessions: [],
|
||||
sessionTitleOverrides: shallowReactive(new Map()),
|
||||
projects: [],
|
||||
stats: {},
|
||||
view: 'active', // 'active' | 'archived'
|
||||
@@ -21,6 +22,14 @@ export const state = reactive({
|
||||
loaded: false
|
||||
});
|
||||
|
||||
export function getSessionSummary(sessionId) {
|
||||
const id = String(sessionId || '');
|
||||
const session = state.sessions.find(candidate => candidate.id === id);
|
||||
const title = state.sessionTitleOverrides.get(id);
|
||||
if (title === undefined) return session;
|
||||
return { ...(session || { id }), title };
|
||||
}
|
||||
|
||||
// SVG icon constants
|
||||
export const FOLDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, shallowRef, computed, reactive, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { state, FOLDER_SVG } from '../store.js';
|
||||
import { getCachedSessionDetail, loadSessionDetail, loadSessionDetailPatch, loadFullText } from '../data.js';
|
||||
import { state, FOLDER_SVG, getSessionSummary } from '../store.js';
|
||||
import {
|
||||
fetchSessionDetailPatch,
|
||||
getCachedSessionDetail,
|
||||
loadSessionDetail,
|
||||
loadFullText,
|
||||
materializeSessionDetailPatch,
|
||||
} from '../data.js';
|
||||
import { clearSessionDirty, consumeGlobalSessionDirty, markSessionDirty } from '../session-live.mjs';
|
||||
import { applySnapshot } from '../session-timeline.mjs';
|
||||
import { reconcileTimelineItems } from '../session-timeline-items.mjs';
|
||||
@@ -24,10 +30,14 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// --- Reactive state ---
|
||||
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||
const liveSessionMetadata = shallowRef(null);
|
||||
const session = computed(() => (
|
||||
liveSessionMetadata.value || getSessionSummary(props.id)
|
||||
));
|
||||
const messages = shallowRef([]);
|
||||
const timelineItems = shallowRef([]);
|
||||
const loading = ref(false);
|
||||
const timelineReady = ref(false);
|
||||
const progressPct = ref(0);
|
||||
const active = ref(false);
|
||||
const focusedItemKey = ref(null);
|
||||
@@ -37,6 +47,7 @@ let removeSessionUpdated = null;
|
||||
let keydownAttached = false;
|
||||
let focusTimer = null;
|
||||
let loadRevision = 0;
|
||||
let initialMountComplete = false;
|
||||
|
||||
// DOM refs
|
||||
const wrapRef = ref(null);
|
||||
@@ -55,7 +66,7 @@ const timelineViewport = useSessionTimelineViewport({
|
||||
scrollPaddingEnd: NAV_HEIGHT,
|
||||
userScroll,
|
||||
});
|
||||
const { virtualRows, totalSize, measureElement } = timelineViewport;
|
||||
const { virtualRows, totalSize, measureElement, waitForStableLayout } = timelineViewport;
|
||||
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
||||
isScrolling: () => userScroll.isActive(),
|
||||
load: loadLiveSnapshot,
|
||||
@@ -141,16 +152,23 @@ onMounted(async () => {
|
||||
localStorage.setItem(HINT_KEY, '1');
|
||||
setTimeout(() => { showFontHint.value = false; }, 4000);
|
||||
}
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
||||
await nextTick();
|
||||
syncTimelineScrollMargin();
|
||||
observeSessionHeader();
|
||||
try {
|
||||
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
||||
await nextTick();
|
||||
syncTimelineScrollMargin();
|
||||
observeSessionHeader();
|
||||
} finally {
|
||||
initialMountComplete = true;
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
active.value = true;
|
||||
userScroll.attach(wrapRef.value);
|
||||
attachKeydown();
|
||||
// KeepAlive invokes onActivated during the initial mount as well. The
|
||||
// onMounted path already owns that first load and layout reveal.
|
||||
if (!initialMountComplete) return;
|
||||
if (route.query.focus) {
|
||||
state.pendingFocusUuid = route.query.focus;
|
||||
}
|
||||
@@ -192,8 +210,10 @@ watch(() => props.id, async (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();
|
||||
@@ -214,17 +234,38 @@ async function loadMessages({ force = false } = {}) {
|
||||
if (!requestedSessionId) return;
|
||||
const revision = ++loadRevision;
|
||||
const hadContent = messages.value.length > 0;
|
||||
let latest;
|
||||
let committed = false;
|
||||
|
||||
loading.value = !hadContent;
|
||||
if (!hadContent) timelineReady.value = false;
|
||||
try {
|
||||
latest = await fetchSessionSnapshot(requestedSessionId, { force });
|
||||
const latest = await fetchSessionSnapshot(requestedSessionId, { force });
|
||||
if (revision !== loadRevision || requestedSessionId !== props.id) return;
|
||||
await commitSessionSnapshot(latest);
|
||||
committed = true;
|
||||
} finally {
|
||||
if (revision === loadRevision) loading.value = false;
|
||||
if (revision === loadRevision) {
|
||||
loading.value = false;
|
||||
if (!committed) timelineReady.value = true;
|
||||
}
|
||||
}
|
||||
if (!hadContent) await revealColdTimeline(revision, requestedSessionId);
|
||||
}
|
||||
|
||||
async function revealColdTimeline(revision, sessionId) {
|
||||
await nextTick();
|
||||
if (revision !== loadRevision || sessionId !== props.id) return;
|
||||
syncTimelineScrollMargin();
|
||||
if (timelineItems.value.length === 0) {
|
||||
timelineReady.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (revision !== loadRevision || requestedSessionId !== props.id) return;
|
||||
await commitSessionSnapshot(latest);
|
||||
await waitForStableLayout({
|
||||
isCurrent: () => revision === loadRevision && sessionId === props.id,
|
||||
});
|
||||
if (revision !== loadRevision || sessionId !== props.id) return;
|
||||
timelineReady.value = true;
|
||||
}
|
||||
|
||||
async function fetchSessionSnapshot(sessionId, { force = false } = {}) {
|
||||
@@ -241,8 +282,8 @@ async function loadLiveSnapshot() {
|
||||
const sessionId = props.id;
|
||||
if (!sessionId) return null;
|
||||
const revision = ++loadRevision;
|
||||
const latest = await loadSessionDetailPatch(sessionId);
|
||||
return { sessionId, revision, latest };
|
||||
const patchRequest = await fetchSessionDetailPatch(sessionId);
|
||||
return { sessionId, revision, patchRequest };
|
||||
}
|
||||
|
||||
async function commitLiveSnapshot(snapshot) {
|
||||
@@ -250,8 +291,13 @@ async function commitLiveSnapshot(snapshot) {
|
||||
markSessionDirty(snapshot.sessionId);
|
||||
return;
|
||||
}
|
||||
await commitSessionSnapshot(snapshot.latest);
|
||||
const accepted = snapshot.latest?.acceptMessagePatch?.() ?? true;
|
||||
const latest = await materializeSessionDetailPatch(snapshot.patchRequest);
|
||||
if (snapshot.revision !== loadRevision || snapshot.sessionId !== props.id) {
|
||||
markSessionDirty(snapshot.sessionId);
|
||||
return;
|
||||
}
|
||||
await commitSessionSnapshot(latest);
|
||||
const accepted = latest?.acceptMessagePatch?.() ?? true;
|
||||
if (accepted) clearSessionDirty(snapshot.sessionId);
|
||||
else markSessionDirty(snapshot.sessionId);
|
||||
}
|
||||
@@ -260,6 +306,7 @@ async function commitSessionSnapshot(latest) {
|
||||
// The route can mount before the initial session list arrives. Keep
|
||||
// first-snapshot tail following disabled until an actual session exists.
|
||||
if (!latest) return;
|
||||
liveSessionMetadata.value = latest;
|
||||
const incoming = latest?.messages || [];
|
||||
const tailPatch = latest.messagePatch?.tailOnly
|
||||
? {
|
||||
@@ -417,13 +464,13 @@ function navigateToSubagent(agentId) {
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="empty" style="padding: 60px 0; text-align: center; color: var(--muted);">
|
||||
<div v-if="loading || !timelineReady" class="empty first-open-loading">
|
||||
Loading session...
|
||||
</div>
|
||||
|
||||
<!-- Session header -->
|
||||
<template v-if="session && !loading">
|
||||
<div class="session-header" ref="headerRef">
|
||||
<div class="session-header" :class="{ 'is-preparing': !timelineReady }" ref="headerRef">
|
||||
<div class="session-eyebrow">
|
||||
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
|
||||
@@ -452,6 +499,7 @@ function navigateToSubagent(agentId) {
|
||||
<div
|
||||
ref="timelineRef"
|
||||
class="timeline virtual-timeline"
|
||||
:class="{ 'is-preparing': !timelineReady }"
|
||||
:style="{ height: `${totalSize}px` }"
|
||||
>
|
||||
<div
|
||||
@@ -503,12 +551,27 @@ function navigateToSubagent(agentId) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
position: relative;
|
||||
}
|
||||
.detail-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
.first-open-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.session-header.is-preparing,
|
||||
.virtual-timeline.is-preparing {
|
||||
visibility: hidden;
|
||||
}
|
||||
.virtual-timeline {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user