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:
tommy0103
2026-07-14 03:45:39 +08:00
parent bbf16d8f9f
commit 2cad3b554d
14 changed files with 1193 additions and 816 deletions
@@ -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,
};
}
-148
View File
@@ -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);
}
+328 -254
View File
@@ -1,21 +1,16 @@
<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 { state, FOLDER_SVG } from '../store.js';
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.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 FlapNumber from '../components/FlapNumber.vue';
import {
captureSessionViewState,
createSessionDisclosureRegistry,
createSessionDomIndex,
findLastMessageAtOrAbove,
isFollowingSessionTail,
restoreSessionTail,
restoreSessionViewState,
} from '../session-view-state.mjs';
import {
escapeHTML,
fmtRelative,
@@ -33,18 +28,55 @@ const route = useRoute();
// --- Reactive state ---
const session = computed(() => state.sessions.find(s => s.id === props.id));
const messages = shallowRef([]);
const timelineItems = shallowRef([]);
const loading = ref(false);
const progressPct = ref(0);
const active = ref(false);
const focusedItemKey = ref(null);
const expandedMessageText = reactive(new Map());
const fullTextLoading = reactive(new Set());
let removeSessionUpdated = null;
let keydownAttached = false;
let scrollRevision = 0;
let focusTimer = null;
let loadRevision = 0;
// DOM refs
const wrapRef = ref(null);
const detailRef = ref(null);
let sessionDomIndex = createSessionDomIndex(null);
let disclosureRegistry = createSessionDisclosureRegistry();
const timelineRef = ref(null);
const headerRef = ref(null);
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 ---
const FONT_SIZE_KEY = 'obelisk:session-font-size';
@@ -99,10 +131,9 @@ onMounted(async () => {
if (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;
clearSessionDirty(props.id);
await loadMessages({ force: true });
void liveReloadCoordinator.request();
}) || null;
if (!localStorage.getItem(HINT_KEY)) {
showFontHint.value = true;
@@ -110,6 +141,9 @@ onMounted(async () => {
setTimeout(() => { showFontHint.value = false; }, 4000);
}
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
await nextTick();
syncTimelineScrollMargin();
observeSessionHeader();
});
onActivated(async () => {
@@ -123,6 +157,10 @@ onActivated(async () => {
} else if (state.pendingFocusUuid) {
await focusPendingMessage();
}
await liveReloadCoordinator.flush();
await nextTick();
syncTimelineScrollMargin();
observeSessionHeader();
});
onDeactivated(() => {
@@ -132,79 +170,109 @@ onDeactivated(() => {
onUnmounted(() => {
active.value = false;
loadRevision++;
detachKeydown();
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
scrollFrame = null;
if (focusTimer !== null) clearTimeout(focusTimer);
focusTimer = null;
headerResizeObserver?.disconnect();
headerResizeObserver = null;
liveReloadCoordinator.stop();
removeSessionUpdated?.();
removeSessionUpdated = null;
});
watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) {
loadRevision++;
timelineViewport.resetForInitialSnapshot();
messages.value = [];
disclosureRegistry = createSessionDisclosureRegistry();
timelineItems.value = [];
disclosures.retainMessages(new Set());
expandedMessageText.clear();
fullTextLoading.clear();
progressPct.value = 0;
currentMsgIdx.value = 0;
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 } = {}) {
if (!props.id) return;
const requestedSessionId = props.id;
if (!requestedSessionId) return;
const revision = ++loadRevision;
const hadContent = messages.value.length > 0;
let reconciliation;
let viewState = null;
let followTailBeforePatch = false;
let scrollRevisionBeforePatch = scrollRevision;
let latest;
loading.value = !hadContent;
try {
const s = state.sessions.find(x => x.id === props.id);
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;
}
latest = await fetchSessionSnapshot(requestedSessionId, { force });
} 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 (state.pendingFocusUuid) await focusPendingMessage();
timelineViewport.completeInitialSnapshot();
return;
}
await nextTick();
syncTimelineDom();
disclosureRegistry.reconcile(sessionDomIndex, reconciliation);
if (!state.pendingFocusUuid) {
if (reconciliation.tailOnly) {
restoreSessionTail({
wrap: wrapRef.value,
followTail: followTailBeforePatch,
restoreScroll: scrollRevision === scrollRevisionBeforePatch,
});
} else {
restoreSessionViewState(viewState, {
wrap: wrapRef.value,
domIndex: sessionDomIndex,
restoreScroll: scrollRevision === scrollRevisionBeforePatch,
});
}
onScroll();
}
timelineViewport.completeInitialSnapshot();
if (restoreTail) timelineViewport.scrollToEnd();
syncTimelineScrollMargin();
if (!state.pendingFocusUuid) onScroll();
// Focus pending uuid if any
if (state.pendingFocusUuid) {
@@ -216,34 +284,28 @@ async function focusPendingMessage() {
const targetUuid = state.pendingFocusUuid;
if (!targetUuid) return;
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();
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"], .skill-card[data-uuid="${targetUuid}"], .wf-card[data-uuid="${targetUuid}"]`);
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();
}
onScroll();
}
// --- Scroll / progress tracking ---
const currentMsgIdx = ref(0);
const totalMsgs = ref(0);
const totalMsgs = computed(() => timelineItems.value.length);
let navLock = false;
let scrollFrame = null;
function syncTimelineDom() {
sessionDomIndex = createSessionDomIndex(detailRef.value);
totalMsgs.value = sessionDomIndex.items.length;
}
function onScroll(event) {
if (event) scrollRevision++;
if (navLock) return;
if (scrollFrame !== null) return;
scrollFrame = requestAnimationFrame(() => {
@@ -258,65 +320,58 @@ function setMessagePosition(index, total) {
}
function updateScrollProgress() {
if (!wrapRef.value) return;
const msgs = sessionDomIndex.items;
if (!msgs.length) {
if (!wrapRef.value || !timelineItems.value.length) {
currentMsgIdx.value = 0;
progressPct.value = 0;
return;
}
const el = wrapRef.value;
const navHeight = 52;
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
setMessagePosition(bottomMsgIdx, msgs.length);
const bottomMsgIdx = timelineViewport.indexAtViewportEnd(NAV_HEIGHT);
setMessagePosition(bottomMsgIdx, timelineItems.value.length);
}
function navTo(target) {
if (!wrapRef.value) return;
const msgs = sessionDomIndex.items;
if (!msgs.length) return;
const count = timelineItems.value.length;
if (!count) return;
let idx;
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 === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
else if (target === 'next') idx = Math.min(count - 1, currentMsgIdx.value + 1);
else return;
setMessagePosition(idx, msgs.length);
setMessagePosition(idx, count);
navLock = true;
const navHeight = 52;
const el = wrapRef.value;
const msgEl = msgs[idx];
if (!msgEl) return;
const msgBottom = msgEl.offsetTop + msgEl.offsetHeight;
const scrollTarget = msgBottom - el.clientHeight + navHeight;
el.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'instant' });
setTimeout(() => { navLock = false; }, 50);
timelineViewport.scrollToIndex(idx, { align: 'end' });
setTimeout(() => {
navLock = false;
onScroll();
}, 50);
}
// --- Toggle helpers ---
function toggleDisclosure(event, selector, className = 'open') {
const element = event.currentTarget?.closest(selector);
if (!element) return;
element.classList.toggle(className);
disclosureRegistry.remember(element);
function toggleDisclosure(key, messageUuid) {
disclosures.toggleOpen(key, messageUuid);
}
// --- Full text loading ---
async function handleLoadFullText(event, uuid) {
const btn = event.currentTarget;
btn.textContent = 'Loading...';
const fullText = await loadFullText(uuid);
if (fullText) {
const msgEl = btn.closest('.msg');
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 });
if (bodyEl) bodyEl.outerHTML = rendered;
btn.remove();
} else {
btn.textContent = 'Failed to load full text';
function displayMessageText(message) {
return expandedMessageText.get(message.uuid) ?? message.text;
}
function canLoadFullText(message) {
return !expandedMessageText.has(message.uuid) && isTextTruncated(message.text);
}
async function handleLoadFullText(uuid) {
if (fullTextLoading.has(uuid)) return;
fullTextLoading.add(uuid);
try {
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) ---
function formatToolInput(tc) {
@@ -559,17 +624,8 @@ function renderAutoTable(rows) {
</div>`;
}
function toggleRaw(event) {
const body = event.target.closest('.toolcall-body');
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 toggleRaw(key, messageUuid) {
disclosures.toggleRaw(key, messageUuid);
}
function getSkillMd(msg) {
@@ -587,7 +643,7 @@ function getToolCallParsedInput(tc) {
<template>
<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 -->
<div class="session-progress">
<div class="session-progress-fill" :style="{ width: progressPct + '%' }"></div>
@@ -600,7 +656,7 @@ function getToolCallParsedInput(tc) {
<!-- Session header -->
<template v-if="session && !loading">
<div class="session-header">
<div class="session-header" ref="headerRef">
<div class="session-eyebrow">
<span class="project-icon" v-html="FOLDER_SVG"></span>
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
@@ -626,113 +682,125 @@ function getToolCallParsedInput(tc) {
</div>
<!-- Message timeline -->
<div class="timeline" v-memo="[messages, state.query]">
<template v-for="msg in messages" :key="msg.uuid" v-memo="[msg, state.query]">
<div
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 -->
<template v-if="msg.is_meta === 1">
<div class="msg meta" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
<div class="msg-meta-collapsed" :data-view-key="`meta:${msg.uuid}`">
<button class="meta-toggle" @click="toggleDisclosure($event, '.msg-meta-collapsed')">
<template v-if="item.kind === 'meta'">
<div class="msg meta" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-meta-collapsed" :class="{ open: disclosures.isOpen(`meta:${msg.uuid}`) }" :data-view-key="`meta:${msg.uuid}`">
<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>
<span class="meta-label">System</span>
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
</button>
<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
v-if="isTextTruncated(msg.text)"
v-if="canLoadFullText(msg)"
class="truncated-btn"
@click="handleLoadFullText($event, msg.uuid)"
>Message truncated click to load full text</button>
:disabled="fullTextLoading.has(msg.uuid)"
@click="handleLoadFullText(msg.uuid)"
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
</div>
</div>
</div>
</template>
<!-- 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-if="(() => { const wfCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow); return wfCall && msg.type !== 'user'; })()">
<div class="wf-card" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
<div class="wf-card-header">
<span class="wf-card-icon">&#x2699;</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">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.agents?.length || 0 }} agents</span>
<span
v-if="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
class="wf-card-status"
:class="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
>{{ ((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">&rarr;</span>
</button>
</div>
</template>
</div>
<template v-else-if="item.kind === 'workflow'">
<div class="wf-card" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="wf-card-header">
<span class="wf-card-icon">&#x2699;</span>
<span class="wf-card-name">{{ item.workflowCall.workflow.workflow_name || 'Workflow' }}</span>
<span class="wf-card-count">{{ item.workflowCall.workflow.agents?.length || 0 }} agents</span>
<span
v-if="item.workflowCall.workflow.status"
class="wf-card-status"
:class="item.workflowCall.workflow.status"
>{{ item.workflowCall.workflow.status }}</span>
</div>
<!-- Other tool calls (non-workflow) for this message -->
<template v-if="(msg.tool_calls || []).filter(tc => !(tc.name === 'Workflow' && tc.workflow)).length > 0">
<div class="msg assistant" :data-uuid="msg.uuid + '-tools'" :data-message-uuid="msg.uuid">
<div class="msg-tools">
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
<!-- Render non-workflow tool calls -->
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
<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" @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 class="wf-card-body">
<template v-for="(phaseAgents, phase) in groupWorkflowAgents(item.workflowCall.workflow)" :key="phase">
<div class="wf-card-phase">
<div class="wf-card-phase-title">{{ phase }}</div>
<button
v-for="agent in phaseAgents"
:key="agent.agent_id"
class="wf-card-agent"
@click="navigateToSubagent(agent.agent_id, agent.label || '')"
>
<span class="wf-card-agent-label">{{ agent.label || agent.agent_id }}</span>
<span v-if="agent.state === 'error'" class="wf-card-agent-state error">error</span>
<span class="wf-card-agent-arrow">&rarr;</span>
</button>
</div>
</div>
</template>
</template>
</template>
</div>
</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>
<!-- 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">
<div class="skill-card" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid" :data-view-key="`skill:${msg.uuid}`">
<template v-else-if="item.kind === 'skill'">
<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">
<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>
@@ -743,7 +811,7 @@ function getToolCallParsedInput(tc) {
</div>
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
<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>
<span>SKILL.md</span>
</button>
@@ -754,10 +822,10 @@ function getToolCallParsedInput(tc) {
</template>
<!-- Standalone thinking message -->
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
<div class="msg assistant" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
<div class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleDisclosure($event, '.msg-thinking')">
<template v-else-if="item.kind === 'thinking'">
<div class="msg assistant" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-thinking" :class="{ open: disclosures.isOpen(`thinking:${msg.uuid}`) }" :data-view-key="`thinking:${msg.uuid}`">
<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>
<span class="thinking-label">Thinking</span>
</button>
@@ -770,9 +838,9 @@ function getToolCallParsedInput(tc) {
<template v-else>
<div
class="msg"
:class="msg.type === 'user' ? 'user' : 'assistant'"
:data-uuid="msg.uuid"
:data-message-uuid="msg.uuid"
:class="[msg.type === 'user' ? 'user' : 'assistant', { 'is-focused': focusedItemKey === item.key }]"
:data-uuid="item.anchorUuid"
:data-message-uuid="item.messageUuid"
>
<!-- Message header -->
<div class="msg-head">
@@ -781,8 +849,8 @@ function getToolCallParsedInput(tc) {
</div>
<!-- Attached thinking block (merged from preceding thinking messages) -->
<div v-if="msg._thinking" class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleDisclosure($event, '.msg-thinking')">
<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(`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>
<span class="thinking-label">Thinking</span>
</button>
@@ -791,12 +859,13 @@ function getToolCallParsedInput(tc) {
<!-- Message text body -->
<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
v-if="isTextTruncated(msg.text)"
v-if="canLoadFullText(msg)"
class="truncated-btn"
@click="handleLoadFullText($event, msg.uuid)"
>Message truncated — click to load full text</button>
:disabled="fullTextLoading.has(msg.uuid)"
@click="handleLoadFullText(msg.uuid)"
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
</template>
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
<div class="msg-text empty-text">(no text content)</div>
@@ -816,8 +885,8 @@ function getToolCallParsedInput(tc) {
<!-- Agent/Task tool call (subagent) -->
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
<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(`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 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>
@@ -843,8 +912,8 @@ function getToolCallParsedInput(tc) {
<!-- Workflow tool call (inside assistant bubble) -->
<template v-else-if="tc.name === 'Workflow'">
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
<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(`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 class="tool-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">
<div class="tc-section">Agents &middot; {{ tc.workflow.agents.length }}</div>
<div class="workflow-agent-list">
<template v-for="(phaseAgents, phase) in (() => {
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">
<template v-for="(phaseAgents, phase) in groupWorkflowAgents(tc.workflow)" :key="phase">
<div class="workflow-phase-group">
<div class="workflow-phase-header">{{ phase }}</div>
<div class="workflow-phase-agents">
@@ -891,8 +952,8 @@ function getToolCallParsedInput(tc) {
<!-- Generic tool call -->
<template v-else>
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure($event, '.msg-tool')">
<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>
@@ -903,10 +964,10 @@ function getToolCallParsedInput(tc) {
<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>
<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" v-html="renderPrettyTool(tc)"></div>
<div class="toolcall-raw">
<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">
@@ -922,8 +983,8 @@ function getToolCallParsedInput(tc) {
</div>
<!-- Summary block -->
<div v-if="msg.summary" class="msg-summary" :data-view-key="`summary:${msg.uuid}`">
<button class="summary-toggle" @click="toggleDisclosure($event, '.msg-summary')">
<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(`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>
<span class="label">Session summary</span>
<span class="source">{{ msg.summary.source || '' }}</span>
@@ -933,7 +994,9 @@ function getToolCallParsedInput(tc) {
</div>
</template>
</template>
</template>
</template>
</div>
</div>
</template>
</div>
@@ -970,6 +1033,17 @@ function getToolCallParsedInput(tc) {
min-height: 0;
position: relative;
}
.virtual-timeline {
display: block;
position: relative;
gap: 0;
}
.virtual-timeline-row {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.font-toast {
position: fixed;
bottom: 48px;