Skip to content

fix(web-ui): Team Chat 气泡系列修复(重复气泡 / 高度自适应 / 流式动态边框) - #335

Merged
jsyqrt merged 3 commits into
mainfrom
fix/team-chat-stale-bubble
Oct 3, 2026
Merged

jsyqrt merged 3 commits into
mainfrom
fix/team-chat-stale-bubble

Conversation

@jsyqrt

@jsyqrt jsyqrt commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

现象

老板报告:Team Chat 中一条更早的 Agent 回复气泡,重新出现在最新消息之后(重复气泡,且带乐观时间戳 21:09:26,而 DB 行为 21:10:23)。

决定性证据

DB 中该会话当天只有 4 条、顺序正确、无重复 → 纯前端 UI 假象。
重复那条的 rawCreatedAt = 乐观发送时间 → 坐实是未收尾的 stale streaming 气泡。

根因(三环相扣)

  1. mergeDbWithCache 把任何缓存里的 isStreaming Agent 气泡无条件追加到最后(当「直播尾巴」),而它的时间戳是乐观发送时间,必然排错位。
  2. 该 stale 气泡用乐观本地 id,与 DB 持久化 id 不同 → byId 去重失效 → 重复。
  3. isCacheFresher 按条数/文本长度判断新旧 → 多出的 stale 行让缓存永远显得更新 → DB 永远无法覆盖 → 自我延续。

来源:同一条消息写入显示 buffer 与 session cache 两处,流结束时 finalize 只更新被路由到的那一个,另一个残留 isStreaming: true。

修复

收敛为一条不变量:只有本会话真的有流在跑时,缓存里的 isStreaming 气泡才算权威;一旦空闲,DB 是唯一权威。

  • 新增 isStreamLiveForSession()(streaming 阶段下的空集合是 pre-registration 窗口,不能读作「没有流」)
  • mergeDbWithCache(..., streamLive):非 live 丢弃 stale streaming 行
  • isCacheFresher(..., streamLive):非 live 时排除 streaming 行参与新鲜度比较(解除自我延续)
  • restoreFromCache():切会话恢复时同样剔除 stale 幽灵
  • applyLoadResult():计算一次 streamLive 并贯穿

验证

  • ConversationBufferManager 16/16(新增 3 条回归;2 条断言旧行为的用例改写为新不变量下的真实保护行为)
  • web-ui 全量 642/642(37 文件);tsc --noEmit 0 错误

jsyqrt added 2 commits October 2, 2026 21:38
现象(2026-10-02 老板报告):Team Chat 里一条更早的 Agent 回复气泡,重新出现在
最新消息之后(重复气泡,且携带乐观发送时间戳,例如 21:09:26 而 DB 行是 21:10:23)。

根因(三个环节互相咬合):
1. ConversationBufferManager.mergeDbWithCache 把**任何**缓存里 isStreaming 的 Agent
   气泡无条件追加到最后(当作「直播尾巴」)。其 rawCreatedAt 是乐观发送时间(早于
   该轮 user 消息的落库时间),因此无论怎么排都会落在错误位置。
2. 该 stale 气泡的 id 是乐观本地 id,与 DB 持久化 id 不同 → byId 去重失效 → 重复。
3. isCacheFresher 用「条数/文本长度/段数」判断缓存是否更新。多出来的这条 stale 行
   让缓存**永远显得比 DB 新** → DB(排序权威)永远无法覆盖它 → 自我延续。

根因来源:同一条消息被写入两处缓冲(显示 buffer 与 session cache),而流结束时
finalize 只更新被路由到的那一个,另一个残留 isStreaming: true 的幽灵。

修复(收敛为一条不变量:**只有本会话真的有流在跑时,缓存里的 isStreaming 气泡才
算权威;一旦空闲,DB 是唯一权威**):
- 新增 ConversationBufferManager.isStreamLiveForSession():streamingSessions 是正向
  归属记录;streaming 阶段下「空集合」是 beginStream 早于 session 注册的窗口,不能
  读作「没有流」,否则会误杀在飞的占位气泡。
- mergeDbWithCache(db, cache, streamLive):非 live 时丢弃缓存里的 isStreaming 行,
  不再当作尾巴追加。
- isCacheFresher(sid, dbMsgs, streamLive):非 live 时把 isStreaming 行排除在新鲜度
  比较之外,使权威 DB 列表能正常覆盖陈旧缓存(解除自我延续)。
- restoreFromCache():切会话恢复显示时同样剔除 stale streaming 行,避免幽灵立刻
  回到视图。
- applyLoadResult():计算一次 streamLive 并贯穿上述两处。

验证:
- ConversationBufferManager 16/16 通过(含 3 个新增回归:stale 尾巴不追加、别的会话
  流式时本会话幽灵被丢弃、restoreFromCache 不复活幽灵;并把 2 个断言旧行为的用例
  改写为新不变量下的真实保护行为)。
- web-ui 全量 642/642 通过(37 文件);tsc --noEmit 0 错误。
两个问题(2026-10-02 老板报告,均为聊天气泡):

① 气泡高度有时包不住内容 → 内容右侧出现滚动条
   根因:气泡内的执行卡片给「工具输出 / 展开详情 / 子代理日志 / coding 预览」
   各自套了 `max-h-*` + `overflow-y-auto`,于是滚动条长在**内容块自己**身上,
   而不是由气泡自然撑开:
   - ExecutionTimeline.tsx:883  运行中实时输出  max-h-32 + overflow-y-auto
   - ExecutionTimeline.tsx:898  展开的详情面板  max-h-80 + overflow-y-auto
   - ExecutionTimeline.tsx:164  子代理日志面板  max-h-64 + overflow-y-auto
   - CodingToolCard.tsx:227     coding 预览     max-h-48 + overflow-y-auto
   - ExecutionTimeline.tsx:1124 仅当「embedded 且运行中」才免 cap —— 工具一跑完
     就重新套上 cap,于是同一个块在结束瞬间才长出滚动条("有时候"的来源)
   修法:在气泡内(embedded)一律不设高度上限,由气泡包住内容。过长的文本仍
   由 TruncatedPre 的「展开/收起」承担(那是显式交互,不是隐式滚动条)。
   模态框与 Work/AgentProfile 的非 embedded 分支保持原有高度上限(那里的父容器
   自己负责滚动),行为不变。

② 流式输出中的气泡缺少「正在输出」的动态边框
   现状:`.streaming-bubble` 只有 min-height,没有任何视觉活动感
   修法:复用仓库里已有的动画基建(`@property --border-angle` + `border-rotate`
   keyframes,原本只服务于执行卡片的 exec-card-active),给流式气泡加一圈**锥形
   扫描光环**:
   - 用「遮罩环」(mask-composite: exclude)而不是 border-image ——
     border-image 不遵守 border-radius,会把圆角切方;
   - `inset: -5px` 画在内容外侧:光环不挤占文字,且开始/结束**不产生任何重排**
     (没有 padding 变化),回复结束的瞬间气泡不会跳一下;
   - 由 Team.tsx 的 `showStreamingBubble` 控制:流一结束即摘掉 class,特效自动
     收束 —— 正是「只有输出完成,才结束这个特效」;
   - 附带 `prefers-reduced-motion` 降级(静态环),尊重系统设置。

验证:
- web-ui `tsc --noEmit` 0 错误
- web-ui 全量 642/642 通过(37 文件)
- 气泡路径上残留的内层滚动框 = 0(保留的 2 处分别在模态框与 non-embedded 分支)
@jsyqrt jsyqrt changed the title fix(web-ui): 修复「旧回复气泡重新出现在最新消息之后」— 失效的 streaming 尾巴 fix(web-ui): Team Chat 气泡系列修复(重复气泡 / 高度自适应 / 流式动态边框) Oct 2, 2026
根因:标签条用原生 `scrollbar-thin`。macOS 的滚动条是浮层(实测 offsetHeight -
clientHeight = 0,不占布局),画在 28px 滚动视口的内部底边;而标签按钮下沿距视口
底只有 2.3px,故 hover 显形时缩略块必然压住标签下半部分,点标签下缘就变成拖拽滚动条。
原生浮层的厚度/出现时机/是否占位由系统决定,CSS 无法可靠挪开。

修复:
- 标签条改 `scrollbar-hide`:原生条彻底不绘制,从机制上消除抢占点击的可能;
- 新增 TabScrollRail:自绘滚动轨道,在标签下方独占一行(独立布局带,结构上不可能
  重叠),仅在溢出时出现,缩略块随滚动同步,支持下拖与点击跳转;
- 外层 `self-stretch + flex-col justify-end`:标签行位置基本不变(实测上移 1px),
  轨道落在标签下方原本的留白内。

实测(真实 Chromium,逐字复刻标记 + 本包构建产物 CSS):
- 改前浮层条压住标签下沿(视觉确认);改后 scrollbar-width: none,轨道顶边位于标签
  底边下方 5.3px,标签底部干净;
- 轨道几何:缩略块宽 = 视口/内容(26.8%),滚到最右端右缘贴齐轨道末端(差 0.01px)。

测试:新增 test/tabScrollRail.test.ts(8 例,锁定可见门槛/比例/最小可抓取宽度/边界
夹取);web-ui 全量 650/650 通过;tsc 全包 0 错误;产物 CSS/JS 已含新类与轨道节点。
@jsyqrt
jsyqrt merged commit 2edc48a into main Oct 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant