diff --git a/app/src/main/index.ts b/app/src/main/index.ts
index dbfa692..c44344a 100644
--- a/app/src/main/index.ts
+++ b/app/src/main/index.ts
@@ -13,6 +13,7 @@ import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/sr
import type {
SessionPatchCursor,
SessionPatchSnapshot,
+ SessionMetadata,
SourceQueryOptions,
} from '../shared/ipc-types.ts';
import type {
@@ -463,10 +464,31 @@ function querySessionDisplaySnapshot(sessionId: string): SessionPatchSnapshot {
};
}
+const SESSION_METADATA_COLUMNS = [
+ 'id',
+ 'title',
+ 'project',
+ 'project_path',
+ 'started_at',
+ 'ended_at',
+ 'git_branch',
+ 'version',
+ 'message_count',
+ 'jsonl_path',
+ 'source',
+].join(', ');
+
+function querySessionMetadata(sessionId: string): SessionMetadata | null {
+ if (!db) return null;
+ return (
+ db.prepare(`SELECT ${SESSION_METADATA_COLUMNS} FROM sessions WHERE id = ?`).get(sessionId) as SessionMetadata | undefined
+ ) || null;
+}
+
ipcMain.handle('db:getSessions', (_, opts = {}) => {
if (!db) return [];
const { project, limit = 200 } = opts;
- let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path, source FROM sessions`;
+ let sql = `SELECT ${SESSION_METADATA_COLUMNS} FROM sessions`;
const params: unknown[] = [];
const sourceFilter = sourceWhereClause(opts);
if (sourceFilter.sql) {
@@ -505,7 +527,10 @@ ipcMain.handle('db:getSessionPatch', (
cursor: SessionPatchCursor,
) => {
if (!db) return null;
- return createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor);
+ return {
+ ...createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor),
+ session: querySessionMetadata(sessionId),
+ };
});
ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
diff --git a/app/src/renderer/src/App.vue b/app/src/renderer/src/App.vue
index a6c2d53..29e8c05 100644
--- a/app/src/renderer/src/App.vue
+++ b/app/src/renderer/src/App.vue
@@ -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);
/
- {{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
+ {{ (routeSession?.title || '').slice(0, 30) || route.params.id }}
/
- {{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
+ {{ routeSession?.title || route.params.id }}
diff --git a/app/src/renderer/src/data.js b/app/src/renderer/src/data.js
index 9b9b8a5..964a2ef 100644
--- a/app/src/renderer/src/data.js
+++ b/app/src/renderer/src/data.js
@@ -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),
};
diff --git a/app/src/renderer/src/main.js b/app/src/renderer/src/main.js
index 9051684..51b05a1 100644
--- a/app/src/renderer/src/main.js
+++ b/app/src/renderer/src/main.js
@@ -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 } = {}) => {
diff --git a/app/src/renderer/src/session-global-refresh.mjs b/app/src/renderer/src/session-global-refresh.mjs
new file mode 100644
index 0000000..437678d
--- /dev/null
+++ b/app/src/renderer/src/session-global-refresh.mjs
@@ -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 });
+ },
+ };
+}
diff --git a/app/src/renderer/src/session-live-reload.mjs b/app/src/renderer/src/session-live-reload.mjs
index 6cb1c98..8ffc0a2 100644
--- a/app/src/renderer/src/session-live-reload.mjs
+++ b/app/src/renderer/src/session-live-reload.mjs
@@ -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 {
diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs
index bddb3e9..fc152f7 100644
--- a/app/src/renderer/src/session-timeline-viewport.mjs
+++ b/app/src/renderer/src/session-timeline-viewport.mjs
@@ -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,
};
}
diff --git a/app/src/renderer/src/session-user-scroll.mjs b/app/src/renderer/src/session-user-scroll.mjs
index 81a52ab..d513f53 100644
--- a/app/src/renderer/src/session-user-scroll.mjs
+++ b/app/src/renderer/src/session-user-scroll.mjs
@@ -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() {
diff --git a/app/src/renderer/src/store.js b/app/src/renderer/src/store.js
index 3b0070c..2de99ec 100644
--- a/app/src/renderer/src/store.js
+++ b/app/src/renderer/src/store.js
@@ -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 = ``;
diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue
index f87c93b..65a704d 100644
--- a/app/src/renderer/src/views/SessionDetail.vue
+++ b/app/src/renderer/src/views/SessionDetail.vue
@@ -1,8 +1,14 @@