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:
parent
b6818bc0d0
commit
d60808923f
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user