Branch previews show the first differing message, not the shared child

Rewind & Regenerate resends the identical question, so both branches'
first child matched and every picker option read the same. Snippets now
walk the branches' visible messages (tool scaffolding skipped) in
lockstep and preview the first position where the contents diverge; a
branch that runs out beforehand falls back to its own last message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-08-07 20:07:31 -04:00
parent ee8a11d421
commit 9ce7cbf3df
+92 -10
View File
@@ -315,11 +315,23 @@ impl ChatHistoryStore {
/// `preferred_leaf` when it lives in that child's subtree (so the
/// caller's current view maps onto its own option and can be filtered
/// out client-side), otherwise the most recent (max-id) descendant leaf.
///
/// Snippets preview the first visible message that *differs* between
/// the branches, not blindly the first child: Rewind & Regenerate
/// resends the identical question, so the first child is often shared
/// across siblings and would make every option read the same.
pub fn branch_options_at(&self, fork_node_id: u64, preferred_leaf: u64) -> Vec<BranchLeafInfo> {
self.children_of(fork_node_id)
struct Candidate {
rep: u64,
message_count: usize,
/// User/assistant contents after the divergence, tool
/// scaffolding (tool results, empty dispatch turns) skipped.
visible: Vec<String>,
}
let candidates: Vec<Candidate> = self
.children_of(fork_node_id)
.iter()
.enumerate()
.map(|(i, child)| {
.map(|child| {
let leaves = self.descendant_leaves(child.id);
let rep = if leaves.contains(&preferred_leaf) {
preferred_leaf
@@ -328,21 +340,56 @@ impl ChatHistoryStore {
};
let path = self.path_to_leaf(rep).unwrap_or_default();
let message_count = path.len();
// Snippet: first visible message *after* the divergence point —
// skip tool scaffolding (tool results, empty dispatch turns).
let snippet = path
let visible: Vec<String> = path
.iter()
.skip_while(|n| n.id != child.id)
.find(|n| {
.filter(|n| {
(n.message.role == "user" || n.message.role == "assistant")
&& !n.message.content.trim().is_empty()
})
.map(|n| n.message.content.chars().take(100).collect::<String>())
.map(|n| n.message.content.clone())
.collect();
Candidate {
rep,
message_count,
visible,
}
})
.collect();
// Walk forward past positions where every branch has the same
// content; stop at the first divergence (or when any branch runs
// out — a length difference is itself distinguishing).
let mut snippet_idx = 0usize;
loop {
let contents: Vec<Option<&String>> = candidates
.iter()
.map(|c| c.visible.get(snippet_idx))
.collect();
let all_present_and_equal =
contents.iter().all(|c| c.is_some()) && contents.windows(2).all(|w| w[0] == w[1]);
if !all_present_and_equal {
break;
}
snippet_idx += 1;
}
candidates
.into_iter()
.enumerate()
.map(|(i, c)| {
// A branch exhausted before the divergence index falls back
// to its own last message so it still shows *something*.
let snippet = c
.visible
.get(snippet_idx)
.or_else(|| c.visible.last())
.map(|s| s.chars().take(100).collect::<String>())
.unwrap_or_default();
BranchLeafInfo {
id: rep,
id: c.rep,
snippet,
message_count,
message_count: c.message_count,
position: Some(i + 1),
}
})
@@ -629,6 +676,41 @@ mod tests {
assert_eq!(opts[1].id, c2);
}
#[test]
fn branch_options_snippet_skips_shared_resent_question() {
// Rewind & Regenerate resends the identical question, so both
// branches' first child matches — the preview must advance to the
// first *differing* message (the assistant replies) or every
// option would read the same.
let mut store = ChatHistoryStore::empty();
let root = store.append_node(None, mk_msg("assistant", "prior reply"));
let q1 = store.append_node(Some(root), mk_msg("user", "same question"));
let _a1 = store.append_node(Some(q1), mk_msg("assistant", "first answer"));
let q2 = store.append_node(Some(root), mk_msg("user", "same question"));
let a2 = store.append_node(Some(q2), mk_msg("assistant", "second answer"));
let opts = store.branch_options_at(root, a2);
assert_eq!(opts.len(), 2);
assert_eq!(opts[0].snippet, "first answer");
assert_eq!(opts[1].snippet, "second answer");
}
#[test]
fn branch_options_snippet_falls_back_when_branch_exhausted() {
// One branch is just the shared question (no reply yet); the other
// continues past it. The shorter branch falls back to its own last
// message rather than showing an empty preview.
let mut store = ChatHistoryStore::empty();
let root = store.append_node(None, mk_msg("assistant", "prior reply"));
let _q1 = store.append_node(Some(root), mk_msg("user", "same question"));
let q2 = store.append_node(Some(root), mk_msg("user", "same question"));
let a2 = store.append_node(Some(q2), mk_msg("assistant", "second answer"));
let opts = store.branch_options_at(root, a2);
assert_eq!(opts[0].snippet, "same question");
assert_eq!(opts[1].snippet, "second answer");
}
#[test]
fn branch_options_snippet_skips_tool_scaffolding() {
let mut store = ChatHistoryStore::empty();