搜到了,却跳不过去:一条消息的两个身份
搜索按物理行定位,时间线按逻辑消息渲染——折叠操作把旧身份丢了,跳转就静默失灵。
VibeTrail 的闭环是「浏览 → 搜索 → 恢复」,搜索这一环刚修掉一个 bug:点击搜索结果,应该打开会话、滚动到命中的那条消息并高亮,但有些命中点开后停在会话顶部——不报错、不提示,就当无事发生。更迷惑的是同一个会话里,有的命中能跳、有的不能。
跳转的链路
先交代背景。VibeTrail 的搜索是无索引的(上一篇写过):ripgrep 引擎直接扫磁盘上的会话文件,命中的是物理行。跳转链路是:
- ripgrep 报告命中行,provider 解析这行 JSON,取出这条记录的
uuid; - UI 带着这个 uuid 打开会话,在时间线的消息列表里
findIndex找锚点; - 渲染到锚点所在位置,
scrollIntoView加高亮。
第 2 步找不到锚点时,旧代码的行为是:什么都不做,会话停在顶部。伏笔埋下了。
根因:流式输出把一条消息拆成了好几行
Claude Code 的会话文件是 JSONL,assistant 的回复按流式分块落盘:同一条 API 消息(相同 message.id)拆成多行,每行有自己的 uuid。VibeTrail 渲染前有个 regroup 阶段,按 message.id 把这些块折叠回一条逻辑消息。
折叠时 uuid 取哪一块的?取最后一块——这不是随手写的:下一条消息的 parentUuid 指向最后一块,它才是消息树里的节点身份。于是前面各块的 uuid 在折叠中被丢弃。
对上链路就清楚了:搜索命中的是物理行,拿到的可能是任何一块的 uuid;时间线里只剩最后一块的。命中恰好落在最后一块,跳转正常;落在前面任何一块,findIndex 落空,静默停在顶部。「同一个会话时好时坏」也解释通了——取决于你搜的词出现在流式输出的哪一段。
一句话归因:一条消息在两个子系统里有两个身份。搜索用物理行身份,时间线用逻辑消息身份,而折叠这个操作把旧身份的映射丢了。
修复:折叠时把旧身份记下来
Message 加一个 aliasUuids 字段:regroup 折叠每一块时,把被替换下来的 uuid 收进去——
if let Some(uuid) = entry.uuid {
let previous = std::mem::replace(&mut messages[index].uuid, uuid);
messages[index].alias_uuids.push(previous);
}
时间线找锚点时按 uuid 或 alias 匹配,滚到并高亮那条折叠后的消息。字段上挂了 skip_serializing_if = "Vec::is_empty":其他 provider(Codex、Cursor 这些没有分块问题的)一行不改,CLI 的 --json 输出也不膨胀。
顺手清掉剩下的静默失败
修复后还剩一类命中是真的没有锚点:命中的行在子 agent 的 transcript 里——搜索扫得到这些文件,但时间线渲染的是主线消息。这种情况没法跳,但「没法跳」和「假装跳了」是两回事:现在会 toast 一句解释(中英文),告诉用户命中在子任务的输出里。
这条规矩在上一篇 resume 文章里立过:静默失败比失败更糟。当时说的是权限层的降级路径,这次是 UI 跳转,教训通用——凡是「做不到」的分支,要么做到,要么说出来,不允许第三种状态。
测试钉住
两处:fixture 测试断言流式块折叠后 alias 存活——regroup 是这个 bug 的案发现场,回归必须钉在这里;--json 的 schema snapshot 覆盖新字段——CLI 的 JSON 输出是对外契约,字段增减都该在 review 里显式可见。
结论
折叠、归并、去重,这类操作天然是身份销毁操作。一份数据在多个子系统里各有一套 identity 时,销毁任何一套之前先问一句:还有谁拿着旧身份回来查?这次是搜索拿着物理行 uuid 来查时间线,下次可能是书签、深链、或者任何持久化了旧 id 的东西。合并可以,映射得留。
留言