fix: 修复堆叠块在折叠状态下全局鬼畜与容器动画不同步

根因(双重修复):
1. 容器高度计算改为精确目标高度公式 overhead + contentTarget,替代受CSS过渡
   影响的实时高度测量,消除 JS测量↔CSS过渡 的循环反馈
2. 给容器添加260ms CSS过渡(与块内容max-height过渡同duration/easing),
   用CSS统一负责所有动画,替代之前逐帧rAF追踪的视觉不同步
3. scrollListener中检测到scrollHeight变化时强制延长suppressUserIntentUntil
   至400ms,防止stick-to-bottom spring的自动追底被误判为用户滚动而中断

配套清理:移除调试日志代码和临时/api/debug/stacked-log端点
This commit is contained in:
JOJO 2026-06-08 02:08:43 +08:00
parent b6818bc0d0
commit d60808923f
4 changed files with 118 additions and 32 deletions

View File

@ -1,5 +1,9 @@
"""统一入口:复用 app_legacy便于逐步替换/收敛。"""
import argparse
import os
import json
from datetime import datetime
from flask import request
from .app_legacy import (
app,

View File

@ -805,6 +805,7 @@ let lastUserWheelUpTs = 0;
let lastProgrammaticHintTs = 0;
let lastProgrammaticHintSource = '';
let suppressUserIntentUntil = 0;
let lastLayoutChangeTs = 0;
let scrollListener: ((event: Event) => void) | null = null;
let wheelListener: ((event: WheelEvent) => void) | null = null;
let traceAttachLogged = false;
@ -924,6 +925,21 @@ function attachBounceListener() {
const trusted = (event as any).isTrusted === true;
const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140;
const likelyLayoutDriven = Math.abs(heightDelta) > 2;
// CSS transition / runSync
// stick-to-bottom spring
if (Math.abs(heightDelta) > 1) {
lastLayoutChangeTs = now;
}
const recentlyLayoutDriven = now - lastLayoutChangeTs <= 500;
//
// scrollHeight 使 1-2px sub-pixel
// stick-to-bottom spring
// delta <5px heightDelta likelyLayoutDriven
// stopScroll() spring
// 400ms spring
if (Math.abs(heightDelta) > 0.5) {
suppressUserIntentUntil = Math.max(suppressUserIntentUntil, now + 400);
}
const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240;
const strongManualUp = delta < -18 && now > suppressUserIntentUntil && !closeToProgrammaticHint;
const shouldTreatAsUserIntent =
@ -931,7 +947,10 @@ function attachBounceListener() {
Math.abs(delta) > 5 &&
now > suppressUserIntentUntil &&
(!closeToProgrammaticHint || strongManualUp) &&
(delta < 0 ? strongManualUp || manualUpByWheel || !likelyLayoutDriven : !likelyLayoutDriven);
(delta < 0
? strongManualUp || manualUpByWheel || !likelyLayoutDriven
: !likelyLayoutDriven && !recentlyLayoutDriven);
if (shouldTreatAsUserIntent) {
// stick
stopScroll();
@ -1079,7 +1098,9 @@ async function stickScrollToBottom(
// smooth trusted scroll
//
if (options.force || options.behavior === 'smooth') {
suppressUserIntentUntil = Date.now() + 900;
suppressUserIntentUntil = Date.now() + 1200;
} else {
suppressUserIntentUntil = Date.now() + 600;
}
return await scrollToBottom({
animation: options.behavior === 'smooth' ? 'smooth' : 'auto',

View File

@ -2,6 +2,7 @@
<div
class="stacked-shell"
ref="shell"
:class="{ 'no-transition': disableTransitions }"
:style="{
height: `${shellHeight}px`,
paddingTop: moreVisible ? `${moreHeight}px` : '0px'
@ -199,12 +200,11 @@ const moreHeight = ref(0);
const innerOffset = ref(0);
const viewportHeight = ref(0);
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 disableTransitions = ref(false);
const stackableActions = computed(() =>
(props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))
@ -268,7 +268,9 @@ const moreBaseHeight = () => {
const moreEl = resolveEl(moreBlock.value);
if (!moreEl) return 56;
const label = moreEl.querySelector('.more-title') as HTMLElement | null;
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
const labelH = Math.ceil(label?.getBoundingClientRect().height || 0);
const result = Math.max(48, labelH || 56);
return result;
};
const setShellMetrics = () => {
@ -282,34 +284,71 @@ const setShellMetrics = () => {
children.forEach((el, idx) => {
const action = stackableActions.value[idx];
if (!action) return;
const key = blockKey(action, idx);
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
const contentHeight = content
const contentScrollH = content
? Math.min(Math.ceil(content.scrollHeight), COLLAPSE_MAX_HEIGHT)
: 0;
nextContentHeights[key] = contentHeight;
nextContentHeights[key] = contentScrollH;
// 使/
const liveHeight = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0);
heights.push(liveHeight);
// = overhead + ( ? contentScrollH : 0)
// overhead = - ++padding+
// CSS //
// overhead
// CSS max-height duration/easing
//
const totalRendered = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0);
const contentRendered = content
? Math.ceil(content.getBoundingClientRect().height)
: 0;
const overhead = totalRendered - contentRendered;
const isBlockExpanded = isExpanded(action, idx);
const targetBlockHeight = overhead + (isBlockExpanded ? contentScrollH : 0);
heights.push(targetBlockHeight);
});
contentHeights.value = nextContentHeights;
// ref ResizeObserver
const prevContentHeights = contentHeights.value;
const prevKeys = Object.keys(prevContentHeights);
const nextKeys = Object.keys(nextContentHeights);
let contentChanged = prevKeys.length !== nextKeys.length;
if (!contentChanged) {
for (const k of nextKeys) {
if (nextContentHeights[k] !== prevContentHeights[k]) {
contentChanged = true;
break;
}
}
}
if (contentChanged) {
contentHeights.value = nextContentHeights;
}
const sum = (arr: number[]) => (arr.length ? arr.reduce((a, b) => a + b, 0) : 0);
const totalHeight = sum(heights);
const hiddenHeight = sum(heights.slice(0, Math.max(0, heights.length - VISIBLE_LIMIT)));
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
const newMoreHeight = moreVisible.value ? moreBaseHeight() : 0;
const targetShell =
moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
newMoreHeight + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
const targetViewport = Math.max(0, targetShell - newMoreHeight - 2);
shellHeight.value = targetShell;
innerOffset.value = targetOffset;
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
// ResizeObserver
let changed = false;
if (moreHeight.value !== newMoreHeight) { moreHeight.value = newMoreHeight; changed = true; }
if (contentChanged) { contentHeights.value = nextContentHeights; changed = true; }
if (shellHeight.value !== targetShell) { shellHeight.value = targetShell; changed = true; }
if (innerOffset.value !== targetOffset) { innerOffset.value = targetOffset; changed = true; }
if (viewportHeight.value !== targetViewport) { viewportHeight.value = targetViewport; changed = true; }
// ResizeObserver
if (!changed) {
suppressObserverUntil = performance.now() + 100;
}
};
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
@ -358,6 +397,7 @@ const animateMoreToggle = (nextShowAll: boolean) => {
moreHeight.value = start.more;
isMoreAnimating.value = true;
disableTransitions.value = true;
const startedAt = performance.now();
const step = () => {
@ -374,6 +414,7 @@ const animateMoreToggle = (nextShowAll: boolean) => {
requestAnimationFrame(step);
} else {
isMoreAnimating.value = false;
disableTransitions.value = false;
shellHeight.value = target.shell;
innerOffset.value = target.offset;
viewportHeight.value = target.viewport;
@ -391,25 +432,20 @@ const runSync = () => {
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);
// overhead + contentTarget
// CSS rAF
setShellMetrics();
};
let resizeObserver: ResizeObserver | null = null;
let heightRaf: number | null = null;
let suppressObserverUntil = 0;
onMounted(() => {
const innerEl = resolveEl(inner.value);
const el = resolveEl(inner.value);
setShellMetrics();
resizeObserver = new ResizeObserver(() => {
if (performance.now() < suppressObserverUntil) return;
if (heightRaf) {
cancelAnimationFrame(heightRaf);
}
@ -417,8 +453,8 @@ onMounted(() => {
setShellMetrics();
});
});
if (innerEl) {
resizeObserver.observe(innerEl);
if (el) {
resizeObserver.observe(el);
}
});
@ -437,9 +473,19 @@ onBeforeUnmount(() => {
}
});
watch([stackableActions, moreVisible], () => {
nextTick(() => runSync());
});
// action runSync
watch(
() => {
const acts = stackableActions.value;
// + blockId + action streaming
const firstId = acts[0]?.blockId || acts[0]?.id || '';
const lastId = acts[acts.length - 1]?.blockId || acts[acts.length - 1]?.id || '';
return `${acts.length}|${firstId}|${lastId}|${moreVisible.value}`;
},
() => {
nextTick(() => runSync());
}
);
watch(
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),

View File

@ -792,17 +792,32 @@
box-shadow: none;
overflow: hidden;
min-height: 0;
// 容器高度过渡与块内 collapsible-content max-height 过渡260ms同步
transition: height 260ms cubic-bezier(0.4, 0, 0.2, 1),
padding-top 260ms cubic-bezier(0.4, 0, 0.2, 1);
}
// JS 动画animateMoreToggle期间禁用容器及子元素的 CSS 过渡
// 避免手动插值与 CSS 过渡冲突导致抖动
.stacked-shell.no-transition,
.stacked-shell.no-transition .stacked-viewport,
.stacked-shell.no-transition .stacked-inner {
transition: none !important;
}
.stacked-inner {
position: relative;
width: 100%;
// 内部容器平移过渡与块动画同步
transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-viewport {
overflow: hidden;
position: relative;
width: 100%;
// 视口高度过渡与块动画同步
transition: height 260ms cubic-bezier(0.4, 0, 0.2, 1);
}
.stacked-item {