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
|
/// `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