From 1e4e5ec2d82483f35d523d1142207d51e47f4675 Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Wed, 15 Jul 2026 22:12:40 +0800 Subject: [PATCH] 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. --- app/src/main/index.ts | 29 +- app/src/renderer/src/App.vue | 11 +- app/src/renderer/src/data.js | 59 ++- app/src/renderer/src/main.js | 28 +- .../renderer/src/session-global-refresh.mjs | 86 +++++ app/src/renderer/src/session-live-reload.mjs | 3 + .../src/session-timeline-viewport.mjs | 63 ++- app/src/renderer/src/session-user-scroll.mjs | 10 +- app/src/renderer/src/store.js | 11 +- app/src/renderer/src/views/SessionDetail.vue | 101 ++++- app/src/shared/ipc-types.ts | 15 + app/tests/electron-session-virtualization.mjs | 365 +++++++++++++++++- tests/session-global-refresh.test.mjs | 122 ++++++ tests/session-live-reload.test.mjs | 44 ++- .../session-timeline-virtualization.test.mjs | 25 +- tests/session-user-scroll.test.mjs | 6 +- 16 files changed, 906 insertions(+), 72 deletions(-) create mode 100644 app/src/renderer/src/session-global-refresh.mjs create mode 100644 tests/session-global-refresh.test.mjs diff --git a/app/src/main/index.ts b/app/src/main/index.ts index dbfa692..c44344a 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -13,6 +13,7 @@ import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/sr import type { SessionPatchCursor, SessionPatchSnapshot, + SessionMetadata, SourceQueryOptions, } from '../shared/ipc-types.ts'; import type { @@ -463,10 +464,31 @@ function querySessionDisplaySnapshot(sessionId: string): SessionPatchSnapshot { }; } +const SESSION_METADATA_COLUMNS = [ + 'id', + 'title', + 'project', + 'project_path', + 'started_at', + 'ended_at', + 'git_branch', + 'version', + 'message_count', + 'jsonl_path', + 'source', +].join(', '); + +function querySessionMetadata(sessionId: string): SessionMetadata | null { + if (!db) return null; + return ( + db.prepare(`SELECT ${SESSION_METADATA_COLUMNS} FROM sessions WHERE id = ?`).get(sessionId) as SessionMetadata | undefined + ) || null; +} + ipcMain.handle('db:getSessions', (_, opts = {}) => { if (!db) return []; const { project, limit = 200 } = opts; - let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path, source FROM sessions`; + let sql = `SELECT ${SESSION_METADATA_COLUMNS} FROM sessions`; const params: unknown[] = []; const sourceFilter = sourceWhereClause(opts); if (sourceFilter.sql) { @@ -505,7 +527,10 @@ ipcMain.handle('db:getSessionPatch', ( cursor: SessionPatchCursor, ) => { if (!db) return null; - return createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor); + return { + ...createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor), + session: querySessionMetadata(sessionId), + }; }); ipcMain.handle('db:getSubagentMessages', (_, agentId) => { diff --git a/app/src/renderer/src/App.vue b/app/src/renderer/src/App.vue index a6c2d53..29e8c05 100644 --- a/app/src/renderer/src/App.vue +++ b/app/src/renderer/src/App.vue @@ -3,6 +3,7 @@ import { computed, watch, ref, provide, onMounted, onUnmounted } from 'vue'; import { useRouter, useRoute } from 'vue-router'; import { state, + getSessionSummary, FOLDER_SVG, resetListState, setView, @@ -21,6 +22,10 @@ const router = useRouter(); const route = useRoute(); let searchTimer = null; +const routeSession = computed(() => { + return getSessionSummary(route.params.id); +}); + // --- Sidebar data --- const activeCount = computed(() => state.memories.filter(m => !m.archived).length); @@ -84,7 +89,7 @@ const windowTitle = computed(() => { scopeText = 'Settings'; } else if (route.name?.startsWith('Session')) { if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') { - const s = state.sessions.find(x => x.id === route.params.id); + const s = routeSession.value; scopeText = s ? `Sessions · ${s.title}` : 'Sessions'; } else { const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : ''; @@ -470,13 +475,13 @@ provide('recapGenerateOpen', recapGenerateOpen);