diff --git a/src/ai/llm_client.rs b/src/ai/llm_client.rs index bdaa463..99f6b7b 100644 --- a/src/ai/llm_client.rs +++ b/src/ai/llm_client.rs @@ -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 { - 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, + } + let candidates: Vec = 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 = 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::()) + .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> = 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::()) .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();