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,便于逐步替换/收敛。"""
|
"""统一入口:复用 app_legacy,便于逐步替换/收敛。"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import request
|
||||||
|
|
||||||
from .app_legacy import (
|
from .app_legacy import (
|
||||||
app,
|
app,
|
||||||
|
|||||||
@ -805,6 +805,7 @@ let lastUserWheelUpTs = 0;
|
|||||||
let lastProgrammaticHintTs = 0;
|
let lastProgrammaticHintTs = 0;
|
||||||
let lastProgrammaticHintSource = '';
|
let lastProgrammaticHintSource = '';
|
||||||
let suppressUserIntentUntil = 0;
|
let suppressUserIntentUntil = 0;
|
||||||
|
let lastLayoutChangeTs = 0;
|
||||||
let scrollListener: ((event: Event) => void) | null = null;
|
let scrollListener: ((event: Event) => void) | null = null;
|
||||||
let wheelListener: ((event: WheelEvent) => void) | null = null;
|
let wheelListener: ((event: WheelEvent) => void) | null = null;
|
||||||
let traceAttachLogged = false;
|
let traceAttachLogged = false;
|
||||||
@ -924,6 +925,21 @@ function attachBounceListener() {
|
|||||||
const trusted = (event as any).isTrusted === true;
|
const trusted = (event as any).isTrusted === true;
|
||||||
const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140;
|
const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140;
|
||||||
const likelyLayoutDriven = Math.abs(heightDelta) > 2;
|
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 manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240;
|
||||||
const strongManualUp = delta < -18 && now > suppressUserIntentUntil && !closeToProgrammaticHint;
|
const strongManualUp = delta < -18 && now > suppressUserIntentUntil && !closeToProgrammaticHint;
|
||||||
const shouldTreatAsUserIntent =
|
const shouldTreatAsUserIntent =
|
||||||
@ -931,7 +947,10 @@ function attachBounceListener() {
|
|||||||
Math.abs(delta) > 5 &&
|
Math.abs(delta) > 5 &&
|
||||||
now > suppressUserIntentUntil &&
|
now > suppressUserIntentUntil &&
|
||||||
(!closeToProgrammaticHint || strongManualUp) &&
|
(!closeToProgrammaticHint || strongManualUp) &&
|
||||||
(delta < 0 ? strongManualUp || manualUpByWheel || !likelyLayoutDriven : !likelyLayoutDriven);
|
(delta < 0
|
||||||
|
? strongManualUp || manualUpByWheel || !likelyLayoutDriven
|
||||||
|
: !likelyLayoutDriven && !recentlyLayoutDriven);
|
||||||
|
|
||||||
if (shouldTreatAsUserIntent) {
|
if (shouldTreatAsUserIntent) {
|
||||||
// 用户手动滚动时,立即中断 stick 引擎中可能仍在运行的动画滚动
|
// 用户手动滚动时,立即中断 stick 引擎中可能仍在运行的动画滚动
|
||||||
stopScroll();
|
stopScroll();
|
||||||
@ -1079,7 +1098,9 @@ async function stickScrollToBottom(
|
|||||||
// 点击“滚动到底部”按钮时会触发 smooth 动画滚动;期间会产生 trusted scroll 事件,
|
// 点击“滚动到底部”按钮时会触发 smooth 动画滚动;期间会产生 trusted scroll 事件,
|
||||||
// 若不短暂抑制会被误判为“用户手动滚动”从而中断动画,表现为只滚动一点点。
|
// 若不短暂抑制会被误判为“用户手动滚动”从而中断动画,表现为只滚动一点点。
|
||||||
if (options.force || options.behavior === 'smooth') {
|
if (options.force || options.behavior === 'smooth') {
|
||||||
suppressUserIntentUntil = Date.now() + 900;
|
suppressUserIntentUntil = Date.now() + 1200;
|
||||||
|
} else {
|
||||||
|
suppressUserIntentUntil = Date.now() + 600;
|
||||||
}
|
}
|
||||||
return await scrollToBottom({
|
return await scrollToBottom({
|
||||||
animation: options.behavior === 'smooth' ? 'smooth' : 'auto',
|
animation: options.behavior === 'smooth' ? 'smooth' : 'auto',
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
class="stacked-shell"
|
class="stacked-shell"
|
||||||
ref="shell"
|
ref="shell"
|
||||||
|
:class="{ 'no-transition': disableTransitions }"
|
||||||
:style="{
|
:style="{
|
||||||
height: `${shellHeight}px`,
|
height: `${shellHeight}px`,
|
||||||
paddingTop: moreVisible ? `${moreHeight}px` : '0px'
|
paddingTop: moreVisible ? `${moreHeight}px` : '0px'
|
||||||
@ -199,12 +200,11 @@ const moreHeight = ref(0);
|
|||||||
const innerOffset = ref(0);
|
const innerOffset = ref(0);
|
||||||
const viewportHeight = ref(0);
|
const viewportHeight = ref(0);
|
||||||
const contentHeights = ref<Record<string, number>>({});
|
const contentHeights = ref<Record<string, number>>({});
|
||||||
const ANIMATION_SYNC_MS = 360;
|
|
||||||
const MORE_ANIMATION_MS = 300;
|
const MORE_ANIMATION_MS = 300;
|
||||||
const COLLAPSE_MAX_HEIGHT = 600;
|
const COLLAPSE_MAX_HEIGHT = 600;
|
||||||
let syncRaf: number | null = null;
|
let syncRaf: number | null = null;
|
||||||
let syncUntil = 0;
|
|
||||||
const isMoreAnimating = ref(false);
|
const isMoreAnimating = ref(false);
|
||||||
|
const disableTransitions = ref(false);
|
||||||
|
|
||||||
const stackableActions = computed(() =>
|
const stackableActions = computed(() =>
|
||||||
(props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))
|
(props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))
|
||||||
@ -268,7 +268,9 @@ const moreBaseHeight = () => {
|
|||||||
const moreEl = resolveEl(moreBlock.value);
|
const moreEl = resolveEl(moreBlock.value);
|
||||||
if (!moreEl) return 56;
|
if (!moreEl) return 56;
|
||||||
const label = moreEl.querySelector('.more-title') as HTMLElement | null;
|
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 = () => {
|
const setShellMetrics = () => {
|
||||||
@ -282,34 +284,71 @@ const setShellMetrics = () => {
|
|||||||
|
|
||||||
children.forEach((el, idx) => {
|
children.forEach((el, idx) => {
|
||||||
const action = stackableActions.value[idx];
|
const action = stackableActions.value[idx];
|
||||||
|
if (!action) return;
|
||||||
const key = blockKey(action, idx);
|
const key = blockKey(action, idx);
|
||||||
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
|
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
|
||||||
const contentHeight = content
|
const contentScrollH = content
|
||||||
? Math.min(Math.ceil(content.scrollHeight), COLLAPSE_MAX_HEIGHT)
|
? Math.min(Math.ceil(content.scrollHeight), COLLAPSE_MAX_HEIGHT)
|
||||||
: 0;
|
: 0;
|
||||||
nextContentHeights[key] = contentHeight;
|
nextContentHeights[key] = contentScrollH;
|
||||||
|
|
||||||
// 使用当前真实高度而非「展开/折叠」状态推导值,避免在折叠动画过程中出现高度骤减导致的闪烁
|
// ── 精确目标高度 = overhead + (展开 ? contentScrollH : 0) ──
|
||||||
const liveHeight = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0);
|
// overhead = 块总渲染高 - 内容渲染高,恒等于「头部+边框+padding+进度条」等
|
||||||
heights.push(liveHeight);
|
// 所有非折叠内容元素之和。该值不受 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 sum = (arr: number[]) => (arr.length ? arr.reduce((a, b) => a + b, 0) : 0);
|
||||||
const totalHeight = sum(heights);
|
const totalHeight = sum(heights);
|
||||||
const hiddenHeight = sum(heights.slice(0, Math.max(0, heights.length - VISIBLE_LIMIT)));
|
const hiddenHeight = sum(heights.slice(0, Math.max(0, heights.length - VISIBLE_LIMIT)));
|
||||||
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
|
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
|
||||||
|
|
||||||
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
|
const newMoreHeight = moreVisible.value ? moreBaseHeight() : 0;
|
||||||
|
|
||||||
const targetShell =
|
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 targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
|
||||||
|
const targetViewport = Math.max(0, targetShell - newMoreHeight - 2);
|
||||||
|
|
||||||
shellHeight.value = targetShell;
|
// 仅在实际变化时更新,防止无变化的重渲染再度触发 ResizeObserver
|
||||||
innerOffset.value = targetOffset;
|
let changed = false;
|
||||||
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
|
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;
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||||
@ -358,6 +397,7 @@ const animateMoreToggle = (nextShowAll: boolean) => {
|
|||||||
moreHeight.value = start.more;
|
moreHeight.value = start.more;
|
||||||
|
|
||||||
isMoreAnimating.value = true;
|
isMoreAnimating.value = true;
|
||||||
|
disableTransitions.value = true;
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
|
|
||||||
const step = () => {
|
const step = () => {
|
||||||
@ -374,6 +414,7 @@ const animateMoreToggle = (nextShowAll: boolean) => {
|
|||||||
requestAnimationFrame(step);
|
requestAnimationFrame(step);
|
||||||
} else {
|
} else {
|
||||||
isMoreAnimating.value = false;
|
isMoreAnimating.value = false;
|
||||||
|
disableTransitions.value = false;
|
||||||
shellHeight.value = target.shell;
|
shellHeight.value = target.shell;
|
||||||
innerOffset.value = target.offset;
|
innerOffset.value = target.offset;
|
||||||
viewportHeight.value = target.viewport;
|
viewportHeight.value = target.viewport;
|
||||||
@ -391,25 +432,20 @@ const runSync = () => {
|
|||||||
cancelAnimationFrame(syncRaf);
|
cancelAnimationFrame(syncRaf);
|
||||||
syncRaf = null;
|
syncRaf = null;
|
||||||
}
|
}
|
||||||
syncUntil = performance.now() + ANIMATION_SYNC_MS;
|
// 目标高度是确定性的(overhead + contentTarget),单次调用设置目标值,
|
||||||
const step = () => {
|
// 容器的 CSS 过渡自动处理平滑动画,无需逐帧 rAF 追踪
|
||||||
setShellMetrics();
|
setShellMetrics();
|
||||||
if (performance.now() < syncUntil) {
|
|
||||||
syncRaf = requestAnimationFrame(step);
|
|
||||||
} else {
|
|
||||||
syncRaf = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
syncRaf = requestAnimationFrame(step);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let heightRaf: number | null = null;
|
let heightRaf: number | null = null;
|
||||||
|
let suppressObserverUntil = 0;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const innerEl = resolveEl(inner.value);
|
const el = resolveEl(inner.value);
|
||||||
setShellMetrics();
|
setShellMetrics();
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (performance.now() < suppressObserverUntil) return;
|
||||||
if (heightRaf) {
|
if (heightRaf) {
|
||||||
cancelAnimationFrame(heightRaf);
|
cancelAnimationFrame(heightRaf);
|
||||||
}
|
}
|
||||||
@ -417,8 +453,8 @@ onMounted(() => {
|
|||||||
setShellMetrics();
|
setShellMetrics();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (innerEl) {
|
if (el) {
|
||||||
resizeObserver.observe(innerEl);
|
resizeObserver.observe(el);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -437,9 +473,19 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([stackableActions, moreVisible], () => {
|
// 仅在 action 列表内容真正变化时触发 runSync,而非每次父组件重渲染传入新数组引用
|
||||||
nextTick(() => 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(
|
watch(
|
||||||
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
||||||
|
|||||||
@ -792,17 +792,32 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
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 {
|
.stacked-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
// 内部容器平移过渡,与块动画同步
|
||||||
|
transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked-viewport {
|
.stacked-viewport {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
// 视口高度过渡,与块动画同步
|
||||||
|
transition: height 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked-item {
|
.stacked-item {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user