Feature/insight chat branching #109
+92
-10
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user