diff --git a/app/package-lock.json b/app/package-lock.json index a17f096..097cf06 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "chokidar": "^4.0.3" }, "devDependencies": { + "@tanstack/vue-virtual": "^3.13.32", "@types/better-sqlite3": "^7.6.13", "@vitejs/plugin-vue": "^5.0.0", "electron": "^33.0.0", @@ -1839,6 +1840,34 @@ "node": ">=10" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz", + "integrity": "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.32", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.32.tgz", + "integrity": "sha512-E8OCutx7QnwZdvpJijz0Q2PHsYDWBWjnGr3TvgWiqxTU35jB1kVhtkd93scRV7tTFuId2tg3x2iFiw+IE4evjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", diff --git a/app/package.json b/app/package.json index e1f89c9..994c627 100644 --- a/app/package.json +++ b/app/package.json @@ -12,7 +12,8 @@ "dist:mac": "electron-vite build && electron-builder --mac", "dist:win": "electron-vite build && electron-builder --win", "dist:linux": "electron-vite build && electron-builder --linux", - "test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs" + "test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs", + "test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs" }, "build": { "appId": "com.obelisk.app", @@ -61,6 +62,7 @@ "chokidar": "^4.0.3" }, "devDependencies": { + "@tanstack/vue-virtual": "^3.13.32", "@types/better-sqlite3": "^7.6.13", "@vitejs/plugin-vue": "^5.0.0", "electron": "^33.0.0", diff --git a/app/src/renderer/src/session-disclosures.mjs b/app/src/renderer/src/session-disclosures.mjs new file mode 100644 index 0000000..d34bdfe --- /dev/null +++ b/app/src/renderer/src/session-disclosures.mjs @@ -0,0 +1,32 @@ +import { reactive } from 'vue'; + +export function createSessionDisclosureState() { + const entries = reactive(new Map()); + + function update(key, messageUuid, field) { + const current = entries.get(key) || { messageUuid, open: false, raw: false }; + const next = { ...current, messageUuid, [field]: !current[field] }; + if (!next.open && !next.raw) entries.delete(key); + else entries.set(key, next); + } + + return { + isOpen(key) { + return Boolean(entries.get(key)?.open); + }, + isRaw(key) { + return Boolean(entries.get(key)?.raw); + }, + toggleOpen(key, messageUuid) { + update(key, messageUuid, 'open'); + }, + toggleRaw(key, messageUuid) { + update(key, messageUuid, 'raw'); + }, + retainMessages(messageUuids) { + for (const [key, entry] of entries) { + if (!messageUuids.has(entry.messageUuid)) entries.delete(key); + } + }, + }; +} diff --git a/app/src/renderer/src/session-live-reload.mjs b/app/src/renderer/src/session-live-reload.mjs new file mode 100644 index 0000000..efcddfa --- /dev/null +++ b/app/src/renderer/src/session-live-reload.mjs @@ -0,0 +1,58 @@ +export function createSessionLiveReloadCoordinator({ isScrolling, load, commit }) { + let pending = false; + let loadedSnapshot = null; + let inFlight = null; + let stopped = false; + + async function drain() { + while (!stopped && !isScrolling() && (pending || loadedSnapshot)) { + let snapshot = loadedSnapshot; + loadedSnapshot = null; + + if (pending) { + pending = false; + snapshot = await load(); + } + if (stopped) return; + + // Scrolling may start while IPC is loading the snapshot. Keep the loaded + // value, but do not patch the visible timeline until scrolling settles. + if (isScrolling()) { + loadedSnapshot = snapshot; + return; + } + + // A newer update arrived while this snapshot loaded. Skip the stale + // intermediate commit and loop once more for the latest snapshot. + if (pending) continue; + if (snapshot !== null && snapshot !== undefined) await commit(snapshot); + } + } + + async function flush() { + if (stopped || isScrolling() || (!pending && !loadedSnapshot)) return inFlight; + if (inFlight) return inFlight; + inFlight = drain(); + try { + await inFlight; + } finally { + inFlight = null; + } + if ((pending || loadedSnapshot) && !isScrolling()) return flush(); + return undefined; + } + + return { + request() { + if (stopped) return Promise.resolve(); + pending = true; + return flush(); + }, + flush, + stop() { + stopped = true; + pending = false; + loadedSnapshot = null; + }, + }; +} diff --git a/app/src/renderer/src/session-timeline-items.mjs b/app/src/renderer/src/session-timeline-items.mjs new file mode 100644 index 0000000..0991355 --- /dev/null +++ b/app/src/renderer/src/session-timeline-items.mjs @@ -0,0 +1,54 @@ +function timelineItem(kind, message, messageUuid, extras = {}) { + return { + key: `${kind}:${messageUuid}`, + kind, + anchorUuid: kind === 'workflow-tools' ? `${messageUuid}-tools` : messageUuid, + messageUuid, + message, + ...extras, + }; +} + +function messageItems(message, index) { + const messageUuid = message?.uuid || `message-${index}`; + if (message?.is_meta === 1) { + return [timelineItem('meta', message, messageUuid)]; + } + + const workflowCall = message?.type !== 'user' + ? (message?.tool_calls || []).find(call => call.name === 'Workflow' && call.workflow) + : null; + if (workflowCall) { + const items = [timelineItem('workflow', message, messageUuid, { workflowCall })]; + const toolCalls = (message.tool_calls || []).filter(call => call !== workflowCall); + if (toolCalls.length) { + items.push(timelineItem('workflow-tools', message, messageUuid, { toolCalls })); + } + return items; + } + + if ( + message?.type === 'assistant' + && (message.tool_calls || []).length === 1 + && message.tool_calls[0].name === 'Skill' + && !message.text + ) { + return [timelineItem('skill', message, messageUuid)]; + } + + if (message?.type === 'assistant' && message.content_type === 'thinking') { + return [timelineItem('thinking', message, messageUuid)]; + } + + return [timelineItem('message', message, messageUuid)]; +} + +export function reconcileTimelineItems(current = [], messages = []) { + const currentByKey = new Map(current.map(item => [item.key, item])); + return messages.flatMap((message, index) => ( + messageItems(message, index).map(item => { + const existing = currentByKey.get(item.key); + return existing?.message === item.message ? existing : item; + }) + )); +} diff --git a/app/src/renderer/src/session-timeline-viewport.mjs b/app/src/renderer/src/session-timeline-viewport.mjs new file mode 100644 index 0000000..bf57df6 --- /dev/null +++ b/app/src/renderer/src/session-timeline-viewport.mjs @@ -0,0 +1,130 @@ +import { computed, ref } from 'vue'; +import { useVirtualizer } from '@tanstack/vue-virtual'; + +function estimatedTextHeight(text = '') { + return Math.min(560, Math.ceil(String(text).length / 72) * 20); +} + +export function estimateTimelineItemSize(item) { + if (!item) return 96; + if (item.kind === 'meta') return 34; + if (item.kind === 'thinking') return 38; + if (item.kind === 'skill') return 84; + if (item.kind === 'workflow') { + const agents = item.workflowCall?.workflow?.agents?.length || 0; + return 72 + Math.min(360, agents * 34); + } + if (item.kind === 'workflow-tools') { + return 48 + (item.toolCalls?.length || 0) * 38; + } + const message = item.message || {}; + return 72 + + estimatedTextHeight(message.text) + + (message.tool_calls?.length || 0) * 38 + + (message.summary ? 34 : 0) + + (message._thinking ? 34 : 0); +} + +export function useSessionTimelineViewport({ + items, + scrollElement, + scrollMargin, + overscan = 6, + gap = 14, + scrollPaddingEnd = 0, +}) { + const followOnAppend = ref(false); + const virtualizer = useVirtualizer(computed(() => ({ + count: items.value.length, + getScrollElement: () => scrollElement.value, + estimateSize: index => estimateTimelineItemSize(items.value[index]), + getItemKey: index => items.value[index]?.key || index, + scrollMargin: scrollMargin.value, + scrollPaddingEnd, + overscan, + gap, + anchorTo: 'end', + followOnAppend: followOnAppend.value, + scrollEndThreshold: 50, + useAnimationFrameWithResizeObserver: true, + }))); + + const virtualRows = computed(() => virtualizer.value.getVirtualItems()); + const totalSize = computed(() => virtualizer.value.getTotalSize()); + const isScrolling = computed(() => virtualizer.value.isScrolling); + + function measureElement(element) { + if (!element) return; + virtualizer.value.measureElement(element); + } + + function indexAtViewportEnd(inset = 0) { + const instance = virtualizer.value; + const viewportSize = instance.scrollRect?.height || scrollElement.value?.clientHeight || 0; + const offset = (instance.scrollOffset || scrollElement.value?.scrollTop || 0) + + viewportSize + - inset; + return instance.getVirtualItemForOffset(offset)?.index ?? 0; + } + + function runWithMeasurementRetry(scroll) { + scroll(); + const targetWindow = scrollElement.value?.ownerDocument?.defaultView; + targetWindow?.requestAnimationFrame(() => { + targetWindow.requestAnimationFrame(scroll); + }); + } + + function scrollToIndex(index, options = {}) { + const scroll = () => { + virtualizer.value.scrollToIndex(index, { behavior: 'auto', ...options }); + }; + + // A far jump starts from estimates. Re-align after mounted rows have been + // measured so the requested item does not remain only in overscan. + runWithMeasurementRetry(scroll); + } + + function scrollToEnd() { + const scroll = () => { + const element = scrollElement.value; + if (element && 'scrollHeight' in element) { + element.scrollTo({ top: element.scrollHeight, behavior: 'auto' }); + } else { + virtualizer.value.scrollToEnd({ behavior: 'auto' }); + } + }; + runWithMeasurementRetry(scroll); + } + + function isFollowingTail() { + if (!followOnAppend.value) return false; + const element = scrollElement.value; + if (element && 'scrollHeight' in element) { + return element.scrollHeight - element.clientHeight - element.scrollTop <= 50; + } + return virtualizer.value.isAtEnd(50); + } + + function resetForInitialSnapshot() { + followOnAppend.value = false; + virtualizer.value.scrollToOffset(0, { behavior: 'auto' }); + } + + function completeInitialSnapshot() { + followOnAppend.value = true; + } + + return { + virtualRows, + totalSize, + isScrolling, + measureElement, + indexAtViewportEnd, + scrollToIndex, + scrollToEnd, + isFollowingTail, + resetForInitialSnapshot, + completeInitialSnapshot, + }; +} diff --git a/app/src/renderer/src/session-view-state.mjs b/app/src/renderer/src/session-view-state.mjs deleted file mode 100644 index 226e65c..0000000 --- a/app/src/renderer/src/session-view-state.mjs +++ /dev/null @@ -1,148 +0,0 @@ -const DISCLOSURE_CLASSES = ['open', 'skill-md-open']; -const SCROLL_ITEM_SELECTOR = '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]'; - -function arrayFrom(value) { - return value ? Array.from(value) : []; -} - -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; -} - -export function restoreSessionTail({ wrap, followTail, restoreScroll = true } = {}) { - if (!wrap || !followTail || !restoreScroll) return; - wrap.scrollTop = wrap.scrollHeight; -} - -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 - : domIndex?.items?.[anchorIndex]; - - return { - followTail, - scrollTop: wrap.scrollTop, - anchor: anchorElement?.dataset?.uuid - ? { - uuid: anchorElement.dataset.uuid, - messageUuid: anchorElement.dataset.messageUuid || anchorElement.dataset.uuid, - offset: anchorElement.getBoundingClientRect().top - wrapTop, - } - : null, - }; -} - -export function restoreSessionViewState(snapshot, { wrap, domIndex, restoreScroll = true } = {}) { - if (!snapshot || !wrap) return; - - if (!restoreScroll) return; - - if (snapshot.followTail) { - restoreSessionTail({ wrap, followTail: true }); - return; - } - - wrap.scrollTop = snapshot.scrollTop; - if (!snapshot.anchor) return; - const wrapTop = wrap.getBoundingClientRect?.().top || 0; - 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; -} - -export function findLastMessageAtOrAbove(messages, bottomLine) { - if (!messages?.length) return -1; - 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 fcd226f..6637174 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -1,21 +1,16 @@