1use std::collections::{BTreeMap, HashSet};
26
27use crate::{SessionId, SessionView};
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub enum TreeNodeId {
36 Project(String),
38 Worktree(String),
40 Team(String),
42 Agent(SessionId),
44}
45
46#[derive(Debug, Clone)]
48pub enum TreeNode {
49 Project {
51 name: String,
53 root: String,
55 children: Vec<TreeNode>,
57 },
58 Worktree {
60 path: String,
62 branch: Option<String>,
64 children: Vec<TreeNode>,
66 },
67 Team {
69 name: String,
71 children: Vec<TreeNode>,
73 },
74 Agent {
76 session: SessionView,
78 subagents: Vec<TreeNode>,
80 },
81}
82
83impl TreeNode {
84 pub fn agent_count(&self) -> usize {
86 match self {
87 TreeNode::Project { children, .. }
88 | TreeNode::Worktree { children, .. }
89 | TreeNode::Team { children, .. } => children.iter().map(|c| c.agent_count()).sum(),
90 TreeNode::Agent { subagents, .. } => {
91 1 + subagents.iter().map(|s| s.agent_count()).sum::<usize>()
92 }
93 }
94 }
95
96 pub fn needs_attention(&self) -> bool {
98 match self {
99 TreeNode::Project { children, .. }
100 | TreeNode::Worktree { children, .. }
101 | TreeNode::Team { children, .. } => children.iter().any(|c| c.needs_attention()),
102 TreeNode::Agent { session, subagents } => {
103 session.needs_attention || subagents.iter().any(|s| s.needs_attention())
104 }
105 }
106 }
107
108 pub fn node_id(&self) -> TreeNodeId {
110 match self {
111 TreeNode::Project { root, .. } => TreeNodeId::Project(root.clone()),
112 TreeNode::Worktree { path, .. } => TreeNodeId::Worktree(path.clone()),
113 TreeNode::Team { name, .. } => TreeNodeId::Team(name.clone()),
114 TreeNode::Agent { session, .. } => TreeNodeId::Agent(session.id.clone()),
115 }
116 }
117}
118
119#[derive(Debug, Clone)]
125pub enum TreeRowKind {
126 Project {
127 name: String,
128 root: String,
129 },
130 Worktree {
131 path: String,
132 branch: Option<String>,
133 },
134 Team {
135 name: String,
136 },
137 Agent {
138 session: SessionView,
139 },
140}
141
142#[derive(Debug, Clone)]
146pub struct TreeRow {
147 pub depth: u8,
149 pub node_id: TreeNodeId,
151 pub kind: TreeRowKind,
153 pub is_expanded: bool,
155 pub agent_count: usize,
157 pub needs_attention: bool,
159 pub has_children: bool,
161}
162
163const UNGROUPED_PROJECT_NAME: &str = "Other";
169const UNGROUPED_PROJECT_ROOT: &str = "__ungrouped__";
170
171pub fn build_tree(sessions: &[SessionView]) -> Vec<TreeNode> {
177 if sessions.is_empty() {
178 return Vec::new();
179 }
180
181 let child_ids: HashSet<&SessionId> = sessions
183 .iter()
184 .filter(|s| s.parent_session_id.is_some())
185 .map(|s| &s.id)
186 .collect();
187
188 let by_id: BTreeMap<&str, &SessionView> = sessions.iter().map(|s| (s.id.as_str(), s)).collect();
190
191 let mut by_project: BTreeMap<&str, Vec<&SessionView>> = BTreeMap::new();
194
195 for session in sessions {
196 if child_ids.contains(&session.id) {
198 continue;
199 }
200 let project_key = session
201 .project_root
202 .as_deref()
203 .unwrap_or(UNGROUPED_PROJECT_ROOT);
204 by_project.entry(project_key).or_default().push(session);
205 }
206
207 let mut project_nodes = Vec::new();
208
209 for (project_root, project_sessions) in &by_project {
210 let project_name = if *project_root == UNGROUPED_PROJECT_ROOT {
211 UNGROUPED_PROJECT_NAME.to_string()
212 } else {
213 extract_project_name(project_root)
214 };
215
216 let make_agent_node = |session: &SessionView| -> TreeNode {
218 let subagents: Vec<TreeNode> = session
219 .child_session_ids
220 .iter()
221 .filter_map(|child_id| by_id.get(child_id.as_str()))
222 .map(|child| TreeNode::Agent {
223 session: (*child).clone(),
224 subagents: Vec::new(), })
226 .collect();
227 TreeNode::Agent {
228 session: session.clone(),
229 subagents,
230 }
231 };
232
233 let mut by_worktree: BTreeMap<Option<&str>, Vec<&SessionView>> = BTreeMap::new();
235 for session in project_sessions {
236 let wt_key = session.worktree_path.as_deref();
237 by_worktree.entry(wt_key).or_default().push(session);
238 }
239
240 let skip_worktree = by_worktree.len() <= 1;
242
243 if skip_worktree {
244 let mut agents: Vec<TreeNode> = project_sessions
246 .iter()
247 .map(|s| make_agent_node(s))
248 .collect();
249 sort_agent_nodes(&mut agents);
250
251 project_nodes.push(TreeNode::Project {
252 name: project_name,
253 root: project_root.to_string(),
254 children: agents,
255 });
256 } else {
257 let mut worktree_nodes: Vec<TreeNode> = Vec::new();
259 for (wt_path, wt_sessions) in &by_worktree {
260 let branch = wt_sessions.first().and_then(|s| s.worktree_branch.clone());
261 let path = wt_path
262 .map(|p| p.to_string())
263 .unwrap_or_else(|| "unknown".to_string());
264
265 let mut agents: Vec<TreeNode> =
266 wt_sessions.iter().map(|s| make_agent_node(s)).collect();
267 sort_agent_nodes(&mut agents);
268
269 worktree_nodes.push(TreeNode::Worktree {
270 path,
271 branch,
272 children: agents,
273 });
274 }
275
276 project_nodes.push(TreeNode::Project {
277 name: project_name,
278 root: project_root.to_string(),
279 children: worktree_nodes,
280 });
281 }
282 }
283
284 project_nodes
285}
286
287fn sort_agent_nodes(nodes: &mut [TreeNode]) {
289 nodes.sort_by(|a, b| {
290 let a_time = match a {
291 TreeNode::Agent { session, .. } => session.started_at.as_str(),
292 _ => "",
293 };
294 let b_time = match b {
295 TreeNode::Agent { session, .. } => session.started_at.as_str(),
296 _ => "",
297 };
298 b_time.cmp(a_time) });
300}
301
302fn extract_project_name(path: &str) -> String {
304 path.rsplit('/')
305 .find(|s| !s.is_empty())
306 .unwrap_or(path)
307 .to_string()
308}
309
310pub fn flatten_tree(tree: &[TreeNode], expanded: &HashSet<TreeNodeId>) -> Vec<TreeRow> {
321 let mut rows = Vec::new();
322 for node in tree {
323 flatten_node(node, 0, expanded, &mut rows);
324 }
325 rows
326}
327
328fn flatten_node(
329 node: &TreeNode,
330 depth: u8,
331 expanded: &HashSet<TreeNodeId>,
332 rows: &mut Vec<TreeRow>,
333) {
334 let node_id = node.node_id();
335 let is_expanded = expanded.contains(&node_id);
336 let has_children = match node {
337 TreeNode::Project { children, .. }
338 | TreeNode::Worktree { children, .. }
339 | TreeNode::Team { children, .. } => !children.is_empty(),
340 TreeNode::Agent { subagents, .. } => !subagents.is_empty(),
341 };
342
343 let kind = match node {
344 TreeNode::Project { name, root, .. } => TreeRowKind::Project {
345 name: name.clone(),
346 root: root.clone(),
347 },
348 TreeNode::Worktree { path, branch, .. } => TreeRowKind::Worktree {
349 path: path.clone(),
350 branch: branch.clone(),
351 },
352 TreeNode::Team { name, .. } => TreeRowKind::Team { name: name.clone() },
353 TreeNode::Agent { session, .. } => TreeRowKind::Agent {
354 session: session.clone(),
355 },
356 };
357
358 rows.push(TreeRow {
359 depth,
360 node_id: node_id.clone(),
361 kind,
362 is_expanded,
363 agent_count: node.agent_count(),
364 needs_attention: node.needs_attention(),
365 has_children,
366 });
367
368 if is_expanded {
370 let children: &[TreeNode] = match node {
371 TreeNode::Project { children, .. }
372 | TreeNode::Worktree { children, .. }
373 | TreeNode::Team { children, .. } => children,
374 TreeNode::Agent { subagents, .. } => subagents,
375 };
376 for child in children {
377 flatten_node(child, depth.saturating_add(1), expanded, rows);
378 }
379 }
380}
381
382pub fn all_node_ids(tree: &[TreeNode]) -> HashSet<TreeNodeId> {
388 let mut ids = HashSet::new();
389 fn collect(node: &TreeNode, ids: &mut HashSet<TreeNodeId>) {
390 ids.insert(node.node_id());
391 match node {
392 TreeNode::Project { children, .. }
393 | TreeNode::Worktree { children, .. }
394 | TreeNode::Team { children, .. } => {
395 for child in children {
396 collect(child, ids);
397 }
398 }
399 TreeNode::Agent { subagents, .. } => {
400 for child in subagents {
401 collect(child, ids);
402 }
403 }
404 }
405 }
406 for node in tree {
407 collect(node, &mut ids);
408 }
409 ids
410}
411
412#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::SessionStatus;
420
421 fn make_session(id: &str) -> SessionView {
422 SessionView {
423 id: SessionId::new(id),
424 id_short: id.get(..8).unwrap_or(id).to_string(),
425 started_at: "2026-01-01T00:00:00Z".to_string(),
426 status: SessionStatus::Working,
427 ..Default::default()
428 }
429 }
430
431 fn make_session_in_project(
432 id: &str,
433 project_root: &str,
434 worktree_path: &str,
435 branch: &str,
436 started_at: &str,
437 ) -> SessionView {
438 SessionView {
439 id: SessionId::new(id),
440 id_short: id.get(..8).unwrap_or(id).to_string(),
441 project_root: Some(project_root.to_string()),
442 worktree_path: Some(worktree_path.to_string()),
443 worktree_branch: Some(branch.to_string()),
444 started_at: started_at.to_string(),
445 status: SessionStatus::Working,
446 ..Default::default()
447 }
448 }
449
450 #[test]
455 fn test_empty_sessions() {
456 let tree = build_tree(&[]);
457 assert!(tree.is_empty());
458 }
459
460 #[test]
461 fn test_ungrouped_sessions() {
462 let sessions = vec![make_session("a"), make_session("b")];
463 let tree = build_tree(&sessions);
464
465 assert_eq!(tree.len(), 1);
466 match &tree[0] {
467 TreeNode::Project { name, children, .. } => {
468 assert_eq!(name, "Other");
469 assert_eq!(children.len(), 2);
470 }
471 _ => panic!("expected Project node"),
472 }
473 }
474
475 #[test]
476 fn test_single_worktree_skips_nesting() {
477 let sessions = vec![
478 make_session_in_project(
479 "a",
480 "/home/user/myapp",
481 "/home/user/myapp",
482 "main",
483 "2026-01-01T00:00:00Z",
484 ),
485 make_session_in_project(
486 "b",
487 "/home/user/myapp",
488 "/home/user/myapp",
489 "main",
490 "2026-01-01T00:01:00Z",
491 ),
492 ];
493 let tree = build_tree(&sessions);
494
495 assert_eq!(tree.len(), 1);
496 match &tree[0] {
497 TreeNode::Project { name, children, .. } => {
498 assert_eq!(name, "myapp");
499 assert_eq!(children.len(), 2);
501 assert!(matches!(&children[0], TreeNode::Agent { .. }));
502 assert!(matches!(&children[1], TreeNode::Agent { .. }));
503 }
504 _ => panic!("expected Project node"),
505 }
506 }
507
508 #[test]
509 fn test_multiple_worktrees_adds_nesting() {
510 let sessions = vec![
511 make_session_in_project(
512 "a",
513 "/home/user/myapp",
514 "/home/user/myapp",
515 "main",
516 "2026-01-01T00:00:00Z",
517 ),
518 make_session_in_project(
519 "b",
520 "/home/user/myapp",
521 "/home/user/myapp-auth",
522 "feature/auth",
523 "2026-01-01T00:01:00Z",
524 ),
525 ];
526 let tree = build_tree(&sessions);
527
528 assert_eq!(tree.len(), 1);
529 match &tree[0] {
530 TreeNode::Project { name, children, .. } => {
531 assert_eq!(name, "myapp");
532 assert_eq!(children.len(), 2);
534 assert!(matches!(&children[0], TreeNode::Worktree { .. }));
535 assert!(matches!(&children[1], TreeNode::Worktree { .. }));
536 }
537 _ => panic!("expected Project node"),
538 }
539 }
540
541 #[test]
542 fn test_multiple_projects() {
543 let sessions = vec![
544 make_session_in_project(
545 "a",
546 "/home/user/app-a",
547 "/home/user/app-a",
548 "main",
549 "2026-01-01T00:00:00Z",
550 ),
551 make_session_in_project(
552 "b",
553 "/home/user/app-b",
554 "/home/user/app-b",
555 "main",
556 "2026-01-01T00:00:00Z",
557 ),
558 ];
559 let tree = build_tree(&sessions);
560
561 assert_eq!(tree.len(), 2);
563 match &tree[0] {
564 TreeNode::Project { name, .. } => assert_eq!(name, "app-a"),
565 _ => panic!("expected Project"),
566 }
567 match &tree[1] {
568 TreeNode::Project { name, .. } => assert_eq!(name, "app-b"),
569 _ => panic!("expected Project"),
570 }
571 }
572
573 #[test]
574 fn test_subagent_nesting() {
575 let mut parent = make_session_in_project(
576 "parent-1",
577 "/home/user/myapp",
578 "/home/user/myapp",
579 "main",
580 "2026-01-01T00:00:00Z",
581 );
582 parent.child_session_ids = vec![SessionId::new("child-1")];
583
584 let mut child = make_session_in_project(
585 "child-1",
586 "/home/user/myapp",
587 "/home/user/myapp",
588 "main",
589 "2026-01-01T00:00:01Z",
590 );
591 child.parent_session_id = Some(SessionId::new("parent-1"));
592
593 let sessions = vec![parent, child];
594 let tree = build_tree(&sessions);
595
596 assert_eq!(tree.len(), 1);
597 match &tree[0] {
598 TreeNode::Project { children, .. } => {
599 assert_eq!(children.len(), 1, "child should be nested, not top-level");
601 match &children[0] {
602 TreeNode::Agent {
603 session, subagents, ..
604 } => {
605 assert_eq!(session.id.as_str(), "parent-1");
606 assert_eq!(subagents.len(), 1);
607 match &subagents[0] {
608 TreeNode::Agent { session, .. } => {
609 assert_eq!(session.id.as_str(), "child-1");
610 }
611 _ => panic!("expected Agent subagent"),
612 }
613 }
614 _ => panic!("expected Agent"),
615 }
616 }
617 _ => panic!("expected Project"),
618 }
619 }
620
621 #[test]
622 fn test_agent_count() {
623 let sessions = vec![
624 make_session_in_project(
625 "a",
626 "/home/user/myapp",
627 "/home/user/myapp",
628 "main",
629 "2026-01-01T00:00:00Z",
630 ),
631 make_session_in_project(
632 "b",
633 "/home/user/myapp",
634 "/home/user/myapp",
635 "main",
636 "2026-01-01T00:01:00Z",
637 ),
638 make_session_in_project(
639 "c",
640 "/home/user/myapp",
641 "/home/user/myapp-wt",
642 "dev",
643 "2026-01-01T00:02:00Z",
644 ),
645 ];
646 let tree = build_tree(&sessions);
647
648 assert_eq!(tree[0].agent_count(), 3);
649 }
650
651 #[test]
652 fn test_attention_bubbles_up() {
653 let mut session = make_session_in_project(
654 "alert-1",
655 "/home/user/myapp",
656 "/home/user/myapp",
657 "main",
658 "2026-01-01T00:00:00Z",
659 );
660 session.needs_attention = true;
661
662 let normal = make_session_in_project(
663 "normal-1",
664 "/home/user/myapp",
665 "/home/user/myapp",
666 "main",
667 "2026-01-01T00:01:00Z",
668 );
669
670 let tree = build_tree(&[session, normal]);
671 assert!(
672 tree[0].needs_attention(),
673 "project should bubble up attention"
674 );
675 }
676
677 #[test]
678 fn test_agents_sorted_newest_first() {
679 let sessions = vec![
680 make_session_in_project(
681 "old",
682 "/home/user/app",
683 "/home/user/app",
684 "main",
685 "2026-01-01T00:00:00Z",
686 ),
687 make_session_in_project(
688 "new",
689 "/home/user/app",
690 "/home/user/app",
691 "main",
692 "2026-01-01T00:05:00Z",
693 ),
694 make_session_in_project(
695 "mid",
696 "/home/user/app",
697 "/home/user/app",
698 "main",
699 "2026-01-01T00:02:00Z",
700 ),
701 ];
702 let tree = build_tree(&sessions);
703
704 match &tree[0] {
705 TreeNode::Project { children, .. } => {
706 let ids: Vec<&str> = children
707 .iter()
708 .filter_map(|c| match c {
709 TreeNode::Agent { session, .. } => Some(session.id.as_str()),
710 _ => None,
711 })
712 .collect();
713 assert_eq!(ids, vec!["new", "mid", "old"]);
714 }
715 _ => panic!("expected Project"),
716 }
717 }
718
719 #[test]
724 fn test_flatten_empty() {
725 let rows = flatten_tree(&[], &HashSet::new());
726 assert!(rows.is_empty());
727 }
728
729 #[test]
730 fn test_flatten_collapsed_project() {
731 let sessions = vec![
732 make_session_in_project(
733 "a",
734 "/home/user/app",
735 "/home/user/app",
736 "main",
737 "2026-01-01T00:00:00Z",
738 ),
739 make_session_in_project(
740 "b",
741 "/home/user/app",
742 "/home/user/app",
743 "main",
744 "2026-01-01T00:01:00Z",
745 ),
746 ];
747 let tree = build_tree(&sessions);
748 let rows = flatten_tree(&tree, &HashSet::new()); assert_eq!(rows.len(), 1);
752 assert!(!rows[0].is_expanded);
753 assert_eq!(rows[0].agent_count, 2);
754 assert!(rows[0].has_children);
755 }
756
757 #[test]
758 fn test_flatten_expanded_project() {
759 let sessions = vec![
760 make_session_in_project(
761 "a",
762 "/home/user/app",
763 "/home/user/app",
764 "main",
765 "2026-01-01T00:00:00Z",
766 ),
767 make_session_in_project(
768 "b",
769 "/home/user/app",
770 "/home/user/app",
771 "main",
772 "2026-01-01T00:01:00Z",
773 ),
774 ];
775 let tree = build_tree(&sessions);
776
777 let mut expanded = HashSet::new();
778 expanded.insert(TreeNodeId::Project("/home/user/app".to_string()));
779
780 let rows = flatten_tree(&tree, &expanded);
781
782 assert_eq!(rows.len(), 3);
784 assert!(rows[0].is_expanded);
785 assert_eq!(rows[0].depth, 0);
786 assert_eq!(rows[1].depth, 1);
787 assert_eq!(rows[2].depth, 1);
788 }
789
790 #[test]
791 fn test_flatten_with_worktrees() {
792 let sessions = vec![
793 make_session_in_project(
794 "a",
795 "/home/user/app",
796 "/home/user/app",
797 "main",
798 "2026-01-01T00:00:00Z",
799 ),
800 make_session_in_project(
801 "b",
802 "/home/user/app",
803 "/home/user/app-wt",
804 "dev",
805 "2026-01-01T00:01:00Z",
806 ),
807 ];
808 let tree = build_tree(&sessions);
809
810 let expanded = all_node_ids(&tree);
812 let rows = flatten_tree(&tree, &expanded);
813
814 assert_eq!(rows.len(), 5);
816 assert_eq!(rows[0].depth, 0); assert_eq!(rows[1].depth, 1); assert_eq!(rows[2].depth, 2); assert_eq!(rows[3].depth, 1); assert_eq!(rows[4].depth, 2); }
822
823 #[test]
824 fn test_flatten_subagent_nesting() {
825 let mut parent = make_session_in_project(
826 "parent",
827 "/home/user/app",
828 "/home/user/app",
829 "main",
830 "2026-01-01T00:00:00Z",
831 );
832 parent.child_session_ids = vec![SessionId::new("child")];
833
834 let mut child = make_session_in_project(
835 "child",
836 "/home/user/app",
837 "/home/user/app",
838 "main",
839 "2026-01-01T00:00:01Z",
840 );
841 child.parent_session_id = Some(SessionId::new("parent"));
842
843 let tree = build_tree(&[parent, child]);
844 let expanded = all_node_ids(&tree);
845 let rows = flatten_tree(&tree, &expanded);
846
847 assert_eq!(rows.len(), 3);
849 assert_eq!(rows[0].depth, 0); assert_eq!(rows[1].depth, 1); assert_eq!(rows[2].depth, 2); }
853
854 #[test]
859 fn test_extract_project_name() {
860 assert_eq!(extract_project_name("/home/user/myapp"), "myapp");
861 assert_eq!(extract_project_name("/home/user/my-project"), "my-project");
862 assert_eq!(extract_project_name("single"), "single");
863 assert_eq!(extract_project_name("/trailing/slash/"), "slash");
864 }
865
866 #[test]
867 fn test_all_node_ids() {
868 let sessions = vec![make_session_in_project(
869 "a",
870 "/home/user/app",
871 "/home/user/app",
872 "main",
873 "2026-01-01T00:00:00Z",
874 )];
875 let tree = build_tree(&sessions);
876 let ids = all_node_ids(&tree);
877
878 assert!(ids.contains(&TreeNodeId::Project("/home/user/app".to_string())));
879 assert!(ids.contains(&TreeNodeId::Agent(SessionId::new("a"))));
880 assert_eq!(ids.len(), 2); }
882
883 #[test]
884 fn test_mixed_grouped_and_ungrouped() {
885 let sessions = vec![
886 make_session_in_project(
887 "a",
888 "/home/user/app",
889 "/home/user/app",
890 "main",
891 "2026-01-01T00:00:00Z",
892 ),
893 make_session("orphan"),
894 ];
895 let tree = build_tree(&sessions);
896
897 assert_eq!(tree.len(), 2);
899 let names: Vec<&str> = tree
900 .iter()
901 .filter_map(|n| match n {
902 TreeNode::Project { name, .. } => Some(name.as_str()),
903 _ => None,
904 })
905 .collect();
906 assert!(names.contains(&"Other"));
907 assert!(names.contains(&"app"));
908 }
909}