瀏覽代碼

fix(chat): 收尾时补记「打字完成」,修视图被拽回回答顶部

用户报告两个滚动问题之一:文字快渲染完时,视图瞬间返回回答顶部。

链路(三处代码都能指认):
F073 之后正文是即时渲染的(流式期间打字机关着),但最后一条文本行的
textRowTypingFinished 一直停在 false(只有 TextContent 的打字机播完才置 true)。于是收尾时:
1. hasUnfinishedRevealRows 恒为真 → shouldDriveTypewriter 在 finishTask 翻真
2. visibleInlineDisplayRecordRows 走「阻塞」分支:最后一条未完成文本行之后的行
   (正文之后就是 notices / 参考资料)被从 DOM 拿掉 → 容器变矮 → 滚动位置被钳制
3. 挂回来时是新组件实例 → TextContent 走 watch-initial → startTypewriter(0) 重打一遍
   → 上报「打字中」→ isLastAiRowTyping() 为真 → stopAutoScrollAtLastAiRowTopIfNeeded()
   触发 scrollToPositionInstant(行顶 - 8)(全仓库只有这一处会把视图滚到回答顶部)

修法:新增 markStreamedRowsAsTypingFinished(),在 contentStreaming 由真变假时,
把已在屏幕上的文本行补记为「打字完成」——它们在流式期间本就已完整显示。
于是 hasUnfinishedRevealRows 为假:打字机不再被启用、后面的行不再被藏、也不上报打字中。
非流式轮(mock / 旧后端)从不置 contentStreaming,行为不变。

验证:npm run build 通过;verify-answer-stream 165/165;validate-harness 通过。
⚠️ 观感需浏览器确认(harness 不渲染 Vue 组件)。

另一个滚动问题(卡片跳到底部)是自动滚动贴底造成的,属于手感调整,待用户拍板后再动。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 天之前
父節點
當前提交
4b3dac3171
共有 2 個文件被更改,包括 67 次插入0 次删除
  1. 29 0
      harness/progress.md
  2. 38 0
      src/components/Chat/BusinessRecord.vue

+ 29 - 0
harness/progress.md

@@ -3391,3 +3391,32 @@ notices 带 `- ` 前缀),所以重复发生在**上屏那条链路**,不
   阉掉去重 → 【23】那句话 ×2 且 compose 触发 失败
 - 回归全绿(适配层 27/27、多实例 11/11、判空 30/30、聊天任务 33/33、政策卡列表 24/24、DMS 载荷 12/12);
   `npm run build` 与 `validate-harness` 通过
+
+### 续 14:修「收尾时视图被拽回回答顶部」(用户报的两个滚动问题之一)
+
+**用户描述**:① 卡片渲染很快,页面一下子滚动到卡片底部;② 文字快渲染完时,又瞬间返回回答顶部。
+
+**② 已定位并修复(代码链路三处,都能指认)**:
+F073 之后正文是**即时渲染**的(流式期间打字机关着),但**最后一条文本行的
+`textRowTypingFinished` 一直停在 `false`**(只有 TextContent 的打字机播完才会置 true,
+流式期间根本不会播)。于是收尾时:
+1. `hasUnfinishedRevealRows` 恒为真 → `shouldDriveTypewriter` 在 `finishTask` 那一刻翻真
+2. `visibleInlineDisplayRecordRows` 走「阻塞」分支:**最后一条未完成文本行之后的行**
+   (正文之后就是 notices / 参考资料)**被从 DOM 里拿掉** → 容器变矮 → 滚动位置被钳制
+3. 这些行随后挂回来时是**新组件实例** → TextContent 走 `watch-initial` → `startTypewriter(0)`
+   把内容重打一遍 → 上报「打字中」→ `isLastAiRowTyping()` 为真 →
+   `stopAutoScrollAtLastAiRowTopIfNeeded()` 触发 `scrollToPositionInstant(行顶 - 8)`
+   —— **这就是「瞬间返回回答顶部」**(全仓库只有这一处会把视图滚到回答顶部)
+
+**修法**:新增 `markStreamedRowsAsTypingFinished()`,在 `record.contentStreaming` 由真变假时,
+把已在屏幕上的文本行补记为「打字完成」(它们在流式期间本来就已完整显示)。
+这样 `hasUnfinishedRevealRows` 为假 → 打字机不再被启用、后面的行不再被藏、也不会上报打字中。
+
+**运行过的验证**:`npm run build` 通过;`verify-answer-stream` 165/165;`validate-harness` 通过。
+⚠️ **观感只能由浏览器确认**(harness 不渲染 Vue 组件)——请刷新后看收尾时是否还会跳。
+
+**① 待用户拍板**:卡片的跳是**自动滚动贴底**造成的 ——
+`handleBusinessRecordRefreshScroll` 在「高度增长 > 80px(`BUSINESS_RECORD_LARGE_HEIGHT_GROWTH`)
+或本来就在贴底距离内」时会 `scrollToBottom`,卡片是一整块到达(300~900px),
+于是一步滚到它的**底部**,用户看不到刚拿到的卡片。
+改这个是**换手感的决定**(不是修 bug),方案见对话。

+ 38 - 0
src/components/Chat/BusinessRecord.vue

@@ -1628,6 +1628,44 @@ watch(
   }
 );
 
+/**
+ * F073:流式轮收尾(`contentStreaming` 由真变假)时,把**已经在屏幕上的文本行**补记为
+ * 「打字完成」—— 它们在流式期间是**即时渲染**的(打字机关着),早就完整显示过了。
+ *
+ * 不补记的后果(2026-09-21 用户报的两个观感问题都源于此):
+ * ① `hasUnfinishedRevealRows` 恒为真 → `shouldDriveTypewriter` 在收尾那一刻翻真
+ * ② `visibleInlineDisplayRecordRows` 于是走「阻塞」分支:**最后一条未完成文本行之后的行**
+ *    (正文之后就是 notices / 参考资料那几行)被从 DOM 里拿掉 → 容器变矮 → 滚动位置被钳制,
+ *    视图突然跳一下;随后挂回来时又是**新组件实例**
+ * ③ 新实例走 TextContent 的 `watch-initial` → `startTypewriter(0)` 把内容重打一遍,
+ *    并上报「打字中」→ 上级据此把视图**拽到最后一条 AI 消息的顶部**("瞬间返回到回答顶部")
+ *
+ * 非流式轮(mock / 旧后端)从不置 `contentStreaming`,这个 watch 不会触发,行为不变。
+ */
+const markStreamedRowsAsTypingFinished = () => {
+  let changed = false;
+  for (const row of displayRecordRows.value) {
+    if (row.type !== RecordRowTypes.Text) continue;
+    if (textRowTypingFinished.value[row.id] !== true) {
+      textRowTypingFinished.value[row.id] = true;
+      changed = true;
+    }
+    textRowTypingActive.value[row.id] = false;
+  }
+  if (changed) {
+    syncTypewriterState();
+  }
+};
+
+watch(
+  () => record.value.contentStreaming,
+  (val, oldVal) => {
+    if (oldVal && !val) {
+      markStreamedRowsAsTypingFinished();
+    }
+  }
+);
+
 // 反馈组件显示由 shouldRenderFeedback 控制:历史会话立即展示,
 // 新消息等打字机/媒体动画结束(triggerMediaAndFeedback 内把
 // localFeedback 切到 Unselected)后才展示。