Feature/insight chat branching #109

Merged
cameron merged 7 commits from feature/insight-chat-branching into master 2026-08-22 02:06:25 +00:00
Showing only changes of commit 9ce7cbf3df - Show all commits
+92 -10
View File
@@ -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();