fix(frontend): 重构堆叠块高度引擎,根治鬼畜抖动并统一动画

StackedBlocks 旧实现每帧用 getBoundingClientRect 读正在过渡的块高度,
叠加 360ms runSync 轮询 + ResizeObserver + animateMoreToggle 三个驱动源
互相触发,形成不收敛的测量-回写反馈环 = 高频上下抽搐;且新块出现/展开
收起无外框动画(瞬间赋值且无 transition)。

高度引擎重构:
- 改测真实 DOM 稳定值(header.offsetHeight + content-inner.offsetHeight
  + border)算每块高度。content-inner 自身高度不受父级 collapsible-content
  的 max-height 过渡/overflow 裁切影响,是稳定目标态值,杜绝读动画中间帧。
- 删除 runSync 轮询、animateMoreToggle JS lerp、逐帧 getBoundingClientRect。
  measureAndCompute 仅在状态变化时测一次。
- ResizeObserver 只观察 content-inner(流式增长源),不观察被 transition 的
  元素,断开写→RO→写回环。
- shell/viewport/inner 三者同一条 height/transform 260ms transition 同步过渡,
  外框增高、传送带上移、展开收起、收起裁切全平滑。

单块路径统一(修 1→2 块重建鬼畜):
- splitActionGroups 阈值 >=2 改 >=1,单个 thinking/tool 也走 StackedBlocks,
  消除 single↔stack 路径整体切换导致的组件重新挂载(表现为外框瞬间塌扁再展开)。
- 单块用 isSingle + .stacked-shell--single 还原原 single 外观(12px 圆角+透明底)。

首次/后续进场动画区分:
- ChatArea 传 conversation-running + is-latest-message。
- animated(运行中+最新) 控 TransitionGroup :appear/:css(块进场动画);
  ready(首次 measure 后一次性) 控 shell/viewport/inner/collapsible-content
  的 transition:初始挂载/历史批量加载高度直接到位不跳动,之后启用使展开/
  收起在历史对话里也平滑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-06-09 10:05:28 +08:00
parent 0d4f6c9e56
commit 4807c0b9f1
2 changed files with 156 additions and 146 deletions

View File

@ -117,6 +117,8 @@
class="stacked-blocks-wrapper"
:actions="group.actions"
:expanded-blocks="expandedBlocks"
:conversation-running="streamingMessage"
:is-latest-message="index === latestMessageIndex"
:icon-style="iconStyleSafe"
:toggle-block="toggleBlock"
:register-thinking-ref="registerThinkingRef"
@ -1535,20 +1537,15 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
}
const flushBuffer = () => {
if (buffer.length >= 2) {
// (thinking/tool) stack 12 single/stack
// StackedBlocks ()
// StackedBlocks moreVisible=false CSS single
if (buffer.length >= 1) {
result.push({
kind: 'stack',
actions: buffer.slice(),
key: `stack-${messageIndex}-${result.length}`
});
} else if (buffer.length === 1) {
const single = buffer[0];
result.push({
kind: 'single',
action: single,
actionIndex: actions.indexOf(single),
key: single.id || `single-${messageIndex}-${result.length}`
});
}
buffer = [];
};

View File

@ -1,6 +1,7 @@
<template>
<div
class="stacked-shell"
:class="{ 'stacked-shell--single': isSingle, 'stacked-shell--not-ready': !ready }"
ref="shell"
:style="{
height: `${shellHeight}px`,
@ -28,7 +29,8 @@
class="stacked-inner"
ref="inner"
:style="{ transform: `translateY(${innerOffset}px)` }"
appear
:appear="animated"
:css="animated"
>
<div
v-for="(action, idx) in stackableActions"
@ -158,6 +160,8 @@ defineOptions({ name: 'StackedBlocks' });
const props = defineProps<{
actions: any[];
expandedBlocks: Set<string>;
conversationRunning?: boolean;
isLatestMessage?: boolean;
iconStyle: (key: string, size?: string) => Record<string, string>;
toggleBlock: (blockId: string) => void;
registerThinkingRef?: (key: string, el: Element | null) => void;
@ -198,13 +202,13 @@ const shellHeight = ref(0);
const moreHeight = ref(0);
const innerOffset = ref(0);
const viewportHeight = ref(0);
// false / measure true
const ready = ref(false);
const contentHeights = ref<Record<string, number>>({});
const ANIMATION_SYNC_MS = 360;
const MORE_ANIMATION_MS = 300;
const COLLAPSE_MAX_HEIGHT = 600;
let syncRaf: number | null = null;
let syncUntil = 0;
const isMoreAnimating = ref(false);
const HEADER_FALLBACK = 36; // DOM header
// content-inner DOM max-height
const contentInnerEls = new Map<string, HTMLElement>();
const stackableActions = computed(() =>
(props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))
@ -212,6 +216,14 @@ const stackableActions = computed(() =>
const hiddenCount = computed(() => Math.max(0, stackableActions.value.length - VISIBLE_LIMIT));
const moreVisible = computed(() => hiddenCount.value > 0 || showAll.value);
// single (.collapsible-block 12px /)
// .stacked-shell--single
const isSingle = computed(() => stackableActions.value.length === 1);
// +
// / / appear + enter
//
const animated = computed(() => !!props.conversationRunning && !!props.isLatestMessage);
const totalSteps = computed(() => stackableActions.value.length);
const moreTitle = computed(() => (showAll.value ? '已展开全部' : '更多'));
const moreDesc = computed(() =>
@ -235,17 +247,24 @@ const isToolProcessing = (action: any) => {
const isToolCompleted = (action: any) => action?.tool?.status === 'completed';
const toggleMore = () => {
animateMoreToggle(!showAll.value);
showAll.value = !showAll.value;
scheduleMeasure();
};
const toggleBlock = (blockId: string) => {
if (typeof props.toggleBlock === 'function') {
props.toggleBlock(blockId);
nextTick(() => runSync());
scheduleMeasure();
}
};
const registerThinking = (key: string, el: Element | null) => {
// content-inner
if (el instanceof HTMLElement) {
contentInnerEls.set(key, el);
} else {
contentInnerEls.delete(key);
}
if (typeof props.registerThinkingRef === 'function') {
props.registerThinkingRef(key, el);
}
@ -271,27 +290,38 @@ const moreBaseHeight = () => {
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
};
const setShellMetrics = () => {
// DOM shell/offset/viewport
//
// - header.offsetHeight(min/max-height:36)/
// - content-inner.offsetHeight使 collapsible-content max-height
// 0 overflow:hidden content-inner offsetHeight
//
// shellHeight/innerOffset
// CSS transition / /
const measureAndCompute = () => {
const innerEl = resolveEl(inner.value);
const shellEl = resolveEl(shell.value);
if (!shellEl || !innerEl) return;
const children = Array.from(innerEl.children) as HTMLElement[];
const acts = stackableActions.value;
const heights: number[] = [];
const nextContentHeights: Record<string, number> = {};
children.forEach((el, idx) => {
const action = stackableActions.value[idx];
const action = acts[idx];
const key = blockKey(action, idx);
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
const contentHeight = content
? Math.min(Math.ceil(content.scrollHeight), COLLAPSE_MAX_HEIGHT)
: 0;
nextContentHeights[key] = contentHeight;
// 使/
const liveHeight = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0);
heights.push(liveHeight);
const header = el.querySelector('.collapsible-header') as HTMLElement | null;
const innerC = el.querySelector('.content-inner') as HTMLElement | null;
const headerH = header ? Math.ceil(header.offsetHeight) : HEADER_FALLBACK;
// 600content-inner
// max-height:240 collapsible-content
const fullContent = innerC ? Math.min(Math.ceil(innerC.offsetHeight), COLLAPSE_MAX_HEIGHT) : 0;
nextContentHeights[key] = fullContent;
const expanded = isExpandedById(key);
// .stacked-item 1px border-bottom
const borderH = idx === children.length - 1 ? 0 : 1;
heights.push(headerH + (expanded ? fullContent : 0) + borderH);
});
contentHeights.value = nextContentHeights;
@ -302,123 +332,48 @@ const setShellMetrics = () => {
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
const targetShell =
moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
shellHeight.value = targetShell;
innerOffset.value = targetOffset;
innerOffset.value = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
};
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const animateMoreToggle = (nextShowAll: boolean) => {
if (isMoreAnimating.value) return;
//
if (syncRaf) {
cancelAnimationFrame(syncRaf);
syncRaf = null;
}
const innerEl = resolveEl(inner.value);
const shellEl = resolveEl(shell.value);
if (!innerEl || !shellEl) {
showAll.value = nextShowAll;
nextTick(() => runSync());
return;
}
//
setShellMetrics();
const start = {
shell: shellHeight.value,
offset: innerOffset.value,
viewport: viewportHeight.value,
more: moreHeight.value
};
//
showAll.value = nextShowAll;
setShellMetrics();
const target = {
shell: shellHeight.value,
offset: innerOffset.value,
viewport: viewportHeight.value,
more: moreHeight.value
};
//
shellHeight.value = start.shell;
innerOffset.value = start.offset;
viewportHeight.value = start.viewport;
moreHeight.value = start.more;
isMoreAnimating.value = true;
const startedAt = performance.now();
const step = () => {
const now = performance.now();
const t = Math.min(1, (now - startedAt) / MORE_ANIMATION_MS);
const eased = easeOutCubic(t);
shellHeight.value = lerp(start.shell, target.shell, eased);
innerOffset.value = lerp(start.offset, target.offset, eased);
viewportHeight.value = lerp(start.viewport, target.viewport, eased);
moreHeight.value = lerp(start.more, target.more, eased);
if (t < 1) {
requestAnimationFrame(step);
} else {
isMoreAnimating.value = false;
shellHeight.value = target.shell;
innerOffset.value = target.offset;
viewportHeight.value = target.viewport;
moreHeight.value = target.more;
setShellMetrics();
}
};
requestAnimationFrame(step);
};
const runSync = () => {
if (isMoreAnimating.value) return;
if (syncRaf) {
cancelAnimationFrame(syncRaf);
syncRaf = null;
}
syncUntil = performance.now() + ANIMATION_SYNC_MS;
const step = () => {
setShellMetrics();
if (performance.now() < syncUntil) {
syncRaf = requestAnimationFrame(step);
} else {
syncRaf = null;
}
};
syncRaf = requestAnimationFrame(step);
// DOM Vue patch + offsetHeight
const scheduleMeasure = () => {
nextTick(() => {
requestAnimationFrame(() => {
measureAndCompute();
// //
// / 0
if (!ready.value) {
requestAnimationFrame(() => {
ready.value = true;
});
}
});
});
};
let resizeObserver: ResizeObserver | null = null;
let heightRaf: number | null = null;
let roRaf: number | null = null;
const observeAll = () => {
if (!resizeObserver) return;
resizeObserver.disconnect();
// content-inner collapsible-content /
// stacked-inner transition RO
contentInnerEls.forEach((el) => resizeObserver!.observe(el));
};
onMounted(() => {
const innerEl = resolveEl(inner.value);
setShellMetrics();
resizeObserver = new ResizeObserver(() => {
if (heightRaf) {
cancelAnimationFrame(heightRaf);
}
heightRaf = requestAnimationFrame(() => {
setShellMetrics();
scheduleMeasure();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
if (roRaf) cancelAnimationFrame(roRaf);
roRaf = requestAnimationFrame(() => measureAndCompute());
});
});
if (innerEl) {
resizeObserver.observe(innerEl);
nextTick(() => observeAll());
}
});
@ -427,24 +382,82 @@ onBeforeUnmount(() => {
resizeObserver.disconnect();
resizeObserver = null;
}
if (heightRaf) {
cancelAnimationFrame(heightRaf);
heightRaf = null;
if (roRaf) {
cancelAnimationFrame(roRaf);
roRaf = null;
}
if (syncRaf) {
cancelAnimationFrame(syncRaf);
syncRaf = null;
}
});
watch([stackableActions, moreVisible], () => {
nextTick(() => runSync());
});
watch(
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
() => {
nextTick(() => runSync());
const acts = stackableActions.value;
const firstId = acts[0]?.blockId || acts[0]?.id || '';
const lastId = acts[acts.length - 1]?.blockId || acts[acts.length - 1]?.id || '';
// + + moreVisible + /
// watch
const snap = acts
.map((a: any) => {
if (a?.type === 'tool') return `T:${a?.tool?.status || '?'}${a?.tool?.result ? 'R' : '-'}`;
if (a?.type === 'thinking')
return `H:${a?.streaming ? 'S' : '-'}${Math.floor((a?.content?.length || 0) / 48)}`;
return '?';
})
.join(',');
return `${acts.length}|${firstId}|${lastId}|${moreVisible.value}|${snap}`;
},
() => {
nextTick(() => {
observeAll();
scheduleMeasure();
});
}
);
watch(
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
() => scheduleMeasure()
);
</script>
<style scoped>
/* shell viewport inner transition使
内部显示区裁切边界传送带上移/展开收起完全同步过渡时长/缓动与单块
collapsible-content(max-height 260ms) 对齐互不打架
替换了原先 JS 逐帧 lerp(animateMoreToggle) + 360ms runSync 轮询的方案从根上去抖
注意viewport(显示区) 必须与 shell 同步过渡否则收起瞬间 viewport 高度先跳到折叠后
终值把还在收缩的下方块瞬间裁掉再重现造成割裂 */
.stacked-shell {
transition:
height 260ms cubic-bezier(0.4, 0, 0.2, 1),
padding-top 260ms cubic-bezier(0.4, 0, 0.2, 1);
will-change: height;
}
.stacked-viewport {
transition: height 260ms cubic-bezier(0.4, 0, 0.2, 1);
will-change: height;
}
.stacked-inner {
transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
/* ready=false shell/viewport/inner / /
批量历史加载时高度直接到位避免从 0 高度过渡造成整个对话区剧烈跳动
首次 measure 完成后置 ready=true此后展开/收起流式增长传送带等高度变化均平滑
注意展开/收起是用户主动交互历史对话里也必须平滑故用 ready(一次性) 而非 animated 控制
块的 enter/appear 进场动画另由模板 :css/:appear="animated" 控制仅运行中+最新才播
同时禁用内部 collapsible-content max-height/opacity 过渡否则历史加载时已展开块的
内容会从 opacity:0 淡入(表现为内部文字仍有动画) */
.stacked-shell--not-ready,
.stacked-shell--not-ready .stacked-viewport,
.stacked-shell--not-ready .stacked-inner,
.stacked-shell--not-ready .collapsible-content {
transition: none !important;
}
/* single (.collapsible-block)12px +
而非多块外壳的 16px 圆角 + card 其余(1px 边框)两者本就一致
这样单块也走堆叠组件单块视觉与改造前完全相同 */
.stacked-shell--single {
border-radius: 12px;
background: transparent;
}
</style>