diff --git a/app/src/renderer/src/session-view-state.mjs b/app/src/renderer/src/session-view-state.mjs index 3590dbc..226e65c 100644 --- a/app/src/renderer/src/session-view-state.mjs +++ b/app/src/renderer/src/session-view-state.mjs @@ -9,6 +9,71 @@ function scrollItems(detail) { return arrayFrom(detail?.querySelectorAll?.(SCROLL_ITEM_SELECTOR)); } +export function createSessionDomIndex(detail) { + const items = scrollItems(detail); + const byUuid = new Map(); + const byMessageUuid = new Map(); + for (const item of items) { + const uuid = item.dataset?.uuid; + const messageUuid = item.dataset?.messageUuid || uuid; + if (uuid) byUuid.set(uuid, item); + if (!messageUuid) continue; + const roots = byMessageUuid.get(messageUuid) || []; + roots.push(item); + byMessageUuid.set(messageUuid, roots); + } + return { items, byUuid, byMessageUuid }; +} + +function applyDisclosureState(element, state) { + element.classList?.add(...state.classes); + if (!state.rawOpen) return; + element.querySelector?.('.toolcall-raw')?.classList?.add('show'); + element.querySelector?.('.toolcall-pretty')?.classList?.add('hidden'); + element.querySelector?.('.raw-toggle')?.classList?.add('active'); +} + +function viewElements(root) { + const elements = []; + if (root?.matches?.('[data-view-key]')) elements.push(root); + elements.push(...arrayFrom(root?.querySelectorAll?.('[data-view-key]'))); + return elements; +} + +export function createSessionDisclosureRegistry() { + const states = new Map(); + return { + remember(element) { + const key = element?.dataset?.viewKey; + if (!key) return; + const messageRoot = element.closest?.('[data-message-uuid]'); + const messageUuid = messageRoot?.dataset?.messageUuid; + if (!messageUuid) return; + const classes = DISCLOSURE_CLASSES.filter(className => element.classList?.contains(className)); + const rawOpen = Boolean(element.querySelector?.('.toolcall-raw')?.classList?.contains('show')); + if (!classes.length && !rawOpen) { + states.delete(key); + return; + } + states.set(key, { messageUuid, classes, rawOpen }); + }, + reconcile(domIndex, { updatedIds = [], removedIds = [] } = {}) { + const removed = new Set(removedIds); + for (const [key, state] of states) { + if (removed.has(state.messageUuid)) states.delete(key); + } + for (const messageUuid of updatedIds) { + for (const root of domIndex?.byMessageUuid?.get(messageUuid) || []) { + for (const element of viewElements(root)) { + const state = states.get(element.dataset?.viewKey); + if (state?.messageUuid === messageUuid) applyDisclosureState(element, state); + } + } + } + }, + }; +} + export function isFollowingSessionTail(wrap, bottomThreshold = 50) { if (!wrap) return false; return wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < bottomThreshold; @@ -19,22 +84,30 @@ export function restoreSessionTail({ wrap, followTail, restoreScroll = true } = wrap.scrollTop = wrap.scrollHeight; } -export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = {}) { +function firstMessageEndingBelowIndex(messages, line) { + let low = 0; + let high = messages.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (messages[middle].getBoundingClientRect().bottom > line) { + high = middle; + } else { + low = middle + 1; + } + } + return low; +} + +export function captureSessionViewState({ wrap, domIndex, bottomThreshold = 50 } = {}) { if (!wrap) return null; const followTail = isFollowingSessionTail(wrap, bottomThreshold); const wrapTop = wrap.getBoundingClientRect?.().top || 0; + const anchorIndex = followTail + ? -1 + : firstMessageEndingBelowIndex(domIndex?.items || [], wrapTop); const anchorElement = followTail ? null - : scrollItems(detail).find(element => element.getBoundingClientRect().bottom > wrapTop); - - const disclosures = []; - for (const element of arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))) { - const key = element.dataset?.viewKey; - if (!key) continue; - const classes = DISCLOSURE_CLASSES.filter(className => element.classList?.contains(className)); - const rawOpen = Boolean(element.querySelector?.('.toolcall-raw')?.classList?.contains('show')); - if (classes.length || rawOpen) disclosures.push({ key, classes, rawOpen }); - } + : domIndex?.items?.[anchorIndex]; return { followTail, @@ -42,32 +115,16 @@ export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = anchor: anchorElement?.dataset?.uuid ? { uuid: anchorElement.dataset.uuid, + messageUuid: anchorElement.dataset.messageUuid || anchorElement.dataset.uuid, offset: anchorElement.getBoundingClientRect().top - wrapTop, } : null, - disclosures, }; } -export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll = true } = {}) { +export function restoreSessionViewState(snapshot, { wrap, domIndex, restoreScroll = true } = {}) { if (!snapshot || !wrap) return; - const disclosuresByKey = new Map( - arrayFrom(detail?.querySelectorAll?.('[data-view-key]')) - .filter(element => element.dataset?.viewKey) - .map(element => [element.dataset.viewKey, element]), - ); - - for (const disclosure of snapshot.disclosures || []) { - const element = disclosuresByKey.get(disclosure.key); - if (!element) continue; - element.classList?.add(...disclosure.classes); - if (!disclosure.rawOpen) continue; - element.querySelector?.('.toolcall-raw')?.classList?.add('show'); - element.querySelector?.('.toolcall-pretty')?.classList?.add('hidden'); - element.querySelector?.('.raw-toggle')?.classList?.add('active'); - } - if (!restoreScroll) return; if (snapshot.followTail) { @@ -78,9 +135,8 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll wrap.scrollTop = snapshot.scrollTop; if (!snapshot.anchor) return; const wrapTop = wrap.getBoundingClientRect?.().top || 0; - const anchorElement = scrollItems(detail).find( - element => element.dataset?.uuid === snapshot.anchor.uuid, - ); + const anchorElement = domIndex?.byUuid?.get(snapshot.anchor.uuid) + || domIndex?.byMessageUuid?.get(snapshot.anchor.messageUuid)?.[0]; if (!anchorElement) return; const currentOffset = anchorElement.getBoundingClientRect().top - wrapTop; wrap.scrollTop += currentOffset - snapshot.anchor.offset; @@ -88,17 +144,5 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll export function findLastMessageAtOrAbove(messages, bottomLine) { if (!messages?.length) return -1; - let low = 0; - let high = messages.length - 1; - let result = 0; - while (low <= high) { - const middle = Math.floor((low + high) / 2); - if (messages[middle].getBoundingClientRect().bottom <= bottomLine) { - result = middle; - low = middle + 1; - } else { - high = middle - 1; - } - } - return result; + return Math.max(0, firstMessageEndingBelowIndex(messages, bottomLine) - 1); } diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index 515e5b8..fcd226f 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -9,6 +9,8 @@ import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer import FlapNumber from '../components/FlapNumber.vue'; import { captureSessionViewState, + createSessionDisclosureRegistry, + createSessionDomIndex, findLastMessageAtOrAbove, isFollowingSessionTail, restoreSessionTail, @@ -41,6 +43,8 @@ let scrollRevision = 0; // DOM refs const wrapRef = ref(null); const detailRef = ref(null); +let sessionDomIndex = createSessionDomIndex(null); +let disclosureRegistry = createSessionDisclosureRegistry(); // --- Load session on mount or when id changes --- const FONT_SIZE_KEY = 'obelisk:session-font-size'; @@ -138,6 +142,7 @@ onUnmounted(() => { watch(() => props.id, async (newId, oldId) => { if (newId && newId !== oldId) { messages.value = []; + disclosureRegistry = createSessionDisclosureRegistry(); progressPct.value = 0; currentMsgIdx.value = 0; await loadMessages({ force: consumeGlobalSessionDirty(newId) }); @@ -167,7 +172,7 @@ async function loadMessages({ force = false } = {}) { if (hadContent && !reconciliation.tailOnly) { viewState = captureSessionViewState({ wrap: wrapRef.value, - detail: detailRef.value, + domIndex: sessionDomIndex, }); } messages.value = reconciliation.messages; @@ -182,7 +187,8 @@ async function loadMessages({ force = false } = {}) { } await nextTick(); - syncTotalMessages(); + syncTimelineDom(); + disclosureRegistry.reconcile(sessionDomIndex, reconciliation); if (!state.pendingFocusUuid) { if (reconciliation.tailOnly) { restoreSessionTail({ @@ -193,7 +199,7 @@ async function loadMessages({ force = false } = {}) { } else { restoreSessionViewState(viewState, { wrap: wrapRef.value, - detail: detailRef.value, + domIndex: sessionDomIndex, restoreScroll: scrollRevision === scrollRevisionBeforePatch, }); } @@ -231,9 +237,9 @@ const totalMsgs = ref(0); let navLock = false; let scrollFrame = null; -function syncTotalMessages() { - const msgs = detailRef.value?.querySelectorAll('.msg, .wf-card, .skill-card'); - totalMsgs.value = msgs?.length || 0; +function syncTimelineDom() { + sessionDomIndex = createSessionDomIndex(detailRef.value); + totalMsgs.value = sessionDomIndex.items.length; } function onScroll(event) { @@ -252,8 +258,8 @@ function setMessagePosition(index, total) { } function updateScrollProgress() { - if (!wrapRef.value || !detailRef.value) return; - const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card'); + if (!wrapRef.value) return; + const msgs = sessionDomIndex.items; if (!msgs.length) { currentMsgIdx.value = 0; progressPct.value = 0; @@ -268,8 +274,8 @@ function updateScrollProgress() { } function navTo(target) { - if (!wrapRef.value || !detailRef.value) return; - const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card'); + if (!wrapRef.value) return; + const msgs = sessionDomIndex.items; if (!msgs.length) return; let idx; if (target === 'first') idx = 0; @@ -290,24 +296,11 @@ function navTo(target) { } // --- Toggle helpers --- -function toggleToolCall(event) { - const btn = event.currentTarget; - btn.closest('.msg-tool').classList.toggle('open'); -} - -function toggleSummary(event) { - const btn = event.currentTarget; - btn.closest('.msg-summary').classList.toggle('open'); -} - -function toggleThinking(event) { - const btn = event.currentTarget; - btn.closest('.msg-thinking').classList.toggle('open'); -} - -function toggleMeta(event) { - const btn = event.currentTarget; - btn.closest('.msg-meta-collapsed').classList.toggle('open'); +function toggleDisclosure(event, selector, className = 'open') { + const element = event.currentTarget?.closest(selector); + if (!element) return; + element.classList.toggle(className); + disclosureRegistry.remember(element); } // --- Full text loading --- @@ -576,17 +569,13 @@ function toggleRaw(event) { const showing = raw.classList.toggle('show'); pretty.classList.toggle('hidden', showing); btn?.classList.toggle('active', showing); + disclosureRegistry.remember(body.closest('[data-view-key]')); } function getSkillMd(msg) { return msg?._skillMd || null; } -function toggleSkillMd(event) { - const card = event.target.closest('.skill-card'); - if (card) card.classList.toggle('skill-md-open'); -} - function getToolCallParsedInput(tc) { try { return JSON.parse(tc.input_json || '{}'); @@ -637,14 +626,14 @@ function getToolCallParsedInput(tc) { -