Skip to main content

atm_core/
tree.rs

1//! Tree model for grouping sessions by project, worktree, and team.
2//!
3//! Transforms a flat list of [`SessionView`] into a hierarchical tree
4//! structure for TUI rendering. The grouping hierarchy is:
5//!
6//! ```text
7//! Project (git repo root)
8//! ├── Worktree (branch / checkout path)
9//! │   ├── Agent (session)
10//! │   │   └── Subagent (child session)
11//! │   └── Agent
12//! └── Worktree
13//!     └── ...
14//! ```
15//!
16//! **Conditional worktree nesting:** When a project has only one worktree,
17//! the worktree level is skipped — agents appear directly under the project.
18//!
19//! **Ungrouped sessions:** Sessions without a `project_root` are collected
20//! under a synthetic "Other" project node.
21//!
22//! This module is pure logic with no TUI dependency, enabling reuse
23//! in the future web UI.
24
25use std::collections::{BTreeMap, HashSet};
26
27use crate::{SessionId, SessionView};
28
29// ============================================================================
30// Tree Node Types
31// ============================================================================
32
33/// Unique identifier for a tree node, used for expand/collapse state tracking.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub enum TreeNodeId {
36    /// A project node, keyed by project root path.
37    Project(String),
38    /// A worktree node, keyed by worktree path.
39    Worktree(String),
40    /// A team node (placeholder for CC Agent Teams).
41    Team(String),
42    /// An agent (session) node, keyed by session ID.
43    Agent(SessionId),
44}
45
46/// A node in the session grouping tree.
47#[derive(Debug, Clone)]
48pub enum TreeNode {
49    /// A git project (repo root). Groups all agents across its worktrees.
50    Project {
51        /// Display name (last path component of project_root).
52        name: String,
53        /// Full project root path.
54        root: String,
55        /// Child nodes (Worktree or Agent when single-worktree).
56        children: Vec<TreeNode>,
57    },
58    /// A git worktree within a project.
59    Worktree {
60        /// Full worktree path.
61        path: String,
62        /// Branch name (e.g., "main", "feature/auth").
63        branch: Option<String>,
64        /// Child nodes (Agent).
65        children: Vec<TreeNode>,
66    },
67    /// Placeholder for CC Agent Teams integration (Phase 6).
68    Team {
69        /// Team name.
70        name: String,
71        /// Child nodes (Agent).
72        children: Vec<TreeNode>,
73    },
74    /// A single session (leaf node).
75    Agent {
76        /// The session view data.
77        session: SessionView,
78        /// Child subagent sessions.
79        subagents: Vec<TreeNode>,
80    },
81}
82
83impl TreeNode {
84    /// Returns the total number of agent sessions in this subtree.
85    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    /// Returns true if any agent in this subtree needs attention.
97    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    /// Returns the [`TreeNodeId`] for this node.
109    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// ============================================================================
120// Flattened Row (for cursor navigation)
121// ============================================================================
122
123/// The kind of row in the flattened tree.
124#[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/// A single row in the flattened, navigable tree view.
143///
144/// Created by [`flatten_tree`] from a `Vec<TreeNode>` + expand/collapse state.
145#[derive(Debug, Clone)]
146pub struct TreeRow {
147    /// Nesting depth (0 = top-level project, 1 = worktree/agent, etc.).
148    pub depth: u8,
149    /// Unique node identifier for expand/collapse state tracking.
150    pub node_id: TreeNodeId,
151    /// What kind of row this is.
152    pub kind: TreeRowKind,
153    /// Whether this node is currently expanded (only meaningful for non-leaf nodes).
154    pub is_expanded: bool,
155    /// Total agent count in this subtree (for collapsed groups).
156    pub agent_count: usize,
157    /// Whether any agent in the subtree needs attention (bubble-up).
158    pub needs_attention: bool,
159    /// Whether this node has children (is expandable).
160    pub has_children: bool,
161}
162
163// ============================================================================
164// Tree Building
165// ============================================================================
166
167/// Label for sessions that don't belong to any git project.
168const UNGROUPED_PROJECT_NAME: &str = "Other";
169const UNGROUPED_PROJECT_ROOT: &str = "__ungrouped__";
170
171/// Builds a tree of [`TreeNode`] from a flat slice of sessions.
172///
173/// Grouping hierarchy: Project > Worktree (conditional) > Agent.
174/// Sessions without `project_root` are grouped under an "Other" project.
175/// Sessions with `parent_session_id` are nested under their parent.
176pub fn build_tree(sessions: &[SessionView]) -> Vec<TreeNode> {
177    if sessions.is_empty() {
178        return Vec::new();
179    }
180
181    // Separate parent-level sessions from subagents
182    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    // Index sessions by ID for subagent lookup
189    let by_id: BTreeMap<&str, &SessionView> = sessions.iter().map(|s| (s.id.as_str(), s)).collect();
190
191    // Group top-level sessions by project_root
192    // BTreeMap for deterministic alphabetical ordering
193    let mut by_project: BTreeMap<&str, Vec<&SessionView>> = BTreeMap::new();
194
195    for session in sessions {
196        // Skip subagents — they'll be nested under their parent
197        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        // Build agent nodes (with subagent nesting)
217        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(), // No recursive subagent nesting for now
225                })
226                .collect();
227            TreeNode::Agent {
228                session: session.clone(),
229                subagents,
230            }
231        };
232
233        // Group by worktree within this project
234        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        // Conditional worktree nesting: skip worktree level if only one
241        let skip_worktree = by_worktree.len() <= 1;
242
243        if skip_worktree {
244            // Agents directly under project
245            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            // Worktree grouping
258            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
287/// Sort agent nodes by started_at descending (newest first).
288fn 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) // newest first
299    });
300}
301
302/// Extracts a short project name from a path (last component).
303fn extract_project_name(path: &str) -> String {
304    path.rsplit('/')
305        .find(|s| !s.is_empty())
306        .unwrap_or(path)
307        .to_string()
308}
309
310// ============================================================================
311// Tree Flattening
312// ============================================================================
313
314/// Flattens a tree into navigable rows, respecting expand/collapse state.
315///
316/// # Arguments
317/// * `tree` — The tree nodes built by [`build_tree`].
318/// * `expanded` — Set of [`TreeNodeId`]s that are currently expanded.
319///   Nodes not in this set are collapsed (children hidden).
320pub 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    // Only recurse into children if expanded
369    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
382// ============================================================================
383// Helpers
384// ============================================================================
385
386/// Returns a set of all [`TreeNodeId`]s in the tree, useful for "expand all".
387pub 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// ============================================================================
413// Tests
414// ============================================================================
415
416#[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    // ------------------------------------------------------------------
451    // build_tree tests
452    // ------------------------------------------------------------------
453
454    #[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                // Should be Agent nodes directly (no Worktree nesting)
500                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                // Should have 2 Worktree nodes
533                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        // BTreeMap ordering: app-a before app-b
562        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                // Single worktree → agents directly under project
600                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    // ------------------------------------------------------------------
720    // flatten_tree tests
721    // ------------------------------------------------------------------
722
723    #[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()); // nothing expanded
749
750        // Should only see the project row (collapsed)
751        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        // Project (expanded) + 2 agents = 3 rows
783        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        // Expand everything
811        let expanded = all_node_ids(&tree);
812        let rows = flatten_tree(&tree, &expanded);
813
814        // Project + Worktree(main) + Agent(a) + Worktree(dev) + Agent(b) = 5
815        assert_eq!(rows.len(), 5);
816        assert_eq!(rows[0].depth, 0); // Project
817        assert_eq!(rows[1].depth, 1); // Worktree
818        assert_eq!(rows[2].depth, 2); // Agent
819        assert_eq!(rows[3].depth, 1); // Worktree
820        assert_eq!(rows[4].depth, 2); // Agent
821    }
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        // Project + Parent Agent + Child Agent = 3
848        assert_eq!(rows.len(), 3);
849        assert_eq!(rows[0].depth, 0); // Project
850        assert_eq!(rows[1].depth, 1); // Parent agent
851        assert_eq!(rows[2].depth, 2); // Child subagent
852    }
853
854    // ------------------------------------------------------------------
855    // Helper tests
856    // ------------------------------------------------------------------
857
858    #[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); // project + agent (no worktree since single)
881    }
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        // __ungrouped__ sorts before /home/... in BTreeMap
898        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}