feat(chat): 优化块展开/折叠滚动锚定,避免 stick-to-bottom 弹簧追底顿挫

This commit is contained in:
JOJO 2026-06-17 15:24:15 +08:00
parent 55b7b7deea
commit edb557bdd3
5 changed files with 278 additions and 0 deletions

View File

@ -108,6 +108,7 @@
:render-markdown="renderMarkdown"
:register-thinking-ref="registerThinkingRef"
:handle-thinking-scroll="props.handleThinkingScroll"
@group-toggle="handleMinimalGroupToggle"
/>
</template>
<template v-else-if="stackedBlocksEnabled">
@ -154,6 +155,7 @@
group.action.blockId || `${index}-thinking-${group.actionIndex}`
)
}"
:data-block-id="group.action.blockId || `${index}-thinking-${group.actionIndex}`"
>
<div
class="collapsible-header"
@ -345,6 +347,7 @@
:streaming-message="streamingMessage"
:register-collapse-content="registerCollapseContent"
:collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`"
:block-id="group.action.blockId || `${index}-tool-${group.actionIndex}`"
@toggle="
toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)
"
@ -378,6 +381,7 @@
action.blockId || `${index}-thinking-${actionIndex}`
)
}"
:data-block-id="action.blockId || `${index}-thinking-${actionIndex}`"
>
<div
class="collapsible-header"
@ -551,6 +555,7 @@
:streaming-message="streamingMessage"
:register-collapse-content="registerCollapseContent"
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
:block-id="action.blockId || `${index}-tool-${actionIndex}`"
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
/>
</div>
@ -572,6 +577,7 @@
<div
class="collapsible-block system-block"
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
:data-block-id="`system-${index}`"
>
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
<div class="arrow"></div>
@ -603,6 +609,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useStickToBottom } from 'vue-stick-to-bottom';
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue';
@ -797,6 +804,10 @@ const {
initial: 'instant'
});
const rootEl = scrollRef;
const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpansionAnchor(
scrollRef,
{ stopScroll }
);
const thinkingRefs = new Map<string, HTMLElement | null>();
let scrollbarResizeObserver: ResizeObserver | null = null;
let bounceTraceCount = 0;
@ -1059,6 +1070,78 @@ watch(
},
{ immediate: true }
);
function escapeBlockSelector(value: string) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return value.replace(/["\\]/g, '\\$&');
}
function elementLooksStreaming(el: HTMLElement) {
return (
el.classList.contains('processing') ||
el.classList.contains('running') ||
!!el.querySelector('.processing, .running, .thinking-animation')
);
}
const prevExpandedBlocks = ref(new Set<string>());
watch(
() => props.expandedBlocks,
(newSet, oldSet) => {
const prev = oldSet ?? prevExpandedBlocks.value;
const all = new Set([...Array.from(newSet), ...Array.from(prev)]);
const changed = new Set<string>();
for (const id of all) {
if (newSet.has(id) !== prev.has(id)) {
changed.add(id);
}
}
prevExpandedBlocks.value = new Set(newSet);
if (changed.size === 0) return;
const container = scrollRef.value;
if (!container) return;
for (const id of changed) {
const el = container.querySelector(
`[data-block-id="${escapeBlockSelector(id)}"]`
) as HTMLElement | null;
if (el) {
const isExpanding = newSet.has(id);
const streaming = elementLooksStreaming(el);
anchorBlockElement(el, id, {
direction: 'auto',
duration: streaming ? 5000 : 260,
extendOnGrowth: streaming,
phase: isExpanding ? 'expand' : 'collapse'
});
}
}
},
{ flush: 'post' }
);
function handleMinimalGroupToggle(payload: { groupId: string; expanded: boolean }) {
const { groupId, expanded } = payload;
const container = scrollRef.value;
if (!container) return;
const el = container.querySelector(
`[data-group-id="${escapeBlockSelector(groupId)}"]`
) as HTMLElement | null;
if (el) {
const streaming = elementLooksStreaming(el);
anchorBlockElement(el, groupId, {
direction: 'auto',
duration: streaming ? 5000 : 300,
extendOnGrowth: streaming,
phase: expanded ? 'expand' : 'collapse'
});
}
}
const registerCollapseContent = (key: string, el: Element | null) => {
if (!(el instanceof HTMLElement)) {
return;
@ -1530,6 +1613,7 @@ watch(scrollRef, () => {
onBeforeUnmount(() => {
detachBounceListener();
stopBlockExpansionAnchors();
if (scrollbarResizeObserver) {
scrollbarResizeObserver.disconnect();
scrollbarResizeObserver = null;

View File

@ -8,6 +8,7 @@
<div
class="summary-line-text"
:class="{ running: isSummaryRunning(group.actions, group.id) }"
:data-group-id="group.id"
@click="toggleExpand(group.id)"
>
<div class="summary-content-wrapper">
@ -200,6 +201,10 @@ const props = defineProps<{
handleThinkingScroll?: (blockId: string, event: Event) => void;
}>();
const emit = defineEmits<{
(event: 'group-toggle', payload: { groupId: string; expanded: boolean }): void;
}>();
const personalizationStore = usePersonalizationStore();
const expandedGroups = ref(new Set<string>());
const thinkingRefs = new Map<string, HTMLElement>();
@ -672,6 +677,7 @@ const getSummarySteps = (actions: Action[]) => {
const toggleExpand = (groupId: string) => {
if (expandedGroups.value.has(groupId)) {
expandedGroups.value.delete(groupId);
emit('group-toggle', { groupId, expanded: false });
// ref
nextTick(() => {
@ -689,6 +695,7 @@ const toggleExpand = (groupId: string) => {
});
} else {
expandedGroups.value.add(groupId);
emit('group-toggle', { groupId, expanded: true });
// ref
nextTick(() => {

View File

@ -41,6 +41,7 @@
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block stacked-block"
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
:data-block-id="blockKey(action, idx)"
>
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div>
@ -71,6 +72,7 @@
processing: isToolProcessing(action),
completed: isToolCompleted(action)
}"
:data-block-id="blockKey(action, idx)"
>
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div>

View File

@ -6,6 +6,7 @@
processing: action.tool.status === 'preparing' || action.tool.status === 'running',
completed: action.tool.status === 'completed'
}"
:data-block-id="blockId || collapseKey || action.tool.id || action.id || 'tool'"
>
<div class="collapsible-header" @click="$emit('toggle')">
<div class="arrow"></div>
@ -51,6 +52,7 @@ defineOptions({ name: 'ToolAction' });
const props = defineProps<{
action: any;
expanded: boolean;
blockId?: string;
iconStyle: (key: string) => Record<string, string>;
getToolAnimationClass: (tool: any) => Record<string, unknown>;
getToolIcon: (tool: any) => string;

View File

@ -0,0 +1,183 @@
import { type Ref } from 'vue';
export type BlockExpandDirection = 'auto' | 'up' | 'down';
interface AnchorAnimation {
element: HTMLElement;
startTime: number;
duration: number;
mode: 'up' | 'down';
initialTop: number;
initialBottom: number;
extendOnGrowth: boolean;
phase: 'expand' | 'collapse';
}
interface UseBlockExpansionAnchorOptions {
stopScroll: () => void;
duration?: number;
defaultDirection?: BlockExpandDirection;
}
/**
* /
*
* stick-to-bottom CSS
* composable CSS rAF scrollTop
*
*
* -
* - -
* -
*/
export function useBlockExpansionAnchor(
scrollRef: Ref<HTMLElement | null>,
options: UseBlockExpansionAnchorOptions
) {
const { stopScroll, duration = 300, defaultDirection = 'auto' } = options;
const animations = new Map<string, AnchorAnimation>();
const preferredModes = new Map<string, 'up' | 'down'>();
let rafId: number | null = null;
function resolveMode(rect: DOMRect, containerRect: DOMRect, direction: BlockExpandDirection) {
if (direction !== 'auto') return direction;
const top = rect.top - containerRect.top;
const midY = containerRect.height / 2;
return top > midY ? 'up' : 'down';
}
function tick() {
const container = scrollRef.value;
if (!container) {
rafId = null;
return;
}
const containerRect = container.getBoundingClientRect();
const now = performance.now();
let hasActive = false;
for (const [id, anim] of animations) {
const elapsed = now - anim.startTime;
const stillInDom =
document.body.contains(container) && container.contains(anim.element);
if (!stillInDom) {
animations.delete(id);
continue;
}
const rect = anim.element.getBoundingClientRect();
const currentTop = rect.top - containerRect.top;
const currentBottom = rect.bottom - containerRect.top;
let delta = 0;
if (anim.mode === 'up') {
delta = currentBottom - anim.initialBottom;
} else {
delta = -(currentTop - anim.initialTop);
}
const stillGrowing = Math.abs(delta) > 1;
const shouldExtend = anim.extendOnGrowth && stillGrowing && elapsed > anim.duration * 0.6;
if (shouldExtend) {
anim.startTime = now - anim.duration * 0.3;
}
if (elapsed >= anim.duration && !shouldExtend) {
// 收起动画结束后清除方向记录,下次展开按新位置重新判断
if (anim.phase === 'collapse') {
preferredModes.delete(id);
}
animations.delete(id);
continue;
}
hasActive = true;
if (Math.abs(delta) > 0.5) {
container.scrollTop += delta;
}
}
if (hasActive) {
rafId = requestAnimationFrame(tick);
} else {
rafId = null;
}
}
function anchorBlockElement(
element: HTMLElement,
id: string,
opts?: {
duration?: number;
direction?: BlockExpandDirection;
extendOnGrowth?: boolean;
phase?: 'expand' | 'collapse';
}
) {
const container = scrollRef.value;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
const initialTop = rect.top - containerRect.top;
const initialBottom = rect.bottom - containerRect.top;
// 只处理视口附近可见的块
if (initialBottom < -50 || initialTop > containerRect.height + 50) {
return;
}
const phase = opts?.phase ?? 'expand';
const explicitDirection = opts?.direction ?? defaultDirection;
let mode: 'up' | 'down';
if (explicitDirection !== 'auto') {
mode = explicitDirection;
} else if (phase === 'collapse' && preferredModes.has(id)) {
// 收起时沿用展开时记录的方向,保证倒放一致
mode = preferredModes.get(id)!;
} else if (phase === 'expand' && preferredModes.has(id)) {
// 同一次周期内再次展开(不应常见),沿用已有方向
mode = preferredModes.get(id)!;
} else {
mode = resolveMode(rect, containerRect, 'auto');
preferredModes.set(id, mode);
}
const extendOnGrowth = opts?.extendOnGrowth ?? false;
stopScroll();
const existing = animations.get(id);
animations.set(id, {
element,
startTime: performance.now(),
duration: opts?.duration ?? duration,
mode,
initialTop,
initialBottom,
extendOnGrowth: extendOnGrowth || existing?.extendOnGrowth || false,
phase
});
if (rafId === null) {
rafId = requestAnimationFrame(tick);
}
}
function stopAll() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
animations.clear();
preferredModes.clear();
}
return {
anchorBlockElement,
stopAll
};
}