feat(app): virtualize session timeline
Render SessionDetail through measured dynamic-height virtual rows while preserving disclosure state, UUID navigation, reader anchoring, and tail-follow across live updates. Add focused state/reconciliation tests plus a production Electron harness covering long-session DOM bounds, scrolling performance, offscreen navigation, and live viewport stability.
This commit is contained in:
Generated
+29
@@ -12,6 +12,7 @@
|
|||||||
"chokidar": "^4.0.3"
|
"chokidar": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tanstack/vue-virtual": "^3.13.32",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
@@ -1839,6 +1840,34 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,8 @@
|
|||||||
"dist:mac": "electron-vite build && electron-builder --mac",
|
"dist:mac": "electron-vite build && electron-builder --mac",
|
||||||
"dist:win": "electron-vite build && electron-builder --win",
|
"dist:win": "electron-vite build && electron-builder --win",
|
||||||
"dist:linux": "electron-vite build && electron-builder --linux",
|
"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": {
|
"build": {
|
||||||
"appId": "com.obelisk.app",
|
"appId": "com.obelisk.app",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"chokidar": "^4.0.3"
|
"chokidar": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tanstack/vue-virtual": "^3.13.32",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, shallowRef, computed, 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 } from '../store.js';
|
||||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||||
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
||||||
import { applySnapshot } from '../session-timeline.mjs';
|
import { applySnapshot } from '../session-timeline.mjs';
|
||||||
|
import { reconcileTimelineItems } from '../session-timeline-items.mjs';
|
||||||
|
import { createSessionDisclosureState } from '../session-disclosures.mjs';
|
||||||
|
import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs';
|
||||||
|
import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs';
|
||||||
import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js';
|
import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js';
|
||||||
import FlapNumber from '../components/FlapNumber.vue';
|
import FlapNumber from '../components/FlapNumber.vue';
|
||||||
import {
|
|
||||||
captureSessionViewState,
|
|
||||||
createSessionDisclosureRegistry,
|
|
||||||
createSessionDomIndex,
|
|
||||||
findLastMessageAtOrAbove,
|
|
||||||
isFollowingSessionTail,
|
|
||||||
restoreSessionTail,
|
|
||||||
restoreSessionViewState,
|
|
||||||
} from '../session-view-state.mjs';
|
|
||||||
import {
|
import {
|
||||||
escapeHTML,
|
escapeHTML,
|
||||||
fmtRelative,
|
fmtRelative,
|
||||||
@@ -33,18 +28,55 @@ const route = useRoute();
|
|||||||
// --- Reactive state ---
|
// --- Reactive state ---
|
||||||
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||||
const messages = shallowRef([]);
|
const messages = shallowRef([]);
|
||||||
|
const timelineItems = shallowRef([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const progressPct = ref(0);
|
const progressPct = ref(0);
|
||||||
const active = ref(false);
|
const active = ref(false);
|
||||||
|
const focusedItemKey = ref(null);
|
||||||
|
const expandedMessageText = reactive(new Map());
|
||||||
|
const fullTextLoading = reactive(new Set());
|
||||||
let removeSessionUpdated = null;
|
let removeSessionUpdated = null;
|
||||||
let keydownAttached = false;
|
let keydownAttached = false;
|
||||||
let scrollRevision = 0;
|
let focusTimer = null;
|
||||||
|
let loadRevision = 0;
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
const wrapRef = ref(null);
|
const wrapRef = ref(null);
|
||||||
const detailRef = ref(null);
|
const timelineRef = ref(null);
|
||||||
let sessionDomIndex = createSessionDomIndex(null);
|
const headerRef = ref(null);
|
||||||
let disclosureRegistry = createSessionDisclosureRegistry();
|
const timelineScrollMargin = ref(0);
|
||||||
|
const disclosures = createSessionDisclosureState();
|
||||||
|
let headerResizeObserver = null;
|
||||||
|
const NAV_HEIGHT = 52;
|
||||||
|
|
||||||
|
const timelineViewport = useSessionTimelineViewport({
|
||||||
|
items: timelineItems,
|
||||||
|
scrollElement: wrapRef,
|
||||||
|
scrollMargin: timelineScrollMargin,
|
||||||
|
scrollPaddingEnd: NAV_HEIGHT,
|
||||||
|
});
|
||||||
|
const { virtualRows, totalSize, measureElement } = timelineViewport;
|
||||||
|
const liveReloadCoordinator = createSessionLiveReloadCoordinator({
|
||||||
|
isScrolling: () => timelineViewport.isScrolling.value,
|
||||||
|
load: loadLiveSnapshot,
|
||||||
|
commit: commitLiveSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(timelineViewport.isScrolling, scrolling => {
|
||||||
|
if (!scrolling && active.value) void liveReloadCoordinator.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncTimelineScrollMargin() {
|
||||||
|
timelineScrollMargin.value = timelineRef.value?.offsetTop || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeSessionHeader() {
|
||||||
|
headerResizeObserver?.disconnect();
|
||||||
|
headerResizeObserver = null;
|
||||||
|
if (!headerRef.value || typeof ResizeObserver === 'undefined') return;
|
||||||
|
headerResizeObserver = new ResizeObserver(syncTimelineScrollMargin);
|
||||||
|
headerResizeObserver.observe(headerRef.value);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Load session on mount or when id changes ---
|
// --- Load session on mount or when id changes ---
|
||||||
const FONT_SIZE_KEY = 'obelisk:session-font-size';
|
const FONT_SIZE_KEY = 'obelisk:session-font-size';
|
||||||
@@ -99,10 +131,9 @@ onMounted(async () => {
|
|||||||
if (route.query.focus) {
|
if (route.query.focus) {
|
||||||
state.pendingFocusUuid = route.query.focus;
|
state.pendingFocusUuid = route.query.focus;
|
||||||
}
|
}
|
||||||
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(async ({ sessionId } = {}) => {
|
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
|
||||||
if (!active.value || !props.id || sessionId !== props.id) return;
|
if (!active.value || !props.id || sessionId !== props.id) return;
|
||||||
clearSessionDirty(props.id);
|
void liveReloadCoordinator.request();
|
||||||
await loadMessages({ force: true });
|
|
||||||
}) || null;
|
}) || null;
|
||||||
if (!localStorage.getItem(HINT_KEY)) {
|
if (!localStorage.getItem(HINT_KEY)) {
|
||||||
showFontHint.value = true;
|
showFontHint.value = true;
|
||||||
@@ -110,6 +141,9 @@ onMounted(async () => {
|
|||||||
setTimeout(() => { showFontHint.value = false; }, 4000);
|
setTimeout(() => { showFontHint.value = false; }, 4000);
|
||||||
}
|
}
|
||||||
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
|
||||||
|
await nextTick();
|
||||||
|
syncTimelineScrollMargin();
|
||||||
|
observeSessionHeader();
|
||||||
});
|
});
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
@@ -123,6 +157,10 @@ onActivated(async () => {
|
|||||||
} else if (state.pendingFocusUuid) {
|
} else if (state.pendingFocusUuid) {
|
||||||
await focusPendingMessage();
|
await focusPendingMessage();
|
||||||
}
|
}
|
||||||
|
await liveReloadCoordinator.flush();
|
||||||
|
await nextTick();
|
||||||
|
syncTimelineScrollMargin();
|
||||||
|
observeSessionHeader();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
@@ -132,79 +170,109 @@ onDeactivated(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
active.value = false;
|
active.value = false;
|
||||||
|
loadRevision++;
|
||||||
detachKeydown();
|
detachKeydown();
|
||||||
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||||
scrollFrame = null;
|
scrollFrame = null;
|
||||||
|
if (focusTimer !== null) clearTimeout(focusTimer);
|
||||||
|
focusTimer = null;
|
||||||
|
headerResizeObserver?.disconnect();
|
||||||
|
headerResizeObserver = null;
|
||||||
|
liveReloadCoordinator.stop();
|
||||||
removeSessionUpdated?.();
|
removeSessionUpdated?.();
|
||||||
removeSessionUpdated = null;
|
removeSessionUpdated = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.id, async (newId, oldId) => {
|
watch(() => props.id, async (newId, oldId) => {
|
||||||
if (newId && newId !== oldId) {
|
if (newId && newId !== oldId) {
|
||||||
|
loadRevision++;
|
||||||
|
timelineViewport.resetForInitialSnapshot();
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
disclosureRegistry = createSessionDisclosureRegistry();
|
timelineItems.value = [];
|
||||||
|
disclosures.retainMessages(new Set());
|
||||||
|
expandedMessageText.clear();
|
||||||
|
fullTextLoading.clear();
|
||||||
progressPct.value = 0;
|
progressPct.value = 0;
|
||||||
currentMsgIdx.value = 0;
|
currentMsgIdx.value = 0;
|
||||||
await loadMessages({ force: consumeGlobalSessionDirty(newId) });
|
await loadMessages({ force: consumeGlobalSessionDirty(newId) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => session.value?.id, async sessionId => {
|
||||||
|
if (sessionId === props.id && messages.value.length === 0) {
|
||||||
|
await loadMessages({ force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadMessages({ force = false } = {}) {
|
async function loadMessages({ force = false } = {}) {
|
||||||
if (!props.id) return;
|
const requestedSessionId = props.id;
|
||||||
|
if (!requestedSessionId) return;
|
||||||
|
const revision = ++loadRevision;
|
||||||
const hadContent = messages.value.length > 0;
|
const hadContent = messages.value.length > 0;
|
||||||
let reconciliation;
|
let latest;
|
||||||
let viewState = null;
|
|
||||||
let followTailBeforePatch = false;
|
|
||||||
let scrollRevisionBeforePatch = scrollRevision;
|
|
||||||
|
|
||||||
loading.value = !hadContent;
|
loading.value = !hadContent;
|
||||||
try {
|
try {
|
||||||
const s = state.sessions.find(x => x.id === props.id);
|
latest = await fetchSessionSnapshot(requestedSessionId, { force });
|
||||||
let latest = s;
|
|
||||||
if (s && (force || !s.messages || s.messages.length === 0)) {
|
|
||||||
latest = await loadSessionDetail(props.id);
|
|
||||||
}
|
|
||||||
const incoming = latest?.messages || [];
|
|
||||||
reconciliation = applySnapshot(messages.value, incoming);
|
|
||||||
if (reconciliation.changed) {
|
|
||||||
followTailBeforePatch = hadContent && isFollowingSessionTail(wrapRef.value);
|
|
||||||
scrollRevisionBeforePatch = scrollRevision;
|
|
||||||
if (hadContent && !reconciliation.tailOnly) {
|
|
||||||
viewState = captureSessionViewState({
|
|
||||||
wrap: wrapRef.value,
|
|
||||||
domIndex: sessionDomIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
messages.value = reconciliation.messages;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (revision === loadRevision) loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revision !== loadRevision || requestedSessionId !== props.id) return;
|
||||||
|
await commitSessionSnapshot(latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSessionSnapshot(sessionId, { force = false } = {}) {
|
||||||
|
const cached = state.sessions.find(session => session.id === sessionId);
|
||||||
|
if (cached && (force || !cached.messages || cached.messages.length === 0)) {
|
||||||
|
return loadSessionDetail(sessionId);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLiveSnapshot() {
|
||||||
|
const sessionId = props.id;
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const revision = ++loadRevision;
|
||||||
|
const latest = await fetchSessionSnapshot(sessionId, { force: true });
|
||||||
|
clearSessionDirty(sessionId);
|
||||||
|
return { sessionId, revision, latest };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitLiveSnapshot(snapshot) {
|
||||||
|
if (snapshot.revision !== loadRevision || snapshot.sessionId !== props.id) return;
|
||||||
|
await commitSessionSnapshot(snapshot.latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitSessionSnapshot(latest) {
|
||||||
|
// The route can mount before the initial session list arrives. Keep
|
||||||
|
// first-snapshot tail following disabled until an actual session exists.
|
||||||
|
if (!latest) return;
|
||||||
|
const incoming = latest?.messages || [];
|
||||||
|
const reconciliation = applySnapshot(messages.value, incoming);
|
||||||
|
const restoreTail = reconciliation.tailOnly && timelineViewport.isFollowingTail();
|
||||||
|
if (reconciliation.changed) {
|
||||||
|
messages.value = reconciliation.messages;
|
||||||
|
timelineItems.value = reconcileTimelineItems(timelineItems.value, reconciliation.messages);
|
||||||
|
const retainedMessageUuids = new Set(reconciliation.messages.map(message => message.uuid));
|
||||||
|
disclosures.retainMessages(retainedMessageUuids);
|
||||||
|
for (const uuid of reconciliation.updatedIds) expandedMessageText.delete(uuid);
|
||||||
|
for (const uuid of expandedMessageText.keys()) {
|
||||||
|
if (!retainedMessageUuids.has(uuid)) expandedMessageText.delete(uuid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reconciliation.changed) {
|
if (!reconciliation.changed) {
|
||||||
if (state.pendingFocusUuid) await focusPendingMessage();
|
if (state.pendingFocusUuid) await focusPendingMessage();
|
||||||
|
timelineViewport.completeInitialSnapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
syncTimelineDom();
|
timelineViewport.completeInitialSnapshot();
|
||||||
disclosureRegistry.reconcile(sessionDomIndex, reconciliation);
|
if (restoreTail) timelineViewport.scrollToEnd();
|
||||||
if (!state.pendingFocusUuid) {
|
syncTimelineScrollMargin();
|
||||||
if (reconciliation.tailOnly) {
|
if (!state.pendingFocusUuid) onScroll();
|
||||||
restoreSessionTail({
|
|
||||||
wrap: wrapRef.value,
|
|
||||||
followTail: followTailBeforePatch,
|
|
||||||
restoreScroll: scrollRevision === scrollRevisionBeforePatch,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
restoreSessionViewState(viewState, {
|
|
||||||
wrap: wrapRef.value,
|
|
||||||
domIndex: sessionDomIndex,
|
|
||||||
restoreScroll: scrollRevision === scrollRevisionBeforePatch,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus pending uuid if any
|
// Focus pending uuid if any
|
||||||
if (state.pendingFocusUuid) {
|
if (state.pendingFocusUuid) {
|
||||||
@@ -216,34 +284,28 @@ async function focusPendingMessage() {
|
|||||||
const targetUuid = state.pendingFocusUuid;
|
const targetUuid = state.pendingFocusUuid;
|
||||||
if (!targetUuid) return;
|
if (!targetUuid) return;
|
||||||
state.pendingFocusUuid = null;
|
state.pendingFocusUuid = null;
|
||||||
|
const targetIndex = timelineItems.value.findIndex(item => (
|
||||||
|
item.anchorUuid === targetUuid || item.messageUuid === targetUuid
|
||||||
|
));
|
||||||
|
if (targetIndex < 0) return;
|
||||||
|
focusedItemKey.value = timelineItems.value[targetIndex].key;
|
||||||
|
timelineViewport.scrollToIndex(targetIndex, { align: 'end' });
|
||||||
|
if (focusTimer !== null) clearTimeout(focusTimer);
|
||||||
|
focusTimer = setTimeout(() => {
|
||||||
|
focusedItemKey.value = null;
|
||||||
|
focusTimer = null;
|
||||||
|
}, 2000);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"], .skill-card[data-uuid="${targetUuid}"], .wf-card[data-uuid="${targetUuid}"]`);
|
onScroll();
|
||||||
if (target && wrapRef.value) {
|
|
||||||
const navHeight = 52;
|
|
||||||
const msgBottom = target.offsetTop + target.offsetHeight;
|
|
||||||
const scrollTarget = msgBottom - wrapRef.value.clientHeight + navHeight;
|
|
||||||
wrapRef.value.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'instant' });
|
|
||||||
target.classList.add('is-focused');
|
|
||||||
setTimeout(() => target.classList.remove('is-focused'), 2000);
|
|
||||||
// Update nav position
|
|
||||||
await nextTick();
|
|
||||||
onScroll();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Scroll / progress tracking ---
|
// --- Scroll / progress tracking ---
|
||||||
const currentMsgIdx = ref(0);
|
const currentMsgIdx = ref(0);
|
||||||
const totalMsgs = ref(0);
|
const totalMsgs = computed(() => timelineItems.value.length);
|
||||||
let navLock = false;
|
let navLock = false;
|
||||||
let scrollFrame = null;
|
let scrollFrame = null;
|
||||||
|
|
||||||
function syncTimelineDom() {
|
|
||||||
sessionDomIndex = createSessionDomIndex(detailRef.value);
|
|
||||||
totalMsgs.value = sessionDomIndex.items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll(event) {
|
function onScroll(event) {
|
||||||
if (event) scrollRevision++;
|
|
||||||
if (navLock) return;
|
if (navLock) return;
|
||||||
if (scrollFrame !== null) return;
|
if (scrollFrame !== null) return;
|
||||||
scrollFrame = requestAnimationFrame(() => {
|
scrollFrame = requestAnimationFrame(() => {
|
||||||
@@ -258,65 +320,58 @@ function setMessagePosition(index, total) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateScrollProgress() {
|
function updateScrollProgress() {
|
||||||
if (!wrapRef.value) return;
|
if (!wrapRef.value || !timelineItems.value.length) {
|
||||||
const msgs = sessionDomIndex.items;
|
|
||||||
if (!msgs.length) {
|
|
||||||
currentMsgIdx.value = 0;
|
currentMsgIdx.value = 0;
|
||||||
progressPct.value = 0;
|
progressPct.value = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const bottomMsgIdx = timelineViewport.indexAtViewportEnd(NAV_HEIGHT);
|
||||||
const el = wrapRef.value;
|
setMessagePosition(bottomMsgIdx, timelineItems.value.length);
|
||||||
const navHeight = 52;
|
|
||||||
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
|
||||||
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
|
|
||||||
setMessagePosition(bottomMsgIdx, msgs.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function navTo(target) {
|
function navTo(target) {
|
||||||
if (!wrapRef.value) return;
|
if (!wrapRef.value) return;
|
||||||
const msgs = sessionDomIndex.items;
|
const count = timelineItems.value.length;
|
||||||
if (!msgs.length) return;
|
if (!count) return;
|
||||||
let idx;
|
let idx;
|
||||||
if (target === 'first') idx = 0;
|
if (target === 'first') idx = 0;
|
||||||
else if (target === 'last') idx = msgs.length - 1;
|
else if (target === 'last') idx = count - 1;
|
||||||
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
||||||
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
|
else if (target === 'next') idx = Math.min(count - 1, currentMsgIdx.value + 1);
|
||||||
else return;
|
else return;
|
||||||
setMessagePosition(idx, msgs.length);
|
setMessagePosition(idx, count);
|
||||||
navLock = true;
|
navLock = true;
|
||||||
const navHeight = 52;
|
timelineViewport.scrollToIndex(idx, { align: 'end' });
|
||||||
const el = wrapRef.value;
|
setTimeout(() => {
|
||||||
const msgEl = msgs[idx];
|
navLock = false;
|
||||||
if (!msgEl) return;
|
onScroll();
|
||||||
const msgBottom = msgEl.offsetTop + msgEl.offsetHeight;
|
}, 50);
|
||||||
const scrollTarget = msgBottom - el.clientHeight + navHeight;
|
|
||||||
el.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'instant' });
|
|
||||||
setTimeout(() => { navLock = false; }, 50);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Toggle helpers ---
|
// --- Toggle helpers ---
|
||||||
function toggleDisclosure(event, selector, className = 'open') {
|
function toggleDisclosure(key, messageUuid) {
|
||||||
const element = event.currentTarget?.closest(selector);
|
disclosures.toggleOpen(key, messageUuid);
|
||||||
if (!element) return;
|
|
||||||
element.classList.toggle(className);
|
|
||||||
disclosureRegistry.remember(element);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Full text loading ---
|
// --- Full text loading ---
|
||||||
async function handleLoadFullText(event, uuid) {
|
function displayMessageText(message) {
|
||||||
const btn = event.currentTarget;
|
return expandedMessageText.get(message.uuid) ?? message.text;
|
||||||
btn.textContent = 'Loading...';
|
}
|
||||||
const fullText = await loadFullText(uuid);
|
|
||||||
if (fullText) {
|
function canLoadFullText(message) {
|
||||||
const msgEl = btn.closest('.msg');
|
return !expandedMessageText.has(message.uuid) && isTextTruncated(message.text);
|
||||||
const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact');
|
}
|
||||||
const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg';
|
|
||||||
const rendered = renderMarkdown(fullText, { variant, query: state.query });
|
async function handleLoadFullText(uuid) {
|
||||||
if (bodyEl) bodyEl.outerHTML = rendered;
|
if (fullTextLoading.has(uuid)) return;
|
||||||
btn.remove();
|
fullTextLoading.add(uuid);
|
||||||
} else {
|
try {
|
||||||
btn.textContent = 'Failed to load full text';
|
const fullText = await loadFullText(uuid);
|
||||||
|
if (fullText && messages.value.some(message => message.uuid === uuid)) {
|
||||||
|
expandedMessageText.set(uuid, fullText);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fullTextLoading.delete(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +383,16 @@ function navigateToSubagent(agentId, description) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupWorkflowAgents(workflow) {
|
||||||
|
const phases = {};
|
||||||
|
for (const agent of (workflow?.agents || [])) {
|
||||||
|
const phase = agent.phase || 'Other';
|
||||||
|
if (!phases[phase]) phases[phase] = [];
|
||||||
|
phases[phase].push(agent);
|
||||||
|
}
|
||||||
|
return phases;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
|
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
|
||||||
|
|
||||||
function formatToolInput(tc) {
|
function formatToolInput(tc) {
|
||||||
@@ -559,17 +624,8 @@ function renderAutoTable(rows) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRaw(event) {
|
function toggleRaw(key, messageUuid) {
|
||||||
const body = event.target.closest('.toolcall-body');
|
disclosures.toggleRaw(key, messageUuid);
|
||||||
if (!body) return;
|
|
||||||
const pretty = body.querySelector('.toolcall-pretty');
|
|
||||||
const raw = body.querySelector('.toolcall-raw');
|
|
||||||
const btn = body.querySelector('.raw-toggle');
|
|
||||||
if (!pretty || !raw) return;
|
|
||||||
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) {
|
function getSkillMd(msg) {
|
||||||
@@ -587,7 +643,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll" :style="{ '--text-base': fontSize, '--text-md': fontSize }">
|
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll" :style="{ '--text-base': fontSize, '--text-md': fontSize }">
|
||||||
<div class="detail" ref="detailRef">
|
<div class="detail">
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<div class="session-progress">
|
<div class="session-progress">
|
||||||
<div class="session-progress-fill" :style="{ width: progressPct + '%' }"></div>
|
<div class="session-progress-fill" :style="{ width: progressPct + '%' }"></div>
|
||||||
@@ -600,7 +656,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<!-- Session header -->
|
<!-- Session header -->
|
||||||
<template v-if="session && !loading">
|
<template v-if="session && !loading">
|
||||||
<div class="session-header">
|
<div class="session-header" 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>
|
||||||
@@ -626,113 +682,125 @@ function getToolCallParsedInput(tc) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message timeline -->
|
<!-- Message timeline -->
|
||||||
<div class="timeline" v-memo="[messages, state.query]">
|
<div
|
||||||
<template v-for="msg in messages" :key="msg.uuid" v-memo="[msg, state.query]">
|
ref="timelineRef"
|
||||||
|
class="timeline virtual-timeline"
|
||||||
|
:style="{ height: `${totalSize}px` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="virtualRow in virtualRows"
|
||||||
|
:key="virtualRow.key"
|
||||||
|
:ref="measureElement"
|
||||||
|
class="virtual-timeline-row"
|
||||||
|
:data-index="virtualRow.index"
|
||||||
|
:style="{ transform: `translateY(${virtualRow.start - timelineScrollMargin}px)` }"
|
||||||
|
>
|
||||||
|
<template v-for="item in [timelineItems[virtualRow.index]]" :key="item.key">
|
||||||
|
<template v-for="msg in [item.message]" :key="msg.uuid">
|
||||||
|
|
||||||
<!-- Meta messages: collapsed system indicator -->
|
<!-- Meta messages: collapsed system indicator -->
|
||||||
<template v-if="msg.is_meta === 1">
|
<template v-if="item.kind === 'meta'">
|
||||||
<div class="msg meta" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
|
<div class="msg meta" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
|
||||||
<div class="msg-meta-collapsed" :data-view-key="`meta:${msg.uuid}`">
|
<div class="msg-meta-collapsed" :class="{ open: disclosures.isOpen(`meta:${msg.uuid}`) }" :data-view-key="`meta:${msg.uuid}`">
|
||||||
<button class="meta-toggle" @click="toggleDisclosure($event, '.msg-meta-collapsed')">
|
<button class="meta-toggle" @click="toggleDisclosure(`meta:${msg.uuid}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span class="meta-label">System</span>
|
<span class="meta-label">System</span>
|
||||||
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
|
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="meta-body">
|
<div class="meta-body">
|
||||||
<div v-html="renderMarkdown(msg.text, { variant: 'compact', query: state.query })"></div>
|
<div v-html="renderMarkdown(displayMessageText(msg), { variant: 'compact', query: state.query })"></div>
|
||||||
<button
|
<button
|
||||||
v-if="isTextTruncated(msg.text)"
|
v-if="canLoadFullText(msg)"
|
||||||
class="truncated-btn"
|
class="truncated-btn"
|
||||||
@click="handleLoadFullText($event, msg.uuid)"
|
:disabled="fullTextLoading.has(msg.uuid)"
|
||||||
>Message truncated — click to load full text</button>
|
@click="handleLoadFullText(msg.uuid)"
|
||||||
|
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Workflow card (standalone, outside assistant bubble) -->
|
<!-- Workflow card (standalone, outside assistant bubble) -->
|
||||||
<template v-else-if="!msg.type || msg.type !== 'user' ? (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow) : false">
|
<template v-else-if="item.kind === 'workflow'">
|
||||||
<template v-if="(() => { const wfCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow); return wfCall && msg.type !== 'user'; })()">
|
<div class="wf-card" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
|
||||||
<div class="wf-card" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
|
<div class="wf-card-header">
|
||||||
<div class="wf-card-header">
|
<span class="wf-card-icon">⚙</span>
|
||||||
<span class="wf-card-icon">⚙</span>
|
<span class="wf-card-name">{{ item.workflowCall.workflow.workflow_name || 'Workflow' }}</span>
|
||||||
<span class="wf-card-name">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.workflow_name || 'Workflow' }}</span>
|
<span class="wf-card-count">{{ item.workflowCall.workflow.agents?.length || 0 }} agents</span>
|
||||||
<span class="wf-card-count">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.agents?.length || 0 }} agents</span>
|
<span
|
||||||
<span
|
v-if="item.workflowCall.workflow.status"
|
||||||
v-if="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
class="wf-card-status"
|
||||||
class="wf-card-status"
|
:class="item.workflowCall.workflow.status"
|
||||||
:class="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
>{{ item.workflowCall.workflow.status }}</span>
|
||||||
>{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="wf-card-body">
|
|
||||||
<!-- Group agents by phase -->
|
|
||||||
<template v-for="(phaseAgents, phase) in (() => {
|
|
||||||
const wf = ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow;
|
|
||||||
const phases = {};
|
|
||||||
for (const a of (wf.agents || [])) {
|
|
||||||
const p = a.phase || 'Other';
|
|
||||||
if (!phases[p]) phases[p] = [];
|
|
||||||
phases[p].push(a);
|
|
||||||
}
|
|
||||||
return phases;
|
|
||||||
})()" :key="phase">
|
|
||||||
<div class="wf-card-phase">
|
|
||||||
<div class="wf-card-phase-title">{{ phase }}</div>
|
|
||||||
<button
|
|
||||||
v-for="a in phaseAgents"
|
|
||||||
:key="a.agent_id"
|
|
||||||
class="wf-card-agent"
|
|
||||||
@click="navigateToSubagent(a.agent_id, a.label || '')"
|
|
||||||
>
|
|
||||||
<span class="wf-card-agent-label">{{ a.label || a.agent_id }}</span>
|
|
||||||
<span v-if="a.state === 'error'" class="wf-card-agent-state error">error</span>
|
|
||||||
<span class="wf-card-agent-arrow">→</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Other tool calls (non-workflow) for this message -->
|
<div class="wf-card-body">
|
||||||
<template v-if="(msg.tool_calls || []).filter(tc => !(tc.name === 'Workflow' && tc.workflow)).length > 0">
|
<template v-for="(phaseAgents, phase) in groupWorkflowAgents(item.workflowCall.workflow)" :key="phase">
|
||||||
<div class="msg assistant" :data-uuid="msg.uuid + '-tools'" :data-message-uuid="msg.uuid">
|
<div class="wf-card-phase">
|
||||||
<div class="msg-tools">
|
<div class="wf-card-phase-title">{{ phase }}</div>
|
||||||
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
|
<button
|
||||||
<!-- Render non-workflow tool calls -->
|
v-for="agent in phaseAgents"
|
||||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
|
:key="agent.agent_id"
|
||||||
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
|
class="wf-card-agent"
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
@click="navigateToSubagent(agent.agent_id, agent.label || '')"
|
||||||
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
>
|
||||||
<span class="tool-name">{{ tc.name }}</span>
|
<span class="wf-card-agent-label">{{ agent.label || agent.agent_id }}</span>
|
||||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
<span v-if="agent.state === 'error'" class="wf-card-agent-state error">error</span>
|
||||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
<span class="wf-card-agent-arrow">→</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="toolcall-body">
|
|
||||||
<div class="toolcall-body-strip">
|
|
||||||
<span class="strip-label">{{ tc.name }}</span>
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
|
|
||||||
</div>
|
|
||||||
<div class="toolcall-pretty" v-html="renderPrettyTool(tc)"></div>
|
|
||||||
<div class="toolcall-raw">
|
|
||||||
<div class="tc-section">Input</div>
|
|
||||||
<pre>{{ formatToolInput(tc) }}</pre>
|
|
||||||
<template v-if="tc.result">
|
|
||||||
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
|
||||||
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Non-workflow tools attached to a standalone workflow card -->
|
||||||
|
<template v-else-if="item.kind === 'workflow-tools'">
|
||||||
|
<div class="msg assistant" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
|
||||||
|
<div class="msg-tools">
|
||||||
|
<template v-for="tc in item.toolCalls" :key="tc.id">
|
||||||
|
<div
|
||||||
|
class="msg-tool"
|
||||||
|
:class="{ open: disclosures.isOpen(`tool:${tc.id}`), 'is-error': tc.result && tc.result.is_error }"
|
||||||
|
:data-view-key="`tool:${tc.id}`"
|
||||||
|
>
|
||||||
|
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
||||||
|
<span class="tool-name">{{ tc.name }}</span>
|
||||||
|
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||||
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
<div class="toolcall-body-strip">
|
||||||
|
<span class="strip-label">{{ tc.name }}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="raw-toggle" :class="{ active: disclosures.isRaw(`tool:${tc.id}`) }" @click.stop="toggleRaw(`tool:${tc.id}`, msg.uuid)">{ } Raw</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolcall-pretty" :class="{ hidden: disclosures.isRaw(`tool:${tc.id}`) }" v-html="renderPrettyTool(tc)"></div>
|
||||||
|
<div class="toolcall-raw" :class="{ show: disclosures.isRaw(`tool:${tc.id}`) }">
|
||||||
|
<div class="tc-section">Input</div>
|
||||||
|
<pre>{{ formatToolInput(tc) }}</pre>
|
||||||
|
<template v-if="tc.result">
|
||||||
|
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
||||||
|
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Skill card (standalone, like workflow) -->
|
<!-- Skill card (standalone, like workflow) -->
|
||||||
<template v-else-if="msg.type === 'assistant' && (msg.tool_calls || []).length === 1 && msg.tool_calls[0].name === 'Skill' && !msg.text">
|
<template v-else-if="item.kind === 'skill'">
|
||||||
<div class="skill-card" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid" :data-view-key="`skill:${msg.uuid}`">
|
<div
|
||||||
|
class="skill-card"
|
||||||
|
:class="{ 'skill-md-open': disclosures.isOpen(`skill:${msg.uuid}`), 'is-focused': focusedItemKey === item.key }"
|
||||||
|
:data-uuid="item.anchorUuid"
|
||||||
|
:data-message-uuid="item.messageUuid"
|
||||||
|
:data-view-key="`skill:${msg.uuid}`"
|
||||||
|
>
|
||||||
<div class="skill-card-icon">
|
<div class="skill-card-icon">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -743,7 +811,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
|
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
|
||||||
<div v-if="getSkillMd(msg)" class="skill-card-md">
|
<div v-if="getSkillMd(msg)" class="skill-card-md">
|
||||||
<button class="skill-md-toggle" @click="toggleDisclosure($event, '.skill-card', 'skill-md-open')">
|
<button class="skill-md-toggle" @click="toggleDisclosure(`skill:${msg.uuid}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span>SKILL.md</span>
|
<span>SKILL.md</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -754,10 +822,10 @@ function getToolCallParsedInput(tc) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Standalone thinking message -->
|
<!-- Standalone thinking message -->
|
||||||
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
<template v-else-if="item.kind === 'thinking'">
|
||||||
<div class="msg assistant" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
|
<div class="msg assistant" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
|
||||||
<div class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
|
<div class="msg-thinking" :class="{ open: disclosures.isOpen(`thinking:${msg.uuid}`) }" :data-view-key="`thinking:${msg.uuid}`">
|
||||||
<button class="thinking-toggle" @click="toggleDisclosure($event, '.msg-thinking')">
|
<button class="thinking-toggle" @click="toggleDisclosure(`thinking:${msg.uuid}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span class="thinking-label">Thinking</span>
|
<span class="thinking-label">Thinking</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -770,9 +838,9 @@ function getToolCallParsedInput(tc) {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
class="msg"
|
class="msg"
|
||||||
:class="msg.type === 'user' ? 'user' : 'assistant'"
|
:class="[msg.type === 'user' ? 'user' : 'assistant', { 'is-focused': focusedItemKey === item.key }]"
|
||||||
:data-uuid="msg.uuid"
|
:data-uuid="item.anchorUuid"
|
||||||
:data-message-uuid="msg.uuid"
|
:data-message-uuid="item.messageUuid"
|
||||||
>
|
>
|
||||||
<!-- Message header -->
|
<!-- Message header -->
|
||||||
<div class="msg-head">
|
<div class="msg-head">
|
||||||
@@ -781,8 +849,8 @@ function getToolCallParsedInput(tc) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attached thinking block (merged from preceding thinking messages) -->
|
<!-- Attached thinking block (merged from preceding thinking messages) -->
|
||||||
<div v-if="msg._thinking" class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
|
<div v-if="msg._thinking" class="msg-thinking" :class="{ open: disclosures.isOpen(`thinking:${msg.uuid}`) }" :data-view-key="`thinking:${msg.uuid}`">
|
||||||
<button class="thinking-toggle" @click="toggleDisclosure($event, '.msg-thinking')">
|
<button class="thinking-toggle" @click="toggleDisclosure(`thinking:${msg.uuid}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span class="thinking-label">Thinking</span>
|
<span class="thinking-label">Thinking</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -791,12 +859,13 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<!-- Message text body -->
|
<!-- Message text body -->
|
||||||
<template v-if="msg.text">
|
<template v-if="msg.text">
|
||||||
<div v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
|
<div v-html="renderMarkdown(displayMessageText(msg), { variant: 'msg', query: state.query })"></div>
|
||||||
<button
|
<button
|
||||||
v-if="isTextTruncated(msg.text)"
|
v-if="canLoadFullText(msg)"
|
||||||
class="truncated-btn"
|
class="truncated-btn"
|
||||||
@click="handleLoadFullText($event, msg.uuid)"
|
:disabled="fullTextLoading.has(msg.uuid)"
|
||||||
>Message truncated — click to load full text</button>
|
@click="handleLoadFullText(msg.uuid)"
|
||||||
|
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
|
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
|
||||||
<div class="msg-text empty-text">(no text content)</div>
|
<div class="msg-text empty-text">(no text content)</div>
|
||||||
@@ -816,8 +885,8 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<!-- Agent/Task tool call (subagent) -->
|
<!-- Agent/Task tool call (subagent) -->
|
||||||
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
|
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
|
||||||
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
|
<div class="msg-tool agent-call" :class="{ open: disclosures.isOpen(`tool:${tc.id}`) }" :data-view-key="`tool:${tc.id}`">
|
||||||
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
|
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
|
<span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
|
||||||
<span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span>
|
<span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span>
|
||||||
@@ -843,8 +912,8 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<!-- Workflow tool call (inside assistant bubble) -->
|
<!-- Workflow tool call (inside assistant bubble) -->
|
||||||
<template v-else-if="tc.name === 'Workflow'">
|
<template v-else-if="tc.name === 'Workflow'">
|
||||||
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
|
<div class="msg-tool agent-call" :class="{ open: disclosures.isOpen(`tool:${tc.id}`) }" :data-view-key="`tool:${tc.id}`">
|
||||||
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
|
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span class="tool-name">Workflow</span>
|
<span class="tool-name">Workflow</span>
|
||||||
<span class="tool-arg">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span>
|
<span class="tool-arg">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span>
|
||||||
@@ -859,15 +928,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
<template v-if="tc.workflow?.agents?.length">
|
<template v-if="tc.workflow?.agents?.length">
|
||||||
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
||||||
<div class="workflow-agent-list">
|
<div class="workflow-agent-list">
|
||||||
<template v-for="(phaseAgents, phase) in (() => {
|
<template v-for="(phaseAgents, phase) in groupWorkflowAgents(tc.workflow)" :key="phase">
|
||||||
const phases = {};
|
|
||||||
for (const a of (tc.workflow.agents || [])) {
|
|
||||||
const p = a.phase || 'Other';
|
|
||||||
if (!phases[p]) phases[p] = [];
|
|
||||||
phases[p].push(a);
|
|
||||||
}
|
|
||||||
return phases;
|
|
||||||
})()" :key="phase">
|
|
||||||
<div class="workflow-phase-group">
|
<div class="workflow-phase-group">
|
||||||
<div class="workflow-phase-header">{{ phase }}</div>
|
<div class="workflow-phase-header">{{ phase }}</div>
|
||||||
<div class="workflow-phase-agents">
|
<div class="workflow-phase-agents">
|
||||||
@@ -891,8 +952,8 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<!-- Generic tool call -->
|
<!-- Generic tool call -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
|
<div class="msg-tool" :class="{ open: disclosures.isOpen(`tool:${tc.id}`), 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
|
||||||
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
|
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
||||||
<span class="tool-name">{{ tc.name }}</span>
|
<span class="tool-name">{{ tc.name }}</span>
|
||||||
@@ -903,10 +964,10 @@ function getToolCallParsedInput(tc) {
|
|||||||
<div class="toolcall-body-strip">
|
<div class="toolcall-body-strip">
|
||||||
<span class="strip-label">{{ tc.name }}</span>
|
<span class="strip-label">{{ tc.name }}</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
|
<button class="raw-toggle" :class="{ active: disclosures.isRaw(`tool:${tc.id}`) }" @click.stop="toggleRaw(`tool:${tc.id}`, msg.uuid)">{ } Raw</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolcall-pretty" v-html="renderPrettyTool(tc)"></div>
|
<div class="toolcall-pretty" :class="{ hidden: disclosures.isRaw(`tool:${tc.id}`) }" v-html="renderPrettyTool(tc)"></div>
|
||||||
<div class="toolcall-raw">
|
<div class="toolcall-raw" :class="{ show: disclosures.isRaw(`tool:${tc.id}`) }">
|
||||||
<div class="tc-section">Input</div>
|
<div class="tc-section">Input</div>
|
||||||
<pre>{{ formatToolInput(tc) }}</pre>
|
<pre>{{ formatToolInput(tc) }}</pre>
|
||||||
<template v-if="tc.result">
|
<template v-if="tc.result">
|
||||||
@@ -922,8 +983,8 @@ function getToolCallParsedInput(tc) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary block -->
|
<!-- Summary block -->
|
||||||
<div v-if="msg.summary" class="msg-summary" :data-view-key="`summary:${msg.uuid}`">
|
<div v-if="msg.summary" class="msg-summary" :class="{ open: disclosures.isOpen(`summary:${msg.uuid}`) }" :data-view-key="`summary:${msg.uuid}`">
|
||||||
<button class="summary-toggle" @click="toggleDisclosure($event, '.msg-summary')">
|
<button class="summary-toggle" @click="toggleDisclosure(`summary:${msg.uuid}`, msg.uuid)">
|
||||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
<span class="label">Session summary</span>
|
<span class="label">Session summary</span>
|
||||||
<span class="source">{{ msg.summary.source || '' }}</span>
|
<span class="source">{{ msg.summary.source || '' }}</span>
|
||||||
@@ -933,7 +994,9 @@ function getToolCallParsedInput(tc) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -970,6 +1033,17 @@ function getToolCallParsedInput(tc) {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.virtual-timeline {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.virtual-timeline-row {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.font-toast {
|
.font-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 48px;
|
bottom: 48px;
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// Production renderer integration test for the dynamic SessionDetail timeline.
|
||||||
|
// Run: npm run test:electron:timeline
|
||||||
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const appRoot = join(here, '..');
|
||||||
|
const sessionId = 'test-session';
|
||||||
|
const channels = [
|
||||||
|
'db:getSessions',
|
||||||
|
'db:getSessionMessages',
|
||||||
|
'db:getSessionToolCalls',
|
||||||
|
'db:getSessionToolResults',
|
||||||
|
'db:getSessionSubagents',
|
||||||
|
'db:getSessionWorkflows',
|
||||||
|
'db:getSessionSummaries',
|
||||||
|
'db:getMemories',
|
||||||
|
'db:getProjects',
|
||||||
|
'db:getStats',
|
||||||
|
'settings:get',
|
||||||
|
];
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
let firstSessionListRead = true;
|
||||||
|
const messages = Array.from({ length: 2000 }, (_, index) => ({
|
||||||
|
uuid: `message-${index}`,
|
||||||
|
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||||
|
timestamp: new Date(Date.UTC(2026, 6, 14, 0, 0, index)).toISOString(),
|
||||||
|
text: index === 1
|
||||||
|
? ''
|
||||||
|
: `Message ${index} ${'dynamic-height content '.repeat((index % 7) + 1)}`,
|
||||||
|
content_type: index === 1 ? 'tool_use' : 'text',
|
||||||
|
is_meta: 0,
|
||||||
|
}));
|
||||||
|
const toolCalls = [{
|
||||||
|
id: 'call-1',
|
||||||
|
message_uuid: 'message-1',
|
||||||
|
name: 'Bash',
|
||||||
|
input_json: JSON.stringify({ command: 'printf virtualized' }),
|
||||||
|
}];
|
||||||
|
const toolResults = [{
|
||||||
|
tool_use_id: 'call-1',
|
||||||
|
content: `${'virtualized output\n'.repeat(80)}`,
|
||||||
|
is_error: 0,
|
||||||
|
}];
|
||||||
|
|
||||||
|
function sessionSummary() {
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
title: 'Virtualized timeline integration',
|
||||||
|
project: 'quiet-zero',
|
||||||
|
project_path: '/tmp/quiet-zero',
|
||||||
|
source: 'claude',
|
||||||
|
started_at: '2026-07-14T00:00:00.000Z',
|
||||||
|
ended_at: '2026-07-14T01:00:00.000Z',
|
||||||
|
message_count: messages.length,
|
||||||
|
git_branch: 'main',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (condition) console.log(`PASS: ${message}`);
|
||||||
|
else {
|
||||||
|
failures++;
|
||||||
|
console.error(`FAIL: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(webContents, expression, message, timeoutMs = 8000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await webContents.executeJavaScript(`Boolean(${expression})`, true)) return;
|
||||||
|
await delay(40);
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerHandlers() {
|
||||||
|
ipcMain.handle('db:getSessions', async () => {
|
||||||
|
if (firstSessionListRead) {
|
||||||
|
firstSessionListRead = false;
|
||||||
|
await delay(120);
|
||||||
|
}
|
||||||
|
return [sessionSummary()];
|
||||||
|
});
|
||||||
|
ipcMain.handle('db:getSessionMessages', () => messages);
|
||||||
|
ipcMain.handle('db:getSessionToolCalls', () => toolCalls);
|
||||||
|
ipcMain.handle('db:getSessionToolResults', () => toolResults);
|
||||||
|
ipcMain.handle('db:getSessionSubagents', () => []);
|
||||||
|
ipcMain.handle('db:getSessionWorkflows', () => []);
|
||||||
|
ipcMain.handle('db:getSessionSummaries', () => []);
|
||||||
|
ipcMain.handle('db:getMemories', () => []);
|
||||||
|
ipcMain.handle('db:getProjects', () => [{ project: 'quiet-zero', count: 1 }]);
|
||||||
|
ipcMain.handle('db:getStats', () => ({}));
|
||||||
|
ipcMain.handle('settings:get', () => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(win, index) {
|
||||||
|
messages.push({
|
||||||
|
uuid: `message-${index}`,
|
||||||
|
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
text: `Live message ${index}`,
|
||||||
|
content_type: 'text',
|
||||||
|
is_meta: 0,
|
||||||
|
});
|
||||||
|
win.webContents.send('obelisk:session-updated', { sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
registerHandlers();
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(appRoot, 'out', 'preload', 'index.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), {
|
||||||
|
hash: `/sessions/${sessionId}`,
|
||||||
|
});
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2000'`,
|
||||||
|
'the cold-start session snapshot',
|
||||||
|
);
|
||||||
|
|
||||||
|
const initial = await win.webContents.executeJavaScript(`(() => ({
|
||||||
|
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||||
|
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
|
||||||
|
rows: document.querySelectorAll('.virtual-timeline-row').length,
|
||||||
|
roots: document.querySelectorAll('.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]').length,
|
||||||
|
scrollTop: document.querySelector('.detail-wrap')?.scrollTop,
|
||||||
|
scrollHeight: document.querySelector('.detail-wrap')?.scrollHeight,
|
||||||
|
}))()`, true);
|
||||||
|
assert(initial.scrollTop < 2 && initial.current < 100, `cold start stays at the beginning (scrollTop ${initial.scrollTop}, item ${initial.current})`);
|
||||||
|
assert(initial.total === 2000, `timeline exposes all 2000 items (got ${initial.total})`);
|
||||||
|
assert(initial.rows < 60 && initial.roots === initial.rows, `only ${initial.rows} virtual rows are mounted`);
|
||||||
|
|
||||||
|
const disclosure = await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
const toggle = document.querySelector('.toolcall-toggle');
|
||||||
|
const row = toggle?.closest('.virtual-timeline-row');
|
||||||
|
const before = row?.getBoundingClientRect().height || 0;
|
||||||
|
toggle?.click();
|
||||||
|
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const after = row?.getBoundingClientRect().height || 0;
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
wrap.scrollTop = wrap.scrollHeight * 0.55;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 250));
|
||||||
|
const unmounted = !document.querySelector('[data-view-key="tool:call-1"]');
|
||||||
|
document.querySelector('button[title="First"]')?.click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 350));
|
||||||
|
return {
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
unmounted,
|
||||||
|
restored: Boolean(document.querySelector('[data-view-key="tool:call-1"].open')),
|
||||||
|
};
|
||||||
|
})()`, true);
|
||||||
|
assert(disclosure.after > disclosure.before, `expanded tool row remeasures from ${disclosure.before}px to ${disclosure.after}px`);
|
||||||
|
assert(disclosure.unmounted, 'the expanded tool row unmounts outside overscan');
|
||||||
|
assert(disclosure.restored, 'disclosure state survives unmount and remount');
|
||||||
|
|
||||||
|
await win.webContents.executeJavaScript(`window.location.hash = '#/sessions'`, true);
|
||||||
|
await waitFor(win.webContents, `!document.querySelector('.virtual-timeline')`, 'session detail deactivation');
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`window.location.hash = '#/sessions/${sessionId}?focus=message-1500'`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('[data-uuid="message-1500"].is-focused')`,
|
||||||
|
'offscreen UUID focus',
|
||||||
|
);
|
||||||
|
const focusState = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const target = document.querySelector('[data-uuid="message-1500"].is-focused');
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||||
|
visible: targetRect.bottom > wrapRect.top && targetRect.top < wrapRect.bottom,
|
||||||
|
};
|
||||||
|
})()`, true);
|
||||||
|
assert(focusState.visible, `UUID navigation mounts and reveals message-1500 (viewport ends at item ${focusState.current})`);
|
||||||
|
|
||||||
|
setTimeout(() => appendMessage(win, 2000), 250);
|
||||||
|
const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const gaps = [];
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let previous = startedAt;
|
||||||
|
function frame(now) {
|
||||||
|
gaps.push(now - previous);
|
||||||
|
previous = now;
|
||||||
|
wrap.scrollTop += 70;
|
||||||
|
if (now - startedAt < 1200) requestAnimationFrame(frame);
|
||||||
|
else {
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const anchorRow = [...document.querySelectorAll('.virtual-timeline-row')]
|
||||||
|
.find(row => {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom;
|
||||||
|
});
|
||||||
|
const anchorElement = anchorRow?.querySelector('[data-uuid]');
|
||||||
|
resolve({
|
||||||
|
maxFrameGap: Math.max(...gaps),
|
||||||
|
frames: gaps.length,
|
||||||
|
rows: document.querySelectorAll('.virtual-timeline-row').length,
|
||||||
|
distanceFromTail: wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop,
|
||||||
|
anchor: anchorElement && {
|
||||||
|
uuid: anchorElement.getAttribute('data-uuid'),
|
||||||
|
offset: anchorRow.getBoundingClientRect().top - wrapRect.top,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
})`, true);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2001'`,
|
||||||
|
'reader-position live update',
|
||||||
|
);
|
||||||
|
const readerState = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
const anchorElement = document.querySelector(
|
||||||
|
${JSON.stringify(`[data-uuid="${scrollProbe.anchor?.uuid}"]`)},
|
||||||
|
);
|
||||||
|
const anchorRow = anchorElement?.closest('.virtual-timeline-row');
|
||||||
|
return {
|
||||||
|
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||||
|
anchor: anchorElement && {
|
||||||
|
uuid: anchorElement.getAttribute('data-uuid'),
|
||||||
|
offset: anchorRow.getBoundingClientRect().top - wrap.getBoundingClientRect().top,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})()`, true);
|
||||||
|
assert(scrollProbe.rows < 60, `live scrolling keeps mounted rows bounded (${scrollProbe.rows})`);
|
||||||
|
assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit');
|
||||||
|
assert(
|
||||||
|
scrollProbe.distanceFromTail > 1000
|
||||||
|
&& readerState.current < 2001
|
||||||
|
&& readerState.anchor?.uuid === scrollProbe.anchor?.uuid
|
||||||
|
&& Math.abs(readerState.anchor.offset - scrollProbe.anchor.offset) < 2,
|
||||||
|
`live append preserves reader anchor ${scrollProbe.anchor?.uuid} (${scrollProbe.anchor?.offset}px -> ${readerState.anchor?.offset}px)`,
|
||||||
|
);
|
||||||
|
assert(scrollProbe.maxFrameGap < 250, `live scroll has no catastrophic long frame (${scrollProbe.maxFrameGap.toFixed(1)}ms)`);
|
||||||
|
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('.msg-nav-current')?.textContent === '2001'`,
|
||||||
|
'last-item navigation',
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`,
|
||||||
|
'last-item scroll settlement',
|
||||||
|
);
|
||||||
|
appendMessage(win, 2001);
|
||||||
|
await waitFor(
|
||||||
|
win.webContents,
|
||||||
|
`document.querySelector('.flap-number')?.getAttribute('aria-label') === '2002'`,
|
||||||
|
'tail-follow total update',
|
||||||
|
);
|
||||||
|
await delay(1000);
|
||||||
|
const tailState = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const wrap = document.querySelector('.detail-wrap');
|
||||||
|
return {
|
||||||
|
current: Number(document.querySelector('.msg-nav-current')?.textContent),
|
||||||
|
total: Number(document.querySelector('.flap-number')?.getAttribute('aria-label')),
|
||||||
|
scrollTop: wrap.scrollTop,
|
||||||
|
maxScrollTop: wrap.scrollHeight - wrap.clientHeight,
|
||||||
|
distanceFromTail: wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop,
|
||||||
|
};
|
||||||
|
})()`, true);
|
||||||
|
assert(
|
||||||
|
tailState.current === 2002 && tailState.distanceFromTail < 2,
|
||||||
|
`tail follow reaches item 2002 (${JSON.stringify(tailState)})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reduction = (1 - initial.rows / initial.total) * 100;
|
||||||
|
console.log(`PERF: ${initial.total} timeline items -> ${initial.rows} mounted rows (${reduction.toFixed(2)}% fewer roots)`);
|
||||||
|
console.log(`PERF: ${scrollProbe.frames} frames, max frame gap ${scrollProbe.maxFrameGap.toFixed(1)}ms during live scroll`);
|
||||||
|
win.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady()
|
||||||
|
.then(run)
|
||||||
|
.catch(error => {
|
||||||
|
failures++;
|
||||||
|
console.error(error.stack || error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
for (const channel of channels) ipcMain.removeHandler(channel);
|
||||||
|
app.exit(failures ? 1 : 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createSessionDisclosureState } from '../app/src/renderer/src/session-disclosures.mjs';
|
||||||
|
|
||||||
|
test('disclosure state survives virtual row unmounts without depending on DOM nodes', () => {
|
||||||
|
const disclosures = createSessionDisclosureState();
|
||||||
|
|
||||||
|
disclosures.toggleOpen('tool:call-1', 'message-1');
|
||||||
|
disclosures.toggleRaw('tool:call-1', 'message-1');
|
||||||
|
|
||||||
|
assert.equal(disclosures.isOpen('tool:call-1'), true);
|
||||||
|
assert.equal(disclosures.isRaw('tool:call-1'), true);
|
||||||
|
|
||||||
|
disclosures.toggleRaw('tool:call-1', 'message-1');
|
||||||
|
disclosures.toggleOpen('tool:call-1', 'message-1');
|
||||||
|
|
||||||
|
assert.equal(disclosures.isOpen('tool:call-1'), false);
|
||||||
|
assert.equal(disclosures.isRaw('tool:call-1'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disclosure state forgets entries owned by removed messages', () => {
|
||||||
|
const disclosures = createSessionDisclosureState();
|
||||||
|
disclosures.toggleOpen('tool:call-1', 'message-1');
|
||||||
|
disclosures.toggleOpen('tool:call-2', 'message-2');
|
||||||
|
|
||||||
|
disclosures.retainMessages(new Set(['message-2']));
|
||||||
|
|
||||||
|
assert.equal(disclosures.isOpen('tool:call-1'), false);
|
||||||
|
assert.equal(disclosures.isOpen('tool:call-2'), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createSessionLiveReloadCoordinator } from '../app/src/renderer/src/session-live-reload.mjs';
|
||||||
|
|
||||||
|
test('live snapshots coalesce while scrolling and commit once after scroll end', async () => {
|
||||||
|
let scrolling = true;
|
||||||
|
let loads = 0;
|
||||||
|
const commits = [];
|
||||||
|
const coordinator = createSessionLiveReloadCoordinator({
|
||||||
|
isScrolling: () => scrolling,
|
||||||
|
load: async () => ++loads,
|
||||||
|
commit: async snapshot => { commits.push(snapshot); },
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.request();
|
||||||
|
await coordinator.request();
|
||||||
|
await coordinator.request();
|
||||||
|
assert.equal(loads, 0);
|
||||||
|
assert.deepEqual(commits, []);
|
||||||
|
|
||||||
|
scrolling = false;
|
||||||
|
await coordinator.flush();
|
||||||
|
assert.equal(loads, 1);
|
||||||
|
assert.deepEqual(commits, [1]);
|
||||||
|
|
||||||
|
await coordinator.flush();
|
||||||
|
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 () => {
|
||||||
|
let releaseFirstLoad;
|
||||||
|
let activeLoads = 0;
|
||||||
|
let maxActiveLoads = 0;
|
||||||
|
let loads = 0;
|
||||||
|
const commits = [];
|
||||||
|
const firstLoadGate = new Promise(resolve => { releaseFirstLoad = resolve; });
|
||||||
|
const coordinator = createSessionLiveReloadCoordinator({
|
||||||
|
isScrolling: () => false,
|
||||||
|
load: async () => {
|
||||||
|
loads++;
|
||||||
|
activeLoads++;
|
||||||
|
maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
|
||||||
|
if (loads === 1) await firstLoadGate;
|
||||||
|
activeLoads--;
|
||||||
|
return loads;
|
||||||
|
},
|
||||||
|
commit: async snapshot => { commits.push(snapshot); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = coordinator.request();
|
||||||
|
const second = coordinator.request();
|
||||||
|
releaseFirstLoad();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
assert.equal(loads, 2);
|
||||||
|
assert.equal(maxActiveLoads, 1, 'snapshot loads remain serialized');
|
||||||
|
assert.deepEqual(commits, [2], 'only the freshest loaded snapshot is committed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolling that starts during IPC defers the loaded snapshot commit', async () => {
|
||||||
|
let scrolling = false;
|
||||||
|
let releaseLoad;
|
||||||
|
let loads = 0;
|
||||||
|
const commits = [];
|
||||||
|
const loadGate = new Promise(resolve => { releaseLoad = resolve; });
|
||||||
|
const coordinator = createSessionLiveReloadCoordinator({
|
||||||
|
isScrolling: () => scrolling,
|
||||||
|
load: async () => {
|
||||||
|
loads++;
|
||||||
|
await loadGate;
|
||||||
|
return 'loaded-before-scroll-ended';
|
||||||
|
},
|
||||||
|
commit: async snapshot => { commits.push(snapshot); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = coordinator.request();
|
||||||
|
scrolling = true;
|
||||||
|
releaseLoad();
|
||||||
|
await request;
|
||||||
|
|
||||||
|
assert.equal(loads, 1);
|
||||||
|
assert.deepEqual(commits, []);
|
||||||
|
|
||||||
|
scrolling = false;
|
||||||
|
await coordinator.flush();
|
||||||
|
assert.equal(loads, 1, 'the already-loaded snapshot is reused');
|
||||||
|
assert.deepEqual(commits, ['loaded-before-scroll-ended']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { reconcileTimelineItems } from '../app/src/renderer/src/session-timeline-items.mjs';
|
||||||
|
|
||||||
|
function message(uuid, overrides = {}) {
|
||||||
|
return {
|
||||||
|
uuid,
|
||||||
|
type: 'assistant',
|
||||||
|
text: 'message',
|
||||||
|
tool_calls: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('timeline items preserve the rendered order and identity of every navigable root', () => {
|
||||||
|
const messages = [
|
||||||
|
message('meta', { is_meta: 1 }),
|
||||||
|
message('workflow', {
|
||||||
|
text: '',
|
||||||
|
tool_calls: [
|
||||||
|
{ id: 'workflow-call', name: 'Workflow', workflow: { workflow_name: 'Build' } },
|
||||||
|
{ id: 'bash-call', name: 'Bash' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
message('skill', {
|
||||||
|
text: '',
|
||||||
|
tool_calls: [{ id: 'skill-call', name: 'Skill' }],
|
||||||
|
}),
|
||||||
|
message('thinking', { content_type: 'thinking' }),
|
||||||
|
message('normal'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const items = reconcileTimelineItems([], messages);
|
||||||
|
|
||||||
|
assert.deepEqual(items.map(item => ({
|
||||||
|
key: item.key,
|
||||||
|
kind: item.kind,
|
||||||
|
anchorUuid: item.anchorUuid,
|
||||||
|
messageUuid: item.messageUuid,
|
||||||
|
})), [
|
||||||
|
{ key: 'meta:meta', kind: 'meta', anchorUuid: 'meta', messageUuid: 'meta' },
|
||||||
|
{ key: 'workflow:workflow', kind: 'workflow', anchorUuid: 'workflow', messageUuid: 'workflow' },
|
||||||
|
{ key: 'workflow-tools:workflow', kind: 'workflow-tools', anchorUuid: 'workflow-tools', messageUuid: 'workflow' },
|
||||||
|
{ key: 'skill:skill', kind: 'skill', anchorUuid: 'skill', messageUuid: 'skill' },
|
||||||
|
{ key: 'thinking:thinking', kind: 'thinking', anchorUuid: 'thinking', messageUuid: 'thinking' },
|
||||||
|
{ key: 'message:normal', kind: 'message', anchorUuid: 'normal', messageUuid: 'normal' },
|
||||||
|
]);
|
||||||
|
assert.equal(items[1].workflowCall.id, 'workflow-call');
|
||||||
|
assert.deepEqual(items[2].toolCalls.map(call => call.id), ['bash-call']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot reconciliation reuses unchanged timeline items and replaces only updated roots', () => {
|
||||||
|
const first = message('first');
|
||||||
|
const second = message('second');
|
||||||
|
const initial = reconcileTimelineItems([], [first, second]);
|
||||||
|
const updatedSecond = { ...second, text: 'updated' };
|
||||||
|
|
||||||
|
const reconciled = reconcileTimelineItems(initial, [first, updatedSecond]);
|
||||||
|
|
||||||
|
assert.equal(reconciled[0], initial[0]);
|
||||||
|
assert.notEqual(reconciled[1], initial[1]);
|
||||||
|
assert.equal(reconciled[1].message, updatedSecond);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tail appends do not rebuild existing timeline items', () => {
|
||||||
|
const existingMessages = Array.from({ length: 1000 }, (_, index) => message(`message-${index}`));
|
||||||
|
const initial = reconcileTimelineItems([], existingMessages);
|
||||||
|
const appended = reconcileTimelineItems(initial, [
|
||||||
|
...existingMessages,
|
||||||
|
message('message-1000'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(appended.length, 1001);
|
||||||
|
assert.equal(appended[0], initial[0]);
|
||||||
|
assert.equal(appended[999], initial[999]);
|
||||||
|
assert.equal(appended[1000].key, 'message:message-1000');
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const sessionDetail = readFileSync(
|
||||||
|
new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const viewportModule = readFileSync(
|
||||||
|
new URL('../app/src/renderer/src/session-timeline-viewport.mjs', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const appPackage = JSON.parse(readFileSync(
|
||||||
|
new URL('../app/package.json', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
));
|
||||||
|
|
||||||
|
test('SessionDetail renders a measured virtual window instead of the complete timeline DOM', () => {
|
||||||
|
assert.match(sessionDetail, /useSessionTimelineViewport/);
|
||||||
|
assert.match(sessionDetail, /v-for="virtualRow in virtualRows"/);
|
||||||
|
assert.match(sessionDetail, /:data-index="virtualRow\.index"/);
|
||||||
|
assert.match(sessionDetail, /:ref="measureElement"/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /querySelectorAll/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /v-memo/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /session-view-state/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /outerHTML/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /closest\(['"]\.msg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeline viewport owns dynamic measurement, overscan, anchoring, and tail-follow', () => {
|
||||||
|
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
|
||||||
|
assert.match(viewportModule, /useVirtualizer/);
|
||||||
|
assert.match(viewportModule, /overscan/);
|
||||||
|
assert.match(viewportModule, /anchorTo:\s*'end'/);
|
||||||
|
assert.match(viewportModule, /followOnAppend:\s*followOnAppend\.value/);
|
||||||
|
assert.match(viewportModule, /resetForInitialSnapshot/);
|
||||||
|
assert.match(viewportModule, /completeInitialSnapshot/);
|
||||||
|
assert.doesNotMatch(viewportModule, /followOnAppend:\s*true/);
|
||||||
|
assert.match(viewportModule, /useAnimationFrameWithResizeObserver:\s*true/);
|
||||||
|
assert.match(viewportModule, /scrollPaddingEnd/);
|
||||||
|
assert.match(viewportModule, /scrollToIndex/);
|
||||||
|
assert.match(viewportModule, /if \(!element\) return/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, /disclosures\.isOpen/);
|
||||||
|
assert.match(sessionDetail, /disclosures\.isRaw/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /function toggleDisclosure[\s\S]{0,200}classList/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /function toggleRaw[\s\S]{0,200}classList/);
|
||||||
|
assert.doesNotMatch(sessionDetail, /createSessionDisclosureRegistry/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cold startup does not enable append-follow before a real session snapshot exists', () => {
|
||||||
|
assert.match(sessionDetail, /if \(!latest\) return/);
|
||||||
|
assert.match(sessionDetail, /timelineViewport\.completeInitialSnapshot\(\)/);
|
||||||
|
});
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
import {
|
|
||||||
captureSessionViewState,
|
|
||||||
createSessionDisclosureRegistry,
|
|
||||||
createSessionDomIndex,
|
|
||||||
findLastMessageAtOrAbove,
|
|
||||||
isFollowingSessionTail,
|
|
||||||
restoreSessionTail,
|
|
||||||
restoreSessionViewState,
|
|
||||||
} from '../app/src/renderer/src/session-view-state.mjs';
|
|
||||||
|
|
||||||
class FakeClassList {
|
|
||||||
constructor(classes = []) { this.classes = new Set(classes); }
|
|
||||||
add(...classes) { for (const value of classes) this.classes.add(value); }
|
|
||||||
contains(value) { return this.classes.has(value); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function disclosure(key, classes = [], { rawOpen = false } = {}) {
|
|
||||||
const raw = { classList: new FakeClassList(rawOpen ? ['show'] : []) };
|
|
||||||
const pretty = { classList: new FakeClassList() };
|
|
||||||
const button = { classList: new FakeClassList() };
|
|
||||||
return {
|
|
||||||
dataset: { viewKey: key },
|
|
||||||
classList: new FakeClassList(classes),
|
|
||||||
querySelector(selector) {
|
|
||||||
if (selector === '.toolcall-raw') return raw;
|
|
||||||
if (selector === '.toolcall-pretty') return pretty;
|
|
||||||
if (selector === '.raw-toggle') return button;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
raw,
|
|
||||||
pretty,
|
|
||||||
button,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollItem(uuid, top, bottom) {
|
|
||||||
return {
|
|
||||||
dataset: { uuid },
|
|
||||||
getBoundingClientRect: () => ({ top, bottom }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function detail(disclosures, scrollItems) {
|
|
||||||
return {
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
if (selector === '[data-view-key]') return disclosures;
|
|
||||||
if (selector === '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]') return scrollItems;
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function domIndex(items) {
|
|
||||||
return createSessionDomIndex(detail([], items));
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrap({ scrollTop, scrollHeight, clientHeight, top = 0 }) {
|
|
||||||
return {
|
|
||||||
scrollTop,
|
|
||||||
scrollHeight,
|
|
||||||
clientHeight,
|
|
||||||
getBoundingClientRect: () => ({ top }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function functionSource(source, name) {
|
|
||||||
const start = source.indexOf(`function ${name}(`);
|
|
||||||
assert.notEqual(start, -1, `${name} should exist`);
|
|
||||||
const signatureEnd = source.indexOf(') {', start);
|
|
||||||
assert.notEqual(signatureEnd, -1, `${name} should have a function body`);
|
|
||||||
const bodyStart = signatureEnd + 2;
|
|
||||||
let depth = 0;
|
|
||||||
for (let index = bodyStart; index < source.length; index++) {
|
|
||||||
if (source[index] === '{') depth++;
|
|
||||||
if (source[index] === '}') depth--;
|
|
||||||
if (depth === 0) return source.slice(start, index + 1);
|
|
||||||
}
|
|
||||||
assert.fail(`${name} should have a complete function body`);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('session refresh restores the visible scroll anchor from cached DOM indexes', () => {
|
|
||||||
const oldWrap = wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 });
|
|
||||||
const snapshot = captureSessionViewState({
|
|
||||||
wrap: oldWrap,
|
|
||||||
domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
|
|
||||||
restoreSessionViewState(snapshot, {
|
|
||||||
wrap: newWrap,
|
|
||||||
domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(newWrap.scrollTop, 600, '100 px inserted above the anchor is compensated');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('session refresh falls back to the owning message when its rendered root changes', () => {
|
|
||||||
const oldRoot = scrollItem('message-1-tools', -20, 180);
|
|
||||||
oldRoot.dataset.messageUuid = 'message-1';
|
|
||||||
const snapshot = captureSessionViewState({
|
|
||||||
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
|
|
||||||
domIndex: domIndex([oldRoot]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const newRoot = scrollItem('message-1', 80, 280);
|
|
||||||
newRoot.dataset.messageUuid = 'message-1';
|
|
||||||
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
|
|
||||||
restoreSessionViewState(snapshot, {
|
|
||||||
wrap: newWrap,
|
|
||||||
domIndex: domIndex([newRoot]),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(snapshot.anchor.messageUuid, 'message-1');
|
|
||||||
assert.equal(newWrap.scrollTop, 600, 'message identity preserves the anchor across render shapes');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('session refresh follows appended content only when already at the tail', () => {
|
|
||||||
const oldWrap = wrap({ scrollTop: 1390, scrollHeight: 2000, clientHeight: 600 });
|
|
||||||
const snapshot = captureSessionViewState({
|
|
||||||
wrap: oldWrap,
|
|
||||||
domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
|
|
||||||
});
|
|
||||||
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2400, clientHeight: 600 });
|
|
||||||
|
|
||||||
restoreSessionViewState(snapshot, {
|
|
||||||
wrap: newWrap,
|
|
||||||
domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(newWrap.scrollTop, 2400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tail-follow detection uses only the scroll container metrics', () => {
|
|
||||||
assert.equal(isFollowingSessionTail(wrap({
|
|
||||||
scrollTop: 1360,
|
|
||||||
scrollHeight: 2000,
|
|
||||||
clientHeight: 600,
|
|
||||||
})), true);
|
|
||||||
assert.equal(isFollowingSessionTail(wrap({
|
|
||||||
scrollTop: 1200,
|
|
||||||
scrollHeight: 2000,
|
|
||||||
clientHeight: 600,
|
|
||||||
})), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tail append preserves an active reader and follows only without newer scroll input', () => {
|
|
||||||
const reader = wrap({ scrollTop: 600, scrollHeight: 2400, clientHeight: 600 });
|
|
||||||
restoreSessionTail({ wrap: reader, followTail: false });
|
|
||||||
assert.equal(reader.scrollTop, 600);
|
|
||||||
|
|
||||||
const userMoved = wrap({ scrollTop: 800, scrollHeight: 2400, clientHeight: 600 });
|
|
||||||
restoreSessionTail({ wrap: userMoved, followTail: true, restoreScroll: false });
|
|
||||||
assert.equal(userMoved.scrollTop, 800);
|
|
||||||
|
|
||||||
const follower = wrap({ scrollTop: 1400, scrollHeight: 2400, clientHeight: 600 });
|
|
||||||
restoreSessionTail({ wrap: follower, followTail: true });
|
|
||||||
assert.equal(follower.scrollTop, 2400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('session refresh never restores an old anchor over newer user scrolling', () => {
|
|
||||||
const snapshot = captureSessionViewState({
|
|
||||||
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
|
|
||||||
domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
|
|
||||||
});
|
|
||||||
const userScrolledWrap = wrap({ scrollTop: 800, scrollHeight: 2200, clientHeight: 600 });
|
|
||||||
|
|
||||||
restoreSessionViewState(snapshot, {
|
|
||||||
wrap: userScrolledWrap,
|
|
||||||
domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
|
|
||||||
restoreScroll: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scroll progress locates the visible message without scanning the full session', () => {
|
|
||||||
let layoutReads = 0;
|
|
||||||
const messages = Array.from({ length: 2048 }, (_, index) => ({
|
|
||||||
getBoundingClientRect() {
|
|
||||||
layoutReads++;
|
|
||||||
return { bottom: (index + 1) * 20 };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert.equal(findLastMessageAtOrAbove(messages, 20100), 1004);
|
|
||||||
assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('view-state capture locates its anchor logarithmically from the DOM index', () => {
|
|
||||||
let layoutReads = 0;
|
|
||||||
const items = Array.from({ length: 4096 }, (_, index) => ({
|
|
||||||
dataset: { uuid: `message-${index}` },
|
|
||||||
getBoundingClientRect() {
|
|
||||||
layoutReads++;
|
|
||||||
return { top: index * 20, bottom: (index + 1) * 20 };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const snapshot = captureSessionViewState({
|
|
||||||
wrap: wrap({ scrollTop: 20000, scrollHeight: 90000, clientHeight: 600, top: 30000 }),
|
|
||||||
domIndex: {
|
|
||||||
items,
|
|
||||||
byUuid: new Map(items.map(item => [item.dataset.uuid, item])),
|
|
||||||
byMessageUuid: new Map(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(snapshot.anchor.uuid, 'message-1500');
|
|
||||||
assert.ok(layoutReads < 20, `expected logarithmic anchor reads, got ${layoutReads}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('session DOM index scans the timeline once and groups roots by message UUID', () => {
|
|
||||||
let queries = 0;
|
|
||||||
const first = scrollItem('render-1', 0, 20);
|
|
||||||
first.dataset.messageUuid = 'message-1';
|
|
||||||
const second = scrollItem('render-2', 20, 40);
|
|
||||||
second.dataset.messageUuid = 'message-2';
|
|
||||||
const secondTools = scrollItem('render-2-tools', 40, 60);
|
|
||||||
secondTools.dataset.messageUuid = 'message-2';
|
|
||||||
const index = createSessionDomIndex({
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
queries++;
|
|
||||||
assert.equal(selector, '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]');
|
|
||||||
return [first, second, secondTools];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(queries, 1);
|
|
||||||
assert.deepEqual(index.items, [first, second, secondTools]);
|
|
||||||
assert.equal(index.byUuid.get('render-2-tools'), secondTools);
|
|
||||||
assert.deepEqual(index.byMessageUuid.get('message-2'), [second, secondTools]);
|
|
||||||
|
|
||||||
findLastMessageAtOrAbove(index.items, 35);
|
|
||||||
findLastMessageAtOrAbove(index.items, 55);
|
|
||||||
assert.equal(queries, 1, 'scroll reads reuse the index instead of querying the DOM');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('disclosure registry restores only message roots replaced by the snapshot', () => {
|
|
||||||
const previous = disclosure('tool:call-1', ['open'], { rawOpen: true });
|
|
||||||
previous.closest = selector => selector === '[data-message-uuid]'
|
|
||||||
? { dataset: { messageUuid: 'message-1' } }
|
|
||||||
: null;
|
|
||||||
const registry = createSessionDisclosureRegistry();
|
|
||||||
registry.remember(previous);
|
|
||||||
|
|
||||||
const replacement = disclosure('tool:call-1');
|
|
||||||
const updatedRoot = {
|
|
||||||
matches: () => false,
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
assert.equal(selector, '[data-view-key]');
|
|
||||||
return [replacement];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const untouchedRoot = {
|
|
||||||
matches: () => false,
|
|
||||||
querySelectorAll() {
|
|
||||||
assert.fail('unchanged message roots must not be scanned');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
registry.reconcile({
|
|
||||||
byMessageUuid: new Map([
|
|
||||||
['message-1', [updatedRoot]],
|
|
||||||
['message-2', [untouchedRoot]],
|
|
||||||
]),
|
|
||||||
}, {
|
|
||||||
updatedIds: ['message-1'],
|
|
||||||
removedIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(replacement.classList.contains('open'), true);
|
|
||||||
assert.equal(replacement.raw.classList.contains('show'), true);
|
|
||||||
assert.equal(replacement.pretty.classList.contains('hidden'), true);
|
|
||||||
assert.equal(replacement.button.classList.contains('active'), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('removed messages discard their remembered disclosure state', () => {
|
|
||||||
const previous = disclosure('tool:call-1', ['open']);
|
|
||||||
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
|
|
||||||
const registry = createSessionDisclosureRegistry();
|
|
||||||
registry.remember(previous);
|
|
||||||
registry.reconcile({ byMessageUuid: new Map() }, {
|
|
||||||
updatedIds: [],
|
|
||||||
removedIds: ['message-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const replacement = disclosure('tool:call-1');
|
|
||||||
registry.reconcile({
|
|
||||||
byMessageUuid: new Map([['message-1', [{
|
|
||||||
matches: () => false,
|
|
||||||
querySelectorAll: () => [replacement],
|
|
||||||
}]]]),
|
|
||||||
}, {
|
|
||||||
updatedIds: ['message-1'],
|
|
||||||
removedIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(replacement.classList.contains('open'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('disclosure registry restores root-level skill cards', () => {
|
|
||||||
const previous = disclosure('skill:message-1', ['skill-md-open']);
|
|
||||||
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
|
|
||||||
const registry = createSessionDisclosureRegistry();
|
|
||||||
registry.remember(previous);
|
|
||||||
|
|
||||||
const replacement = disclosure('skill:message-1');
|
|
||||||
replacement.matches = selector => selector === '[data-view-key]';
|
|
||||||
replacement.dataset.messageUuid = 'message-1';
|
|
||||||
registry.reconcile({
|
|
||||||
byMessageUuid: new Map([['message-1', [replacement]]]),
|
|
||||||
}, {
|
|
||||||
updatedIds: ['message-1'],
|
|
||||||
removedIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(replacement.classList.contains('skill-md-open'), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('view-state capture and restore do not query the full DOM', () => {
|
|
||||||
const source = readFileSync(new URL('../app/src/renderer/src/session-view-state.mjs', import.meta.url), 'utf8');
|
|
||||||
const capture = functionSource(source, 'captureSessionViewState');
|
|
||||||
const restore = functionSource(source, 'restoreSessionViewState');
|
|
||||||
|
|
||||||
assert.doesNotMatch(capture, /querySelectorAll|scrollItems\(|\bdetail\b/);
|
|
||||||
assert.doesNotMatch(restore, /querySelectorAll|scrollItems\(|\bdetail\b/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SessionDetail isolates unchanged rows and gives tail appends a scan-free path', () => {
|
|
||||||
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
|
||||||
const dataSource = readFileSync(new URL('../app/src/renderer/src/data.js', import.meta.url), 'utf8');
|
|
||||||
const loadMessages = functionSource(source, 'loadMessages');
|
|
||||||
const getSkillMd = functionSource(source, 'getSkillMd');
|
|
||||||
const toggleDisclosure = functionSource(source, 'toggleDisclosure');
|
|
||||||
|
|
||||||
assert.match(source, /import\s*\{[^}]*shallowRef[^}]*\}\s*from ['"]vue['"]/s);
|
|
||||||
assert.match(source, /const messages = shallowRef\(\[\]\)/);
|
|
||||||
assert.match(source, /applySnapshot/);
|
|
||||||
assert.match(source, /isFollowingSessionTail/);
|
|
||||||
assert.match(source, /captureSessionViewState/);
|
|
||||||
assert.match(source, /createSessionDomIndex/);
|
|
||||||
assert.match(source, /createSessionDisclosureRegistry/);
|
|
||||||
assert.match(source, /restoreSessionViewState/);
|
|
||||||
assert.match(source, /class="timeline"\s+v-memo="\[messages, state\.query\]"/);
|
|
||||||
assert.match(source, /:key="msg\.uuid"\s+v-memo=/);
|
|
||||||
assert.match(source, /v-memo="\[msg, state\.query\]"/);
|
|
||||||
assert.match(source, /loading\.value\s*=\s*!hadContent/);
|
|
||||||
assert.match(source, /scrollRevision/);
|
|
||||||
assert.match(source, /restoreScroll:\s*scrollRevision\s*===\s*scrollRevisionBeforePatch/);
|
|
||||||
assert.match(source, /requestAnimationFrame/);
|
|
||||||
assert.match(source, /findLastMessageAtOrAbove/);
|
|
||||||
assert.match(loadMessages, /if \(hadContent && !reconciliation\.tailOnly\)/);
|
|
||||||
assert.match(loadMessages, /domIndex:\s*sessionDomIndex/);
|
|
||||||
assert.match(loadMessages, /disclosureRegistry\.reconcile\(sessionDomIndex, reconciliation\)/);
|
|
||||||
assert.match(loadMessages, /if \(!reconciliation\.changed\)/);
|
|
||||||
assert.ok(
|
|
||||||
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('captureSessionViewState('),
|
|
||||||
'the expensive disclosure and anchor scan happens only after classifying the snapshot',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('isFollowingSessionTail('),
|
|
||||||
'tail state is sampled immediately before the patch, after the async snapshot load',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
loadMessages.indexOf('isFollowingSessionTail(') < loadMessages.indexOf('messages.value = reconciliation.messages'),
|
|
||||||
'tail state is sampled before assigning the new timeline',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
loadMessages.indexOf('if (!reconciliation.changed)') < loadMessages.indexOf('await nextTick()'),
|
|
||||||
'a no-op snapshot returns before awaiting a timeline patch',
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(getSkillMd, /messages\.value/);
|
|
||||||
assert.match(source, /getSkillMd\(msg\)/);
|
|
||||||
assert.match(toggleDisclosure, /disclosureRegistry\.remember\(element\)/);
|
|
||||||
assert.ok(
|
|
||||||
(source.match(/:data-message-uuid="msg\.uuid"/g) || []).length >= 6,
|
|
||||||
'every timeline root identifies its owning message for targeted disclosure restore',
|
|
||||||
);
|
|
||||||
assert.match(dataSource, /messages:\s*markRaw\(assembledMessages\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('live totals and scroll position remain isolated across interleaved updates', () => {
|
|
||||||
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
|
||||||
const loadMessages = functionSource(source, 'loadMessages');
|
|
||||||
const syncTimelineDom = functionSource(source, 'syncTimelineDom');
|
|
||||||
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
|
|
||||||
const navTo = functionSource(source, 'navTo');
|
|
||||||
|
|
||||||
assert.match(loadMessages, /await nextTick\(\);[\s\S]*syncTimelineDom\(\)/);
|
|
||||||
assert.match(syncTimelineDom, /createSessionDomIndex\(detailRef\.value\)/);
|
|
||||||
assert.match(syncTimelineDom, /totalMsgs\.value\s*=/);
|
|
||||||
assert.doesNotMatch(syncTimelineDom, /currentMsgIdx\.value\s*=/);
|
|
||||||
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
|
|
||||||
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
|
|
||||||
assert.doesNotMatch(updateScrollProgress, /querySelectorAll/);
|
|
||||||
assert.doesNotMatch(navTo, /querySelectorAll/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('message navigation keeps the top progress bar aligned with the current position', () => {
|
|
||||||
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
|
||||||
const setMessagePosition = functionSource(source, 'setMessagePosition');
|
|
||||||
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
|
|
||||||
const navTo = functionSource(source, 'navTo');
|
|
||||||
|
|
||||||
assert.match(setMessagePosition, /currentMsgIdx\.value\s*=\s*index/);
|
|
||||||
assert.match(setMessagePosition, /progressPct\.value\s*=/);
|
|
||||||
assert.match(updateScrollProgress, /setMessagePosition\(bottomMsgIdx,\s*msgs\.length\)/);
|
|
||||||
assert.match(navTo, /setMessagePosition\(idx,\s*msgs\.length\)/);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user