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:
+92
-10
@@ -315,11 +315,23 @@ impl ChatHistoryStore {
|
|||||||
/// `preferred_leaf` when it lives in that child's subtree (so the
|
/// `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
|
/// caller's current view maps onto its own option and can be filtered
|
||||||
/// out client-side), otherwise the most recent (max-id) descendant leaf.
|
/// 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> {
|
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()
|
.iter()
|
||||||
.enumerate()
|
.map(|child| {
|
||||||
.map(|(i, child)| {
|
|
||||||
let leaves = self.descendant_leaves(child.id);
|
let leaves = self.descendant_leaves(child.id);
|
||||||
let rep = if leaves.contains(&preferred_leaf) {
|
let rep = if leaves.contains(&preferred_leaf) {
|
||||||
preferred_leaf
|
preferred_leaf
|
||||||
@@ -328,21 +340,56 @@ impl ChatHistoryStore {
|
|||||||
};
|
};
|
||||||
let path = self.path_to_leaf(rep).unwrap_or_default();
|
let path = self.path_to_leaf(rep).unwrap_or_default();
|
||||||
let message_count = path.len();
|
let message_count = path.len();
|
||||||
// Snippet: first visible message *after* the divergence point —
|
let visible: Vec<String> = path
|
||||||
// skip tool scaffolding (tool results, empty dispatch turns).
|
|
||||||
let snippet = path
|
|
||||||
.iter()
|
.iter()
|
||||||
.skip_while(|n| n.id != child.id)
|
.skip_while(|n| n.id != child.id)
|
||||||
.find(|n| {
|
.filter(|n| {
|
||||||
(n.message.role == "user" || n.message.role == "assistant")
|
(n.message.role == "user" || n.message.role == "assistant")
|
||||||
&& !n.message.content.trim().is_empty()
|
&& !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();
|
.unwrap_or_default();
|
||||||
BranchLeafInfo {
|
BranchLeafInfo {
|
||||||
id: rep,
|
id: c.rep,
|
||||||
snippet,
|
snippet,
|
||||||
message_count,
|
message_count: c.message_count,
|
||||||
position: Some(i + 1),
|
position: Some(i + 1),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -629,6 +676,41 @@ mod tests {
|
|||||||
assert_eq!(opts[1].id, c2);
|
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]
|
#[test]
|
||||||
fn branch_options_snippet_skips_tool_scaffolding() {
|
fn branch_options_snippet_skips_tool_scaffolding() {
|
||||||
let mut store = ChatHistoryStore::empty();
|
let mut store = ChatHistoryStore::empty();
|
||||||
|
|||||||
Reference in New Issue
Block a user