新增: UX-19消息列表虚拟滚动(自研IntersectionObserver+sentinel占位+流式末条保活)

This commit is contained in:
2026-06-17 02:55:27 +08:00
parent 3f160464ea
commit e38474b794
2 changed files with 202 additions and 4 deletions

View File

@@ -363,9 +363,15 @@
<!-- 消息列表(F-15 阶段2: renderItems 扁平渲染 折叠段 emit 'sep' ,
正常消息 + 展开的折叠段内消息 emit 'msg' 连续 archived_segment/compressed
合并一条分隔条,点击展开/收起看原文) -->
合并一条分隔条,点击展开/收起看原文)
UX-2025-19: 每项外层包 sentinel div(虚拟滚动观测点 + 高度占位),
不可见窗口外的 msg 内容卸载sentinel 留存占位高度(scrollHeight 不塌) -->
<template v-for="item in renderItems" :key="item.key">
<!-- 折叠分隔条(归档段 / 压缩段) -->
<div
class="ai-msg-slot"
:ref="(el) => registerMsgSentinel(item.key, el as Element)"
>
<!-- 折叠分隔条(归档段 / 压缩段) 轻量,始终渲染不过虚拟化 -->
<div
v-if="item.kind === 'sep'"
class="ai-msg-segment"
@@ -389,9 +395,10 @@
</span>
</div>
<!-- 消息(正常段 + 展开的折叠段内消息) -->
<!-- 消息(正常段 + 展开的折叠段内消息) 不可见时卸载(sentinel 占位高度),
流式末条经 pinnedKeys 保活(shouldRenderMsg true) -->
<div
v-else
v-else-if="shouldRenderMsg(item.key)"
class="ai-msg"
:class="'ai-msg--' + item.msg.role"
:data-msg-id="item.msg.id"
@@ -543,6 +550,7 @@
</div>
</div>
</div>
</div><!-- /.ai-msg-slot(sentinel,UX-2025-19 虚拟滚动观测点) -->
</template>
</div>
@@ -732,6 +740,7 @@ import { useRouter } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
import { useAiStore } from '../stores/ai'
import { pendingMaxRounds } from '../composables/ai/useAiEvents'
import { useAiVirtualScroll } from '../composables/ai/useAiVirtualScroll'
import { aiApi } from '../api'
import ConfirmDialog from './ConfirmDialog.vue'
import { useConfirm } from '../composables/useConfirm'
@@ -944,6 +953,13 @@ const inputEl = ref<HTMLTextAreaElement>()
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
const editingMsgId = ref<string | null>(null)
const messagesContainer = ref<HTMLDivElement>()
// UX-2025-19: 虚拟滚动(可见窗口懒渲染 + 流式末条保活)。渲染层裁剪,不触碰既有滚动/收起/流式逻辑。
const {
registerSentinel: registerMsgSentinel,
setupOnMount: setupVirtualScroll,
shouldRender: shouldRenderMsg,
setPinned: setPinnedMsgs,
} = useAiVirtualScroll({ root: messagesContainer, rootMarginPx: 600 })
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
@@ -1906,6 +1922,8 @@ watch(
onMounted(async () => {
// 全局快捷键(UX-2025-07):window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期)
window.addEventListener('keydown', onGlobalKeydown)
// UX-2025-19: 滚动容器 ref 就绪,补建 IntersectionObserver(模板 sentinel 已在挂载时逐项 observe)
setupVirtualScroll()
// UX-18: 点击外部关闭导出格式菜单
document.addEventListener('click', onExportOutsideClick)
const t0 = (window as any).__APP_T0 ?? 0
@@ -2339,6 +2357,21 @@ const hasActiveMessages = computed(() =>
store.state.messages.some(m => !isFoldedStatus(m)),
)
// UX-2025-19: 流式末条保活 — streaming 时把末条 AI 消息的 renderItem key 钉进虚拟滚动
// pinned 集合,使其无论是否在可见窗口都渲染内容,保证 ARC-08 块级 memo + streamingBlocks
// v-for + 选区保存/恢复不因虚拟化卸载丢帧。非 streaming 时清空(回归正常虚拟化裁剪)。
const lastStreamingRenderKey = computed<string | null>(() => {
if (!store.state.streaming) return null
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
if (!last || last.role !== 'assistant') return null
// 末条 streaming AI 消息必为 normal 段(renderItems 里 key='m-'+id)
return 'm-' + last.id
})
watch(lastStreamingRenderKey, (key) => {
setPinnedMsgs(key ? new Set([key]) : new Set())
}, { immediate: true })
/**
* 清空当前对话上下文(二次确认 → aiApi.clearContext)。
* 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。
@@ -2903,6 +2936,9 @@ body.ai-sidebar-resizing * {
flex-direction: column;
gap: 14px;
}
/* UX-2025-19: 虚拟滚动 sentinel 容器 — 取代 msg/sep 成为 .ai-messages 的直接 flex 子
(gap:14px 由它承担,内部消息间距零变化)。占位时 minHeight 锁住上次实测高度防塌。 */
.ai-msg-slot { display: flex; flex-direction: column; }
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area ── */
.ai-back-to-bottom {