style/优化聊天历史页 #590
Reference in New Issue
Block a user
Delete Branch "feat/smoother-dialog-history"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
close #510
优化聊天历史:
1. 更早历史的加载态可能被「上一个项目」的读取提前关掉(原标 medium)
loadEarlierHistory()先开加载窗口(useDirectProjectChatController.ts:694-695 的historyLoadingRef.current = true; setHistoryLoading(true)),成功路径(:708
if (projectPathRef.current !== nextProjectPath) return;)与失败路径(catch 第一行)都用项目守卫挡了后到的旧项目结果,但收口的
finally(修改前 :713-716)没有守卫,无条件historyLoadingRef.current = false; setHistoryLoading(false)。finally把新项目的加载态关掉:① 加载行提前消失,用户看不出还在读;②
historyLoadingRef这道并发闸门被重新打开,同一个项目上能再并起第二次读取(同一段历史重复读、
historyOldestItemIdRef被两次推进、前插锚点也会抖)。原 review 判断成立。finally补上同一条项目守卫,只有仍属于当前项目的那次读取才关自己的加载态(现在 :724-731)。controller/useDirectProjectChatController.test.tsx,用window.__TAURI__.core.invoke把历史切片读取全部挂起:先让
/a的翻页读取挂住 → 切到/b并让/b也发起翻页读取 → 再让/a那次落地,断言/b的historyLoading仍为true,直到
/b自己的读取落地才变false。修前红(expected false to be true)、修后绿。77401adfe修复更早历史加载被旧项目读取提前关闭加载态(新增测试里 invoke 替身装不进泛型签名的类型修正另起一条小提交
986136171;tsc通过后这两条提交的代码才是全绿的)2. 平滑滚动的中间帧翻转「跟随最新」/ 刷新锚点(原标 medium)——已过时,当前实现已经修掉
useConversationScroll.ts有programmaticScrollRef(:89-97):onScroll在动画在飞时直接return,只有滚到贴底阈值才交还控制权(:257-268);compensateLayout在飞期间整段跳过、不写scrollTop(:138-148),也就不会再「写一次scrollTop把动画取消在半路」;followLatestRef并刷新preserveAnchorRef→ 补偿写scrollTop取消动画 → 用户卡在半路」在当前代码里已经不成立,正是上一个提交
238c3c60d修掉的那个缺陷。本次不再改代码,仅留档说明这条已过期。238c3c60d修复:点回到底部后平滑滚动不再被自身补偿打断(本次无新提交)3. 切换项目时滚动所有权状态不重置(原标 medium)
useConversationScroll的滚动所有权状态followLatestRef/atBottom/hasNewReply/preserveAnchorRef(:86-103)只在挂载时初始化一次,之后没有任何重置入口;
DirectProjectChatView只渲染一处、没有key,内部
<DirectProjectConversation>也没有key,所以切项目时这个组件不会重挂载,hook 里的旧值一直留着。对照之下控制器自己按项目重置了历史状态(useDirectProjectChatController.ts:212-223)。原 review 判断成立。
followLatestRef仍是false、atBottom仍是false,于是① B 的首屏不会自动贴底,用户得自己往下滚;②
turns一换,终态指纹变化而在不跟随分支里触发setHasNewReply(true),胶囊在新项目上直接显示「有新回复 · 回到底部」,而用户根本没在这个项目里离开过底部。
useConversationScroll增加必填的conversationKey,身份变化时复位programmaticScrollRef/followLatestRef/preserveAnchorRef/foldRef,把terminalSignatureRef同步成新会话内容,清掉
atBottom/hasNewReply并贴底;复位 effect 排在「内容变化」effect 之前,同一次提交里的内容变化因此不会被算成「有新回复」。身份键由
DirectProjectConversation透传、DirectProjectChatView传projectPath ?? '';没有改成key(B),避免整份消息列表连同加载行的 150ms 延迟计时被一起重建。
useConversationScroll.test.tsx新增三例(新会话首屏贴底且胶囊为空、切会话后在 B 里往上滚只显示「回到底部」、复位之后新会话里不跟随时内容增加仍点亮「有新回复」);
DirectProjectConversation.test.tsx新增一例断言换身份时.project-chat-message-list仍是同一个 DOM 节点,守住「不靠 key 重建」这个取舍。ccc9b776d修复:切项目时复位 DirectProject 对话滚动所有权(文档先行的 ADR 第 6 条与决策日志在95a32a316)4.
historyError为空串时挂起闸门与错误行一起消失(原标 low)const message = error instanceof Error ? error.message : String(error)(修改前 :710)直接写进historyErrorRef/setHistoryError。下游只把它当真假用:canStartHistoryLoad的!gate.historyError(conversationScrollPolicy.ts:86-88)、
loadEarlierHistory开头的historyErrorRef.current守卫、以及
DirectProjectConversation.tsx:78的historyError ? <DirectProjectHistoryErrorRow/>;错误文案本身从来不渲染,内联行只显示固定文案「加载更早对话失败」。
new Error()(或空串)时 message 是'',是 falsy:挂起闸门和错误行一起消失,用户滚到顶会反复重试同一个失败读取,而且没有任何反馈。
readDirectHistoryPages会把抛出值原样带回来(directHistoryPaging.ts:58-64),所以空 message 确实能走到这里。原 review 判断成立。
DIRECT_HISTORY_LOAD_ERROR_FALLBACK = '读取更早的对话历史失败'(useDirectProjectChatController.ts:47-54),空 message 不会再让
historyError变 falsy。保留「非空即挂起」的既有契约(ADR 也是这么写的),比把三处判据都改成
!== null更小、更稳。reject(new Error('')),断言historyError仍为真值、historyLoading已收口。修前红(
expected '' to be truthy)、修后绿。9cff7bf04修复更早历史读取失败消息为空时挂起态与错误行一起消失本轮 3 条我都在当前代码上核过:1、2 上一轮已修;3 按你选的方案 B(换一套块身份)本轮已修,只留真机观感验收。
(本文件是临时工作文件:不提交、不覆盖你自己的编辑。)
1. 更早历史读取的「项目路径守卫」分不清同一项目的两次加载(原标 bug · medium)
apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts:728-731(finally那段),同一条守卫在成功路径与
catch里各有一份。loadEarlierHistory的三处守卫都只比projectPathRef.current === nextProjectPath;而historyLoadingRef身兼两职——既是渲染顶部加载行的状态,又是「同一时间只允许一次读取」的并发闸门。
①
finally把historyLoadingRef/historyLoading关掉——新一代 A 的加载行提前消失,闸门被重新打开,同一个项目上还能再并起第三次读取;② 同一段
try里它照样合并旧条目、覆盖historyOldestItemIdRef游标。只比路径区分不出「同一项目的上一世代」。historyLoadTokenRef——切项目时 +1、每次读取启动时 +1;读取落地时拿启动时捕获的
loadToken与当前值比对(isStaleLoad),过期就整段丢弃:不合并条目、不写游标、不关加载态,失败也不算到当代头上。controller/useDirectProjectChatController.test.tsx新增一例,驱动 A→B→A 且同路径新读取在飞,断言旧读取落地后
historyLoading仍为真、由新一代自己收口;修正前确实红。e3c3091ec修复:更早历史读取按世代号失效旧请求loadFirstScreenHistory仍是按路径守卫;它在切项目时由 effect 的disposed兜住,且首屏读取可能被同一项目连调两次,要一起改得先定「两次调用谁作废」的口径,本次没动。
2. 锚点读取与还原每个滚动帧都全表查询(原标 performance · medium)
components/DirectProjectConversation/conversationScrollAnchor.ts:56-58(collectTurnBlocks,现在在 :47-55)。readTopVisibleTurnAnchor(onScroll里每次滚动都调)与restoreTurnAnchor→findTurnBlock各自调一次collectTurnBlocks;后者是
list.querySelectorAll('[data-turn-key]'),之后还要逐块getBoundingClientRect()找第一条露出视口的块。compensateLayout还原一次锚点,就是每个滚动帧两三遍整份列表的查询;列表可以无限往上加载,块数越大滚动越抖(review 判断准确)。
collectTurnBlocks的结果按列表缓存进WeakMap,新增invalidateTurnBlocks;useConversationScroll里已有的子元素MutationObserver(原本只用来重订阅ResizeObserver)在subscribe里顺手失效缓存。失效信号选子元素增删是有依据的:只有子元素变化才可能改变「哪些块带回合身份」,而 running→finished 的 details 包裹同样是列表直接子元素增删。
没采纳 review 的另一个备选(从上次锚点续扫):那改的是遍历策略,还要引入跨帧状态,和缓存不是同一档改动。
getBoundingClientRect()」这次遍历;要再压得改成从上次锚点续扫,属于另一次取舍。conversationScrollAnchor.test.ts新增两例——同一列表连续收集只查一次 DOM(spyquerySelectorAll)、子元素变化并invalidateTurnBlocks后重新收集。1caf8ae8b优化:会话滚动锚点复用块集合缓存a589dad00)。[data-turn-key][data-block-key],缓存与失效机制未变。3.
findTurnBlock用位置序号定位,回合 running→finished 后序号错位(原标 bug · low)——本轮已按「换一套块身份」修好conversationScrollAnchor.ts:43(当时的findTurnBlock)与readTopVisibleTurnAnchor/restoreTurnAnchor;结构来源是
components/DirectProjectConversation/DirectProjectTurn.tsx:53-70的renderTurnProcess。{key, index, offset},findTurnBlock取同一data-turn-key下的第index个块(blocks[index] ?? null),只在「这个序号取不到任何块」时回落到
null;文件头写的不变式是「已有回合的 key、块序号和块内结构都不变」,这只在前插时成立。而
renderTurnProcess对运行中的回合把 process 块平铺,对已结束的回合把它们包进一个新的<details data-turn-key={turn.key}>。[0]=用户气泡, [1]=工具组 1, [2]=工具组 2;收口后变成
[0]=用户气泡, [1]=<details>, [2]=工具组 1, [3]=工具组 2, [4]=终态正文, [5]=用量。原来指向
index=2(第二个工具组)的锚点现在解析到第一个工具组,restoreTurnAnchor按错误基准写scrollTop,表现为一帧跳动。同一场景里还有一个更隐蔽的点:被折进
<details>的 process 块是收起的(没有open),真实浏览器里它的getBoundingClientRect()全是 0,所以即使序号对上也算不出可用偏移;「锚点块已被折叠隐藏」本身需要一条规则。
① 锚点改存
blockKey,即DirectChatBlock.key(条目块是${回合 key}:${条目 itemId},本地说明块是messageId),只要求同一回合内唯一;② 展示层给每个可锚定块加
data-block-key:DirectProjectTurn的正文块用block.key,「执行过程」包装块与终态文案各用一个回合内唯一的字面量块身份,ToolCallGroup/AgentReasoning新增可选blockKey透传;③
findTurnBlock按「回合 key + 块身份」定位,收集选择器收敛为[data-turn-key][data-block-key](缺块身份的块干脆不进候选);④ 补上 review 点出的另一半:锚点块没有布局盒(被折进收起的
<details>)时读锚点跳过它、restoreTurnAnchor判失败返回 false,调用方
useConversationScroll.compensateLayout拿当前位置重新起锚——不回跳,也不按别的块硬对齐(未采纳「改找同回合其它可见块」:那会按另一条消息的基准写scrollTop)。conversationScrollAnchor.test.ts改为按[回合 key, 块身份]构造,新增「块序号变了不影响定位(收口把过程折进 details)」「回合收口后按块身份仍还原到同一个块」「锚点块被折进收起的 details:读锚点跳过、还原返回 false 且不动 scrollTop」
「只收集同时带回合 key 与块身份的块」;
DirectProjectConversation.test.tsx新增「每个可锚定块都有 data-block-key 且同回合内唯一」「running→finished 前后块序号会变、块身份不变」。
9f7c2e318(ADR + 决策记录,文档先行)、42de1b4c3(展示层加块身份 + 组件用例)、429557ea6(锚点定位与判失败规则 + 纯函数用例)。directProjectTurn/directProjectProcessStatus全绿;appSurface195 passed / 9 skipped(既有跳过);ai-game-creator-shell:typecheck、eslint --max-warnings 0、check:encoding(5124 文件)、git diff --check通过。(锚点块被折进收起的 details 时允许不自动对齐,但不应该跳)。
DirectProjectTurn 给正文块写 data-block-key={block.key},「执行过程」包装块与终态文案各给一个回合内唯一的字面量块身份。 ToolCallGroup 与 AgentReasoning 新增可选 blockKey 并透传到 data-block-key,turnKey 的注释改成「按回合 key + 块身份定位」。 组件用例断言每个可锚定块都有块身份且同回合内唯一,并用 running→finished 前后对照钉住「块序号会整体后移、块身份不变」。 Co-authored-by: Junie <junie@jetbrains.com>本文件是临时工作文件(不提交;按你的要求放在 .git/info/exclude 里)。
本轮 review 的 5 条我都在当前代码上核过:1、2、3 修好并各自单独提交;4 你决定不修(一行差异可忽略);5 按你的要求只加 TODO 注释、不改实现。
(你本地已有的 .env 改动我没碰。)
1. 历史加载失败行的重试按钮嵌在
role="alert"里(原标 other · low)apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectHistoryRow.tsx:40-44的DirectProjectHistoryErrorRow把整行做成<p ... role="alert">,重试<button>是它的子节点。role="alert"隐含aria-live="assertive"+aria-atomic="true",整行会被当成一条断言性播报;可交互控件嵌在 live region 内部,读屏不一定会把它当可聚焦按钮播报,用户交互也落在播报区里。review 的判断成立。
<div>(布局类原样保留),role="alert"只包住「加载更早对话失败」文案,重试按钮成为 live region 之外的兄弟;仍是同一行、同样的样式,重试仍是按钮移除后唯一的手动出路。
DirectProjectConversation.test.tsx新增一例,断言role="alert"节点不包含重试按钮、文案仍在 alert 内、点击仍触发重试。修正前该断言红。8f91227d1修复:历史加载错误行的重试按钮移出断言式 live region2. 平滑滚动期间内容变高,
programmaticScrollRef卡住不交还(原标 bug · medium)components/DirectProjectConversation/useConversationScroll.ts的compensateLayout在programmaticScrollRef.current为真时直接return;scrollToBottom用点击那一刻的list.scrollHeight作为平滑滚动目标;标记只在「贴底」的那次滚动事件、或滚轮 / 触摸 / 键盘接手时交还。scrollHeight在动画途中继续增长,动画停在旧目标上,nearBottom永远不成立 → 标记永远为真 →布局补偿整段跳过(列表不再跟随新内容),而
scrollToBottom已把atBottom置真、胶囊按「已贴底」隐掉:用户停在底部之上,既没有指示也没有自动跟随,只能靠手动滚一下才解开。review 的判断成立。
scrollTop(写一次就会取消动画),但内容变高时把动画目标重新对准新的底部(
compensateLayout里改调scrollListToBottom(list, 'smooth'));动画落到真正的底后照常在贴底事件里交还。没有采纳 review 的另一半备选
scrollend:它在部分 WebView / Safari 版本上不可用,而且把目标重对准底部之后动画自己会发贴底事件,不需要另加监听。useConversationScroll.test.tsx新增一例——点击回到底部后scrollHeight1200→1500 并触发一次布局变化,断言动画目标由
[1200]变为[1200, 1500],落到新底部后仍能交还控制权(再往上滚胶囊照常出现)。修正前在目标数组处红。2b0147689修复:动画期间内容变高时把回到底部动画重新对准新底部3. 世代号只在复位 effect 里推进,切换项目时守卫不即时生效(原标 bug · medium)
controller/useDirectProjectChatController.ts里projectPathRef.current = projectPath是渲染期赋值,而
historyLoadTokenRef.current += 1在[projectPath]的复位 effect 里;loadEarlierHistory的守卫是「启动时捕获的世代号 === 当前世代号」。旧读取在这个窗口里落地时世代号还是旧的,守卫放行,
mergeHistoryItems/setHistoryHasMore/ 覆盖historyOldestItemIdRef照常执行。review 说终态不会被弄坏(复位 effect 随后清状态、订阅复位清条目)——这点我同意,但那条白跑的跨项目读取与瞬时脏状态是真的。
projectPathRef同一处、只在路径真的变化时推进;复位 effect 不再推进。DirectProjectConversation的「填充视口」effect)的判据[historyError, historyHasMore, historyLoading, loadEarlier, turns]都不变——订阅状态复位自己也是 effect、turns来自useMemo、loadEarlier是useCallback([])的稳定回调——所以这个窗口里不可能新起一次「拿旧游标读新项目」的读取,去掉那次推进不会放过它。act会把 effect 与断言放进同一作用域冲掉,jsdom 下构造不出「旧读取先于复位 effect 落地」的确定性用例,因此本条没有新增红灯用例,只有既有的控制器世代用例(切项目、A→B→A)保持全绿 + 上面的依赖分析。
要硬证据只能真机:慢读取期间切项目,看是否还多打一次跨项目读取。
0ae94caf1修复:更早历史的世代号在渲染期推进,切换项目的守卫不等 effect4. 「加载期间的锚点冻结被
onScroll覆盖」(原标 bug · medium)——你决定不修compensateLayout不刷新preserveAnchorRef(只在!historyLoadingRef.current时刷新),但onScroll:306无条件刷新。restoreTurnAnchor写scrollTop→ 触发滚动事件 →onScroll把加载行高度烘进offset→ 加载行卸载后最后一次还原偏移了一行的高度」。这一步算不平:加载行挂载时这次补偿先用冻结锚点把块还原到原偏移,由这次写入触发的滚动事件再读回来仍是同一个偏移
(
offset = 块的屏幕 top − 列表顶边,还原刚刚把它对齐),不会是「加载行高度 + 原偏移」;而插入加载行本身不改变scrollTop,不会自己产生滚动事件。我没能找到一条能稳定产生「少补一行高度」的路径,所以把它判为存疑而不是照改。
else if (!historyLoadingRef.current)有明确代价:加载期间用户真的滚动时锚点不再更新,下一次补偿(加载行挂载 / 卸载、历史合并)会把用户拉回滚动之前的位置——慢加载叠加长会话时表现为「我明明滚了,它自己弹回去」。
「冻结」该冻结的是补偿自己造成的位移,不该顺手冻掉用户意图。
scrollTop触发的滚动事件」显式标记出来(例如补偿前后同步一次锚点、或给这类写入挂一个与
programmaticScrollRef同类的标记),而不是用historyLoading这个更粗的条件关掉刷新。真机验收里如果真能看到「加载行消失时跳一行」,再做这一步也不迟。
5.
readTopVisibleTurnAnchor每个滚动帧全表量测(原标 performance · low)——按你的要求只加 TODO 注释,不改实现conversationScrollAnchor.ts的readTopVisibleTurnAnchor按文档顺序遍历缓存的块集合、跳过不可见块(含被折进收起<details>的块),遇到第一条
bottom > 列表顶边的块才停;调用点是onScroll(离底时)与每次compensateLayout。getBoundingClientRect(),但同一帧里第一次调用之后布局已经干净,后续调用不会各自再触发一次 reflow,所以「每帧几百次强制布局」这个说法偏重;量级上是几百次廉价矩形读取,属于 low,不是振荡源。
② 「只在补偿前才读」——但
onScroll里读锚点就是为了在补偿发生之前拿到「用户此刻在哪儿」,去掉它,前插之后就没有可还原的基准了。两者都省不掉那次遍历。陷阱是 review 没提到的:被折进收起
<details>的块在真实浏览器里矩形全 0,而它们同样带data-turn-key/data-block-key、也在候选集合里,这些 0 会打断单调性;二分前必须先定「隐藏块怎么参与」(现在的线性扫描天然跳过它们)。要给锚点集合加「只收可见块」的规则,
代价是每次子元素变化都要先量一遍,等于把省下的遍历又搬回来。
conversationScrollAnchor.ts的readTopVisibleTurnAnchor循环上方留一条// TODO(perf):注释,把上面的量测口径、两个建议修法为何省不掉遍历、二分被 0 矩形打断、以及首选的「缓存上次命中块 + 附近续扫」方案写清楚,便于真机出问题时就地接手。TODO注释随本次变更一起提交(仅注释,无行为变化)。