"""Session tree widget for the CLI board. Displays tasks in a tree structure where parent tasks are expandable nodes and child work items appear as leaves beneath them. """ from __future__ import annotations from rich.console import Group, RenderableType from rich.panel import Panel from rich.text import Text from textual.widgets import Static from ..state.models import BoardTaskView, SessionSummaryView from ..state.store import BoardStateStore from .render_utils import badge, humanize_age, priority_style, status_style, truncate_text _STATUS_SYMBOL = { "done": ("\u2713", "bold #22c55e"), "running": ("\u25cf", "bold #38bdf8"), "idle": ("\u25cf", "bold #38bdf8"), "pending": ("\u25cb", "dim"), "failed": ("\u2717", "bold #ef4444"), "cancelled": ("\u2717", "bold #9333ea"), "blocked": ("\u25a0", "bold #f59e0b"), "awaiting_peer": ("\u25a0", "bold #f59e0b"), "awaiting_review": ("\u25a0", "bold #f59e0b"), } class SessionSidebarWidget(Static): """Render a session tree with parent-child relationships.""" def __init__(self, state: BoardStateStore) -> None: super().__init__(id="session-sidebar") self.state = state def render(self) -> RenderableType: company_mode = self.state.snapshot.mode == "company" title = "Runtime Sessions" if company_mode else "Sessions" if self.state.pane_focus == "session-rail": title += " [Focused]" tree = self._build_tree() if not tree: return Panel( Text("No runtime sessions available." if company_mode else "No sessions available.", style="dim"), title=title, border_style="cyan" if self.state.pane_focus == "session-rail" else "white", ) return Panel( Group(*tree), title=title, border_style="cyan" if self.state.pane_focus == "session-rail" else "white", ) def _build_tree(self) -> list[RenderableType]: """Build a tree from tasks, grouping children under parents.""" all_tasks = self.state.filtered_tasks() if not all_tasks: return [] # Separate parent (visible/top-level) tasks from child tasks # Child tasks have origin_task_id pointing to their parent parent_tasks: list[BoardTaskView] = [] children_by_parent: dict[str, list[BoardTaskView]] = {} # First pass: identify parents and children from the visible task list # Also look at linked executions from the snapshot data visible_ids = {t.task_id for t in all_tasks} for task in all_tasks: if task.origin_task_id and task.origin_task_id in visible_ids: children_by_parent.setdefault(task.origin_task_id, []).append(task) else: parent_tasks.append(task) # Also pull in hidden linked tasks from snapshot metadata for task in self.state.all_tasks(): if task.task_id in visible_ids: continue if task.origin_task_id and task.origin_task_id in visible_ids: children_by_parent.setdefault(task.origin_task_id, []).append(task) # Sort parents: live first, then queue, then archive parent_tasks.sort(key=lambda t: ( 0 if self._is_live(t) else 1 if t.column_id != "done" else 2, -float(t.updated_at), )) lines: list[RenderableType] = [] for parent in parent_tasks: children = children_by_parent.get(parent.task_id, []) children.sort(key=lambda t: (float(t.created_at), t.title)) lines.append(self._render_parent(parent, children)) return lines def _render_parent(self, task: BoardTaskView, children: list[BoardTaskView]) -> Text: selected = task.task_id == self.state.selected_task_id runtime = self.state.runtime_for(task.task_id) has_children = bool(children) sym, sym_style = _STATUS_SYMBOL.get(task.status, ("\u25cb", "dim")) text = Text() # Expand/collapse indicator if has_children: text.append("\u25bc ", style="bold white") # ▼ else: text.append(" ", style="") # Selection marker if selected: text.append("\u25c6 ", style="bold cyan") # ◆ else: text.append(" ", style="") # Status + title text.append(f"{sym} ", style=sym_style) title_style = "bold white" if selected else "white" text.append(truncate_text(task.title, 22), style=title_style) text.append(f" {humanize_age(task.updated_at)}", style="dim") # Runtime info if runtime and runtime.current_tool: text.append(f" \u2699{runtime.current_tool}", style="dim") # Badges on next line if relevant if task.pending_checkpoint or task.priority: text.append("\n ") if task.priority: text += badge(task.priority.upper(), priority_style(task.priority)) text.append(" ") if task.pending_checkpoint: text += badge("REVIEW", status_style("warn")) # Children for i, child in enumerate(children[:10]): is_last = i == len(children) - 1 or i == 9 text.append("\n") text.append(self._render_child(child, is_last=is_last)) if len(children) > 10: text.append(f"\n \u2026 +{len(children) - 10} more", style="dim") text.append("\n") return text def _render_child(self, task: BoardTaskView, *, is_last: bool) -> Text: selected = task.task_id == self.state.selected_task_id runtime = self.state.runtime_for(task.task_id) sym, sym_style = _STATUS_SYMBOL.get(task.status, ("\u25cb", "dim")) connector = " \u2514\u2500 " if is_last else " \u251c\u2500 " # └─ or ├─ text = Text() text.append(connector, style="dim") if selected: text.append(f"{sym} ", style=sym_style) text.append(truncate_text(task.title, 18), style="bold cyan") else: text.append(f"{sym} ", style=sym_style) text.append(truncate_text(task.title, 18), style="white") # Assignee if task.assigned_to: text.append(f" {truncate_text(task.assigned_to, 10)}", style="dim") # Elapsed text.append(f" {humanize_age(task.updated_at)}", style="dim") # Current tool if runtime and runtime.current_tool: text.append(f" \u2699{truncate_text(runtime.current_tool, 12)}", style="dim") return text @staticmethod def _is_live(task: BoardTaskView) -> bool: return task.status in {"running", "idle", "blocked", "awaiting_peer", "awaiting_review"} or task.pending_checkpoint is not None