fix(app): keep live session updates incremental
Preserve unchanged message identities and isolate Vue row rendering during live refreshes. Use a tail-only scroll path so updates do not interrupt active readers or expanded tools. Add regression coverage for 908-message appends, changed snapshots, and scroll policy.
This commit is contained in:
@@ -242,7 +242,7 @@ export async function loadSessionDetail(sessionId) {
|
||||
const assembled = {
|
||||
...(session || {}),
|
||||
id: sessionId,
|
||||
messages: assembledMessages
|
||||
messages: markRaw(assembledMessages)
|
||||
};
|
||||
|
||||
if (workflow) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
function sameSnapshotValue(current, incoming) {
|
||||
if (Object.is(current, incoming)) return true;
|
||||
if (current === null || incoming === null) return false;
|
||||
if (typeof current !== 'object' || typeof incoming !== 'object') return false;
|
||||
|
||||
const currentIsArray = Array.isArray(current);
|
||||
if (currentIsArray !== Array.isArray(incoming)) return false;
|
||||
|
||||
const currentKeys = Object.keys(current);
|
||||
const incomingKeys = Object.keys(incoming);
|
||||
if (currentKeys.length !== incomingKeys.length) return false;
|
||||
|
||||
for (const key of currentKeys) {
|
||||
if (!Object.hasOwn(incoming, key)) return false;
|
||||
if (!sameSnapshotValue(current[key], incoming[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a complete timeline snapshot while preserving the identity of
|
||||
* messages whose rendered content did not change.
|
||||
*/
|
||||
export function applySnapshot(current = [], incoming = []) {
|
||||
const currentByUuid = new Map(
|
||||
current
|
||||
.filter(message => message?.uuid)
|
||||
.map(message => [message.uuid, message]),
|
||||
);
|
||||
const incomingUuids = new Set(
|
||||
incoming.filter(message => message?.uuid).map(message => message.uuid),
|
||||
);
|
||||
const addedIds = [];
|
||||
const updatedIds = [];
|
||||
const messages = incoming.map((message, index) => {
|
||||
const uuid = message?.uuid;
|
||||
const existing = uuid ? currentByUuid.get(uuid) : current[index];
|
||||
if (!existing || (uuid && existing.uuid !== uuid)) {
|
||||
if (uuid) addedIds.push(uuid);
|
||||
return message;
|
||||
}
|
||||
if (sameSnapshotValue(existing, message)) return existing;
|
||||
if (uuid) updatedIds.push(uuid);
|
||||
return message;
|
||||
});
|
||||
const removedIds = current
|
||||
.filter(message => message?.uuid && !incomingUuids.has(message.uuid))
|
||||
.map(message => message.uuid);
|
||||
const changed = messages.length !== current.length
|
||||
|| messages.some((message, index) => message !== current[index]);
|
||||
const unchangedPrefix = incoming.length > current.length
|
||||
&& current.every((message, index) => messages[index] === message);
|
||||
const tailOnly = changed
|
||||
&& unchangedPrefix
|
||||
&& updatedIds.length === 0
|
||||
&& removedIds.length === 0;
|
||||
|
||||
return {
|
||||
messages: changed ? messages : current,
|
||||
addedIds,
|
||||
updatedIds,
|
||||
removedIds,
|
||||
changed,
|
||||
tailOnly,
|
||||
};
|
||||
}
|
||||
@@ -9,10 +9,19 @@ function scrollItems(detail) {
|
||||
return arrayFrom(detail?.querySelectorAll?.(SCROLL_ITEM_SELECTOR));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = {}) {
|
||||
if (!wrap) return null;
|
||||
const distanceFromBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;
|
||||
const followTail = distanceFromBottom < bottomThreshold;
|
||||
const followTail = isFollowingSessionTail(wrap, bottomThreshold);
|
||||
const wrapTop = wrap.getBoundingClientRect?.().top || 0;
|
||||
const anchorElement = followTail
|
||||
? null
|
||||
@@ -62,7 +71,7 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll
|
||||
if (!restoreScroll) return;
|
||||
|
||||
if (snapshot.followTail) {
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
restoreSessionTail({ wrap, followTail: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,19 +86,6 @@ export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll
|
||||
wrap.scrollTop += currentOffset - snapshot.anchor.offset;
|
||||
}
|
||||
|
||||
export function reconcileSessionMessages(current = [], incoming = []) {
|
||||
const currentByUuid = new Map(
|
||||
current.filter(message => message?.uuid).map(message => [message.uuid, message]),
|
||||
);
|
||||
return incoming.map(message => {
|
||||
if (!message?.uuid) return message;
|
||||
const existing = currentByUuid.get(message.uuid);
|
||||
if (!existing) return message;
|
||||
Object.assign(existing, message);
|
||||
return existing;
|
||||
});
|
||||
}
|
||||
|
||||
export function findLastMessageAtOrAbove(messages, bottomLine) {
|
||||
if (!messages?.length) return -1;
|
||||
let low = 0;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
|
||||
import { ref, shallowRef, computed, 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 { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js';
|
||||
import FlapNumber from '../components/FlapNumber.vue';
|
||||
import {
|
||||
captureSessionViewState,
|
||||
findLastMessageAtOrAbove,
|
||||
reconcileSessionMessages,
|
||||
isFollowingSessionTail,
|
||||
restoreSessionTail,
|
||||
restoreSessionViewState,
|
||||
} from '../session-view-state.mjs';
|
||||
import {
|
||||
@@ -28,7 +30,7 @@ const route = useRoute();
|
||||
|
||||
// --- Reactive state ---
|
||||
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||
const messages = ref([]);
|
||||
const messages = shallowRef([]);
|
||||
const loading = ref(false);
|
||||
const progressPct = ref(0);
|
||||
const active = ref(false);
|
||||
@@ -145,34 +147,56 @@ watch(() => props.id, async (newId, oldId) => {
|
||||
async function loadMessages({ force = false } = {}) {
|
||||
if (!props.id) return;
|
||||
const hadContent = messages.value.length > 0;
|
||||
const viewState = hadContent
|
||||
? captureSessionViewState({ wrap: wrapRef.value, detail: detailRef.value })
|
||||
: null;
|
||||
const scrollRevisionAtLoad = scrollRevision;
|
||||
let reconciliation;
|
||||
let viewState = null;
|
||||
let followTailBeforePatch = false;
|
||||
let scrollRevisionBeforePatch = scrollRevision;
|
||||
|
||||
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)) {
|
||||
await loadSessionDetail(props.id);
|
||||
latest = await loadSessionDetail(props.id);
|
||||
}
|
||||
const latest = state.sessions.find(x => x.id === props.id);
|
||||
const incoming = latest?.messages || [];
|
||||
messages.value = hadContent
|
||||
? reconcileSessionMessages(messages.value, incoming)
|
||||
: incoming;
|
||||
reconciliation = applySnapshot(messages.value, incoming);
|
||||
if (reconciliation.changed) {
|
||||
followTailBeforePatch = hadContent && isFollowingSessionTail(wrapRef.value);
|
||||
scrollRevisionBeforePatch = scrollRevision;
|
||||
if (hadContent && !reconciliation.tailOnly) {
|
||||
viewState = captureSessionViewState({
|
||||
wrap: wrapRef.value,
|
||||
detail: detailRef.value,
|
||||
});
|
||||
}
|
||||
messages.value = reconciliation.messages;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
if (!reconciliation.changed) {
|
||||
if (state.pendingFocusUuid) await focusPendingMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
syncTotalMessages();
|
||||
if (!state.pendingFocusUuid) {
|
||||
if (reconciliation.tailOnly) {
|
||||
restoreSessionTail({
|
||||
wrap: wrapRef.value,
|
||||
followTail: followTailBeforePatch,
|
||||
restoreScroll: scrollRevision === scrollRevisionBeforePatch,
|
||||
});
|
||||
} else {
|
||||
restoreSessionViewState(viewState, {
|
||||
wrap: wrapRef.value,
|
||||
detail: detailRef.value,
|
||||
restoreScroll: scrollRevision === scrollRevisionAtLoad,
|
||||
restoreScroll: scrollRevision === scrollRevisionBeforePatch,
|
||||
});
|
||||
}
|
||||
onScroll();
|
||||
}
|
||||
|
||||
@@ -554,17 +578,8 @@ function toggleRaw(event) {
|
||||
btn?.classList.toggle('active', showing);
|
||||
}
|
||||
|
||||
function getSkillMd(skillMsgIdx) {
|
||||
const msg = messages.value[skillMsgIdx];
|
||||
if (msg?._skillMd) return msg._skillMd;
|
||||
// Fallback: search next few messages
|
||||
for (let i = skillMsgIdx + 1; i < Math.min(skillMsgIdx + 3, messages.value.length); i++) {
|
||||
const m = messages.value[i];
|
||||
if (m.is_meta === 1 && m.text && m.text.includes('Base directory for this skill')) {
|
||||
return m.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
function getSkillMd(msg) {
|
||||
return msg?._skillMd || null;
|
||||
}
|
||||
|
||||
function toggleSkillMd(event) {
|
||||
@@ -623,7 +638,7 @@ function getToolCallParsedInput(tc) {
|
||||
|
||||
<!-- Message timeline -->
|
||||
<div class="timeline">
|
||||
<template v-for="(msg, idx) in messages" :key="msg.uuid || idx">
|
||||
<template v-for="msg in messages" :key="msg.uuid" v-memo="[msg, state.query]">
|
||||
|
||||
<!-- Meta messages: collapsed system indicator -->
|
||||
<template v-if="msg.is_meta === 1">
|
||||
@@ -738,12 +753,12 @@ function getToolCallParsedInput(tc) {
|
||||
<span class="skill-card-name">{{ getToolCallParsedInput(msg.tool_calls[0]).skill || '?' }}</span>
|
||||
</div>
|
||||
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
|
||||
<div v-if="getSkillMd(idx)" class="skill-card-md">
|
||||
<div v-if="getSkillMd(msg)" class="skill-card-md">
|
||||
<button class="skill-md-toggle" @click="toggleSkillMd">
|
||||
<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>
|
||||
<div class="skill-md-body" v-html="renderMarkdown(getSkillMd(idx), { variant: 'compact' })"></div>
|
||||
<div class="skill-md-body" v-html="renderMarkdown(getSkillMd(msg), { variant: 'compact' })"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applySnapshot } from '../app/src/renderer/src/session-timeline.mjs';
|
||||
|
||||
test('tail append reuses 908 existing messages without writing to them', () => {
|
||||
let oldMessageWrites = 0;
|
||||
const current = Array.from({ length: 908 }, (_, index) => new Proxy({
|
||||
uuid: `message-${index}`,
|
||||
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||
text: `message ${index}`,
|
||||
}, {
|
||||
set(target, property, value) {
|
||||
oldMessageWrites++;
|
||||
return Reflect.set(target, property, value);
|
||||
},
|
||||
}));
|
||||
const incoming = [
|
||||
...current.map(message => ({ ...message })),
|
||||
{ uuid: 'message-908', type: 'assistant', text: 'new tail' },
|
||||
];
|
||||
|
||||
const result = applySnapshot(current, incoming);
|
||||
|
||||
assert.equal(result.messages.length, 909);
|
||||
for (let index = 0; index < current.length; index++) {
|
||||
assert.equal(result.messages[index], current[index]);
|
||||
}
|
||||
assert.equal(oldMessageWrites, 0);
|
||||
assert.deepEqual(result.addedIds, ['message-908']);
|
||||
assert.deepEqual(result.updatedIds, []);
|
||||
assert.deepEqual(result.removedIds, []);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.tailOnly, true);
|
||||
});
|
||||
|
||||
test('a completed tool result replaces only its owning message', () => {
|
||||
const first = { uuid: 'message-1', type: 'user', text: 'run it' };
|
||||
const second = {
|
||||
uuid: 'message-2',
|
||||
type: 'assistant',
|
||||
tool_calls: [{ id: 'call-1', name: 'Bash', result: null }],
|
||||
};
|
||||
const third = { uuid: 'message-3', type: 'assistant', text: 'waiting' };
|
||||
const incomingSecond = {
|
||||
uuid: 'message-2',
|
||||
type: 'assistant',
|
||||
tool_calls: [{ id: 'call-1', name: 'Bash', result: { content: 'done' } }],
|
||||
};
|
||||
|
||||
const result = applySnapshot(
|
||||
[first, second, third],
|
||||
[{ ...first }, incomingSecond, { ...third }],
|
||||
);
|
||||
|
||||
assert.equal(result.messages[0], first);
|
||||
assert.equal(result.messages[1], incomingSecond);
|
||||
assert.equal(result.messages[2], third);
|
||||
assert.deepEqual(result.updatedIds, ['message-2']);
|
||||
assert.deepEqual(result.addedIds, []);
|
||||
assert.deepEqual(result.removedIds, []);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.tailOnly, false);
|
||||
});
|
||||
|
||||
test('an identical snapshot returns the original array', () => {
|
||||
const current = [
|
||||
{ uuid: 'message-1', text: 'same', tool_calls: [{ id: 'call-1' }] },
|
||||
{ uuid: 'message-2', text: 'same too' },
|
||||
];
|
||||
|
||||
const result = applySnapshot(current, structuredClone(current));
|
||||
|
||||
assert.equal(result.messages, current);
|
||||
assert.deepEqual(result.addedIds, []);
|
||||
assert.deepEqual(result.updatedIds, []);
|
||||
assert.deepEqual(result.removedIds, []);
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.tailOnly, false);
|
||||
});
|
||||
|
||||
test('removals and reorders preserve matching identities without claiming a tail append', () => {
|
||||
const first = { uuid: 'message-1', text: 'first' };
|
||||
const second = { uuid: 'message-2', text: 'second' };
|
||||
const removed = applySnapshot([first, second], [{ ...second }]);
|
||||
|
||||
assert.deepEqual(removed.removedIds, ['message-1']);
|
||||
assert.equal(removed.messages[0], second);
|
||||
assert.equal(removed.tailOnly, false);
|
||||
|
||||
const reordered = applySnapshot(
|
||||
[first, second],
|
||||
[{ ...second }, { ...first }],
|
||||
);
|
||||
assert.equal(reordered.messages[0], second);
|
||||
assert.equal(reordered.messages[1], first);
|
||||
assert.deepEqual(reordered.addedIds, []);
|
||||
assert.deepEqual(reordered.updatedIds, []);
|
||||
assert.deepEqual(reordered.removedIds, []);
|
||||
assert.equal(reordered.changed, true);
|
||||
assert.equal(reordered.tailOnly, false);
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
captureSessionViewState,
|
||||
findLastMessageAtOrAbove,
|
||||
reconcileSessionMessages,
|
||||
isFollowingSessionTail,
|
||||
restoreSessionTail,
|
||||
restoreSessionViewState,
|
||||
} from '../app/src/renderer/src/session-view-state.mjs';
|
||||
|
||||
@@ -113,6 +114,33 @@ test('session refresh follows appended content only when already at the tail', (
|
||||
assert.equal(newWrap.scrollTop, 2400);
|
||||
});
|
||||
|
||||
test('tail-follow detection uses only the scroll container metrics', () => {
|
||||
assert.equal(isFollowingSessionTail(wrap({
|
||||
scrollTop: 1360,
|
||||
scrollHeight: 2000,
|
||||
clientHeight: 600,
|
||||
})), true);
|
||||
assert.equal(isFollowingSessionTail(wrap({
|
||||
scrollTop: 1200,
|
||||
scrollHeight: 2000,
|
||||
clientHeight: 600,
|
||||
})), false);
|
||||
});
|
||||
|
||||
test('tail append preserves an active reader and follows only without newer scroll input', () => {
|
||||
const reader = wrap({ scrollTop: 600, scrollHeight: 2400, clientHeight: 600 });
|
||||
restoreSessionTail({ wrap: reader, followTail: false });
|
||||
assert.equal(reader.scrollTop, 600);
|
||||
|
||||
const userMoved = wrap({ scrollTop: 800, scrollHeight: 2400, clientHeight: 600 });
|
||||
restoreSessionTail({ wrap: userMoved, followTail: true, restoreScroll: false });
|
||||
assert.equal(userMoved.scrollTop, 800);
|
||||
|
||||
const follower = wrap({ scrollTop: 1400, scrollHeight: 2400, clientHeight: 600 });
|
||||
restoreSessionTail({ wrap: follower, followTail: true });
|
||||
assert.equal(follower.scrollTop, 2400);
|
||||
});
|
||||
|
||||
test('session refresh never restores an old anchor over newer user scrolling', () => {
|
||||
const oldTool = disclosure('tool:call-1', ['open']);
|
||||
const snapshot = captureSessionViewState({
|
||||
@@ -132,22 +160,6 @@ test('session refresh never restores an old anchor over newer user scrolling', (
|
||||
assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state');
|
||||
});
|
||||
|
||||
test('message reconciliation preserves existing identities and appends the tail', () => {
|
||||
const first = { uuid: 'm1', text: 'old', tool_calls: [{ id: 't1' }] };
|
||||
const current = [first];
|
||||
const incoming = [
|
||||
{ uuid: 'm1', text: 'updated', tool_calls: [{ id: 't1', result: { content: 'progress' } }] },
|
||||
{ uuid: 'm2', text: 'new tail' },
|
||||
];
|
||||
|
||||
const reconciled = reconcileSessionMessages(current, incoming);
|
||||
|
||||
assert.equal(reconciled[0], first);
|
||||
assert.equal(reconciled[0].text, 'updated');
|
||||
assert.equal(reconciled[0].tool_calls[0].result.content, 'progress');
|
||||
assert.equal(reconciled[1], incoming[1]);
|
||||
});
|
||||
|
||||
test('scroll progress locates the visible message without scanning the full session', () => {
|
||||
let layoutReads = 0;
|
||||
const messages = Array.from({ length: 2048 }, (_, index) => ({
|
||||
@@ -161,17 +173,46 @@ test('scroll progress locates the visible message without scanning the full sess
|
||||
assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`);
|
||||
});
|
||||
|
||||
test('SessionDetail integrates view-state capture and restore into live reloads', () => {
|
||||
test('SessionDetail isolates unchanged rows and gives tail appends a scan-free path', () => {
|
||||
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
||||
const dataSource = readFileSync(new URL('../app/src/renderer/src/data.js', import.meta.url), 'utf8');
|
||||
const loadMessages = functionSource(source, 'loadMessages');
|
||||
const getSkillMd = functionSource(source, 'getSkillMd');
|
||||
|
||||
assert.match(source, /import\s*\{[^}]*shallowRef[^}]*\}\s*from ['"]vue['"]/s);
|
||||
assert.match(source, /const messages = shallowRef\(\[\]\)/);
|
||||
assert.match(source, /applySnapshot/);
|
||||
assert.match(source, /isFollowingSessionTail/);
|
||||
assert.match(source, /captureSessionViewState/);
|
||||
assert.match(source, /reconcileSessionMessages/);
|
||||
assert.match(source, /restoreSessionViewState/);
|
||||
assert.match(source, /:key="msg\.uuid"\s+v-memo=/);
|
||||
assert.match(source, /v-memo="\[msg, state\.query\]"/);
|
||||
assert.match(source, /loading\.value\s*=\s*!hadContent/);
|
||||
assert.match(source, /scrollRevision/);
|
||||
assert.match(source, /restoreScroll:\s*scrollRevision\s*===\s*scrollRevisionAtLoad/);
|
||||
assert.match(source, /restoreScroll:\s*scrollRevision\s*===\s*scrollRevisionBeforePatch/);
|
||||
assert.match(source, /requestAnimationFrame/);
|
||||
assert.match(source, /findLastMessageAtOrAbove/);
|
||||
assert.match(loadMessages, /if \(hadContent && !reconciliation\.tailOnly\)/);
|
||||
assert.match(loadMessages, /if \(!reconciliation\.changed\)/);
|
||||
assert.ok(
|
||||
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('captureSessionViewState('),
|
||||
'the expensive disclosure and anchor scan happens only after classifying the snapshot',
|
||||
);
|
||||
assert.ok(
|
||||
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('isFollowingSessionTail('),
|
||||
'tail state is sampled immediately before the patch, after the async snapshot load',
|
||||
);
|
||||
assert.ok(
|
||||
loadMessages.indexOf('isFollowingSessionTail(') < loadMessages.indexOf('messages.value = reconciliation.messages'),
|
||||
'tail state is sampled before assigning the new timeline',
|
||||
);
|
||||
assert.ok(
|
||||
loadMessages.indexOf('if (!reconciliation.changed)') < loadMessages.indexOf('await nextTick()'),
|
||||
'a no-op snapshot returns before awaiting a timeline patch',
|
||||
);
|
||||
assert.doesNotMatch(getSkillMd, /messages\.value/);
|
||||
assert.match(source, /getSkillMd\(msg\)/);
|
||||
assert.match(dataSource, /messages:\s*markRaw\(assembledMessages\)/);
|
||||
});
|
||||
|
||||
test('live totals and scroll position remain isolated across interleaved updates', () => {
|
||||
|
||||
Reference in New Issue
Block a user