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:
+27
-2
@@ -13,6 +13,7 @@ import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/sr
|
|||||||
import type {
|
import type {
|
||||||
SessionPatchCursor,
|
SessionPatchCursor,
|
||||||
SessionPatchSnapshot,
|
SessionPatchSnapshot,
|
||||||
|
SessionMetadata,
|
||||||
SourceQueryOptions,
|
SourceQueryOptions,
|
||||||
} from '../shared/ipc-types.ts';
|
} from '../shared/ipc-types.ts';
|
||||||
import type {
|
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 = {}) => {
|
ipcMain.handle('db:getSessions', (_, opts = {}) => {
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
const { project, limit = 200 } = opts;
|
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 params: unknown[] = [];
|
||||||
const sourceFilter = sourceWhereClause(opts);
|
const sourceFilter = sourceWhereClause(opts);
|
||||||
if (sourceFilter.sql) {
|
if (sourceFilter.sql) {
|
||||||
@@ -505,7 +527,10 @@ ipcMain.handle('db:getSessionPatch', (
|
|||||||
cursor: SessionPatchCursor,
|
cursor: SessionPatchCursor,
|
||||||
) => {
|
) => {
|
||||||
if (!db) return null;
|
if (!db) return null;
|
||||||
return createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor);
|
return {
|
||||||
|
...createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor),
|
||||||
|
session: querySessionMetadata(sessionId),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
|
ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, watch, ref, provide, onMounted, onUnmounted } from 'vue';
|
|||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
state,
|
state,
|
||||||
|
getSessionSummary,
|
||||||
FOLDER_SVG,
|
FOLDER_SVG,
|
||||||
resetListState,
|
resetListState,
|
||||||
setView,
|
setView,
|
||||||
@@ -21,6 +22,10 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
|
|
||||||
|
const routeSession = computed(() => {
|
||||||
|
return getSessionSummary(route.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Sidebar data ---
|
// --- Sidebar data ---
|
||||||
|
|
||||||
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
|
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
|
||||||
@@ -84,7 +89,7 @@ const windowTitle = computed(() => {
|
|||||||
scopeText = 'Settings';
|
scopeText = 'Settings';
|
||||||
} else if (route.name?.startsWith('Session')) {
|
} else if (route.name?.startsWith('Session')) {
|
||||||
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
|
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';
|
scopeText = s ? `Sessions · ${s.title}` : 'Sessions';
|
||||||
} else {
|
} else {
|
||||||
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
||||||
@@ -470,13 +475,13 @@ provide('recapGenerateOpen', recapGenerateOpen);
|
|||||||
<template v-if="route.name === 'SubagentDetail'">
|
<template v-if="route.name === 'SubagentDetail'">
|
||||||
<span class="crumb-sep">/</span>
|
<span class="crumb-sep">/</span>
|
||||||
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
|
<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>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="route.name === 'SessionDetail'">
|
<template v-if="route.name === 'SessionDetail'">
|
||||||
<span class="crumb-sep">/</span>
|
<span class="crumb-sep">/</span>
|
||||||
<span class="crumb terminal">
|
<span class="crumb terminal">
|
||||||
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
|
{{ routeSession?.title || route.params.id }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="route.name === 'SubagentDetail'">
|
<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);
|
const session = state.sessions.find(candidate => candidate.id === sessionId);
|
||||||
if (session?.messages?.length) session.messages = markRaw([]);
|
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,
|
* Fetch the global catalogue without mutating renderer state. Navigation can
|
||||||
* and state.projects.
|
* 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([
|
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
|
||||||
window.obelisk.getMemories(),
|
window.obelisk.getMemories(),
|
||||||
window.obelisk.getSessions({ source: 'all', limit: 1000 }),
|
window.obelisk.getSessions({ source: 'all', limit: 1000 }),
|
||||||
window.obelisk.getStats(),
|
window.obelisk.getStats(),
|
||||||
window.obelisk.getProjects()
|
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
|
// Transform memories: DB records -> render-layer shape
|
||||||
state.memories = (rawMemories || []).map(m => ({
|
state.memories = (rawMemories || []).map(m => ({
|
||||||
...m,
|
...m,
|
||||||
@@ -47,6 +63,9 @@ export async function loadInitialData() {
|
|||||||
markdown: null // loaded on demand via loadMemoryMarkdown
|
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
|
// Sessions: merge with existing data to preserve already-loaded messages
|
||||||
const existingSessions = new Map(state.sessions.map(s => [s.id, s]));
|
const existingSessions = new Map(state.sessions.map(s => [s.id, s]));
|
||||||
state.sessions = (rawSessions || []).map(s => {
|
state.sessions = (rawSessions || []).map(s => {
|
||||||
@@ -81,26 +100,36 @@ export async function loadSessionDetail(sessionId) {
|
|||||||
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }),
|
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }),
|
||||||
workflows,
|
workflows,
|
||||||
};
|
};
|
||||||
|
const metadata = sessionMetadata(state.sessions.find(candidate => candidate.id === sessionId));
|
||||||
rememberSessionMessageSnapshot(sessionId, {
|
rememberSessionMessageSnapshot(sessionId, {
|
||||||
snapshot,
|
snapshot,
|
||||||
cursor: createSessionPatchCursor(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);
|
const current = sessionMessageSnapshots.get(sessionId);
|
||||||
if (!current || typeof window.obelisk.getSessionPatch !== 'function') {
|
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);
|
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 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 = () => {
|
latest.acceptMessagePatch = () => {
|
||||||
if (sessionMessageSnapshots.get(sessionId) !== current) return false;
|
if (sessionMessageSnapshots.get(sessionId) !== current) return false;
|
||||||
rememberSessionMessageSnapshot(sessionId, next);
|
rememberSessionMessageSnapshot(sessionId, { ...next, session: metadata });
|
||||||
invalidateStoredSessionMessages(sessionId);
|
commitStoredSessionMetadata(sessionId, metadata);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
latest.messagePatch = {
|
latest.messagePatch = {
|
||||||
@@ -119,13 +148,17 @@ export async function loadSessionDetailPatch(sessionId) {
|
|||||||
export function getCachedSessionDetail(sessionId) {
|
export function getCachedSessionDetail(sessionId) {
|
||||||
const current = sessionMessageSnapshots.get(sessionId);
|
const current = sessionMessageSnapshots.get(sessionId);
|
||||||
if (!current) return null;
|
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 session = state.sessions.find(candidate => candidate.id === sessionId);
|
||||||
const assembled = {
|
const assembled = {
|
||||||
...(session || {}),
|
...(session || {}),
|
||||||
|
...(metadata || {}),
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
messages: markRaw(messages),
|
messages: markRaw(messages),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router.js';
|
import router from './router.js';
|
||||||
import { loadInitialData } from './data.js';
|
import { commitInitialData, fetchInitialData } from './data.js';
|
||||||
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
|
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
|
||||||
|
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
|
||||||
|
|
||||||
// Import shared renderer CSS globally
|
// Import shared renderer CSS globally
|
||||||
import '../styles/base.css';
|
import '../styles/base.css';
|
||||||
@@ -17,20 +18,39 @@ const app = createApp(App);
|
|||||||
|
|
||||||
app.use(router);
|
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
|
// Load data on startup
|
||||||
router.isReady().then(() => {
|
router.isReady().then(() => {
|
||||||
loadInitialData();
|
reportGlobalRefreshFailure(globalDataRefresh.initialize());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
reportGlobalRefreshFailure(globalDataRefresh.flush());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh data when window regains focus
|
// Refresh data when window regains focus
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
loadInitialData();
|
reportGlobalRefreshFailure(globalDataRefresh.invalidate());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.obelisk?.onIndexUpdated?.(() => {
|
window.obelisk?.onIndexUpdated?.(() => {
|
||||||
loadInitialData();
|
reportGlobalRefreshFailure(globalDataRefresh.invalidate());
|
||||||
});
|
});
|
||||||
|
|
||||||
window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
|
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() {
|
async function processPending() {
|
||||||
if (stopped || (!pending && !loadedSnapshot)) return inFlight;
|
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;
|
if (inFlight) return inFlight;
|
||||||
inFlight = drain();
|
inFlight = drain();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, ref } from 'vue';
|
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';
|
import { createSessionTimelineScrollPolicy } from './session-timeline-scroll-policy.mjs';
|
||||||
|
|
||||||
function estimatedTextHeight(text = '') {
|
function estimatedTextHeight(text = '') {
|
||||||
@@ -26,6 +26,37 @@ export function estimateTimelineItemSize(item) {
|
|||||||
+ (message._thinking ? 34 : 0);
|
+ (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({
|
export function useSessionTimelineViewport({
|
||||||
items,
|
items,
|
||||||
scrollElement,
|
scrollElement,
|
||||||
@@ -40,7 +71,12 @@ export function useSessionTimelineViewport({
|
|||||||
isUserScrolling: () => userScroll?.isActive() ?? false,
|
isUserScrolling: () => userScroll?.isActive() ?? false,
|
||||||
writeScroll: elementScroll,
|
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,
|
count: items.value.length,
|
||||||
getScrollElement: () => scrollElement.value,
|
getScrollElement: () => scrollElement.value,
|
||||||
estimateSize: index => estimateTimelineItemSize(items.value[index]),
|
estimateSize: index => estimateTimelineItemSize(items.value[index]),
|
||||||
@@ -48,6 +84,7 @@ export function useSessionTimelineViewport({
|
|||||||
scrollMargin: scrollMargin.value,
|
scrollMargin: scrollMargin.value,
|
||||||
scrollPaddingEnd,
|
scrollPaddingEnd,
|
||||||
overscan,
|
overscan,
|
||||||
|
rangeExtractor,
|
||||||
gap,
|
gap,
|
||||||
anchorTo: 'end',
|
anchorTo: 'end',
|
||||||
followOnAppend: false,
|
followOnAppend: false,
|
||||||
@@ -133,6 +170,27 @@ export function useSessionTimelineViewport({
|
|||||||
tailFollowReady.value = true;
|
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 {
|
return {
|
||||||
virtualRows,
|
virtualRows,
|
||||||
totalSize,
|
totalSize,
|
||||||
@@ -143,5 +201,6 @@ export function useSessionTimelineViewport({
|
|||||||
isFollowingTail,
|
isFollowingTail,
|
||||||
resetForInitialSnapshot,
|
resetForInitialSnapshot,
|
||||||
completeInitialSnapshot,
|
completeInitialSnapshot,
|
||||||
|
waitForStableLayout,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export function createSessionUserScroll({
|
export function createSessionUserScroll({
|
||||||
quietMs = 450,
|
quietMs = 450,
|
||||||
|
scrollEndGraceMs = 100,
|
||||||
setTimeout: schedule = globalThis.setTimeout.bind(globalThis),
|
setTimeout: schedule = globalThis.setTimeout.bind(globalThis),
|
||||||
clearTimeout: cancel = globalThis.clearTimeout.bind(globalThis),
|
clearTimeout: cancel = globalThis.clearTimeout.bind(globalThis),
|
||||||
onEnd = () => {},
|
onEnd = () => {},
|
||||||
@@ -22,12 +23,12 @@ export function createSessionUserScroll({
|
|||||||
if (notify) onEnd();
|
if (notify) onEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleFallback() {
|
function scheduleFallback(delay = quietMs) {
|
||||||
clearQuietTimer();
|
clearQuietTimer();
|
||||||
quietTimer = schedule(() => {
|
quietTimer = schedule(() => {
|
||||||
quietTimer = null;
|
quietTimer = null;
|
||||||
finish();
|
finish();
|
||||||
}, quietMs);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
function begin() {
|
function begin() {
|
||||||
@@ -51,7 +52,10 @@ export function createSessionUserScroll({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleScrollEnd() {
|
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() {
|
function detach() {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Shared renderer state. Navigation state belongs to Vue Router; this store
|
// Shared renderer state. Navigation state belongs to Vue Router; this store
|
||||||
// holds only data and cross-view UI preferences.
|
// holds only data and cross-view UI preferences.
|
||||||
|
|
||||||
import { reactive, markRaw } from 'vue';
|
import { reactive, shallowReactive, markRaw } from 'vue';
|
||||||
|
|
||||||
export const state = reactive({
|
export const state = reactive({
|
||||||
memories: [],
|
memories: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
sessionTitleOverrides: shallowReactive(new Map()),
|
||||||
projects: [],
|
projects: [],
|
||||||
stats: {},
|
stats: {},
|
||||||
view: 'active', // 'active' | 'archived'
|
view: 'active', // 'active' | 'archived'
|
||||||
@@ -21,6 +22,14 @@ export const state = reactive({
|
|||||||
loaded: false
|
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
|
// 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>`;
|
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>
|
<script setup>
|
||||||
import { ref, shallowRef, computed, reactive, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
|
import { ref, shallowRef, computed, reactive, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { state, FOLDER_SVG } from '../store.js';
|
import { state, FOLDER_SVG, getSessionSummary } from '../store.js';
|
||||||
import { getCachedSessionDetail, loadSessionDetail, loadSessionDetailPatch, loadFullText } from '../data.js';
|
import {
|
||||||
|
fetchSessionDetailPatch,
|
||||||
|
getCachedSessionDetail,
|
||||||
|
loadSessionDetail,
|
||||||
|
loadFullText,
|
||||||
|
materializeSessionDetailPatch,
|
||||||
|
} from '../data.js';
|
||||||
import { clearSessionDirty, consumeGlobalSessionDirty, markSessionDirty } from '../session-live.mjs';
|
import { clearSessionDirty, consumeGlobalSessionDirty, markSessionDirty } from '../session-live.mjs';
|
||||||
import { applySnapshot } from '../session-timeline.mjs';
|
import { applySnapshot } from '../session-timeline.mjs';
|
||||||
import { reconcileTimelineItems } from '../session-timeline-items.mjs';
|
import { reconcileTimelineItems } from '../session-timeline-items.mjs';
|
||||||
@@ -24,10 +30,14 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// --- Reactive state ---
|
// --- 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 messages = shallowRef([]);
|
||||||
const timelineItems = shallowRef([]);
|
const timelineItems = shallowRef([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
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);
|
||||||
@@ -37,6 +47,7 @@ let removeSessionUpdated = null;
|
|||||||
let keydownAttached = false;
|
let keydownAttached = false;
|
||||||
let focusTimer = null;
|
let focusTimer = null;
|
||||||
let loadRevision = 0;
|
let loadRevision = 0;
|
||||||
|
let initialMountComplete = false;
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
const wrapRef = ref(null);
|
const wrapRef = ref(null);
|
||||||
@@ -55,7 +66,7 @@ const timelineViewport = useSessionTimelineViewport({
|
|||||||
scrollPaddingEnd: NAV_HEIGHT,
|
scrollPaddingEnd: NAV_HEIGHT,
|
||||||
userScroll,
|
userScroll,
|
||||||
});
|
});
|
||||||
const { virtualRows, totalSize, measureElement } = timelineViewport;
|
const { virtualRows, totalSize, measureElement, waitForStableLayout } = timelineViewport;
|
||||||
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
||||||
isScrolling: () => userScroll.isActive(),
|
isScrolling: () => userScroll.isActive(),
|
||||||
load: loadLiveSnapshot,
|
load: loadLiveSnapshot,
|
||||||
@@ -141,16 +152,23 @@ onMounted(async () => {
|
|||||||
localStorage.setItem(HINT_KEY, '1');
|
localStorage.setItem(HINT_KEY, '1');
|
||||||
setTimeout(() => { showFontHint.value = false; }, 4000);
|
setTimeout(() => { showFontHint.value = false; }, 4000);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
||||||
await nextTick();
|
await nextTick();
|
||||||
syncTimelineScrollMargin();
|
syncTimelineScrollMargin();
|
||||||
observeSessionHeader();
|
observeSessionHeader();
|
||||||
|
} finally {
|
||||||
|
initialMountComplete = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
active.value = true;
|
active.value = true;
|
||||||
userScroll.attach(wrapRef.value);
|
userScroll.attach(wrapRef.value);
|
||||||
attachKeydown();
|
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) {
|
if (route.query.focus) {
|
||||||
state.pendingFocusUuid = route.query.focus;
|
state.pendingFocusUuid = route.query.focus;
|
||||||
}
|
}
|
||||||
@@ -192,8 +210,10 @@ watch(() => props.id, async (newId, oldId) => {
|
|||||||
loadRevision++;
|
loadRevision++;
|
||||||
userScroll.clearUpwardIntent();
|
userScroll.clearUpwardIntent();
|
||||||
timelineViewport.resetForInitialSnapshot();
|
timelineViewport.resetForInitialSnapshot();
|
||||||
|
liveSessionMetadata.value = null;
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
timelineItems.value = [];
|
timelineItems.value = [];
|
||||||
|
timelineReady.value = false;
|
||||||
disclosures.retainMessages(new Set());
|
disclosures.retainMessages(new Set());
|
||||||
expandedMessageText.clear();
|
expandedMessageText.clear();
|
||||||
fullTextLoading.clear();
|
fullTextLoading.clear();
|
||||||
@@ -214,17 +234,38 @@ async function loadMessages({ force = false } = {}) {
|
|||||||
if (!requestedSessionId) return;
|
if (!requestedSessionId) return;
|
||||||
const revision = ++loadRevision;
|
const revision = ++loadRevision;
|
||||||
const hadContent = messages.value.length > 0;
|
const hadContent = messages.value.length > 0;
|
||||||
let latest;
|
let committed = false;
|
||||||
|
|
||||||
loading.value = !hadContent;
|
loading.value = !hadContent;
|
||||||
|
if (!hadContent) timelineReady.value = false;
|
||||||
try {
|
try {
|
||||||
latest = await fetchSessionSnapshot(requestedSessionId, { force });
|
const latest = await fetchSessionSnapshot(requestedSessionId, { force });
|
||||||
} finally {
|
|
||||||
if (revision === loadRevision) loading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (revision !== loadRevision || requestedSessionId !== props.id) return;
|
if (revision !== loadRevision || requestedSessionId !== props.id) return;
|
||||||
await commitSessionSnapshot(latest);
|
await commitSessionSnapshot(latest);
|
||||||
|
committed = true;
|
||||||
|
} finally {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForStableLayout({
|
||||||
|
isCurrent: () => revision === loadRevision && sessionId === props.id,
|
||||||
|
});
|
||||||
|
if (revision !== loadRevision || sessionId !== props.id) return;
|
||||||
|
timelineReady.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSessionSnapshot(sessionId, { force = false } = {}) {
|
async function fetchSessionSnapshot(sessionId, { force = false } = {}) {
|
||||||
@@ -241,8 +282,8 @@ async function loadLiveSnapshot() {
|
|||||||
const sessionId = props.id;
|
const sessionId = props.id;
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
const revision = ++loadRevision;
|
const revision = ++loadRevision;
|
||||||
const latest = await loadSessionDetailPatch(sessionId);
|
const patchRequest = await fetchSessionDetailPatch(sessionId);
|
||||||
return { sessionId, revision, latest };
|
return { sessionId, revision, patchRequest };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commitLiveSnapshot(snapshot) {
|
async function commitLiveSnapshot(snapshot) {
|
||||||
@@ -250,8 +291,13 @@ async function commitLiveSnapshot(snapshot) {
|
|||||||
markSessionDirty(snapshot.sessionId);
|
markSessionDirty(snapshot.sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await commitSessionSnapshot(snapshot.latest);
|
const latest = await materializeSessionDetailPatch(snapshot.patchRequest);
|
||||||
const accepted = snapshot.latest?.acceptMessagePatch?.() ?? true;
|
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);
|
if (accepted) clearSessionDirty(snapshot.sessionId);
|
||||||
else markSessionDirty(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
|
// The route can mount before the initial session list arrives. Keep
|
||||||
// first-snapshot tail following disabled until an actual session exists.
|
// first-snapshot tail following disabled until an actual session exists.
|
||||||
if (!latest) return;
|
if (!latest) return;
|
||||||
|
liveSessionMetadata.value = latest;
|
||||||
const incoming = latest?.messages || [];
|
const incoming = latest?.messages || [];
|
||||||
const tailPatch = latest.messagePatch?.tailOnly
|
const tailPatch = latest.messagePatch?.tailOnly
|
||||||
? {
|
? {
|
||||||
@@ -417,13 +464,13 @@ function navigateToSubagent(agentId) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- 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...
|
Loading session...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Session header -->
|
<!-- Session header -->
|
||||||
<template v-if="session && !loading">
|
<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">
|
<div class="session-eyebrow">
|
||||||
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||||
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
|
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
|
||||||
@@ -452,6 +499,7 @@ function navigateToSubagent(agentId) {
|
|||||||
<div
|
<div
|
||||||
ref="timelineRef"
|
ref="timelineRef"
|
||||||
class="timeline virtual-timeline"
|
class="timeline virtual-timeline"
|
||||||
|
:class="{ 'is-preparing': !timelineReady }"
|
||||||
:style="{ height: `${totalSize}px` }"
|
:style="{ height: `${totalSize}px` }"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -503,12 +551,27 @@ function navigateToSubagent(agentId) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.detail {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.detail-wrap {
|
.detail-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
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 {
|
.virtual-timeline {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -16,11 +16,26 @@ export type SessionPatchRow = Record<string, unknown>;
|
|||||||
export type SessionPatchSnapshot = Partial<Record<SessionPatchTable, SessionPatchRow[]>>;
|
export type SessionPatchSnapshot = Partial<Record<SessionPatchTable, SessionPatchRow[]>>;
|
||||||
export type SessionPatchCursor = Record<SessionPatchTable, Record<string, string>>;
|
export type SessionPatchCursor = Record<SessionPatchTable, Record<string, string>>;
|
||||||
|
|
||||||
|
export interface SessionMetadata {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
project?: string | null;
|
||||||
|
project_path?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
ended_at?: string | null;
|
||||||
|
git_branch?: string | null;
|
||||||
|
version?: string | null;
|
||||||
|
message_count?: number | null;
|
||||||
|
jsonl_path?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionPatch {
|
export interface SessionPatch {
|
||||||
changes: Record<SessionPatchTable, SessionPatchRow[]>;
|
changes: Record<SessionPatchTable, SessionPatchRow[]>;
|
||||||
removed: Record<SessionPatchTable, string[]>;
|
removed: Record<SessionPatchTable, string[]>;
|
||||||
hashes: Record<SessionPatchTable, Record<string, string>>;
|
hashes: Record<SessionPatchTable, Record<string, string>>;
|
||||||
positions: Record<SessionPatchTable, Record<string, number>>;
|
positions: Record<SessionPatchTable, Record<string, number>>;
|
||||||
|
session?: SessionMetadata | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppliedSessionPatch {
|
export interface AppliedSessionPatch {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Production renderer integration test for the dynamic SessionDetail timeline.
|
// Production renderer integration test for the dynamic SessionDetail timeline.
|
||||||
// Run: npm run test:electron:timeline
|
// Run: npm run test:electron:timeline
|
||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
import { app, BrowserWindow, ipcMain, nativeImage } from 'electron';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
@@ -36,6 +36,9 @@ const channels = [
|
|||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
let firstSessionListRead = true;
|
let firstSessionListRead = true;
|
||||||
|
let nextPatchDelayMs = 0;
|
||||||
|
let stressGlobalCatalogue = false;
|
||||||
|
let currentSessionTitle = 'Virtualized timeline integration';
|
||||||
const ipcReads = {
|
const ipcReads = {
|
||||||
messages: 0,
|
messages: 0,
|
||||||
toolCalls: 0,
|
toolCalls: 0,
|
||||||
@@ -46,6 +49,12 @@ const ipcReads = {
|
|||||||
patches: 0,
|
patches: 0,
|
||||||
patchMessageRows: [],
|
patchMessageRows: [],
|
||||||
};
|
};
|
||||||
|
const globalReads = {
|
||||||
|
sessions: 0,
|
||||||
|
memories: 0,
|
||||||
|
projects: 0,
|
||||||
|
stats: 0,
|
||||||
|
};
|
||||||
const messages = Array.from({ length: messageCount }, (_, index) => ({
|
const messages = Array.from({ length: messageCount }, (_, index) => ({
|
||||||
uuid: `message-${index}`,
|
uuid: `message-${index}`,
|
||||||
type: index % 2 === 0 ? 'user' : 'assistant',
|
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||||
@@ -60,6 +69,10 @@ messages[focusMessageIndex].type = 'assistant';
|
|||||||
messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repeat(700)}`;
|
messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repeat(700)}`;
|
||||||
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
||||||
const codexExecSource = 'const result = { ok: true };\nreturn result;';
|
const codexExecSource = 'const result = { ok: true };\nreturn result;';
|
||||||
|
const liveBashToolInput = {
|
||||||
|
command: "cat > /tmp/q_jul15b.mjs <<'EOF'\nconst codex = sessions({ source: 'codex', project: '%quiet-zero%', limit: 3 });\n\nconst tail = sql(`\n SELECT substr(text, 1, 500) as snippet, timestamp, role\n FROM messages\n WHERE session_id = ?\n AND timestamp > '2026-07-14T18:20:00'\n AND text IS NOT NULL\n AND COALESCE(is_meta, 0) = 0\n AND length(text) > 30\n ORDER BY timestamp DESC\n LIMIT 5\n`, codex[0]?.id);\n\n// Any new codex sessions for quiet-zero\nconst newer = sql(`\n SELECT id, title, started_at, ended_at, message_count\n FROM sessions\n WHERE COALESCE(source,'claude') = 'codex'\n AND project LIKE '%quiet-zero%'\n AND started_at > '2026-07-14T18:00:00'\n ORDER BY started_at DESC\n LIMIT 5\n`);\n\nreturn {\n main: { id: codex[0]?.id, ended: codex[0]?.ended_at, msgs: codex[0]?.message_count },\n afterLastSync: tail,\n newerSessions: newer,\n};\nEOF\nnode /Users/tomiya/.claude/skills/obelisk/scripts/runtime.js --query /tmp/q_jul15b.mjs",
|
||||||
|
description: 'Query for activity since last sync',
|
||||||
|
};
|
||||||
let codexExecOutput = JSON.stringify([{
|
let codexExecOutput = JSON.stringify([{
|
||||||
type: 'input_text',
|
type: 'input_text',
|
||||||
text: 'Script completed\nWall time 0.1 seconds\nOutput:\n{"ok":true}',
|
text: 'Script completed\nWall time 0.1 seconds\nOutput:\n{"ok":true}',
|
||||||
@@ -88,7 +101,7 @@ const toolResults = [{
|
|||||||
function sessionSummary() {
|
function sessionSummary() {
|
||||||
return {
|
return {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
title: 'Virtualized timeline integration',
|
title: currentSessionTitle,
|
||||||
project: 'quiet-zero',
|
project: 'quiet-zero',
|
||||||
project_path: '/tmp/quiet-zero',
|
project_path: '/tmp/quiet-zero',
|
||||||
source: 'claude',
|
source: 'claude',
|
||||||
@@ -99,6 +112,17 @@ function sessionSummary() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionSummaries() {
|
||||||
|
return [sessionSummary(), ...Array.from({ length: 999 }, (_, index) => ({
|
||||||
|
...sessionSummary(),
|
||||||
|
id: `background-session-${index}`,
|
||||||
|
title: `Background session ${index}`,
|
||||||
|
project: `project-${index % 250}`,
|
||||||
|
project_path: `/tmp/project-${index % 250}`,
|
||||||
|
message_count: index % 200,
|
||||||
|
}))];
|
||||||
|
}
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (condition) console.log(`PASS: ${message}`);
|
if (condition) console.log(`PASS: ${message}`);
|
||||||
else {
|
else {
|
||||||
@@ -116,7 +140,7 @@ async function waitFor(webContents, expression, message, timeoutMs = 8000) {
|
|||||||
throw new Error(`Timed out waiting for ${message}`);
|
throw new Error(`Timed out waiting for ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startRendererTrace(win) {
|
async function startRendererTrace(win, { captureScreenshots = false } = {}) {
|
||||||
const traceEvents = [];
|
const traceEvents = [];
|
||||||
let completeTrace;
|
let completeTrace;
|
||||||
const traceComplete = new Promise(resolve => { completeTrace = resolve; });
|
const traceComplete = new Promise(resolve => { completeTrace = resolve; });
|
||||||
@@ -127,7 +151,13 @@ async function startRendererTrace(win) {
|
|||||||
win.webContents.debugger.attach('1.3');
|
win.webContents.debugger.attach('1.3');
|
||||||
win.webContents.debugger.on('message', onMessage);
|
win.webContents.debugger.on('message', onMessage);
|
||||||
await win.webContents.debugger.sendCommand('Tracing.start', {
|
await win.webContents.debugger.sendCommand('Tracing.start', {
|
||||||
categories: 'devtools.timeline,disabled-by-default-devtools.timeline,blink.user_timing,toplevel',
|
categories: [
|
||||||
|
'devtools.timeline',
|
||||||
|
'disabled-by-default-devtools.timeline',
|
||||||
|
'blink.user_timing',
|
||||||
|
'toplevel',
|
||||||
|
captureScreenshots ? 'disabled-by-default-devtools.screenshot' : '',
|
||||||
|
].filter(Boolean).join(','),
|
||||||
options: 'record-as-much-as-possible',
|
options: 'record-as-much-as-possible',
|
||||||
transferMode: 'ReportEvents',
|
transferMode: 'ReportEvents',
|
||||||
});
|
});
|
||||||
@@ -140,6 +170,139 @@ async function startRendererTrace(win) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function screenshotContentDeviation(event) {
|
||||||
|
const image = nativeImage.createFromBuffer(Buffer.from(event.args.snapshot, 'base64'));
|
||||||
|
const size = image.getSize();
|
||||||
|
const crop = image.crop({
|
||||||
|
x: Math.floor(size.width * 0.32),
|
||||||
|
y: Math.floor(size.height * 0.2),
|
||||||
|
width: Math.max(1, Math.floor(size.width * 0.5)),
|
||||||
|
height: Math.max(1, Math.floor(size.height * 0.6)),
|
||||||
|
});
|
||||||
|
const bitmap = crop.toBitmap();
|
||||||
|
let sum = 0;
|
||||||
|
let sumSquares = 0;
|
||||||
|
let samples = 0;
|
||||||
|
for (let offset = 0; offset + 3 < bitmap.length; offset += 4) {
|
||||||
|
const value = (bitmap[offset] + bitmap[offset + 1] + bitmap[offset + 2]) / 3;
|
||||||
|
sum += value;
|
||||||
|
sumSquares += value * value;
|
||||||
|
samples++;
|
||||||
|
}
|
||||||
|
const mean = sum / samples;
|
||||||
|
return Math.sqrt(Math.max(0, sumSquares / samples - mean * mean)) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wheelTraceRun = 0;
|
||||||
|
async function traceWheelPaintContinuity(win, { updateTool = false } = {}) {
|
||||||
|
const runId = wheelTraceRun++;
|
||||||
|
const startMark = `obelisk-wheel-${runId}-start`;
|
||||||
|
const endMark = `obelisk-wheel-${runId}-end`;
|
||||||
|
win.showInactive();
|
||||||
|
await delay(180);
|
||||||
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const tool = document.querySelector('[data-view-key="tool:call-1"]');
|
||||||
|
if (tool && !tool.classList.contains('open')) tool.querySelector('.toolcall-toggle')?.click();
|
||||||
|
})()`, true);
|
||||||
|
await delay(120);
|
||||||
|
const before = await win.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('.detail-wrap')?.scrollTop || 0`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const stopRendererTrace = await startRendererTrace(win, { captureScreenshots: true });
|
||||||
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const tool = document.querySelector('[data-view-key="tool:call-1"]');
|
||||||
|
const probe = {
|
||||||
|
gaps: [],
|
||||||
|
previous: performance.now(),
|
||||||
|
stop: false,
|
||||||
|
wheels: 0,
|
||||||
|
updateVisibleAtWheel: null,
|
||||||
|
};
|
||||||
|
const recordWheel = () => { probe.wheels++; };
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (
|
||||||
|
probe.updateVisibleAtWheel === null
|
||||||
|
&& tool?.querySelector('.prompt-cmd')?.textContent.includes('/tmp/q_jul15b.mjs')
|
||||||
|
) probe.updateVisibleAtWheel = probe.wheels;
|
||||||
|
});
|
||||||
|
wrap?.addEventListener('wheel', recordWheel, { passive: true });
|
||||||
|
if (tool) observer.observe(tool, { childList: true, characterData: true, subtree: true });
|
||||||
|
probe.cleanup = () => {
|
||||||
|
wrap?.removeEventListener('wheel', recordWheel);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
window.__wheelFrameProbe = probe;
|
||||||
|
function frame(now) {
|
||||||
|
probe.gaps.push(now - probe.previous);
|
||||||
|
probe.previous = now;
|
||||||
|
if (!probe.stop) requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
})()`, true);
|
||||||
|
if (updateTool) {
|
||||||
|
nextPatchDelayMs = 80;
|
||||||
|
replaceToolInput(win, 'call-1', liveBashToolInput, { notify: false });
|
||||||
|
}
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`performance.mark(${JSON.stringify(startMark)})`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (let index = 0; index < 8; index++) {
|
||||||
|
win.webContents.sendInputEvent({
|
||||||
|
type: 'mouseWheel',
|
||||||
|
x: 800,
|
||||||
|
y: 400,
|
||||||
|
deltaX: 0,
|
||||||
|
deltaY: -120,
|
||||||
|
canScroll: true,
|
||||||
|
});
|
||||||
|
if (updateTool && index === 2) {
|
||||||
|
// Production sends both notifications for one daemon build. The global
|
||||||
|
// catalogue invalidation must not reload 1000 sessions into the renderer
|
||||||
|
// while the current conversation owns the scroll gesture.
|
||||||
|
win.webContents.send('obelisk:index-updated', { affectedSessionIds: [sessionId] });
|
||||||
|
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||||
|
}
|
||||||
|
await delay(45);
|
||||||
|
}
|
||||||
|
// Stop inside the scrollend grace window. Any patch preparation or DOM
|
||||||
|
// mutation seen here competed with the physical wheel burst.
|
||||||
|
await delay(60);
|
||||||
|
const after = await win.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('.detail-wrap')?.scrollTop || 0`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const frameProbe = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
performance.mark(${JSON.stringify(endMark)});
|
||||||
|
const probe = window.__wheelFrameProbe;
|
||||||
|
probe.stop = true;
|
||||||
|
probe.cleanup();
|
||||||
|
delete window.__wheelFrameProbe;
|
||||||
|
return { gaps: probe.gaps, updateVisibleAtWheel: probe.updateVisibleAtWheel };
|
||||||
|
})()`, true);
|
||||||
|
const traceEvents = await stopRendererTrace();
|
||||||
|
const screenshots = traceEvents
|
||||||
|
.filter(event => event.name === 'Screenshot' && event.args?.snapshot);
|
||||||
|
const deviations = screenshots.map(screenshotContentDeviation);
|
||||||
|
const taskMetrics = rendererTaskMetrics(traceEvents, startMark, endMark);
|
||||||
|
return {
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
screenshots: screenshots.length,
|
||||||
|
minDeviation: Math.min(Infinity, ...deviations),
|
||||||
|
maxFrameGap: Math.max(0, ...frameProbe.gaps),
|
||||||
|
maxTaskMs: taskMetrics.maxTaskMs,
|
||||||
|
maxFunctionCallMs: taskMetrics.maxFunctionCallMs,
|
||||||
|
updateVisibleAtWheel: frameProbe.updateVisibleAtWheel,
|
||||||
|
slowestChildren: taskMetrics.slowestChildren,
|
||||||
|
// A blank content crop is almost uniform (< 0.035); rendered fixture rows
|
||||||
|
// stay comfortably above 0.06 even while the compositor is scrolling.
|
||||||
|
blankFrames: deviations.filter(value => value < 0.035).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function rendererTaskMetrics(traceEvents, startMark, endMark) {
|
function rendererTaskMetrics(traceEvents, startMark, endMark) {
|
||||||
const start = traceEvents.find(event => event.name === startMark);
|
const start = traceEvents.find(event => event.name === startMark);
|
||||||
const end = [...traceEvents].reverse().find(event => event.name === endMark);
|
const end = [...traceEvents].reverse().find(event => event.name === endMark);
|
||||||
@@ -173,6 +336,16 @@ function rendererTaskMetrics(traceEvents, startMark, endMark) {
|
|||||||
return {
|
return {
|
||||||
tasks: taskDurations.length,
|
tasks: taskDurations.length,
|
||||||
maxTaskMs: Math.max(0, ...taskDurations),
|
maxTaskMs: Math.max(0, ...taskDurations),
|
||||||
|
maxFunctionCallMs: Math.max(0, ...traceEvents
|
||||||
|
.filter(event => (
|
||||||
|
event.name === 'FunctionCall'
|
||||||
|
&& event.ph === 'X'
|
||||||
|
&& event.pid === start.pid
|
||||||
|
&& event.tid === start.tid
|
||||||
|
&& event.ts >= start.ts
|
||||||
|
&& event.ts <= end.ts
|
||||||
|
))
|
||||||
|
.map(event => (event.dur || 0) / 1000)),
|
||||||
slowestChildren,
|
slowestChildren,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -211,31 +384,38 @@ async function traceStationaryAppend(win, index, expectedTotal, runIndex) {
|
|||||||
|
|
||||||
function registerHandlers() {
|
function registerHandlers() {
|
||||||
ipcMain.handle('db:getSessions', async () => {
|
ipcMain.handle('db:getSessions', async () => {
|
||||||
|
globalReads.sessions++;
|
||||||
if (firstSessionListRead) {
|
if (firstSessionListRead) {
|
||||||
firstSessionListRead = false;
|
firstSessionListRead = false;
|
||||||
await delay(120);
|
await delay(120);
|
||||||
}
|
}
|
||||||
return [sessionSummary()];
|
return stressGlobalCatalogue ? sessionSummaries() : [sessionSummary()];
|
||||||
});
|
});
|
||||||
ipcMain.handle('db:getSessionMessages', () => { ipcReads.messages++; return messages; });
|
ipcMain.handle('db:getSessionMessages', () => { ipcReads.messages++; return messages; });
|
||||||
ipcMain.handle('db:getSessionToolCalls', () => { ipcReads.toolCalls++; return toolCalls; });
|
ipcMain.handle('db:getSessionToolCalls', () => { ipcReads.toolCalls++; return toolCalls; });
|
||||||
ipcMain.handle('db:getSessionToolResults', () => { ipcReads.toolResults++; return toolResults; });
|
ipcMain.handle('db:getSessionToolResults', () => { ipcReads.toolResults++; return toolResults; });
|
||||||
ipcMain.handle('db:getSessionPatch', (_event, _sessionId, cursor) => {
|
ipcMain.handle('db:getSessionPatch', async (_event, _sessionId, cursor) => {
|
||||||
ipcReads.patches++;
|
ipcReads.patches++;
|
||||||
|
const delayMs = nextPatchDelayMs;
|
||||||
|
nextPatchDelayMs = 0;
|
||||||
|
if (delayMs > 0) await delay(delayMs);
|
||||||
const patch = createSessionPatch({
|
const patch = createSessionPatch({
|
||||||
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents: [], workflows: [] }),
|
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents: [], workflows: [] }),
|
||||||
workflows: [],
|
workflows: [],
|
||||||
}, cursor);
|
}, cursor);
|
||||||
ipcReads.patchMessageRows.push(patch.changes.messages.length);
|
ipcReads.patchMessageRows.push(patch.changes.messages.length);
|
||||||
return patch;
|
return { ...patch, session: sessionSummary() };
|
||||||
});
|
});
|
||||||
ipcMain.handle('db:getSessionSubagents', () => { ipcReads.subagents++; return []; });
|
ipcMain.handle('db:getSessionSubagents', () => { ipcReads.subagents++; return []; });
|
||||||
ipcMain.handle('db:getSessionWorkflows', () => { ipcReads.workflows++; return []; });
|
ipcMain.handle('db:getSessionWorkflows', () => { ipcReads.workflows++; return []; });
|
||||||
ipcMain.handle('db:getSessionSummaries', () => { ipcReads.summaries++; return []; });
|
ipcMain.handle('db:getSessionSummaries', () => { ipcReads.summaries++; return []; });
|
||||||
ipcMain.handle('db:getMessageFullText', (_event, uuid) => uuid === focusMessageUuid ? fullTextSentinel : null);
|
ipcMain.handle('db:getMessageFullText', (_event, uuid) => uuid === focusMessageUuid ? fullTextSentinel : null);
|
||||||
ipcMain.handle('db:getMemories', () => []);
|
ipcMain.handle('db:getMemories', () => { globalReads.memories++; return []; });
|
||||||
ipcMain.handle('db:getProjects', () => [{ project: 'quiet-zero', count: 1 }]);
|
ipcMain.handle('db:getProjects', () => {
|
||||||
ipcMain.handle('db:getStats', () => ({}));
|
globalReads.projects++;
|
||||||
|
return [{ project: 'quiet-zero', count: 1 }];
|
||||||
|
});
|
||||||
|
ipcMain.handle('db:getStats', () => { globalReads.stats++; return {}; });
|
||||||
ipcMain.handle('settings:get', () => ({}));
|
ipcMain.handle('settings:get', () => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +445,13 @@ function replaceToolResult(win, toolUseId, content) {
|
|||||||
win.webContents.send('obelisk:session-updated', { sessionId });
|
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceToolInput(win, toolUseId, input, { notify = true } = {}) {
|
||||||
|
const index = toolCalls.findIndex(toolCall => toolCall.id === toolUseId);
|
||||||
|
if (index < 0) throw new Error(`Cannot update missing tool call ${toolUseId}`);
|
||||||
|
toolCalls[index] = { ...toolCalls[index], input_json: JSON.stringify(input) };
|
||||||
|
if (notify) win.webContents.send('obelisk:session-updated', { sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
registerHandlers();
|
registerHandlers();
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
@@ -279,13 +466,72 @@ async function run() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), {
|
await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), {
|
||||||
hash: `/sessions/${sessionId}`,
|
hash: '/sessions',
|
||||||
});
|
});
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.body.textContent.includes('Virtualized timeline integration')`,
|
||||||
|
'the session list before cold open',
|
||||||
|
);
|
||||||
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const probe = {
|
||||||
|
maxOverlaps: 0,
|
||||||
|
framesWithOverlap: 0,
|
||||||
|
samples: 0,
|
||||||
|
examples: [],
|
||||||
|
stop: false,
|
||||||
|
};
|
||||||
|
window.__coldOpenOverlapProbe = probe;
|
||||||
|
function sample() {
|
||||||
|
if (probe.stop) return;
|
||||||
|
const timeline = document.querySelector('.virtual-timeline');
|
||||||
|
const rows = timeline && getComputedStyle(timeline).visibility !== 'hidden'
|
||||||
|
? [...timeline.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.map(row => row.getBoundingClientRect())
|
||||||
|
.filter(rect => rect.height > 0)
|
||||||
|
: [];
|
||||||
|
let overlaps = 0;
|
||||||
|
for (let index = 1; index < rows.length; index++) {
|
||||||
|
if (rows[index].top < rows[index - 1].bottom - 1) overlaps++;
|
||||||
|
}
|
||||||
|
probe.maxOverlaps = Math.max(probe.maxOverlaps, overlaps);
|
||||||
|
if (overlaps > 0) {
|
||||||
|
probe.framesWithOverlap++;
|
||||||
|
if (probe.examples.length < 2) {
|
||||||
|
probe.examples.push({
|
||||||
|
overlaps,
|
||||||
|
totalSize: document.querySelector('.virtual-timeline')?.style.height,
|
||||||
|
rows: [...document.querySelectorAll('.virtual-timeline-row')].slice(0, 4).map(row => ({
|
||||||
|
index: row.dataset.index,
|
||||||
|
transform: row.style.transform,
|
||||||
|
top: row.getBoundingClientRect().top,
|
||||||
|
height: row.getBoundingClientRect().height,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probe.samples++;
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
window.location.hash = ${JSON.stringify(`/sessions/${sessionId}`)};
|
||||||
|
})()`, true);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
win.webContents,
|
win.webContents,
|
||||||
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount}'`,
|
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount}'`,
|
||||||
'the cold-start session snapshot',
|
'the cold-start session snapshot',
|
||||||
);
|
);
|
||||||
|
await delay(100);
|
||||||
|
const coldOpenOverlap = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const probe = window.__coldOpenOverlapProbe;
|
||||||
|
probe.stop = true;
|
||||||
|
delete window.__coldOpenOverlapProbe;
|
||||||
|
return probe;
|
||||||
|
})()`, true);
|
||||||
|
assert(
|
||||||
|
coldOpenOverlap.maxOverlaps === 0,
|
||||||
|
`cold-open timeline never paints intersecting message rows (${JSON.stringify(coldOpenOverlap)})`,
|
||||||
|
);
|
||||||
|
|
||||||
const initial = await win.webContents.executeJavaScript(`(() => ({
|
const initial = await win.webContents.executeJavaScript(`(() => ({
|
||||||
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||||
@@ -362,8 +608,55 @@ async function run() {
|
|||||||
await win.webContents.executeJavaScript(`document.querySelector('button[title="First"]')?.click()`, true);
|
await win.webContents.executeJavaScript(`document.querySelector('button[title="First"]')?.click()`, true);
|
||||||
await delay(350);
|
await delay(350);
|
||||||
|
|
||||||
|
const wheelBaseline = await traceWheelPaintContinuity(win);
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('button[title="First"]')?.click()`, true);
|
||||||
|
await delay(350);
|
||||||
|
const globalReadsBeforeWheelUpdate = { ...globalReads };
|
||||||
|
stressGlobalCatalogue = true;
|
||||||
|
const wheelPaint = await traceWheelPaintContinuity(win, { updateTool: true });
|
||||||
|
stressGlobalCatalogue = false;
|
||||||
|
assert(
|
||||||
|
Math.abs(wheelPaint.after - wheelPaint.before) > 500 && wheelPaint.screenshots >= 4,
|
||||||
|
`wheel trace exercises compositor scrolling (${JSON.stringify(wheelPaint)})`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
wheelPaint.blankFrames === 0,
|
||||||
|
`fast wheel scrolling never presents a blank timeline frame (${JSON.stringify(wheelPaint)})`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
wheelPaint.maxFrameGap < 50,
|
||||||
|
`Bash tool update avoids a multi-frame renderer stall while scrolling (${JSON.stringify(wheelPaint)})`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
wheelPaint.maxFunctionCallMs <= wheelBaseline.maxFunctionCallMs + 2,
|
||||||
|
`Bash patch preparation stays off the scrolling renderer task budget (baseline ${wheelBaseline.maxFunctionCallMs.toFixed(2)}ms, update ${wheelPaint.maxFunctionCallMs.toFixed(2)}ms)`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
wheelPaint.updateVisibleAtWheel === null,
|
||||||
|
`Bash tool update stays out of the timeline DOM for the complete wheel burst (became visible after wheel ${wheelPaint.updateVisibleAtWheel})`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(globalReads) === JSON.stringify(globalReadsBeforeWheelUpdate),
|
||||||
|
`conversation updates do not reload global catalogues while scrolling (${JSON.stringify(globalReads)})`,
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`Boolean(document.querySelector('[data-view-key="tool:call-1"] .prompt-cmd')?.textContent.includes('/tmp/q_jul15b.mjs'))`,
|
||||||
|
'post-scroll Bash tool update',
|
||||||
|
);
|
||||||
|
|
||||||
await win.webContents.executeJavaScript(`window.location.hash = '#/sessions'`, true);
|
await win.webContents.executeJavaScript(`window.location.hash = '#/sessions'`, true);
|
||||||
await waitFor(win.webContents, `!document.querySelector('.virtual-timeline')`, 'session detail deactivation');
|
await waitFor(win.webContents, `!document.querySelector('.virtual-timeline')`, 'session detail deactivation');
|
||||||
|
for (let attempt = 0; attempt < 100 && globalReads.sessions === globalReadsBeforeWheelUpdate.sessions; attempt++) {
|
||||||
|
await delay(20);
|
||||||
|
}
|
||||||
|
const expectedGlobalReadsAfterLeaving = Object.fromEntries(
|
||||||
|
Object.entries(globalReadsBeforeWheelUpdate).map(([key, value]) => [key, value + 1]),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(globalReads) === JSON.stringify(expectedGlobalReadsAfterLeaving),
|
||||||
|
`leaving conversation detail flushes one coalesced global refresh (${JSON.stringify(globalReads)})`,
|
||||||
|
);
|
||||||
await win.webContents.executeJavaScript(`(async () => {
|
await win.webContents.executeJavaScript(`(async () => {
|
||||||
const search = document.querySelector('#search');
|
const search = document.querySelector('#search');
|
||||||
search.value = 'SENTINEL';
|
search.value = 'SENTINEL';
|
||||||
@@ -494,6 +787,13 @@ async function run() {
|
|||||||
));
|
));
|
||||||
await delay(250);
|
await delay(250);
|
||||||
}
|
}
|
||||||
|
const liveHeaderMetadata = await win.webContents.executeJavaScript(`(() => ({
|
||||||
|
text: document.querySelector('.session-meta-inline')?.textContent || '',
|
||||||
|
}))()`, true);
|
||||||
|
assert(
|
||||||
|
liveHeaderMetadata.text.includes(`${messageCount + stationaryAppendRuns} messages`),
|
||||||
|
`session header metadata follows incremental patches without a global refresh (${liveHeaderMetadata.text.trim()})`,
|
||||||
|
);
|
||||||
const stationaryAnchorSelector = `[data-uuid="${stationaryAnchorBefore?.uuid}"]`;
|
const stationaryAnchorSelector = `[data-uuid="${stationaryAnchorBefore?.uuid}"]`;
|
||||||
const stationaryAnchorAfter = await win.webContents.executeJavaScript(`(() => {
|
const stationaryAnchorAfter = await win.webContents.executeJavaScript(`(() => {
|
||||||
const wrap = document.querySelector('.detail-wrap');
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
@@ -525,6 +825,20 @@ async function run() {
|
|||||||
assert(trace.maxTaskMs < 8.33, `stationary live commit ${runIndex + 1} stays inside a 120Hz renderer task budget (${trace.maxTaskMs.toFixed(2)}ms across ${trace.tasks} tasks)`);
|
assert(trace.maxTaskMs < 8.33, `stationary live commit ${runIndex + 1} stays inside a 120Hz renderer task budget (${trace.maxTaskMs.toFixed(2)}ms across ${trace.tasks} tasks)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const probe = { minRows: Infinity, zeroFrames: 0, samples: 0, stop: false };
|
||||||
|
window.__liveTimelineRowProbe = probe;
|
||||||
|
function sample() {
|
||||||
|
if (probe.stop) return;
|
||||||
|
const rows = document.querySelectorAll('.virtual-timeline-row').length;
|
||||||
|
probe.minRows = Math.min(probe.minRows, rows);
|
||||||
|
if (rows === 0) probe.zeroFrames++;
|
||||||
|
probe.samples++;
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
})()`, true);
|
||||||
|
currentSessionTitle = 'Live metadata title';
|
||||||
setTimeout(() => appendMessage(win, scrollingAppendIndex), 250);
|
setTimeout(() => appendMessage(win, scrollingAppendIndex), 250);
|
||||||
const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||||
const wrap = document.querySelector('.detail-wrap');
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
@@ -587,6 +901,25 @@ async function run() {
|
|||||||
`document.querySelector('.flap-slot.flipping')`,
|
`document.querySelector('.flap-slot.flipping')`,
|
||||||
'post-scrollend flap animation',
|
'post-scrollend flap animation',
|
||||||
);
|
);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.title.includes('Live metadata title') && document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title')`,
|
||||||
|
'shared route metadata update',
|
||||||
|
);
|
||||||
|
const sharedMetadataState = await win.webContents.executeJavaScript(`(() => ({
|
||||||
|
windowTitle: document.title.includes('Live metadata title'),
|
||||||
|
breadcrumb: document.querySelector('.breadcrumb')?.textContent.includes('Live metadata title'),
|
||||||
|
}))()`, true);
|
||||||
|
assert(
|
||||||
|
sharedMetadataState.windowTitle && sharedMetadataState.breadcrumb,
|
||||||
|
'session title patch updates the breadcrumb and window title without a catalogue reload',
|
||||||
|
);
|
||||||
|
const liveTimelineContinuity = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const probe = window.__liveTimelineRowProbe;
|
||||||
|
probe.stop = true;
|
||||||
|
delete window.__liveTimelineRowProbe;
|
||||||
|
return { minRows: probe.minRows, zeroFrames: probe.zeroFrames, samples: probe.samples };
|
||||||
|
})()`, true);
|
||||||
const readerState = await win.webContents.executeJavaScript(`(() => {
|
const readerState = await win.webContents.executeJavaScript(`(() => {
|
||||||
const wrap = document.querySelector('.detail-wrap');
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
const anchorElement = document.querySelector(
|
const anchorElement = document.querySelector(
|
||||||
@@ -601,7 +934,11 @@ async function run() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
})()`, true);
|
})()`, true);
|
||||||
assert(scrollProbe.rows < 60, `live scrolling keeps mounted rows bounded (${scrollProbe.rows})`);
|
assert(scrollProbe.rows < 80, `live scrolling keeps mounted rows bounded (${scrollProbe.rows})`);
|
||||||
|
assert(
|
||||||
|
liveTimelineContinuity.minRows > 0 && liveTimelineContinuity.zeroFrames === 0,
|
||||||
|
`live update never clears the mounted timeline (${JSON.stringify(liveTimelineContinuity)})`,
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
scrollProbe.totalBeforeScrollEnd === scrollProbe.totalBeforeGesture,
|
scrollProbe.totalBeforeScrollEnd === scrollProbe.totalBeforeGesture,
|
||||||
`wheel-to-scrollend freezes the visible timeline (${scrollProbe.totalBeforeGesture} -> ${scrollProbe.totalBeforeScrollEnd})`,
|
`wheel-to-scrollend freezes the visible timeline (${scrollProbe.totalBeforeGesture} -> ${scrollProbe.totalBeforeScrollEnd})`,
|
||||||
@@ -664,9 +1001,9 @@ async function run() {
|
|||||||
&& ipcReads.subagents === 1
|
&& ipcReads.subagents === 1
|
||||||
&& ipcReads.workflows === 1
|
&& ipcReads.workflows === 1
|
||||||
&& ipcReads.summaries === 1
|
&& ipcReads.summaries === 1
|
||||||
&& ipcReads.patches === 6
|
&& ipcReads.patches === 7
|
||||||
&& ipcReads.patchMessageRows.every(count => count === 1),
|
&& ipcReads.patchMessageRows.every(count => count === 1),
|
||||||
`live updates use six single-message patches after one full snapshot (${JSON.stringify(ipcReads)})`,
|
`live updates use seven single-message patches after one full snapshot (${JSON.stringify(ipcReads)})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true);
|
await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createGlobalDataRefreshCoordinator } from '../app/src/renderer/src/session-global-refresh.mjs';
|
||||||
|
|
||||||
|
test('conversation detail defers and coalesces global catalogue invalidations until route exit', async () => {
|
||||||
|
let conversationDetailActive = true;
|
||||||
|
let loads = 0;
|
||||||
|
const coordinator = createGlobalDataRefreshCoordinator({
|
||||||
|
isDeferred: () => conversationDetailActive,
|
||||||
|
load: async () => ++loads,
|
||||||
|
commit: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.invalidate();
|
||||||
|
await coordinator.invalidate();
|
||||||
|
await coordinator.invalidate();
|
||||||
|
assert.equal(loads, 0, 'detail updates never start a global catalogue IPC');
|
||||||
|
|
||||||
|
conversationDetailActive = false;
|
||||||
|
await coordinator.flush();
|
||||||
|
assert.equal(loads, 1, 'route exit loads the latest invalidation exactly once');
|
||||||
|
|
||||||
|
await coordinator.flush();
|
||||||
|
assert.equal(loads, 1, 'an idle route flush is a no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial catalogue load is allowed on a cold conversation route', async () => {
|
||||||
|
let loads = 0;
|
||||||
|
const coordinator = createGlobalDataRefreshCoordinator({
|
||||||
|
isDeferred: () => true,
|
||||||
|
load: async () => ++loads,
|
||||||
|
commit: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.initialize();
|
||||||
|
assert.equal(loads, 1, 'deep links still receive the catalogue needed to resolve the session');
|
||||||
|
|
||||||
|
await coordinator.invalidate();
|
||||||
|
assert.equal(loads, 1, 'later daemon invalidations remain deferred');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an invalidation arriving during a load is retained without overlapping loads', async () => {
|
||||||
|
let deferred = false;
|
||||||
|
let loads = 0;
|
||||||
|
let activeLoads = 0;
|
||||||
|
let maxActiveLoads = 0;
|
||||||
|
let releaseFirstLoad;
|
||||||
|
const firstLoadGate = new Promise(resolve => { releaseFirstLoad = resolve; });
|
||||||
|
const coordinator = createGlobalDataRefreshCoordinator({
|
||||||
|
isDeferred: () => deferred,
|
||||||
|
load: async () => {
|
||||||
|
loads++;
|
||||||
|
activeLoads++;
|
||||||
|
maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
|
||||||
|
if (loads === 1) await firstLoadGate;
|
||||||
|
activeLoads--;
|
||||||
|
return loads;
|
||||||
|
},
|
||||||
|
commit: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = coordinator.invalidate();
|
||||||
|
const second = coordinator.invalidate();
|
||||||
|
deferred = true;
|
||||||
|
releaseFirstLoad();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
assert.equal(loads, 1, 'detail activation prevents the queued reload');
|
||||||
|
assert.equal(maxActiveLoads, 1, 'global snapshots never overlap');
|
||||||
|
|
||||||
|
deferred = false;
|
||||||
|
await coordinator.flush();
|
||||||
|
assert.equal(loads, 2, 'route exit catches up with the retained invalidation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed catalogue load remains dirty for the next flush', async () => {
|
||||||
|
let loads = 0;
|
||||||
|
const coordinator = createGlobalDataRefreshCoordinator({
|
||||||
|
isDeferred: () => false,
|
||||||
|
load: async () => {
|
||||||
|
loads++;
|
||||||
|
if (loads === 1) throw new Error('temporary IPC failure');
|
||||||
|
return loads;
|
||||||
|
},
|
||||||
|
commit: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(coordinator.invalidate(), /temporary IPC failure/);
|
||||||
|
await coordinator.flush();
|
||||||
|
assert.equal(loads, 2, 'the failed invalidation is retried instead of being lost');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a catalogue fetched before navigation never commits inside conversation detail', async () => {
|
||||||
|
let deferred = false;
|
||||||
|
let loads = 0;
|
||||||
|
let releaseLoad;
|
||||||
|
const loadGate = new Promise(resolve => { releaseLoad = resolve; });
|
||||||
|
const commits = [];
|
||||||
|
const coordinator = createGlobalDataRefreshCoordinator({
|
||||||
|
isDeferred: () => deferred,
|
||||||
|
load: async () => {
|
||||||
|
loads++;
|
||||||
|
await loadGate;
|
||||||
|
return 'catalogue-snapshot';
|
||||||
|
},
|
||||||
|
commit: snapshot => { commits.push(snapshot); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = coordinator.invalidate();
|
||||||
|
deferred = true;
|
||||||
|
releaseLoad();
|
||||||
|
await request;
|
||||||
|
|
||||||
|
assert.equal(loads, 1);
|
||||||
|
assert.deepEqual(commits, [], 'route activation gates the reactive commit after IPC resolves');
|
||||||
|
|
||||||
|
deferred = false;
|
||||||
|
await coordinator.flush();
|
||||||
|
assert.equal(loads, 1, 'the already-fetched snapshot is reused');
|
||||||
|
assert.deepEqual(commits, ['catalogue-snapshot']);
|
||||||
|
});
|
||||||
@@ -4,14 +4,15 @@ import assert from 'node:assert/strict';
|
|||||||
import { createSessionLiveReloadCoordinator } from '../app/src/renderer/src/session-live-reload.mjs';
|
import { createSessionLiveReloadCoordinator } from '../app/src/renderer/src/session-live-reload.mjs';
|
||||||
import { state } from '../app/src/renderer/src/store.js';
|
import { state } from '../app/src/renderer/src/store.js';
|
||||||
import {
|
import {
|
||||||
|
fetchSessionDetailPatch,
|
||||||
getCachedSessionDetail,
|
getCachedSessionDetail,
|
||||||
loadSessionDetail,
|
loadSessionDetail,
|
||||||
loadSessionDetailPatch,
|
materializeSessionDetailPatch,
|
||||||
} from '../app/src/renderer/src/data.js';
|
} from '../app/src/renderer/src/data.js';
|
||||||
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
|
import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs';
|
||||||
import { createSessionPatch } from '../app/src/shared/session-patch.mjs';
|
import { createSessionPatch } from '../app/src/shared/session-patch.mjs';
|
||||||
|
|
||||||
test('live snapshots keep loading while scrolling and commit only the latest after scroll end', async () => {
|
test('live updates coalesce while scrolling and load only the latest after scroll end', async () => {
|
||||||
let scrolling = true;
|
let scrolling = true;
|
||||||
let loads = 0;
|
let loads = 0;
|
||||||
const commits = [];
|
const commits = [];
|
||||||
@@ -24,16 +25,16 @@ test('live snapshots keep loading while scrolling and commit only the latest aft
|
|||||||
await coordinator.request();
|
await coordinator.request();
|
||||||
await coordinator.request();
|
await coordinator.request();
|
||||||
await coordinator.request();
|
await coordinator.request();
|
||||||
assert.equal(loads, 3, 'patches are loaded into the pending snapshot while the timeline is frozen');
|
assert.equal(loads, 0, 'patch preparation stays off the scrolling renderer task budget');
|
||||||
assert.deepEqual(commits, []);
|
assert.deepEqual(commits, []);
|
||||||
|
|
||||||
scrolling = false;
|
scrolling = false;
|
||||||
await coordinator.flush();
|
await coordinator.flush();
|
||||||
assert.equal(loads, 3, 'scroll end reuses the freshest pending snapshot');
|
assert.equal(loads, 1, 'scroll end loads the latest coalesced state once');
|
||||||
assert.deepEqual(commits, [3]);
|
assert.deepEqual(commits, [1]);
|
||||||
|
|
||||||
await coordinator.flush();
|
await coordinator.flush();
|
||||||
assert.equal(loads, 3, 'an idle flush without another update is a no-op');
|
assert.equal(loads, 1, 'an idle flush without another update is a no-op');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an update arriving during an in-flight load skips the stale snapshot without overlap', async () => {
|
test('an update arriving during an in-flight load skips the stale snapshot without overlap', async () => {
|
||||||
@@ -101,6 +102,7 @@ test('a skipped live patch does not advance the visible patch baseline', async t
|
|||||||
const previousSessions = state.sessions;
|
const previousSessions = state.sessions;
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
state.sessions = previousSessions;
|
state.sessions = previousSessions;
|
||||||
|
state.sessionTitleOverrides.delete(sessionId);
|
||||||
delete globalThis.window;
|
delete globalThis.window;
|
||||||
});
|
});
|
||||||
let rows = [
|
let rows = [
|
||||||
@@ -133,21 +135,30 @@ test('a skipped live patch does not advance the visible patch baseline', async t
|
|||||||
firstPatchStarted();
|
firstPatchStarted();
|
||||||
await firstPatchGate;
|
await firstPatchGate;
|
||||||
}
|
}
|
||||||
return createSessionPatch(snapshotAtCall, cursor);
|
return {
|
||||||
|
...createSessionPatch(snapshotAtCall, cursor),
|
||||||
|
session: {
|
||||||
|
id: sessionId,
|
||||||
|
title: 'Live session title',
|
||||||
|
message_count: rows.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
state.sessions = [{ id: sessionId, messages: [] }];
|
state.sessions = [{ id: sessionId, title: 'Initial title', message_count: 1, messages: [] }];
|
||||||
await loadSessionDetail(sessionId);
|
await loadSessionDetail(sessionId);
|
||||||
|
|
||||||
const commits = [];
|
const commits = [];
|
||||||
const coordinator = createSessionLiveReloadCoordinator({
|
const coordinator = createSessionLiveReloadCoordinator({
|
||||||
isScrolling: () => false,
|
isScrolling: () => false,
|
||||||
load: () => loadSessionDetailPatch(sessionId),
|
load: async () => materializeSessionDetailPatch(await fetchSessionDetailPatch(sessionId)),
|
||||||
commit: async latest => {
|
commit: async latest => {
|
||||||
commits.push({
|
commits.push({
|
||||||
messages: latest.messages.map(message => message.uuid),
|
messages: latest.messages.map(message => message.uuid),
|
||||||
changedIds: latest.messagePatch.changedIds,
|
changedIds: latest.messagePatch.changedIds,
|
||||||
|
title: latest.title,
|
||||||
|
messageCount: latest.message_count,
|
||||||
});
|
});
|
||||||
latest.acceptMessagePatch?.();
|
latest.acceptMessagePatch?.();
|
||||||
},
|
},
|
||||||
@@ -164,17 +175,32 @@ test('a skipped live patch does not advance the visible patch baseline', async t
|
|||||||
assert.deepEqual(commits, [{
|
assert.deepEqual(commits, [{
|
||||||
messages: ['message-1', 'message-2', 'message-3'],
|
messages: ['message-1', 'message-2', 'message-3'],
|
||||||
changedIds: ['message-2', 'message-3'],
|
changedIds: ['message-2', 'message-3'],
|
||||||
|
title: 'Live session title',
|
||||||
|
messageCount: 3,
|
||||||
}]);
|
}]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
getCachedSessionDetail(sessionId).messages.map(message => message.uuid),
|
getCachedSessionDetail(sessionId).messages.map(message => message.uuid),
|
||||||
['message-1', 'message-2', 'message-3'],
|
['message-1', 'message-2', 'message-3'],
|
||||||
'accepted patches become the reusable session-detail snapshot',
|
'accepted patches become the reusable session-detail snapshot',
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
title: getCachedSessionDetail(sessionId).title,
|
||||||
|
messageCount: getCachedSessionDetail(sessionId).message_count,
|
||||||
|
},
|
||||||
|
{ title: 'Live session title', messageCount: 3 },
|
||||||
|
'accepted patches retain the current session metadata without a global catalogue reload',
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
state.sessions.find(session => session.id === sessionId).messages,
|
state.sessions.find(session => session.id === sessionId).messages,
|
||||||
[],
|
[],
|
||||||
'the stale full-snapshot copy is invalidated after patch acceptance',
|
'the stale full-snapshot copy is invalidated after patch acceptance',
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
state.sessionTitleOverrides.get(sessionId),
|
||||||
|
'Live session title',
|
||||||
|
'accepted title changes update the shared breadcrumb/window-title overlay after the visible commit',
|
||||||
|
);
|
||||||
|
|
||||||
const evictionSessionIds = ['eviction-session-1', 'eviction-session-2', 'eviction-session-3'];
|
const evictionSessionIds = ['eviction-session-1', 'eviction-session-2', 'eviction-session-3'];
|
||||||
state.sessions.push(...evictionSessionIds.map(id => ({ id, messages: [] })));
|
state.sessions.push(...evictionSessionIds.map(id => ({ id, messages: [] })));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createViewportRangeExtractor } from '../app/src/renderer/src/session-timeline-viewport.mjs';
|
||||||
|
|
||||||
const sessionDetail = readFileSync(
|
const sessionDetail = readFileSync(
|
||||||
new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url),
|
new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url),
|
||||||
@@ -42,6 +43,7 @@ test('timeline viewport owns measurement and anchoring while SessionDetail alone
|
|||||||
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
|
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
|
||||||
assert.match(viewportModule, /useVirtualizer/);
|
assert.match(viewportModule, /useVirtualizer/);
|
||||||
assert.match(viewportModule, /overscan/);
|
assert.match(viewportModule, /overscan/);
|
||||||
|
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, /resetForInitialSnapshot/);
|
||||||
@@ -62,6 +64,26 @@ test('timeline viewport owns measurement and anchoring while SessionDetail alone
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('timeline viewport buffers by rendered pixels instead of a fixed row count', () => {
|
||||||
|
const rangeExtractor = createViewportRangeExtractor({
|
||||||
|
getScrollElement: () => ({ clientHeight: 700, scrollTop: 5000 }),
|
||||||
|
getVirtualizer: () => ({
|
||||||
|
getVirtualItemForOffset: offset => ({ index: Math.floor(offset / 50) }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexes = rangeExtractor({
|
||||||
|
startIndex: 100,
|
||||||
|
endIndex: 113,
|
||||||
|
overscan: 6,
|
||||||
|
count: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(indexes[0], 44);
|
||||||
|
assert.equal(indexes.at(-1), 170);
|
||||||
|
assert.equal(indexes.length, 127);
|
||||||
|
});
|
||||||
|
|
||||||
test('timeline count and disclosure classes come from renderer state rather than DOM state', () => {
|
test('timeline count and disclosure classes come from renderer state rather than DOM state', () => {
|
||||||
assert.match(sessionDetail, /const totalMsgs = computed\(\(\) => timelineItems\.value\.length\)/);
|
assert.match(sessionDetail, /const totalMsgs = computed\(\(\) => timelineItems\.value\.length\)/);
|
||||||
assert.match(timelineRow, /disclosures\.isOpen/);
|
assert.match(timelineRow, /disclosures\.isOpen/);
|
||||||
@@ -90,9 +112,10 @@ test('live patch state advances only after the visible snapshot commit is accept
|
|||||||
const commitLiveSnapshot = sessionDetail.match(/async function commitLiveSnapshot\(snapshot\) \{([\s\S]*?)\n\}/)?.[1] || '';
|
const commitLiveSnapshot = sessionDetail.match(/async function commitLiveSnapshot\(snapshot\) \{([\s\S]*?)\n\}/)?.[1] || '';
|
||||||
|
|
||||||
assert.doesNotMatch(loadLiveSnapshot, /clearSessionDirty|acceptMessagePatch/);
|
assert.doesNotMatch(loadLiveSnapshot, /clearSessionDirty|acceptMessagePatch/);
|
||||||
|
assert.match(loadLiveSnapshot, /fetchSessionDetailPatch\(sessionId\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
commitLiveSnapshot,
|
commitLiveSnapshot,
|
||||||
/await commitSessionSnapshot\(snapshot\.latest\);[\s\S]*acceptMessagePatch[\s\S]*clearSessionDirty/,
|
/materializeSessionDetailPatch\(snapshot\.patchRequest\);[\s\S]*await commitSessionSnapshot\(latest\);[\s\S]*acceptMessagePatch[\s\S]*clearSessionDirty/,
|
||||||
);
|
);
|
||||||
assert.match(commitLiveSnapshot, /markSessionDirty\(snapshot\.sessionId\)/);
|
assert.match(commitLiveSnapshot, /markSessionDirty\(snapshot\.sessionId\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function dispatch(target, type, properties = {}) {
|
|||||||
target.dispatchEvent(event);
|
target.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', () => {
|
test('native scrollend ends a user scroll after a short wheel-burst grace period', () => {
|
||||||
const scheduler = createScheduler();
|
const scheduler = createScheduler();
|
||||||
const target = new EventTarget();
|
const target = new EventTarget();
|
||||||
target.scrollTop = 100;
|
target.scrollTop = 100;
|
||||||
@@ -46,6 +46,7 @@ test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', ()
|
|||||||
let ended = 0;
|
let ended = 0;
|
||||||
const userScroll = createSessionUserScroll({
|
const userScroll = createSessionUserScroll({
|
||||||
quietMs: 450,
|
quietMs: 450,
|
||||||
|
scrollEndGraceMs: 100,
|
||||||
setTimeout: scheduler.setTimeout,
|
setTimeout: scheduler.setTimeout,
|
||||||
clearTimeout: scheduler.clearTimeout,
|
clearTimeout: scheduler.clearTimeout,
|
||||||
onEnd: () => { ended++; },
|
onEnd: () => { ended++; },
|
||||||
@@ -61,6 +62,9 @@ test('native scrollend, not the virtualizer 150ms reset, ends a user scroll', ()
|
|||||||
assert.equal(ended, 0);
|
assert.equal(ended, 0);
|
||||||
|
|
||||||
dispatch(target, 'scrollend');
|
dispatch(target, 'scrollend');
|
||||||
|
scheduler.advance(99);
|
||||||
|
assert.equal(userScroll.isActive(), true, 'a following wheel packet can retain scroll ownership');
|
||||||
|
scheduler.advance(1);
|
||||||
assert.equal(userScroll.isActive(), false);
|
assert.equal(userScroll.isActive(), false);
|
||||||
assert.equal(ended, 1);
|
assert.equal(ended, 1);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user