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:
@@ -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>
|
||||
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">⚙</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">→</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">⚙</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">→</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 · {{ 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;
|
||||
|
||||
Reference in New Issue
Block a user