fix(app): keep live session updates responsive

Cache timeline DOM indexes and memoize the timeline so progress and flap updates avoid rescanning or rerendering old messages. Preserve scroll anchors and disclosure state across targeted live message replacements.
This commit is contained in:
tommy0103
2026-07-14 00:53:38 +08:00
parent 7e6e9d9fc3
commit bbf16d8f9f
3 changed files with 319 additions and 113 deletions
+89 -45
View File
@@ -9,6 +9,71 @@ function scrollItems(detail) {
return arrayFrom(detail?.querySelectorAll?.(SCROLL_ITEM_SELECTOR)); 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) { export function isFollowingSessionTail(wrap, bottomThreshold = 50) {
if (!wrap) return false; if (!wrap) return false;
return wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < bottomThreshold; return wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < bottomThreshold;
@@ -19,22 +84,30 @@ export function restoreSessionTail({ wrap, followTail, restoreScroll = true } =
wrap.scrollTop = wrap.scrollHeight; wrap.scrollTop = wrap.scrollHeight;
} }
export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = {}) { function firstMessageEndingBelowIndex(messages, line) {
let low = 0;
let high = messages.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (messages[middle].getBoundingClientRect().bottom > line) {
high = middle;
} else {
low = middle + 1;
}
}
return low;
}
export function captureSessionViewState({ wrap, domIndex, bottomThreshold = 50 } = {}) {
if (!wrap) return null; if (!wrap) return null;
const followTail = isFollowingSessionTail(wrap, bottomThreshold); const followTail = isFollowingSessionTail(wrap, bottomThreshold);
const wrapTop = wrap.getBoundingClientRect?.().top || 0; const wrapTop = wrap.getBoundingClientRect?.().top || 0;
const anchorIndex = followTail
? -1
: firstMessageEndingBelowIndex(domIndex?.items || [], wrapTop);
const anchorElement = followTail const anchorElement = followTail
? null ? null
: scrollItems(detail).find(element => element.getBoundingClientRect().bottom > wrapTop); : domIndex?.items?.[anchorIndex];
const disclosures = [];
for (const element of arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))) {
const key = element.dataset?.viewKey;
if (!key) continue;
const classes = DISCLOSURE_CLASSES.filter(className => element.classList?.contains(className));
const rawOpen = Boolean(element.querySelector?.('.toolcall-raw')?.classList?.contains('show'));
if (classes.length || rawOpen) disclosures.push({ key, classes, rawOpen });
}
return { return {
followTail, followTail,
@@ -42,32 +115,16 @@ export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } =
anchor: anchorElement?.dataset?.uuid anchor: anchorElement?.dataset?.uuid
? { ? {
uuid: anchorElement.dataset.uuid, uuid: anchorElement.dataset.uuid,
messageUuid: anchorElement.dataset.messageUuid || anchorElement.dataset.uuid,
offset: anchorElement.getBoundingClientRect().top - wrapTop, offset: anchorElement.getBoundingClientRect().top - wrapTop,
} }
: null, : null,
disclosures,
}; };
} }
export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll = true } = {}) { export function restoreSessionViewState(snapshot, { wrap, domIndex, restoreScroll = true } = {}) {
if (!snapshot || !wrap) return; if (!snapshot || !wrap) return;
const disclosuresByKey = new Map(
arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))
.filter(element => element.dataset?.viewKey)
.map(element => [element.dataset.viewKey, element]),
);
for (const disclosure of snapshot.disclosures || []) {
const element = disclosuresByKey.get(disclosure.key);
if (!element) continue;
element.classList?.add(...disclosure.classes);
if (!disclosure.rawOpen) continue;
element.querySelector?.('.toolcall-raw')?.classList?.add('show');
element.querySelector?.('.toolcall-pretty')?.classList?.add('hidden');
element.querySelector?.('.raw-toggle')?.classList?.add('active');
}
if (!restoreScroll) return; if (!restoreScroll) return;
if (snapshot.followTail) { if (snapshot.followTail) {
@@ -78,9 +135,8 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll
wrap.scrollTop = snapshot.scrollTop; wrap.scrollTop = snapshot.scrollTop;
if (!snapshot.anchor) return; if (!snapshot.anchor) return;
const wrapTop = wrap.getBoundingClientRect?.().top || 0; const wrapTop = wrap.getBoundingClientRect?.().top || 0;
const anchorElement = scrollItems(detail).find( const anchorElement = domIndex?.byUuid?.get(snapshot.anchor.uuid)
element => element.dataset?.uuid === snapshot.anchor.uuid, || domIndex?.byMessageUuid?.get(snapshot.anchor.messageUuid)?.[0];
);
if (!anchorElement) return; if (!anchorElement) return;
const currentOffset = anchorElement.getBoundingClientRect().top - wrapTop; const currentOffset = anchorElement.getBoundingClientRect().top - wrapTop;
wrap.scrollTop += currentOffset - snapshot.anchor.offset; wrap.scrollTop += currentOffset - snapshot.anchor.offset;
@@ -88,17 +144,5 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll
export function findLastMessageAtOrAbove(messages, bottomLine) { export function findLastMessageAtOrAbove(messages, bottomLine) {
if (!messages?.length) return -1; if (!messages?.length) return -1;
let low = 0; return Math.max(0, firstMessageEndingBelowIndex(messages, bottomLine) - 1);
let high = messages.length - 1;
let result = 0;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
if (messages[middle].getBoundingClientRect().bottom <= bottomLine) {
result = middle;
low = middle + 1;
} else {
high = middle - 1;
}
}
return result;
} }
+38 -48
View File
@@ -9,6 +9,8 @@ import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer
import FlapNumber from '../components/FlapNumber.vue'; import FlapNumber from '../components/FlapNumber.vue';
import { import {
captureSessionViewState, captureSessionViewState,
createSessionDisclosureRegistry,
createSessionDomIndex,
findLastMessageAtOrAbove, findLastMessageAtOrAbove,
isFollowingSessionTail, isFollowingSessionTail,
restoreSessionTail, restoreSessionTail,
@@ -41,6 +43,8 @@ let scrollRevision = 0;
// DOM refs // DOM refs
const wrapRef = ref(null); const wrapRef = ref(null);
const detailRef = ref(null); const detailRef = ref(null);
let sessionDomIndex = createSessionDomIndex(null);
let disclosureRegistry = createSessionDisclosureRegistry();
// --- Load session on mount or when id changes --- // --- Load session on mount or when id changes ---
const FONT_SIZE_KEY = 'obelisk:session-font-size'; const FONT_SIZE_KEY = 'obelisk:session-font-size';
@@ -138,6 +142,7 @@ onUnmounted(() => {
watch(() => props.id, async (newId, oldId) => { watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) { if (newId && newId !== oldId) {
messages.value = []; messages.value = [];
disclosureRegistry = createSessionDisclosureRegistry();
progressPct.value = 0; progressPct.value = 0;
currentMsgIdx.value = 0; currentMsgIdx.value = 0;
await loadMessages({ force: consumeGlobalSessionDirty(newId) }); await loadMessages({ force: consumeGlobalSessionDirty(newId) });
@@ -167,7 +172,7 @@ async function loadMessages({ force = false } = {}) {
if (hadContent && !reconciliation.tailOnly) { if (hadContent && !reconciliation.tailOnly) {
viewState = captureSessionViewState({ viewState = captureSessionViewState({
wrap: wrapRef.value, wrap: wrapRef.value,
detail: detailRef.value, domIndex: sessionDomIndex,
}); });
} }
messages.value = reconciliation.messages; messages.value = reconciliation.messages;
@@ -182,7 +187,8 @@ async function loadMessages({ force = false } = {}) {
} }
await nextTick(); await nextTick();
syncTotalMessages(); syncTimelineDom();
disclosureRegistry.reconcile(sessionDomIndex, reconciliation);
if (!state.pendingFocusUuid) { if (!state.pendingFocusUuid) {
if (reconciliation.tailOnly) { if (reconciliation.tailOnly) {
restoreSessionTail({ restoreSessionTail({
@@ -193,7 +199,7 @@ async function loadMessages({ force = false } = {}) {
} else { } else {
restoreSessionViewState(viewState, { restoreSessionViewState(viewState, {
wrap: wrapRef.value, wrap: wrapRef.value,
detail: detailRef.value, domIndex: sessionDomIndex,
restoreScroll: scrollRevision === scrollRevisionBeforePatch, restoreScroll: scrollRevision === scrollRevisionBeforePatch,
}); });
} }
@@ -231,9 +237,9 @@ const totalMsgs = ref(0);
let navLock = false; let navLock = false;
let scrollFrame = null; let scrollFrame = null;
function syncTotalMessages() { function syncTimelineDom() {
const msgs = detailRef.value?.querySelectorAll('.msg, .wf-card, .skill-card'); sessionDomIndex = createSessionDomIndex(detailRef.value);
totalMsgs.value = msgs?.length || 0; totalMsgs.value = sessionDomIndex.items.length;
} }
function onScroll(event) { function onScroll(event) {
@@ -252,8 +258,8 @@ function setMessagePosition(index, total) {
} }
function updateScrollProgress() { function updateScrollProgress() {
if (!wrapRef.value || !detailRef.value) return; if (!wrapRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card'); const msgs = sessionDomIndex.items;
if (!msgs.length) { if (!msgs.length) {
currentMsgIdx.value = 0; currentMsgIdx.value = 0;
progressPct.value = 0; progressPct.value = 0;
@@ -268,8 +274,8 @@ function updateScrollProgress() {
} }
function navTo(target) { function navTo(target) {
if (!wrapRef.value || !detailRef.value) return; if (!wrapRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card'); const msgs = sessionDomIndex.items;
if (!msgs.length) return; if (!msgs.length) return;
let idx; let idx;
if (target === 'first') idx = 0; if (target === 'first') idx = 0;
@@ -290,24 +296,11 @@ function navTo(target) {
} }
// --- Toggle helpers --- // --- Toggle helpers ---
function toggleToolCall(event) { function toggleDisclosure(event, selector, className = 'open') {
const btn = event.currentTarget; const element = event.currentTarget?.closest(selector);
btn.closest('.msg-tool').classList.toggle('open'); if (!element) return;
} element.classList.toggle(className);
disclosureRegistry.remember(element);
function toggleSummary(event) {
const btn = event.currentTarget;
btn.closest('.msg-summary').classList.toggle('open');
}
function toggleThinking(event) {
const btn = event.currentTarget;
btn.closest('.msg-thinking').classList.toggle('open');
}
function toggleMeta(event) {
const btn = event.currentTarget;
btn.closest('.msg-meta-collapsed').classList.toggle('open');
} }
// --- Full text loading --- // --- Full text loading ---
@@ -576,17 +569,13 @@ function toggleRaw(event) {
const showing = raw.classList.toggle('show'); const showing = raw.classList.toggle('show');
pretty.classList.toggle('hidden', showing); pretty.classList.toggle('hidden', showing);
btn?.classList.toggle('active', showing); btn?.classList.toggle('active', showing);
disclosureRegistry.remember(body.closest('[data-view-key]'));
} }
function getSkillMd(msg) { function getSkillMd(msg) {
return msg?._skillMd || null; return msg?._skillMd || null;
} }
function toggleSkillMd(event) {
const card = event.target.closest('.skill-card');
if (card) card.classList.toggle('skill-md-open');
}
function getToolCallParsedInput(tc) { function getToolCallParsedInput(tc) {
try { try {
return JSON.parse(tc.input_json || '{}'); return JSON.parse(tc.input_json || '{}');
@@ -637,14 +626,14 @@ function getToolCallParsedInput(tc) {
</div> </div>
<!-- Message timeline --> <!-- Message timeline -->
<div class="timeline"> <div class="timeline" v-memo="[messages, state.query]">
<template v-for="msg in messages" :key="msg.uuid" v-memo="[msg, state.query]"> <template v-for="msg in messages" :key="msg.uuid" v-memo="[msg, state.query]">
<!-- Meta messages: collapsed system indicator --> <!-- Meta messages: collapsed system indicator -->
<template v-if="msg.is_meta === 1"> <template v-if="msg.is_meta === 1">
<div class="msg meta" :data-uuid="msg.uuid"> <div class="msg meta" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
<div class="msg-meta-collapsed" :data-view-key="`meta:${msg.uuid}`"> <div class="msg-meta-collapsed" :data-view-key="`meta:${msg.uuid}`">
<button class="meta-toggle" @click="toggleMeta"> <button class="meta-toggle" @click="toggleDisclosure($event, '.msg-meta-collapsed')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="meta-label">System</span> <span class="meta-label">System</span>
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span> <span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
@@ -664,7 +653,7 @@ function getToolCallParsedInput(tc) {
<!-- Workflow card (standalone, outside assistant bubble) --> <!-- Workflow card (standalone, outside assistant bubble) -->
<template v-else-if="!msg.type || msg.type !== 'user' ? (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow) : false"> <template v-else-if="!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'; })()"> <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"> <div class="wf-card" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
<div class="wf-card-header"> <div class="wf-card-header">
<span class="wf-card-icon">&#x2699;</span> <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-name">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.workflow_name || 'Workflow' }}</span>
@@ -705,12 +694,12 @@ function getToolCallParsedInput(tc) {
</div> </div>
<!-- Other tool calls (non-workflow) for this message --> <!-- Other tool calls (non-workflow) for this message -->
<template v-if="(msg.tool_calls || []).filter(tc => !(tc.name === 'Workflow' && tc.workflow)).length > 0"> <template v-if="(msg.tool_calls || []).filter(tc => !(tc.name === 'Workflow' && tc.workflow)).length > 0">
<div class="msg assistant" :data-uuid="msg.uuid + '-tools'"> <div class="msg assistant" :data-uuid="msg.uuid + '-tools'" :data-message-uuid="msg.uuid">
<div class="msg-tools"> <div class="msg-tools">
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id"> <template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
<!-- Render non-workflow tool calls --> <!-- Render non-workflow tool calls -->
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`"> <div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall"> <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> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span> <span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
<span class="tool-name">{{ tc.name }}</span> <span class="tool-name">{{ tc.name }}</span>
@@ -743,7 +732,7 @@ function getToolCallParsedInput(tc) {
<!-- Skill card (standalone, like workflow) --> <!-- Skill card (standalone, like workflow) -->
<template v-else-if="msg.type === 'assistant' && (msg.tool_calls || []).length === 1 && msg.tool_calls[0].name === 'Skill' && !msg.text"> <template v-else-if="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-view-key="`skill:${msg.uuid}`"> <div class="skill-card" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid" :data-view-key="`skill:${msg.uuid}`">
<div class="skill-card-icon"> <div class="skill-card-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
</div> </div>
@@ -754,7 +743,7 @@ function getToolCallParsedInput(tc) {
</div> </div>
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div> <div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
<div v-if="getSkillMd(msg)" class="skill-card-md"> <div v-if="getSkillMd(msg)" class="skill-card-md">
<button class="skill-md-toggle" @click="toggleSkillMd"> <button class="skill-md-toggle" @click="toggleDisclosure($event, '.skill-card', 'skill-md-open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span>SKILL.md</span> <span>SKILL.md</span>
</button> </button>
@@ -766,9 +755,9 @@ function getToolCallParsedInput(tc) {
<!-- Standalone thinking message --> <!-- Standalone thinking message -->
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'"> <template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
<div class="msg assistant" :data-uuid="msg.uuid"> <div class="msg assistant" :data-uuid="msg.uuid" :data-message-uuid="msg.uuid">
<div class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`"> <div class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleThinking"> <button class="thinking-toggle" @click="toggleDisclosure($event, '.msg-thinking')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span> <span class="thinking-label">Thinking</span>
</button> </button>
@@ -783,6 +772,7 @@ function getToolCallParsedInput(tc) {
class="msg" class="msg"
:class="msg.type === 'user' ? 'user' : 'assistant'" :class="msg.type === 'user' ? 'user' : 'assistant'"
:data-uuid="msg.uuid" :data-uuid="msg.uuid"
:data-message-uuid="msg.uuid"
> >
<!-- Message header --> <!-- Message header -->
<div class="msg-head"> <div class="msg-head">
@@ -792,7 +782,7 @@ function getToolCallParsedInput(tc) {
<!-- Attached thinking block (merged from preceding thinking messages) --> <!-- Attached thinking block (merged from preceding thinking messages) -->
<div v-if="msg._thinking" class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`"> <div v-if="msg._thinking" class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleThinking"> <button class="thinking-toggle" @click="toggleDisclosure($event, '.msg-thinking')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span> <span class="thinking-label">Thinking</span>
</button> </button>
@@ -827,7 +817,7 @@ function getToolCallParsedInput(tc) {
<!-- Agent/Task tool call (subagent) --> <!-- Agent/Task tool call (subagent) -->
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'"> <template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`"> <div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall"> <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> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span> <span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
<span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span> <span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span>
@@ -854,7 +844,7 @@ function getToolCallParsedInput(tc) {
<!-- Workflow tool call (inside assistant bubble) --> <!-- Workflow tool call (inside assistant bubble) -->
<template v-else-if="tc.name === 'Workflow'"> <template v-else-if="tc.name === 'Workflow'">
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`"> <div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall"> <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> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">Workflow</span> <span class="tool-name">Workflow</span>
<span class="tool-arg">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span> <span class="tool-arg">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span>
@@ -902,7 +892,7 @@ function getToolCallParsedInput(tc) {
<!-- Generic tool call --> <!-- Generic tool call -->
<template v-else> <template v-else>
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`"> <div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleToolCall"> <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> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span> <span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
<span class="tool-name">{{ tc.name }}</span> <span class="tool-name">{{ tc.name }}</span>
@@ -933,7 +923,7 @@ function getToolCallParsedInput(tc) {
<!-- Summary block --> <!-- Summary block -->
<div v-if="msg.summary" class="msg-summary" :data-view-key="`summary:${msg.uuid}`"> <div v-if="msg.summary" class="msg-summary" :data-view-key="`summary:${msg.uuid}`">
<button class="summary-toggle" @click="toggleSummary"> <button class="summary-toggle" @click="toggleDisclosure($event, '.msg-summary')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="label">Session summary</span> <span class="label">Session summary</span>
<span class="source">{{ msg.summary.source || '' }}</span> <span class="source">{{ msg.summary.source || '' }}</span>
+192 -20
View File
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
import { import {
captureSessionViewState, captureSessionViewState,
createSessionDisclosureRegistry,
createSessionDomIndex,
findLastMessageAtOrAbove, findLastMessageAtOrAbove,
isFollowingSessionTail, isFollowingSessionTail,
restoreSessionTail, restoreSessionTail,
@@ -52,6 +54,10 @@ function detail(disclosures, scrollItems) {
}; };
} }
function domIndex(items) {
return createSessionDomIndex(detail([], items));
}
function wrap({ scrollTop, scrollHeight, clientHeight, top = 0 }) { function wrap({ scrollTop, scrollHeight, clientHeight, top = 0 }) {
return { return {
scrollTop, scrollTop,
@@ -76,39 +82,53 @@ function functionSource(source, name) {
assert.fail(`${name} should have a complete function body`); assert.fail(`${name} should have a complete function body`);
} }
test('session refresh restores disclosure state and the visible scroll anchor', () => { test('session refresh restores the visible scroll anchor from cached DOM indexes', () => {
const oldTool = disclosure('tool:call-1', ['open'], { rawOpen: true });
const oldWrap = wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }); const oldWrap = wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({ const snapshot = captureSessionViewState({
wrap: oldWrap, wrap: oldWrap,
detail: detail([oldTool], [scrollItem('msg-1', -20, 180)]), domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
}); });
const newTool = disclosure('tool:call-1');
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 }); const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, { restoreSessionViewState(snapshot, {
wrap: newWrap, wrap: newWrap,
detail: detail([newTool], [scrollItem('msg-1', 80, 280)]), domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
}); });
assert.equal(newTool.classList.contains('open'), true);
assert.equal(newTool.raw.classList.contains('show'), true);
assert.equal(newTool.pretty.classList.contains('hidden'), true);
assert.equal(newTool.button.classList.contains('active'), true);
assert.equal(newWrap.scrollTop, 600, '100 px inserted above the anchor is compensated'); assert.equal(newWrap.scrollTop, 600, '100 px inserted above the anchor is compensated');
}); });
test('session refresh falls back to the owning message when its rendered root changes', () => {
const oldRoot = scrollItem('message-1-tools', -20, 180);
oldRoot.dataset.messageUuid = 'message-1';
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
domIndex: domIndex([oldRoot]),
});
const newRoot = scrollItem('message-1', 80, 280);
newRoot.dataset.messageUuid = 'message-1';
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
domIndex: domIndex([newRoot]),
});
assert.equal(snapshot.anchor.messageUuid, 'message-1');
assert.equal(newWrap.scrollTop, 600, 'message identity preserves the anchor across render shapes');
});
test('session refresh follows appended content only when already at the tail', () => { test('session refresh follows appended content only when already at the tail', () => {
const oldWrap = wrap({ scrollTop: 1390, scrollHeight: 2000, clientHeight: 600 }); const oldWrap = wrap({ scrollTop: 1390, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({ const snapshot = captureSessionViewState({
wrap: oldWrap, wrap: oldWrap,
detail: detail([], [scrollItem('msg-last', 300, 590)]), domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
}); });
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2400, clientHeight: 600 }); const newWrap = wrap({ scrollTop: 0, scrollHeight: 2400, clientHeight: 600 });
restoreSessionViewState(snapshot, { restoreSessionViewState(snapshot, {
wrap: newWrap, wrap: newWrap,
detail: detail([], [scrollItem('msg-last', 300, 590)]), domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
}); });
assert.equal(newWrap.scrollTop, 2400); assert.equal(newWrap.scrollTop, 2400);
@@ -142,21 +162,18 @@ test('tail append preserves an active reader and follows only without newer scro
}); });
test('session refresh never restores an old anchor over newer user scrolling', () => { test('session refresh never restores an old anchor over newer user scrolling', () => {
const oldTool = disclosure('tool:call-1', ['open']);
const snapshot = captureSessionViewState({ const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }), wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
detail: detail([oldTool], [scrollItem('msg-1', -20, 180)]), domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
}); });
const newTool = disclosure('tool:call-1');
const userScrolledWrap = wrap({ scrollTop: 800, scrollHeight: 2200, clientHeight: 600 }); const userScrolledWrap = wrap({ scrollTop: 800, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, { restoreSessionViewState(snapshot, {
wrap: userScrolledWrap, wrap: userScrolledWrap,
detail: detail([newTool], [scrollItem('msg-1', 80, 280)]), domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
restoreScroll: false, restoreScroll: false,
}); });
assert.equal(newTool.classList.contains('open'), true, 'disclosure state still restores');
assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state'); assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state');
}); });
@@ -173,18 +190,162 @@ test('scroll progress locates the visible message without scanning the full sess
assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`); assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`);
}); });
test('view-state capture locates its anchor logarithmically from the DOM index', () => {
let layoutReads = 0;
const items = Array.from({ length: 4096 }, (_, index) => ({
dataset: { uuid: `message-${index}` },
getBoundingClientRect() {
layoutReads++;
return { top: index * 20, bottom: (index + 1) * 20 };
},
}));
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 20000, scrollHeight: 90000, clientHeight: 600, top: 30000 }),
domIndex: {
items,
byUuid: new Map(items.map(item => [item.dataset.uuid, item])),
byMessageUuid: new Map(),
},
});
assert.equal(snapshot.anchor.uuid, 'message-1500');
assert.ok(layoutReads < 20, `expected logarithmic anchor reads, got ${layoutReads}`);
});
test('session DOM index scans the timeline once and groups roots by message UUID', () => {
let queries = 0;
const first = scrollItem('render-1', 0, 20);
first.dataset.messageUuid = 'message-1';
const second = scrollItem('render-2', 20, 40);
second.dataset.messageUuid = 'message-2';
const secondTools = scrollItem('render-2-tools', 40, 60);
secondTools.dataset.messageUuid = 'message-2';
const index = createSessionDomIndex({
querySelectorAll(selector) {
queries++;
assert.equal(selector, '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]');
return [first, second, secondTools];
},
});
assert.equal(queries, 1);
assert.deepEqual(index.items, [first, second, secondTools]);
assert.equal(index.byUuid.get('render-2-tools'), secondTools);
assert.deepEqual(index.byMessageUuid.get('message-2'), [second, secondTools]);
findLastMessageAtOrAbove(index.items, 35);
findLastMessageAtOrAbove(index.items, 55);
assert.equal(queries, 1, 'scroll reads reuse the index instead of querying the DOM');
});
test('disclosure registry restores only message roots replaced by the snapshot', () => {
const previous = disclosure('tool:call-1', ['open'], { rawOpen: true });
previous.closest = selector => selector === '[data-message-uuid]'
? { dataset: { messageUuid: 'message-1' } }
: null;
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
const replacement = disclosure('tool:call-1');
const updatedRoot = {
matches: () => false,
querySelectorAll(selector) {
assert.equal(selector, '[data-view-key]');
return [replacement];
},
};
const untouchedRoot = {
matches: () => false,
querySelectorAll() {
assert.fail('unchanged message roots must not be scanned');
},
};
registry.reconcile({
byMessageUuid: new Map([
['message-1', [updatedRoot]],
['message-2', [untouchedRoot]],
]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('open'), true);
assert.equal(replacement.raw.classList.contains('show'), true);
assert.equal(replacement.pretty.classList.contains('hidden'), true);
assert.equal(replacement.button.classList.contains('active'), true);
});
test('removed messages discard their remembered disclosure state', () => {
const previous = disclosure('tool:call-1', ['open']);
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
registry.reconcile({ byMessageUuid: new Map() }, {
updatedIds: [],
removedIds: ['message-1'],
});
const replacement = disclosure('tool:call-1');
registry.reconcile({
byMessageUuid: new Map([['message-1', [{
matches: () => false,
querySelectorAll: () => [replacement],
}]]]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('open'), false);
});
test('disclosure registry restores root-level skill cards', () => {
const previous = disclosure('skill:message-1', ['skill-md-open']);
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
const replacement = disclosure('skill:message-1');
replacement.matches = selector => selector === '[data-view-key]';
replacement.dataset.messageUuid = 'message-1';
registry.reconcile({
byMessageUuid: new Map([['message-1', [replacement]]]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('skill-md-open'), true);
});
test('view-state capture and restore do not query the full DOM', () => {
const source = readFileSync(new URL('../app/src/renderer/src/session-view-state.mjs', import.meta.url), 'utf8');
const capture = functionSource(source, 'captureSessionViewState');
const restore = functionSource(source, 'restoreSessionViewState');
assert.doesNotMatch(capture, /querySelectorAll|scrollItems\(|\bdetail\b/);
assert.doesNotMatch(restore, /querySelectorAll|scrollItems\(|\bdetail\b/);
});
test('SessionDetail isolates unchanged rows and gives tail appends a scan-free path', () => { test('SessionDetail isolates unchanged rows and gives tail appends a scan-free path', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8'); const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const dataSource = readFileSync(new URL('../app/src/renderer/src/data.js', import.meta.url), 'utf8'); const dataSource = readFileSync(new URL('../app/src/renderer/src/data.js', import.meta.url), 'utf8');
const loadMessages = functionSource(source, 'loadMessages'); const loadMessages = functionSource(source, 'loadMessages');
const getSkillMd = functionSource(source, 'getSkillMd'); const getSkillMd = functionSource(source, 'getSkillMd');
const toggleDisclosure = functionSource(source, 'toggleDisclosure');
assert.match(source, /import\s*\{[^}]*shallowRef[^}]*\}\s*from ['"]vue['"]/s); assert.match(source, /import\s*\{[^}]*shallowRef[^}]*\}\s*from ['"]vue['"]/s);
assert.match(source, /const messages = shallowRef\(\[\]\)/); assert.match(source, /const messages = shallowRef\(\[\]\)/);
assert.match(source, /applySnapshot/); assert.match(source, /applySnapshot/);
assert.match(source, /isFollowingSessionTail/); assert.match(source, /isFollowingSessionTail/);
assert.match(source, /captureSessionViewState/); assert.match(source, /captureSessionViewState/);
assert.match(source, /createSessionDomIndex/);
assert.match(source, /createSessionDisclosureRegistry/);
assert.match(source, /restoreSessionViewState/); assert.match(source, /restoreSessionViewState/);
assert.match(source, /class="timeline"\s+v-memo="\[messages, state\.query\]"/);
assert.match(source, /:key="msg\.uuid"\s+v-memo=/); assert.match(source, /:key="msg\.uuid"\s+v-memo=/);
assert.match(source, /v-memo="\[msg, state\.query\]"/); assert.match(source, /v-memo="\[msg, state\.query\]"/);
assert.match(source, /loading\.value\s*=\s*!hadContent/); assert.match(source, /loading\.value\s*=\s*!hadContent/);
@@ -193,6 +354,8 @@ test('SessionDetail isolates unchanged rows and gives tail appends a scan-free p
assert.match(source, /requestAnimationFrame/); assert.match(source, /requestAnimationFrame/);
assert.match(source, /findLastMessageAtOrAbove/); assert.match(source, /findLastMessageAtOrAbove/);
assert.match(loadMessages, /if \(hadContent && !reconciliation\.tailOnly\)/); assert.match(loadMessages, /if \(hadContent && !reconciliation\.tailOnly\)/);
assert.match(loadMessages, /domIndex:\s*sessionDomIndex/);
assert.match(loadMessages, /disclosureRegistry\.reconcile\(sessionDomIndex, reconciliation\)/);
assert.match(loadMessages, /if \(!reconciliation\.changed\)/); assert.match(loadMessages, /if \(!reconciliation\.changed\)/);
assert.ok( assert.ok(
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('captureSessionViewState('), loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('captureSessionViewState('),
@@ -212,20 +375,29 @@ test('SessionDetail isolates unchanged rows and gives tail appends a scan-free p
); );
assert.doesNotMatch(getSkillMd, /messages\.value/); assert.doesNotMatch(getSkillMd, /messages\.value/);
assert.match(source, /getSkillMd\(msg\)/); assert.match(source, /getSkillMd\(msg\)/);
assert.match(toggleDisclosure, /disclosureRegistry\.remember\(element\)/);
assert.ok(
(source.match(/:data-message-uuid="msg\.uuid"/g) || []).length >= 6,
'every timeline root identifies its owning message for targeted disclosure restore',
);
assert.match(dataSource, /messages:\s*markRaw\(assembledMessages\)/); assert.match(dataSource, /messages:\s*markRaw\(assembledMessages\)/);
}); });
test('live totals and scroll position remain isolated across interleaved updates', () => { test('live totals and scroll position remain isolated across interleaved updates', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8'); const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const loadMessages = functionSource(source, 'loadMessages'); const loadMessages = functionSource(source, 'loadMessages');
const syncTotalMessages = functionSource(source, 'syncTotalMessages'); const syncTimelineDom = functionSource(source, 'syncTimelineDom');
const updateScrollProgress = functionSource(source, 'updateScrollProgress'); const updateScrollProgress = functionSource(source, 'updateScrollProgress');
const navTo = functionSource(source, 'navTo');
assert.match(loadMessages, /await nextTick\(\);[\s\S]*syncTotalMessages\(\)/); assert.match(loadMessages, /await nextTick\(\);[\s\S]*syncTimelineDom\(\)/);
assert.match(syncTotalMessages, /totalMsgs\.value\s*=/); assert.match(syncTimelineDom, /createSessionDomIndex\(detailRef\.value\)/);
assert.doesNotMatch(syncTotalMessages, /currentMsgIdx\.value\s*=/); assert.match(syncTimelineDom, /totalMsgs\.value\s*=/);
assert.doesNotMatch(syncTimelineDom, /currentMsgIdx\.value\s*=/);
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/); assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/); assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /querySelectorAll/);
assert.doesNotMatch(navTo, /querySelectorAll/);
}); });
test('message navigation keeps the top progress bar aligned with the current position', () => { test('message navigation keeps the top progress bar aligned with the current position', () => {