diff --git a/docs/features/chat-history-navigation/README.md b/docs/features/chat-history-navigation/README.md index 17f644de..8d087580 100644 --- a/docs/features/chat-history-navigation/README.md +++ b/docs/features/chat-history-navigation/README.md @@ -1,6 +1,6 @@ # 聊天历史导航 -聊天历史导航负责长会话的连续滚动、窗口化渲染,以及后续的对话 minimap。第一阶段先取消向上滚动时每次增加 80 条消息的产品限制,保留已有的消息窗口化机制;第二阶段再评估 minimap。 +聊天历史导航负责长会话的连续滚动、窗口化渲染和对话 minimap。当前实现已经取消向上滚动时每次增加 80 条消息的产品限制,保留已有的消息窗口化机制,并用轻量 canvas 提供消息分布和快速定位。 ## 文档导航 @@ -14,4 +14,4 @@ - **历史窗口**:允许滚动访问的消息范围。第一阶段扩大为当前已加载会话的全部消息。 - **DOM 窗口**:实际挂载消息组件的范围。继续由消息高度估算和最多约 60 条挂载消息控制。 -- **minimap**:对话右侧的轻量纵向全局导航概览,不等于完整渲染所有消息。 +- **minimap**:替代消息区原生滚动条的 canvas 全局概览,高度与滚动区一致,悬停时展开为较宽的缩略长图;不等于完整渲染所有消息。 diff --git a/docs/features/chat-history-navigation/progress.md b/docs/features/chat-history-navigation/progress.md index 833cedc1..3f9e28a0 100644 --- a/docs/features/chat-history-navigation/progress.md +++ b/docs/features/chat-history-navigation/progress.md @@ -14,4 +14,62 @@ - App 测试通过 341/349;剩余 8 个失败集中在既有图片附件异步场景,未出现聊天历史窗口相关失败。 - 全局 `npm run typecheck` 仍被既有 `src/main/desktop-storage-electron-fixture.ts:215-217` 类型错误阻断;全局 lint 仍包含生成的 VitePress cache 错误。 - A 组临时恢复后连续两次在 composer 初始化阶段超时,未进入性能场景,因此没有有效 A 组数据,不能据此推导 A/B 数值差异。B 组代码已恢复并重新完成 bundle、定向类型检查、定向 lint 和 58/58 聊天窗口测试。 -- 下一步实现 minimap 前仍需先修复性能驱动在 A 组接线下的 composer 初始化问题,或建立不依赖 composer 的长会话专用 benchmark,再进行严格 A/B 测量。 + +## 2026-10-11 + +- 已确认工作区干净,生产代码保持 `47917094` 的 A 组:向上滚动仍按 80 条揭示,DOM 窗口化仍保留。 +- 当前聊天窗口和时间线定向测试:59/59 通过,包含 2,000 条消息连续滚动、实际挂载数量上限、锚点稳定、状态恢复和 80 条历史揭示。 +- App 单元测试:342/350 通过;8 个失败均为等待状态圆点的既有异步断言,失败位置不涉及历史窗口逻辑。 +- `npm run build:bundle`:通过。 +- 当前 A 组首次可重复的 `npm run perf:app`:通过。长会话打开交互延迟 54.4 ms;长会话滚动无长任务,帧间隔 p95 8.5 ms;流式输入延迟 p95 10.0 ms;流式输入场景无长任务。 +- 性能基准内存回收:24 次切换后 mounted panes 保持 6,DOM 从 24,746 降至约 2,446,堆从 50.5 MB 回收至约 22 MB,后半段增长约 0.1 MB。 +- 之前记录的“A 组 composer 初始化超时”未在当前回退后的基线复现;现在已经取得有效 A 组数据,后续可在同一 bundle 和环境下重新接线 B 组做严格交替 A/B。 +- minimap 尚未实现;在 B 组完成交替测量前不进入 minimap 性能对比。 +- B 组已重新接线:`ConversationHistorySlot` 对完整会话传入 `conversation.messages.length`,App 层不再维护 80 条可见数量状态;摘要会话仍走原有历史加载流程。 +- B 组 App 历史回归:4/4 通过,覆盖无历史闸门、长会话有界提交、暖切换位置和展开状态、超过 500 条历史保留。 +- B 组 `npm run build:bundle`:通过;改动文件 web 类型检查和 lint:通过。 +- B 组 `npm run perf:app`:通过。长会话打开交互延迟 46.8 ms,长会话滚动无长任务、帧间隔 p95 8.5 ms;流式输入延迟 p95 9.5 ms,无长任务。 +- A/B 基准对比:B 组 DOM 在长会话滚动后约 2,644、24 次切换回收后约 2,446;A 组对应约 2,632 和 2,446。B 组 24 次切换后的堆约 22.9 MB,后半段增长约 0.1 MB;A 组约 22.0 MB,后半段增长约 0.1 MB。当前没有观察到明显滚动、流式或内存回收回归。 +- 全量 App 测试:B 组 341/350 通过;剩余 9 个中 1 个为已迁移历史测试前的旧断言结果,8 个仍为既有等待状态圆点异步断言。迁移后的历史相关测试已单独通过。 +- B 组尚未提交;下一步继续做多轮交替 A/B 和低端机模拟,再决定是否进入 minimap。 + +## 2026-10-12 + +- C 组已实现 minimap:使用单个 canvas 绘制用户、助手、工具/待处理和错误消息色带,不为每条消息增加 DOM 节点。 +- minimap 显示当前滚动视口,支持点击、指针拖动、Home、End、PageUp 和 PageDown;使用现有滚动容器和消息高度估算定位。 +- 新增 `ChatMinimap.test.tsx`,覆盖颜色分类、坐标边界、跳转和键盘操作;minimap、历史窗口和时间线定向测试共 62/62 通过。 +- C 组 App 历史相关回归 4/4 通过;改动文件 lint、web 类型检查和 `npm run build:bundle` 通过。 +- C 组 `npm run perf:app`:通过。长会话打开交互延迟 46.4 ms;长会话滚动帧间隔 p95 8.5 ms、无长任务;流式输入延迟 p95 9.6 ms、无长任务。 +- B/C 单次对比:滚动 p95 均为 8.5 ms 且无长任务;B 组流式输入 p95 9.5 ms,C 组 9.6 ms;B 组 24 次切换后 DOM/堆约 2,446/22.9 MB,C 组约 2,449/23.1 MB,后半段堆增长均约 0.1 MB。 +- C 组尚未做多轮交替和低端机模拟;当前结果支持继续保留实现并进入更广泛验证,不能单凭单次数据宣称最终性能结论。 + +### 2026-10-12 交互修订 + +评审发现初版 minimap 是悬浮在中部的 220 px 小条,视口指示经 `requestAnimationFrame` + React 状态更新而滞后于滚动,且没有悬停放大。已修订: + +- minimap 替代消息滚动区的原生滚动条:`.chat-history-pane > .chat` 隐藏原生滚动条,minimap 高度与滚动区 `clientHeight` 一致并贴合右侧。 +- 视口滑块在 `scroll` 事件和 `ResizeObserver` 回调中同步写入 `transform`,不经过 React 提交或下一帧;滚动区尺寸不变时只写 `transform`,不读布局。 +- canvas 仅在消息变化、行高测量变化、尺寸变化或展开/收起时重绘;普通滚动不重绘。 +- 悬停时展开为 96 px(窄屏 64 px)的缩略长图:用户消息靠右、助手消息靠左并按内容长度给出线宽;拖动中离开不收起。 +- 点击轨道将滑块中心移到指针处,拖动滑块保持抓取偏移;滚轮转发给消息区;保留 Home/End/PageUp/PageDown;无障碍角色为 `scrollbar`,文案使用 i18n `chat.minimap`。 +- 验证:minimap、历史窗口和时间线定向测试 65/65;web 类型检查和改动文件 lint 通过;`App.test.tsx` 342/350,8 个失败与此前相同(等待状态圆点异步断言);`npm run build:bundle` 通过。 +- `npm run perf:app` 通过:长会话打开帧 p95 8.6 ms、无长任务;长会话滚动帧 p95 8.6 ms、无长任务;`stream-seeded`/`stream-and-type` 无长任务、帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23 MB,后半段增长 0.1 MB。 +- 未验证:真实鼠标悬停/拖动的视觉效果仍需在桌面端人工确认;多轮交替与低端机模拟仍未做。 + +### 2026-10-12 第二次交互修订 + +- minimap 只覆盖阅读区:顶部从 `--chat-context-height`(任务条)开始,毛玻璃模式下再让出 58 px 标题栏;滑块轨道高度取 rail 实际高度,rail 自身尺寸变化由独立 `ResizeObserver` 触发重算。 +- 展开宽度调为 120 px(后续按反馈改为 80 px)。展开视图改为逐行描线:`minimapLines` 按约 72 列折行(CJK 按两列计)、保留空行、代码块缩进并降低不透明度,每条消息最多取 600 行;消息块高度装不下时按行均匀抽样。行剖面按消息对象缓存在 `WeakMap`,流式更新产生新对象时才重算。 +- 验证:定向测试 67/67(新增折行剖面和逐行绘制用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。 +- `npm run perf:app` 通过,各场景帧 p95 8.5–8.6 ms;长会话打开、长会话滚动、两个流式场景均无长任务;内存回收与上一轮一致。运行中出现的 renderer error 为网络更新检查 `AbortError`,与本功能无关。 + +### 2026-10-12 第三次交互修订 + +- 展开宽度改为 80 px(窄屏仍为 64 px)。 +- 已取消的回复在缩略图中改为助手灰色,与正文状态行一致;只有失败的回复显示红色(复用 `isCancelledMessage`)。 +- 修正滚动时缩略图密度变化:此前每条消息的色块高度取 `MessageRowHeights.size()`,未测量行使用“已测行平均高度”估算;滚动挂载新行后估算值和实测值变化,每次提交都会按新高度重画整张图。现改为双轴: + - 绘制轴:`minimapMessageWeight`(折行数 + 3 行头部 + 每个工具 2 行),只依赖消息内容;画布只在消息数组变化(新消息、流式输出)或尺寸、展开状态变化时重画,行测量不再触发重画。 + - 滚动轴:行高前缀和,仅在 `scrollHeight` 变化时重建。滑块位置和高度按行在两轴之间分段线性映射,覆盖视口内消息在缩略图上的实际区段(最小 16 px);点击和拖动走反向映射。 +- 验证:minimap、历史窗口、时间线定向测试 69/69(新增“行测量不��发重画”和“滑块对齐可见行的概览区段”用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。 +- `npm run perf:app` 连续两次通过。第二次结果:长会话打开、长会话滚动、`stream-seeded`、`stream-and-type` 均无长任务,帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23.2 MB,后半段增长 0.2 MB。第一次运行中长会话滚动帧 p95 为 16.6 ms,`stream-seeded` 有 1 个 62 ms 长任务,第二次未复现,按单次噪声记录。 +- 未验证:真实桌面端滚动时的视觉稳定性需人工确认。 diff --git a/docs/features/chat-history-navigation/technical-design.md b/docs/features/chat-history-navigation/technical-design.md index 5a0a304c..c1cae618 100644 --- a/docs/features/chat-history-navigation/technical-design.md +++ b/docs/features/chat-history-navigation/technical-design.md @@ -2,7 +2,9 @@ ## 当前边界 -`ChatHistoryPane` 当前有两层机制:`messageRenderBatchSize = 80` 控制向上滚动时可访问的历史窗口;`maxRenderedMessageCount = 60` 控制实际挂载的消息组件。第一阶段只移除前者在生产接线中的作用,保留后者以及消息高度占位和锚点恢复。 +`ChatHistoryPane` 当前有两层机制:`messageRenderBatchSize = 80` 控制向上滚动时可访问的历史窗口;`maxRenderedMessageCount = 60` 控制实际挂载的消息组件。当前工作区处于 C 组实验:生产接线直接使用已加载消息总数,并用基于高度估算的 canvas minimap 替代消息区原生滚动条;DOM 窗口化、消息高度占位和锚点恢复保持不变。 + +minimap(`ChatMinimap.tsx`)的更新路径不经过 React:`scroll` 事件和 `ResizeObserver` 回调同步写入滑块 `transform`,只有滚动区尺寸、消息或行高测量变化时才重绘 canvas;悬停展开改写 `data-expanded` 后重绘一次。React 只在 `ChatHistoryPane` 自身提交时刷新消息数据。 已加载历史的消息数组仍由会话 store 提供。带 `messageSummary` 的会话继续先显示加载状态,由现有 `loadHistory` 路径取得完整历史。这样本实验测量的是滚动历史窗口的变化,不混入存储或 IPC 改造。 @@ -14,7 +16,7 @@ | B 本阶段 | 当前已加载消息全部可访问 | 保留 | 无 | | C 下一阶段 | 当前已加载消息全部可访问 | 保留 | 有 | -A 组保留在组件级回归测试中;B 组是当前生产实验;C 组待 B 组稳定后实现。 +A 组是已记录的生产基线;B 组是无 minimap 的历史导航实验;C 组是当前工作区实验,待多轮对比和低端机模拟完成后再决定发布范围。 ## 功能测试计划 @@ -38,7 +40,7 @@ A 组保留在组件级回归测试中;B 组是当前生产实验;C 组待 B - 实际挂载消息数量; - 流式输出时输入延迟。 -通过条件:B 组功能测试全部通过,实际挂载消息数量仍受窗口上限控制;相对 A 组没有明显的输入、流式输出或滚动回归。若 B 组稳定,再以同一批数据实现 C 组并做增量对比。 +通过条件:B 组功能测试全部通过,实际挂载消息数量仍受窗口上限控制;相对 A 组没有明显的输入、流式输出或滚动回归。C 组还需在同一批数据上确认 minimap 的定位行为和增量性能成本。当前 A/B/C 数据记录在 `progress.md`。 ## 验证命令 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 6fb6552a..89264aed 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4608,7 +4608,7 @@ describe("App", () => { expect(scrollTo).not.toHaveBeenCalled(); }); - it("windows the latest 80 messages and preserves scroll when revealing earlier messages", async () => { + it("exposes the full loaded history while keeping the DOM windowed", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: "00000000-0000-4000-8000-000000000421", @@ -4627,62 +4627,17 @@ describe("App", () => { const { container } = render(); expect(await screen.findByText("历史消息 160")).toBeInTheDocument(); - // Only the rows near the bottom are mounted (PERF-14); a spacer stands in - // for the rest of the 80-message batch. + // Only the rows near the bottom are mounted (PERF-14); spacers stand in + // for the rest of the loaded history. const mounted = container.querySelectorAll(".message").length; expect(mounted).toBeGreaterThan(0); expect(mounted).toBeLessThan(80); - expect( - screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), - ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument(); expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument(); - const chat = container.querySelector(".chat"); - if (!chat) { - throw new Error("Missing chat scroll container"); - } - const topSpacerHeight = (): number => - Number.parseFloat( - container.querySelector('[data-window-spacer="top"]')?.style.height ?? "0", - ) || 0; - Object.defineProperties(chat, { - clientHeight: { configurable: true, value: 400 }, - scrollHeight: { - configurable: true, - get: () => container.querySelectorAll(".message").length * 10 + topSpacerHeight(), - }, - scrollTop: { - configurable: true, - writable: true, - value: 125, - }, - }); - - fireEvent.click( - screen.getByRole("button", { - name: "加载更早的消息(还剩 81 条)", - }), - ); - - expect( - screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }), - ).toBeInTheDocument(); - expect(container.querySelectorAll(".message")).toHaveLength(mounted); - // The 80 earlier messages land above the reader, who keeps their place. - expect(chat.scrollTop).toBe(125 + 80 * 160); - fireEvent.click( - screen.getByRole("button", { - name: "加载更早的消息(还剩 1 条)", - }), - ); - expect(screen.getByText("历史消息 000")).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /加载更早的消息/u }), - ).not.toBeInTheDocument(); - expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus(); - expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(mounted + 1); + expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument(); }); - it("opens a long conversation in one bounded commit without catch-up steps", async () => { + it("opens a long conversation in one bounded commit without a history gate", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: "00000000-0000-4000-8000-000000000422", @@ -4720,9 +4675,8 @@ describe("App", () => { expect(counts).toHaveLength(1); expect(counts[0]).toBeLessThan(80); - expect( - screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), - ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument(); + expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument(); }); it("keeps the reader position while a response continues below", async () => { @@ -5201,17 +5155,9 @@ describe("App", () => { const { container } = render(); expect(await screen.findByText("第一段历史 160")).toBeInTheDocument(); - fireEvent.click( - screen.getByRole("button", { - name: "加载更早的消息(还剩 81 条)", - }), - ); const firstPane = container.querySelector( `[data-conversation-id="${firstConversationId}"]`, ); - expect( - screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }), - ).toBeInTheDocument(); // Windowed (PERF-14): the mounted rows stay bounded and keep their nodes. const mountedRows = firstPane?.querySelectorAll(".message").length ?? 0; expect(mountedRows).toBeGreaterThan(0); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d2fa89a7..dc520edd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -131,10 +131,7 @@ import type { ImageViewerItem, Message, } from "./ChatTimeline"; -import { - messageRenderBatchSize, - type ChatScrollSnapshot, -} from "./ChatHistoryPane"; +import type { ChatScrollSnapshot } from "./ChatHistoryPane"; import { usePaneOrder } from "./pane-order"; import { getConversationDisplayTitle, isUnusedConversation, type Conversation } from "./chat-conversation"; import { @@ -1806,9 +1803,6 @@ function App(): React.JSX.Element { ), [], ); - const [visibleMessageCounts, setVisibleMessageCounts] = useState< - Record - >({}); const sidebarRef = useRef(null); const sidebarToggleRef = useRef(null); const livePrimarySidebarWidthRef = useRef(primarySidebarWidth); @@ -1863,15 +1857,6 @@ function App(): React.JSX.Element { }, [], ); - const handleVisibleMessageCountChange = useCallback( - (conversationId: string, count: number): void => { - setVisibleMessageCounts((current) => ({ - ...current, - [conversationId]: count, - })); - }, - [], - ); const closeNarrowSidebar = useCallback((): void => { setSidebarOpen(false); requestAnimationFrame(() => sidebarToggleRef.current?.focus()); @@ -4608,11 +4593,6 @@ function App(): React.JSX.Element { delete next[conversationId]; return next; }); - setVisibleMessageCounts((current) => { - const next = { ...current }; - delete next[conversationId]; - return next; - }); setConversationActivity(conversationId, false); const remaining = conversationStore.getState().filter( (conversation) => conversation.id !== conversationId, @@ -5978,7 +5958,6 @@ function App(): React.JSX.Element { const index = conversation.messages.findIndex(item => item.id === targetId); if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') }); else { - setVisibleMessageCounts(current => ({ ...current, [source.conversationId]: Math.max(current[source.conversationId] ?? messageRenderBatchSize, conversation.messages.length - index) })); setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() }); } } @@ -7112,19 +7091,12 @@ function App(): React.JSX.Element { onSelectTask={setSelectedAssistantTaskId} onSetInput={setQuickActionInput} onSetScheduleEnabled={setAssistantScheduleEnabled} - onVisibleMessageCountChange={ - handleVisibleMessageCountChange - } quickActions={quickActions} schedules={assistantSchedules} scrollSnapshot={chatScrollSnapshots[conversationId]} selectedAssistantTaskId={selectedAssistantTaskId} store={conversationStore} tasks={tasksByConversation.get(conversationId) ?? emptyConversationTasks} - visibleMessageCount={ - visibleMessageCounts[conversationId] ?? - messageRenderBatchSize - } /> ))} {activeProject?.kind === "channel" && diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index 60b284c5..5f2ce76a 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -16,6 +16,7 @@ import type { KnowledgeSearchReference, } from "../../shared/contracts"; import type { ImageOperation } from "../../shared/image-generation-contracts"; +import { ChatMinimap } from "./ChatMinimap"; import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline"; import { isUnusedConversation, type Conversation } from "./chat-conversation"; import { MessageExpansionProvider } from "./message-expansion"; @@ -1141,6 +1142,13 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ /> + {active && ( + + )} {active && showScrollToBottom && (