Found but Unreachable: One Message, Two Identities
Search anchors on physical lines, the timeline renders logical messages--and the fold operation dropped the mapping between the two.
VibeTrail’s loop is “browse → search → resume”, and I just fixed a bug in the search leg: clicking a search hit should open the session, scroll to the matched message, and highlight it. Instead, some hits opened the session stuck at the top–no error, no hint, as if nothing happened. Stranger still, within the same session some hits jumped fine and others didn’t.
The jump pipeline
Some background first. VibeTrail’s search is index-free (covered previously): the ripgrep engine scans session files on disk directly, so a hit is a physical line. The jump works like this:
- ripgrep reports the matched line; the provider parses that line’s JSON and extracts its
uuid; - the UI opens the session carrying that uuid and runs
findIndexover the timeline’s message list to locate the anchor; - render forward to the anchor,
scrollIntoView, highlight.
When step 2 came up empty, the old code did: nothing. Session sits at the top. That’s the setup.
Root cause: streaming splits one message across several lines
Claude Code’s session files are JSONL, and assistant replies land on disk in streamed chunks: one API message (same message.id) spans several lines, each with its own uuid. Before rendering, VibeTrail runs a regroup stage that folds those chunks back into one logical message.
Which chunk’s uuid survives the fold? The last one’s–and that isn’t arbitrary: the next message’s parentUuid points at the last chunk, so it is the node identity in the message tree. The earlier chunks’ uuids get dropped in the fold.
Line that up against the pipeline and it’s obvious: search hits a physical line and may hand back any chunk’s uuid; the timeline only knows the last chunk’s. If the match happens to land in the final chunk, the jump works; land in any earlier chunk and findIndex misses, silently stuck at the top. It also explains the “sometimes works in the same session” mystery–it depends on which segment of the streamed output your query happens to appear in.
One-sentence diagnosis: one message holds two identities in two subsystems. Search speaks physical-line identity, the timeline speaks logical-message identity, and the fold operation dropped the mapping between them.
The fix: record the old identity when folding
Message gains an aliasUuids field: as regroup folds each chunk, the uuid being replaced is kept–
if let Some(uuid) = entry.uuid {
let previous = std::mem::replace(&mut messages[index].uuid, uuid);
messages[index].alias_uuids.push(previous);
}
The timeline now resolves anchors by uuid or alias, then scrolls to and highlights the folded message. The field carries skip_serializing_if = "Vec::is_empty": providers without the chunking problem (Codex, Cursor, and friends) change nothing, and the CLI’s --json output doesn’t bloat.
Sweeping out the remaining silent failure
One class of hit genuinely has no anchor even after the fix: the matched line lives in a subagent transcript–search scans those files, but the timeline renders the main thread. No jump is possible there, but “can’t jump” and “pretend to jump” are different things: the app now toasts an explanation (English and Chinese) telling you the hit is inside a subtask’s output.
I set this rule in the resume post: silent failure is worse than failure. That time it was about permission-layer fallbacks; this time it’s a UI jump. The lesson transfers–every “can’t do it” branch must either do it or say so. There is no third state.
Pinned by tests
Two places: a fixture test asserts the alias survives regrouping–regroup is this bug’s crime scene, so that’s where the regression pin belongs; and the --json schema snapshot covers the new field–the CLI’s JSON output is a public contract, and field changes should be explicit in review.
Takeaway
Folding, merging, deduplicating–these are inherently identity-destroying operations. When one piece of data carries separate identities in separate subsystems, ask before destroying any of them: who still holds the old identity and will come back to look it up? This time it was search querying the timeline with a physical-line uuid; next time it could be bookmarks, deep links, or anything that persisted an old id. Merge away–but keep the mapping.
Comments