1use std::collections::{HashMap, HashSet};
9
10use crate::session::SessionEntry;
11
12#[derive(Debug, Clone)]
14pub struct BranchInfo {
15 pub tip_id: String,
17 pub depth: usize,
19 pub entry_count: usize,
21 pub timestamp: String,
23 pub summary: Option<String>,
25 pub fork_point: Option<String>,
28}
29
30#[derive(Debug, Clone)]
41pub struct SessionTree {
42 branches: Vec<BranchInfo>,
43 leaf_tip: Option<String>,
44 entries_by_id: HashMap<String, EntryMeta>,
46}
47
48#[derive(Debug, Clone)]
50struct EntryMeta {
51 parent_id: Option<String>,
52 timestamp: String,
53 summary: Option<String>,
54}
55
56impl SessionTree {
57 pub fn from_entries(entries: &[SessionEntry]) -> Self {
61 let mut meta_by_id: HashMap<String, EntryMeta> = HashMap::new();
62 let mut children: HashMap<String, Vec<String>> = HashMap::new();
63 let mut last_leaf_tip: Option<String> = None;
64
65 for entry in entries {
66 match entry {
67 SessionEntry::Message(m) => {
68 let summary = extract_message_summary(&m.message);
69 let meta = EntryMeta {
70 parent_id: m.parent_id.clone(),
71 timestamp: m.timestamp.clone(),
72 summary,
73 };
74 if let Some(ref pid) = m.parent_id {
75 children.entry(pid.clone()).or_default().push(m.id.clone());
76 }
77 meta_by_id.insert(m.id.clone(), meta);
78 }
79 SessionEntry::Compaction(c) => {
80 let meta = EntryMeta {
81 parent_id: c.parent_id.clone(),
82 timestamp: c.timestamp.clone(),
83 summary: Some(c.summary.clone()),
84 };
85 if let Some(ref pid) = c.parent_id {
86 children.entry(pid.clone()).or_default().push(c.id.clone());
87 }
88 meta_by_id.insert(c.id.clone(), meta);
89 }
90 SessionEntry::Leaf(l) => {
91 last_leaf_tip = Some(l.entry_id.clone());
92 }
93 #[allow(unreachable_patterns)]
94 _ => {}
95 }
96 }
97 for child_ids in children.values_mut() {
98 child_ids.sort();
99 }
100
101 let all_ids: HashSet<&str> = meta_by_id.keys().map(|s| s.as_str()).collect();
104 let has_valid_parent: HashSet<&str> = meta_by_id
105 .iter()
106 .filter(|(_, meta)| {
107 meta.parent_id
108 .as_deref()
109 .is_some_and(|pid| all_ids.contains(pid))
110 })
111 .map(|(id, _)| id.as_str())
112 .collect();
113
114 let mut roots: Vec<&str> = all_ids
115 .iter()
116 .filter(|id| !has_valid_parent.contains(*id))
117 .copied()
118 .collect();
119 roots.sort_unstable();
120
121 let mut branches: Vec<BranchInfo> = Vec::new();
123 let mut visited: HashSet<String> = HashSet::new();
124
125 for &root_id in &roots {
126 discover_branches(
127 root_id,
128 None,
129 0,
130 &meta_by_id,
131 &children,
132 &mut branches,
133 &mut visited,
134 );
135 }
136
137 if roots.is_empty() && !meta_by_id.is_empty() {
139 let mut ids = meta_by_id.keys().collect::<Vec<_>>();
140 ids.sort();
141 for id in ids {
142 let meta = meta_by_id.get(id).expect("sorted id exists");
143 if visited.insert(id.clone()) {
144 branches.push(BranchInfo {
145 tip_id: id.clone(),
146 depth: 0,
147 entry_count: 1,
148 timestamp: meta.timestamp.clone(),
149 summary: meta.summary.clone(),
150 fork_point: None,
151 });
152 }
153 }
154 }
155
156 Self {
157 branches,
158 leaf_tip: last_leaf_tip,
159 entries_by_id: meta_by_id,
160 }
161 }
162
163 pub fn branches(&self) -> &[BranchInfo] {
165 &self.branches
166 }
167
168 pub fn active_tip(&self) -> Option<&str> {
174 if let Some(ref tip) = self.leaf_tip
176 && self.entries_by_id.contains_key(tip.as_str())
177 {
178 return Some(tip.as_str());
179 }
180 self.branches.first().map(|b| b.tip_id.as_str())
182 }
183
184 pub fn active_branch_index(&self) -> Option<usize> {
188 match &self.leaf_tip {
189 Some(tip) if self.entries_by_id.contains_key(tip.as_str()) => self
190 .branches
191 .iter()
192 .position(|b| b.tip_id == *tip)
193 .or_else(|| {
194 self.branches
195 .iter()
196 .position(|b| self.tip_is_on_branch(tip, &b.tip_id))
197 }),
198 _ => {
199 if self.branches.is_empty() {
200 None
201 } else {
202 Some(0)
203 }
204 }
205 }
206 }
207
208 pub fn branch_at(&self, index: usize) -> Option<&BranchInfo> {
210 self.branches.get(index)
211 }
212
213 fn tip_is_on_branch(&self, candidate_id: &str, tip_id: &str) -> bool {
215 let mut visited = HashSet::new();
216 let mut cursor = Some(tip_id);
217 while let Some(id) = cursor {
218 if id == candidate_id {
219 return true;
220 }
221 if !visited.insert(id) {
222 break;
223 }
224 cursor = self
225 .entries_by_id
226 .get(id)
227 .and_then(|m| m.parent_id.as_deref());
228 }
229 false
230 }
231}
232
233fn discover_branches(
235 start_id: &str,
236 fork_point: Option<&str>,
237 base_depth: usize,
238 meta_by_id: &HashMap<String, EntryMeta>,
239 children: &HashMap<String, Vec<String>>,
240 branches: &mut Vec<BranchInfo>,
241 visited: &mut HashSet<String>,
242) {
243 let mut cursor = start_id.to_owned();
244 let mut depth = base_depth;
245 let mut entry_count = 0;
246
247 loop {
248 entry_count += 1;
249
250 let child_count = children.get(&cursor).map(|v| v.len()).unwrap_or(0);
251
252 if child_count == 0 {
253 if visited.insert(cursor.clone()) {
255 let m = meta_by_id.get(&cursor);
256 branches.push(BranchInfo {
257 tip_id: cursor,
258 depth,
259 entry_count,
260 timestamp: m.map(|m| m.timestamp.clone()).unwrap_or_default(),
261 summary: m.and_then(|m| m.summary.clone()),
262 fork_point: fork_point.map(|s| s.to_owned()),
263 });
264 }
265 return;
266 }
267
268 if child_count == 1 {
269 depth += 1;
271 cursor = children.get(&cursor).unwrap()[0].clone();
272 } else {
273 if visited.insert(cursor.clone()) {
275 let m = meta_by_id.get(&cursor);
276 branches.push(BranchInfo {
277 tip_id: cursor.clone(),
278 depth,
279 entry_count,
280 timestamp: m.map(|m| m.timestamp.clone()).unwrap_or_default(),
281 summary: m.and_then(|m| m.summary.clone()),
282 fork_point: fork_point.map(|s| s.to_owned()),
283 });
284 }
285 for child in children.get(&cursor).unwrap() {
286 discover_branches(
287 child,
288 Some(&cursor),
289 depth + 1,
290 meta_by_id,
291 children,
292 branches,
293 visited,
294 );
295 }
296 return;
297 }
298 }
299}
300
301fn extract_message_summary(message: &opi_ai::message::Message) -> Option<String> {
303 use opi_ai::message::{AssistantContent, InputContent, Message};
304
305 match message {
306 Message::User(u) => u.content.iter().find_map(|c| match c {
307 InputContent::Text { text } => {
308 let summary = if text.len() > 80 {
309 format!("{}...", &text[..text.floor_char_boundary(80)])
310 } else {
311 text.clone()
312 };
313 Some(summary)
314 }
315 _ => None,
316 }),
317 Message::Assistant(a) => a.content.iter().find_map(|c| match c {
318 AssistantContent::Text { text } => {
319 let summary = if text.len() > 80 {
320 format!("{}...", &text[..text.floor_char_boundary(80)])
321 } else {
322 text.clone()
323 };
324 Some(summary)
325 }
326 _ => None,
327 }),
328 _ => None,
329 }
330}