
DeepChat 聊天滚动所有权架构解析单一滚动控制器、请求归因与渲染器状态机【免费下载链接】deepchatDeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat导读本文基于 DeepChat 仓库中的docs/architecture/chat-scroll-ownership/spec.md架构规范系统讲解聊天滚动所有权Chat Scroll Ownership这一重构方案如何让消息视口成为唯一滚动持有者、如何用类型化控制器与请求归因取代基于时间窗口的滚动抢占、以及如何通过纯状态机、独占操作仲裁器与单帧提交保证滚动行为原生、可控且可测试。读完本文你将掌握该方案的页面三段式几何模型、请求优先级体系、会话纪元session epoch防串台机制、会话切换的原子提交与 LRU 缓存策略并能从源码层面理解其背后的实现原理与测试验证路径。背景ChatPage.vue 的滚动问题从何而来在重构之前滚动问题根植于ChatPage.vue这一个组件中。该组件同时承担了页面编排、消息转换、会话恢复、历史分页、有界渲染、行测量、自动跟随、搜索、Spotlight 导航、计划布局、输入框行为和直接 DOM 滚动等职责代码量超过 3000 行见 spec.md 的 Problem statement 一节。更关键的是结构问题同一个外层元素既是页面外壳又是消息滚动容器。因此该元素的滚动几何scroll geometry包含了顶栏、搜索 chrome、消息、计划内边距、待处理输入通道pending lanes、状态内容和吸底输入框。这些区域可以独立于消息内容改变高度——而 Chromium 在最大可滚动范围变化时可能主动钳制clampscrollTop即使 JavaScript 并没有显式发起滚动。这是页面自己跳了一下这类体验问题的根源。与此同时多条代码路径都能通过scrollTop或scrollIntoView写入滚动位置所有权只能靠重叠的毫秒级时间窗口来推断而不是显式的事务模型。单元测试只能验证数值写入无法复现真实的 Chromium 布局、吸底定位、原生滚动锚定scroll anchoring、合成器compositor行为与滚动钳制。用户侧的需求则非常朴素聊天滚动必须像原生控件一样在短对话、长对话、恢复会话、流式输出中始终受用户控制——打开搜索、加载历史、渲染 Markdown、输入框伸缩、展示计划或收到流式更新时不能闪烁、不能向后跳、不能抢占滚动条。重构目标与非目标规范明确列出 9 项目标Goals与 5 项非目标Non-goals核心目标包括让消息视口成为唯一拥有聊天滚动的元素所有编程式滚动都经过一个带明确 reason 与 priority 的类型化控制器用户手势获得持久所有权直到用户明确回到底部或发起导航动作完整保留短聊、长聊、流式、历史、搜索、Spotlight、截图、计划、交互、待输入、只读与会话切换行为保留长聊的有界消息窗口与逻辑消息寻址全部既有会话、消息、光标、设置、草稿、计划与待输入数据无需迁移将 ChatPage 拆分为职责单一、可黑盒测试的组件与 composable增加覆盖布局相关滚动行为的真实 Chromium 测试在不牺牲已加载历史、搜索计数与数据正确性的前提下加快首次聊天绘制与会话切换。非目标同样重要它划定了重构边界不改数据库、IPC、presenter、会话/消息 schema不重设计消息、输入框、搜索、计划与交互 UI不替换现有消息存储或 provider/流式管道不引入第三方虚拟列表库作为消息身份或位置的来源不改变autoScrollEnabled的含义与默认值。必需架构页面几何三分法规范给出的页面结构核心是一张三段式布局ChatPageShell (overflow: hidden) ├── ChatHeaderRegion fixed layout row; never scrolls with messages ├── ChatMessageViewport minmax(0, 1fr); the only overflow-y:auto owner │ ├── history status overlay out of normal message flow │ ├── search overlay out of normal message flow │ └── MessageList messages and virtual spacers only └── ChatComposerRegion fixed layout row; outside message scroll geometry ├── pending input lane ├── plan / interaction layer ├── ChatInputBox └── ChatStatusBar外壳使用display: gridgrid-template-rows: auto minmax(0, 1fr) auto并且overflow: hidden。只有ChatMessageViewport允许暴露overflow-y: auto。顶栏、输入框、计划、状态、待输入与搜索的高度变化不得在消息滚动范围内增删内容视口尺寸变化作为几何变化上报给控制器而不是被误判为用户滚动。在 plan.md 中这一决策进一步拆分为四个聚焦组件ChatPage.vue会话/消息编排与特性接线、ChatPageShell.vue三行布局、ChatMessageViewport.vue唯一可滚动聊天元素与 MessageList 宿主、ChatComposerRegion.vue待输入通道、计划/交互层、输入框、记忆芯片与状态栏的组合。实现策略是以最小的 prop/emit 表面积移动现有模板块在壳几何验证通过前业务逻辑仍留在 ChatPage 中。单一滚动所有者useChatScrollController规范规定所有聊天滚动写入都必须经过useChatScrollController。组件与特性 composable 可以请求导航但不得直接对消息视口写scrollTop、调用scrollTo或scrollIntoView。允许的请求原因reason是显式类型type ChatScrollReason | session-restore | auto-follow | submit | history-prepend | measurement-anchor | search-navigation | spotlight-navigation | user-return-to-bottom每个请求都携带活动会话的 epoch会话纪元来自旧会话的过期请求被直接丢弃控制器每个动画帧最多提交一次物理滚动写入。任意时刻只允许一个滚动操作处于活动状态跨帧亦然请求不能与恢复、跟随、历史、测量、搜索或 Spotlight 工作并发同一所有者的重复请求合并进活动操作更高优先级的显式请求原子地替换活动操作低优先级的被动请求被丢弃而不是排队等待延迟写入避免把视口拉回去一次用户手势在一个步骤中取消活动操作与所有未提交工作。所有权与优先级事件驱动而非超时驱动优先级是显式且稳定的活动用户手势或持久阅读模式reading mode显式用户导航搜索、Spotlight、返回底部历史前置history-prepend锚点保持任何用户打断之前的会话恢复启用且仍被持有时的新一代自动跟随被动测量精化。用户所有权由事件驱动而不是超时驱动。滚轮、触摸、滚动条指针和滚动键都会进入阅读模式。阅读模式持续存在直到发生以下任一情况用户滚回底部阈值之内用户按下显式返回底部操作用户发起显式搜索或 Spotlight 导航会话切换新的会话纪元开始。空闲定时器只允许用于降低测量与渲染工作量绝不能用来决定谁拥有滚动条。在 plan.md 的事件流中这一设计体现为wheel/touch/pointer/key - userOwnedtrue - cancel restore/follow/passive requests - native scroll updates viewport metrics only而流式/布局更新则走特性发出类型化请求 - 状态机评估所有权 - 队列按优先级与 epoch 合并 - 一次 rAF 提交 - 一次视口滚动写入 - 匹配的 scroll 事件完成请求。控制器状态模型纯状态机控制器的状态由以下类型描述type ChatScrollMode | restoring | following | reading | navigating | history-preserving type ChatScrollState { sessionEpoch: number mode: ChatScrollMode userOwned: boolean nearBottom: boolean activeGesture: boolean lastCommittedRequestId: number }编程式写入使用请求 ID 与期望目标位置。后续到来的scroll事件与已提交请求按期望位置匹配而不是按时间窗口分类不匹配的意外位置变化被当作原生布局/钳制事件单独观察。在源码src/renderer/src/composables/chat/chatScrollState.ts中状态转换被实现为一个纯 reducerreduceChatScrollState(state, event)事件类型覆盖begin-session、user-gesture-start/end、bottom-proximity-changed、return-to-bottom、submit-started、history-navigation-start、explicit-navigation-start/complete、history-preservation-start/complete、restore-requested/complete、stream-updated、viewport-resized、measurements-committed、request-committed等。关键规则是一旦userOwned变为 true被动的恢复、流式、resize 与测量事件都无法清除它而session-restore只有在用户尚未拥有滚动且没有显式导航!state.userOwned !state.hasExplicitNavigation时才被接受避免恢复把用户从搜索结果里拽回底部。同文件中的getChatScrollRequestPriority给出了与规范对应的数值优先级user-return-to-bottom / history-navigation / search-navigation / spotlight-navigation为 100submit为 90history-prepend为 80session-restore为 70auto-follow为 60measurement-anchor为 50。canAcceptChatScrollRequest则按当前模式与原因逐项裁决例如auto-follow仅在!userOwned (mode restoring || mode following)时接受measurement-anchor仅在!activeGesture userOwned mode reading时接受。独占仲裁器与单帧提交队列仓库中两个辅助模块支撑了每帧至多一次写入与跨帧独占这两条硬约束ChatScrollOperationArbiterchatScrollOperationArbiter.ts维护唯一活动请求同一 reason 的重复请求合并更高优先级的请求原子替换返回被替换的 requestId低优先级请求被拒绝complete(requestId)只有在 ID 匹配时才清空活动操作。ChatScrollRequestQueuechatScrollRequestQueue.ts只保存一个待提交请求按更高 epoch 优先、同 epoch 下更高优先级优先的规则替换take(sessionEpoch)保证过期会话的请求永不落地。控制器useChatScrollController.ts在此基础上组装request()走 rAF 调度requestImmediate()在当前帧尚未写入时立即提交commitQueuedOperation在每个动画帧边界重置本帧已写守卫目标位置由resolveTargetTop统一钳制在[0, scrollHeight - clientHeight]内message类型目标支持start / center / one-third三种对齐提交后通过scheduleVerification在下一帧验证期望位置若实际scrollTop与期望相差小于 1px 即视为程序化写入完成否则归类为用户/原生几何事件。用户手势notifyUserGestureStart会一次性取消所有已调度的帧、清空队列、取消仲裁器活动操作并进入阅读模式。短对话与长对话有界渲染与锚点保持在既有窗口化阈值以下所有消息保持完整渲染行测量从不写滚动位置阈值以上既有逻辑布局模型继续提供消息 top/bottom 位置与有界 DOM 渲染测量批次每帧更新一次逻辑高度映射height map阅读长对话时在高度映射提交的同一帧内保持一个稳定的逻辑消息锚点Overscan 与已挂载行边界与现有长聊契约保持兼容。plan.md 进一步明确保留useMessageWindow作为消息位置的逻辑来源但将其职责收窄为估算与测量高度缓存、稳定消息条目、总逻辑高度、有界范围计算辅助它不得感知滚动模式或 DOM 所有权——控制器收到提交的测量变更后自行决定是跟随底部、保持长列表锚点还是短列表下不写任何位置。会话恢复与首屏加载关键路径/次要路径分离会话恢复的规则包括会话以新 epoch 和一个初始底部请求开始旧 epoch 的迟到恢复、resize 或消息响应被忽略该 epoch 内任何用户手势都会永久取消恢复请求移除旧的八帧循环或宽泛 ResizeObserver 底部写入异步内容需要稳定时控制器只在restoring/following模式下跟随并合并为单帧写入resize 驱动的跟随遵守autoScrollEnabled而恢复定位不受该偏好限制但之后的 message-root 或输入框几何变化不能绕过该偏好布局引起的原生滚动可以更新底部接近度但没有活动用户手势就不能放弃持久用户所有权。首屏加载被拆分为关键路径与次要路径critical path session selection - prepare latest message window - atomically commit session view - render latest bounded rows - position once - input and message viewport interactive secondary path pending inputs plans metadata adjacent pre-measurement optional pre-hydration配套规则包括目标会话窗口就绪前不清空可见消息视图绝不暴露新会话头 旧会话消息的混合状态未缓存切换在隔离视口内使用固定几何的加载遮罩缓存切换立即恢复渲染器内最近会话视图维护按会话 ID 消息修订号键控、仅驻留内存、绝不持久化的有界 LRU规范默认约 5 个会话视图plan.md 建议数量上限或测量内存预算双约束只有 epoch 仍当前的预备结果才提交选择变化同时推进消息加载与历史两个代数含 A-B-A 循环缓存未命中不推进代数失败或被取代的预备结果不得暴露目标视图或启用其输入框除非匹配的缓存视图已被安全提交历史计数照常加载以保障分页与搜索正确性但只挂载有界视口加 overscan已加载记录不意味着挂载重型 DOM 行会话切换期间保留页面外壳、输入框实例、TipTap 状态与全局布局只替换会话作用域内的视图数据。流式输出与提交行为提交新消息显式地把所有权转移给该新回合的底部跟随流式仅在autoScrollEnabled为 true 且之后用户未进入阅读模式时跟随底部token 修订不独立写滚动位置只使一个合并的 auto-follow 请求失效流快照在请求内单调递增每个请求只 settle 一次更新的同会话请求持有视图时忽略终态事件终态后的快照不能复活流式状态异步提交、排队、steer 与压缩compaction续体在修改输入框或乐观视图状态前仍须持有同一页面代数用户滚走之后流式继续但视口不变化合并的搜索/Spotlight 请求保留第一个活动导航事务捕获的所有权状态直到该事务完成。历史分页锚点保持与 scrollend分页需要完整初始历史窗口、可滚动视口、向上的用户意图以及由控制器持有的阈值跨越已在顶部阈值内时发起的向上意图会武装分页即使浏览器不再发出额外scroll事件每个会话 epoch 只允许一个历史请求预先捕获请求前的逻辑锚点与当前用户偏移前置prepend完成后控制器在绘制前提交一次锚点保持修正加载 chrome 仅为遮罩不改变消息原点或滚动范围触摸所有权与冻结窗口测量在原生惯性滚动期间保持有效以scrollend收尾并带空闲回退。搜索、Spotlight 与编辑器隔离搜索与 Spotlight 通过逻辑布局模型解析messageId窗口外的导航先请求正确的逻辑视口等待目标行挂载然后应用高亮——不再做第二次视口级scrollIntoView同查询的高亮刷新从不导航TipTap 选区滚动被约束在编辑器内部不得滚动消息视口或页面外壳Cmd/CtrlF 的结果计数与导航语义保持不变。31-chat-scroll-ownership.smoke.spec.tstest/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts正是这些规则的 Playwright 证据它通过注入scrollTop/scroll/scrollBy/scrollTo的访问器代理来记录滚动写入验证 4 条消息场景中滚轮进入阅读模式后编辑器填入 12 行草稿、首行消息高度被改为 480px 时零程序化写入且scrollTop漂移 ≤ 1pxCmd/CtrlF 搜索导航产生的写入 ≤ 1 次。该测试需要RUN_PROVIDER_INTEGRATIONtrue环境变量才运行真实 provider 场景。功能兼容矩阵与数据保障规范提供了完整的功能兼容矩阵重构必须逐项满足CapabilityRequired resultSession open/switchLatest messages appear at bottom unless the user interrupts restoreFirst application chat loadShell and input become interactive before secondary session stateCached session switchRecent session view and anchor restore without an empty-state flashUncached session switchFixed viewport loading state, then one atomic target-session commitAuto-scroll enabledNew generation follows bottom until user scrolls awayAuto-scroll disabledGeneration never steals the viewportNew message submitNew turn moves to bottom once, then respects later user ownershipShort conversationsNo pagination, measurement correction, flash, or rollbackLong conversationsBounded mounted rows, stable spacers, no blank gapsOlder-history loadingSame message and intra-message offset remain visibleCmd/CtrlFFull loaded-message count; next/previous works across virtual windowsSpotlight/trace jumpTarget mounts, centers once, highlights, and clears pending navigationStreaming MarkdownNo full-list rebuild and no viewport movement in reading modeImages/artifacts/toolsLate size changes preserve long-list anchors without short-list writesPlan/interaction/pending laneVisual behavior unchanged; no participation in message scroll extentComposer resize/focus/IMEDraft and focus remain intact; message viewport is not scrolledRead-only/subagent sessionsExisting display and navigation behavior remains unchangedCapture/exportFull loaded-message capture behavior remains available数据与兼容性保证同样严格不修改src/main、src/preload、数据库 schema、加密、导入导出或持久化配置不改变消息 ID、renderKey、排序、分页游标、会话身份或消息缓存语义autoScrollEnabled保留其既有持久化键与含义滚动控制器状态仅存在于渲染器、是临时性的随会话纪元重置且绝不持久化现有用户数据无需迁移前后版本均可读回滚仅为源码回滚无需数据回滚。验收标准性能、体验与可维护性性能与体验验收共 14 条重点包括用户手势期间零未授权程序化滚动写入阅读模式下选中逻辑锚点跨 Markdown、图片、工具、计划、输入框与流式布局变化漂移 ≤ 1 CSS 像素1–20 条消息的短对话零测量驱动滚动写入一次用户导航动作至多一次视口写入加一次仅高亮 DOM 遍历一个动画帧至多一次已提交视口滚动写入跨帧至多一个活动滚动事务长对话的重型消息行受活动窗口与 overscan 约束连续滚轮/触控板滚动无超过 50ms 的应用长任务、无逐帧全量列表转换流式滚动工作合并到动画帧token 频率不等于滚动写入频率搜索、历史与 Spotlight 导航无可见中间窗口交换Chromium 集成场景无闪烁、回滚、原生钳制或滚动条抢占在约定参考机器上热缓存会话切换首次有意义消息绘制的 p95 ≤ 100ms、未缓存本地切换 ≤ 250ms、初始聊天外壳可交互 ≤ 150msCI 记录指标机器方差大的场景使用更宽的防回归预算会话切换不为每条已加载消息执行完整重型 DOM 挂载且不产生中间空帧或混合会话帧。可维护性验收则要求ChatPage.vue成为编排外壳而非滚动实现所有者视口直接写入被聚焦的所有权测试限制在控制器模块内状态转换纯函数化且可单测特性 composable 请求类型化滚动意图而非共享可变定时器每个异步操作按会话纪元作用域化并具备可取消清理注释解释不变量与所有权而非时序经验法则。迁移、风险与测试策略plan.md 给出的迁移路径是八阶段源码迁移先加浏览器回归基线与滚动写入插桩再加控制器/状态机然后加分段会话准备与带性能标记的原子提交路由直接写入过控制器引入隔离的壳/视口几何加有界近期会话缓存与渐进式重型行水合迁移搜索/Spotlight/历史/测量/编辑器路径最后删除遗留定时器、重复状态与直接写入辅助。每阶段保持应用可构建、既有单测全绿因为无数据契约变化任何阶段都可以在源码层面回滚。风险表给出了典型风险与对策例如输入框抽取破坏 TipTap/IME 状态对策不动挂载生命周期只移 DOM补焦点与草稿测试搜索/Spotlight 丢失窗口外目标对策保留逻辑消息寻址与先挂载后高亮流程长列表回归对策保留useMessageWindow迁移所有权而非数据模型自动跟随延迟感对策单帧 rAF 合并至多延迟一帧且消除重复写入迁移产生双所有者对策聚焦所有权测试与分阶段删除直接写入原子准备延迟首绘对策只以消息为门槛pending/plan/metadata 稍后附加。测试策略分三层纯单测覆盖状态转换与优先级仲裁、会话纪元拒绝、用户所有权持久性、请求合并与取消、跨帧独占与原子替换、底部接近度与返回底部Vue 组件测试覆盖壳区域保持挂载且 prop/emit 不回归、只有ChatMessageViewport拥有聊天 overflow 滚动、短列表零测量写入、长列表布局批次保持单一逻辑锚点真实 Chromium 集成测试当前 jsdom 套件无法验证这类 bug 类由 Playwright/Electron 或 Vitest Browser 套件逐帧记录scrollTop、scrollHeight、clientHeight、锚点 rect 与已提交请求 reason场景覆盖延迟 Markdown 稳定、输入框生长/IME/待输入/计划/交互/状态变化、数据完成前后用户滚轮打断恢复、启用与禁用自动跟随的流式、159/160/161 条消息的阈值边界、长对话快速滚轮与行测量、连续两页历史锚点保持、Cmd/CtrlF 可见与窗口外导航、会话切换期间的 Spotlight 导航、视口上方图片/工具/产物展开、TipTap 焦点/选区/换行/附件、冷热渲染器缓存的首屏加载、A→B→C 快速切换中 B 最后解析、以及修订变化后缓存失效的近期会话返回。断言包括零未授权写入、锚点漂移 ≤ 1px、无中间闪烁、每帧至多一次写入、无过期会话请求提交、无混合会话帧、重型 DOM 行有界以及记录首绘/切换延迟。当前进展与验证记录规范的状态Status与验证记录Validation record给出了截至 2026-07-15 的真实进度独占控制器、隔离消息视口、请求归因、几何观察器、原子消息视图提交与有界渲染器缓存均已实现真实的 Electron 场景已具备但仍需 opt-in provider 运行来获得最终浏览器证据与性能数字。验证记录同时注明review 加固后的滚动、页面、keyed-parent、缓存与架构套件 120/120 通过完整渲染器套件4 worker、每测试 30 秒预算168 个文件 1267 个测试通过format、i18n、lint、Node/Web typecheck 与生产构建通过Playwright 已成功发现 opt-in 的真实 Electron 滚动场景test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts真实的 Electron/provider 场景与 macOS 触控板人工矩阵仍待完成尚未声称最终物理设备验证。任务清单tasks.md将工作划分为 Phase 0基线与浏览器证据到 Phase 8review 加固其中纯所有权模型、会话准备与性能基线、控制器在当前 DOM 上的集成、以及 Phase 8 的请求队列 epoch 排序单调化、即时写帧守卫在下一帧边界过期、原生布局滚动与合并显式导航下保持用户所有权、resize 驱动自动跟随受autoScrollEnabled门控、触摸所有权贯穿惯性滚动并在无后续 scroll 事件时武装顶部翻页、关键 ChatPage 重挂载生命周期内保留测量快照、提交的消息就绪由 store 持有并针对活动消息变更围栏同会话刷新等加固项均已完成勾选。相关规范与延伸阅读该方案与仓库内两份文档强关联chat-history-search-scroll-coordinates/spec.md滚动坐标问题的保留记录与本方案互为参照ARCHITECTURE.md桌面平台契约Desktop platform contract。实现层面的核心源码入口包括useChatScrollController.ts唯一允许写视口scrollTop的控制器chatScrollState.ts纯状态机、原因类型、优先级与接受裁决chatScrollOperationArbiter.ts跨帧独占操作仲裁chatScrollRequestQueue.ts单帧单写请求队列test/renderer/composables/chat/chatScrollState.test.ts 与 chatScrollArchitecture.test.ts状态机与架构级所有权测试test/e2e/specs/31-chat-scroll-ownership.smoke.spec.tsopt-in 真实 Chromium 冒烟场景。理解这一架构的关键可以浓缩为一句话滚动位置不是效果而是资产——它只能有一个所有者且每一次写入都必须带着原因、优先级和会话纪元在帧边界上以事务方式完成。【免费下载链接】deepchatDeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考