From 7e6e9d9fc3ff4fb5418976aba047eff0394ef158 Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Mon, 13 Jul 2026 22:31:12 +0800 Subject: [PATCH] fix(app): keep live session updates incremental Preserve unchanged message identities and isolate Vue row rendering during live refreshes. Use a tail-only scroll path so updates do not interrupt active readers or expanded tools. Add regression coverage for 908-message appends, changed snapshots, and scroll policy. --- app/src/renderer/src/data.js | 2 +- app/src/renderer/src/session-timeline.mjs | 66 ++++++++++++ app/src/renderer/src/session-view-state.mjs | 28 +++-- app/src/renderer/src/views/SessionDetail.vue | 77 ++++++++------ tests/session-timeline.test.mjs | 102 +++++++++++++++++++ tests/session-view-state.test.mjs | 81 +++++++++++---- 6 files changed, 288 insertions(+), 68 deletions(-) create mode 100644 app/src/renderer/src/session-timeline.mjs create mode 100644 tests/session-timeline.test.mjs diff --git a/app/src/renderer/src/data.js b/app/src/renderer/src/data.js index 6837df8..01863cc 100644 --- a/app/src/renderer/src/data.js +++ b/app/src/renderer/src/data.js @@ -242,7 +242,7 @@ export async function loadSessionDetail(sessionId) { const assembled = { ...(session || {}), id: sessionId, - messages: assembledMessages + messages: markRaw(assembledMessages) }; if (workflow) { diff --git a/app/src/renderer/src/session-timeline.mjs b/app/src/renderer/src/session-timeline.mjs new file mode 100644 index 0000000..bcab275 --- /dev/null +++ b/app/src/renderer/src/session-timeline.mjs @@ -0,0 +1,66 @@ +function sameSnapshotValue(current, incoming) { + if (Object.is(current, incoming)) return true; + if (current === null || incoming === null) return false; + if (typeof current !== 'object' || typeof incoming !== 'object') return false; + + const currentIsArray = Array.isArray(current); + if (currentIsArray !== Array.isArray(incoming)) return false; + + const currentKeys = Object.keys(current); + const incomingKeys = Object.keys(incoming); + if (currentKeys.length !== incomingKeys.length) return false; + + for (const key of currentKeys) { + if (!Object.hasOwn(incoming, key)) return false; + if (!sameSnapshotValue(current[key], incoming[key])) return false; + } + return true; +} + +/** + * Reconcile a complete timeline snapshot while preserving the identity of + * messages whose rendered content did not change. + */ +export function applySnapshot(current = [], incoming = []) { + const currentByUuid = new Map( + current + .filter(message => message?.uuid) + .map(message => [message.uuid, message]), + ); + const incomingUuids = new Set( + incoming.filter(message => message?.uuid).map(message => message.uuid), + ); + const addedIds = []; + const updatedIds = []; + const messages = incoming.map((message, index) => { + const uuid = message?.uuid; + const existing = uuid ? currentByUuid.get(uuid) : current[index]; + if (!existing || (uuid && existing.uuid !== uuid)) { + if (uuid) addedIds.push(uuid); + return message; + } + if (sameSnapshotValue(existing, message)) return existing; + if (uuid) updatedIds.push(uuid); + return message; + }); + const removedIds = current + .filter(message => message?.uuid && !incomingUuids.has(message.uuid)) + .map(message => message.uuid); + const changed = messages.length !== current.length + || messages.some((message, index) => message !== current[index]); + const unchangedPrefix = incoming.length > current.length + && current.every((message, index) => messages[index] === message); + const tailOnly = changed + && unchangedPrefix + && updatedIds.length === 0 + && removedIds.length === 0; + + return { + messages: changed ? messages : current, + addedIds, + updatedIds, + removedIds, + changed, + tailOnly, + }; +} diff --git a/app/src/renderer/src/session-view-state.mjs b/app/src/renderer/src/session-view-state.mjs index 03600d2..3590dbc 100644 --- a/app/src/renderer/src/session-view-state.mjs +++ b/app/src/renderer/src/session-view-state.mjs @@ -9,10 +9,19 @@ function scrollItems(detail) { return arrayFrom(detail?.querySelectorAll?.(SCROLL_ITEM_SELECTOR)); } +export function isFollowingSessionTail(wrap, bottomThreshold = 50) { + if (!wrap) return false; + return wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < bottomThreshold; +} + +export function restoreSessionTail({ wrap, followTail, restoreScroll = true } = {}) { + if (!wrap || !followTail || !restoreScroll) return; + wrap.scrollTop = wrap.scrollHeight; +} + export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = {}) { if (!wrap) return null; - const distanceFromBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight; - const followTail = distanceFromBottom < bottomThreshold; + const followTail = isFollowingSessionTail(wrap, bottomThreshold); const wrapTop = wrap.getBoundingClientRect?.().top || 0; const anchorElement = followTail ? null @@ -62,7 +71,7 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll if (!restoreScroll) return; if (snapshot.followTail) { - wrap.scrollTop = wrap.scrollHeight; + restoreSessionTail({ wrap, followTail: true }); return; } @@ -77,19 +86,6 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll wrap.scrollTop += currentOffset - snapshot.anchor.offset; } -export function reconcileSessionMessages(current = [], incoming = []) { - const currentByUuid = new Map( - current.filter(message => message?.uuid).map(message => [message.uuid, message]), - ); - return incoming.map(message => { - if (!message?.uuid) return message; - const existing = currentByUuid.get(message.uuid); - if (!existing) return message; - Object.assign(existing, message); - return existing; - }); -} - export function findLastMessageAtOrAbove(messages, bottomLine) { if (!messages?.length) return -1; let low = 0; diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index 9741978..515e5b8 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -1,15 +1,17 @@