crates/signaltty-gui/src/sidebar.rs

//! Workspace sidebar: what each project's agents are doing and whether
//! one needs you, at a glance. Rows are keyed by workspace id and
//! updated in place — refreshes never rebuild them, so selection,
//! scroll position and status transitions survive every server event.
//!
//! Root row anatomy (t3code-style, three lines, mark sits inline before name):
//!
//! ```text
//!  [AS] api-server            ◴ Working     mark · name · status slot
//!  [Task Working] Bash…                     task chips, then headline
//!  ▾ 2 tasks  feat/auth       Claude        disclosure · branch/dir · agents
//! ```
//!
//! Child row anatomy (two compact lines, ~20px indent, mark & 3rd line hidden):
//!
//! ```text
//!       api-server-worker     ◴ Working     name · status slot
//!       [Task Working] Tests…               task chips, then headline
//! ```
//!
//! Finished child task rows (`workspace-finished`) recede with reduced opacity
//! until hovered or selected. Finished children sort after unfinished siblings
//! within their group.
//!
//! Close has a separate trailing target, revealed on hover or keyboard
//! focus without hiding urgency. Right-clicking or pressing Menu / Shift+F10
//! displays a context menu. When rows (or any child in a group) need you,
//! they sit under a "Needs you" label with a hairline below.

use std::cell::RefCell;
use std::rc::Rc;

use chrono::{DateTime, Utc};
use gtk4::gio;
use gtk4::prelude::*;

use signaltty_core::{AgentKind, Attention, Lifecycle, Pane, Workspace};

use crate::status::{self, StatusSlot};
use crate::task_chip::{TaskChipView, TaskChipWidget};
use crate::util::{tilde, time_ago};

#[derive(Clone)]
pub struct WsSummary {
    pub id: String,
    pub name: String,
    /// Shown as `name · handle` only when sibling workspaces share the
    /// name (handles are unique by construction); `None` keeps rows quiet.
    pub disambiguator: Option<String>,
    /// Parent workspace id when this workspace is a task workspace nested
    /// under another workspace.
    pub parent: Option<String>,
    /// True when this task workspace has finished its turn or resolved.
    pub finished: bool,
    /// Set during sorting: true if this workspace or any member of its
    /// group needs human attention.
    pub group_needs_you: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub lifecycle: Lifecycle,
    pub attention: Attention,
    /// Latest explicit message; the headline falls back to run state.
    pub message: Option<String>,
    /// Timing of the pane that sets `lifecycle` (see `headline`).
    pub lifecycle_since: Option<DateTime<Utc>>,
    pub last_run_secs: Option<i64>,
    /// When the oldest pane at `attention` was raised to it.
    pub attention_since: Option<DateTime<Utc>>,
    /// Branch, or the directory when not a git repo.
    pub place: String,
    /// Agent display names, comma-joined; empty for plain shells.
    pub agents: String,
    pub last_activity: Option<DateTime<Utc>>,
}

/// Roll a workspace's panes up into one sidebar row (docs/03).
pub fn summarize(ws: &Workspace, panes: &[Pane]) -> WsSummary {
    let lifecycle = status::worst_lifecycle(panes);
    let attention = status::worst_attention(panes);
    let message = panes
        .iter()
        .filter(|p| p.last_message.as_deref().is_some_and(|m| !m.is_empty()))
        .max_by_key(|p| p.last_activity_at)
        .and_then(|p| p.last_message.clone());
    // The longest-running pane in the rolled-up state speaks for the
    // workspace: "Working for 12m…" beats a sibling's 1m. Untimed
    // panes (pre-timing snapshots) sort last — `None` would
    // otherwise win `min_by_key` and hide a timed sibling.
    let lead = panes
        .iter()
        .filter(|p| status::effective_lifecycle(p) == lifecycle)
        .min_by_key(|p| (p.lifecycle_since.is_none(), p.lifecycle_since));
    let mut agents: Vec<&str> = panes
        .iter()
        .filter_map(|p| agent_name(p.agent.kind))
        .collect();
    agents.sort_unstable();
    agents.dedup();
    WsSummary {
        id: ws.id.clone(),
        name: ws.name.clone(),
        disambiguator: None,
        parent: None,
        finished: false,
        group_needs_you: needs_you(attention),
        created_at: ws.created_at,
        lifecycle,
        attention,
        message,
        lifecycle_since: lead.and_then(|p| p.lifecycle_since),
        attention_since: panes
            .iter()
            .filter(|p| p.attention == attention)
            .filter_map(|p| p.attention_since)
            .min(),
        last_run_secs: lead.and_then(|p| p.last_run_secs),
        place: ws.git.branch.clone().unwrap_or_else(|| tilde(&ws.cwd)),
        agents: agents.join(", "),
        last_activity: panes.iter().map(|p| p.last_activity_at).max(),
    }
}

impl WsSummary {
    /// Second-line text: explicit message, else the verb-tense run
    /// state, else the plain lifecycle word.
    pub fn headline(&self, now: DateTime<Utc>) -> String {
        self.message
            .clone()
            .or_else(|| {
                status::run_label(
                    self.lifecycle,
                    self.lifecycle_since,
                    self.last_run_secs,
                    now,
                )
            })
            .unwrap_or_else(|| status::lifecycle_label(self.lifecycle).to_string())
    }
}

/// Priority order for the sidebar (docs/14 §1): groups of root workspaces
/// and their child task workspaces. Groups sort by worst attention severity,
/// then best lifecycle rank (blocked → failed → done → working → idle),
/// then root workspace age (newer first), then root name.
/// Deliberately NOT last-activity: activity timestamps change on every agent event,
/// so a recency tiebreak reorders rows under the pointer constantly — clicks
/// land on the wrong row and the top row never looks settled (spec 001 amendment, 2026-09-29).
/// Fresh output still floats via `unread` severity; recency remains visible
/// as the relative time in the status slot.
///
/// Hierarchy rules:
/// - Tasks with parent panes define child workspaces.
/// - Parent chains flatten to ONE level (root workspace).
/// - Cycles terminate and orphan children become roots.
/// - If any member of a group needs you, the whole group carries `group_needs_you`.
/// - Children follow their root immediately, sorted among themselves by the same key.
pub fn sort_summaries(items: &mut [WsSummary]) {
    if items.is_empty() {
        return;
    }

    // 1. Flatten parent chains to ONE level (root), terminating cycles and
    // demoting orphans whose parent is not present in `items` to roots.
    let known_ids: std::collections::HashSet<String> = items.iter().map(|s| s.id.clone()).collect();
    let parent_map: std::collections::HashMap<String, String> = items
        .iter()
        .filter_map(|s| s.parent.as_ref().map(|p| (s.id.clone(), p.clone())))
        .collect();

    for item in items.iter_mut() {
        if let Some(initial_parent) = &item.parent {
            let mut curr = initial_parent.clone();
            let mut visited = std::collections::HashSet::new();
            visited.insert(item.id.clone());
            let mut root = None;

            while visited.insert(curr.clone()) {
                if !known_ids.contains(&curr) {
                    break;
                }
                if let Some(next_parent) = parent_map.get(&curr) {
                    curr = next_parent.clone();
                } else {
                    root = Some(curr);
                    break;
                }
            }
            item.parent = root;
        }
    }

    // 2. Separate roots and index children by root ID.
    let mut roots: Vec<WsSummary> = Vec::new();
    let mut children_by_root: std::collections::HashMap<String, Vec<WsSummary>> =
        std::collections::HashMap::new();

    for item in items.iter() {
        if let Some(root_id) = &item.parent {
            children_by_root
                .entry(root_id.clone())
                .or_default()
                .push(item.clone());
        } else {
            roots.push(item.clone());
        }
    }

    // Sorting comparator for individual items (children or standalone roots).
    let item_cmp = |a: &WsSummary, b: &WsSummary| {
        b.attention
            .severity()
            .cmp(&a.attention.severity())
            .then(b.lifecycle.sidebar_rank().cmp(&a.lifecycle.sidebar_rank()))
            .then(b.created_at.cmp(&a.created_at))
            .then(a.name.cmp(&b.name))
    };

    let child_cmp =
        |a: &WsSummary, b: &WsSummary| a.finished.cmp(&b.finished).then_with(|| item_cmp(a, b));

    for children in children_by_root.values_mut() {
        children.sort_by(child_cmp);
    }

    // 3. Assemble and rank groups.
    struct Group {
        root: WsSummary,
        children: Vec<WsSummary>,
        worst_attention_severity: u8,
        best_lifecycle_rank: u8,
        group_needs_you: bool,
    }

    let mut groups: Vec<Group> = roots
        .into_iter()
        .map(|root| {
            let children = children_by_root.remove(&root.id).unwrap_or_default();
            let mut worst_severity = root.attention.severity();
            let mut best_rank = root.lifecycle.sidebar_rank();
            let mut any_needs_you = needs_you(root.attention);

            for c in &children {
                worst_severity = worst_severity.max(c.attention.severity());
                best_rank = best_rank.max(c.lifecycle.sidebar_rank());
                if needs_you(c.attention) {
                    any_needs_you = true;
                }
            }

            Group {
                root,
                children,
                worst_attention_severity: worst_severity,
                best_lifecycle_rank: best_rank,
                group_needs_you: any_needs_you,
            }
        })
        .collect();

    groups.sort_by(|a, b| {
        b.worst_attention_severity
            .cmp(&a.worst_attention_severity)
            .then(b.best_lifecycle_rank.cmp(&a.best_lifecycle_rank))
            .then(b.root.created_at.cmp(&a.root.created_at))
            .then(a.root.name.cmp(&b.root.name))
    });

    // 4. Flatten back into the slice with group_needs_you updated on each item.
    let mut result = Vec::with_capacity(items.len());
    for mut group in groups {
        group.root.group_needs_you = group.group_needs_you;
        result.push(group.root);
        for mut child in group.children {
            child.group_needs_you = group.group_needs_you;
            result.push(child);
        }
    }

    items.clone_from_slice(&result);
}

/// Rows the human must act on (warning and up). Sorting puts them
/// first, so they form one contiguous "Needs you" section.
pub fn needs_you(a: Attention) -> bool {
    a.severity() >= Attention::Warning.severity()
}

#[derive(Debug, PartialEq, Eq)]
pub enum SectionHeader {
    /// Label above the first row that needs you.
    NeedsYou,
    /// Hairline between that section and the rest.
    Rest,
}

/// Header for a row given whether it and the row above need you. With
/// nothing waiting there are no headers at all.
pub fn section_header(above: Option<bool>, needs: bool) -> Option<SectionHeader> {
    match (above, needs) {
        (None | Some(false), true) => Some(SectionHeader::NeedsYou),
        (Some(true), false) => Some(SectionHeader::Rest),
        _ => None,
    }
}

/// Tints a workspace mark can take; `mark_tint` picks one by id.
const MARK_TINTS: usize = 4;

/// Two-letter monogram: initials of the first two words ("api-server"
/// → "AS"), else the first two letters ("simone" → "SI").
pub fn monogram(name: &str) -> String {
    let words: Vec<&str> = name
        .split(|c: char| !c.is_alphanumeric())
        .filter(|w| !w.is_empty())
        .collect();
    let letters: String = match words.as_slice() {
        [] => "?".into(),
        [one] => one.chars().take(2).collect(),
        [a, b, ..] => a.chars().take(1).chain(b.chars().take(1)).collect(),
    };
    letters.to_uppercase()
}

/// Stable tint index keyed by workspace id, so renames keep the colour.
pub fn mark_tint(id: &str) -> usize {
    id.bytes()
        .fold(0usize, |h, b| h.wrapping_mul(31).wrapping_add(b as usize))
        % MARK_TINTS
}

/// The workspace's mark: monogram on a stable tint. Shared by the
/// sidebar row and the content header so the two read as one place.
pub fn mark() -> gtk4::Label {
    let l = gtk4::Label::new(None);
    l.add_css_class("ws-mark");
    l.set_valign(gtk4::Align::Center);
    l.set_accessible_role(gtk4::AccessibleRole::Presentation);
    l
}

pub fn set_mark(l: &gtk4::Label, id: &str, name: &str) {
    l.set_text(&monogram(name));
    for i in 0..MARK_TINTS {
        l.remove_css_class(&format!("tint-{i}"));
    }
    l.add_css_class(&format!("tint-{}", mark_tint(id)));
}

/// Display name for real agents; shells and unknown commands have none.
pub fn agent_name(kind: AgentKind) -> Option<&'static str> {
    match kind {
        AgentKind::Claude => Some("Claude"),
        AgentKind::Codex => Some("Codex"),
        AgentKind::Opencode => Some("opencode"),
        AgentKind::Cursor => Some("Cursor"),
        AgentKind::Pi => Some("Pi"),
        AgentKind::Generic | AgentKind::None => None,
    }
}

/// Pure helper calculating the text for the disclosure button on a parent workspace row.
pub fn disclosure_label(
    active_count: usize,
    needs_count: usize,
    finished_count: usize,
    is_collapsed: bool,
) -> Option<String> {
    if active_count == 0 && finished_count == 0 {
        return None;
    }
    let arrow = if is_collapsed { "▸" } else { "▾" };
    let mut parts = Vec::new();

    if active_count > 0 {
        if active_count == 1 {
            parts.push("1 task".to_string());
        } else {
            parts.push(format!("{active_count} tasks"));
        }
        if needs_count > 0 {
            if needs_count == 1 {
                parts.push("1 need you".to_string());
            } else {
                parts.push(format!("{needs_count} need you"));
            }
        }
    }

    if finished_count > 0 {
        parts.push(format!("{finished_count} done"));
    }

    Some(format!("{arrow} {}", parts.join(" · ")))
}

type MenuBuilderCallback = Rc<RefCell<Option<Box<dyn Fn(&str) -> gio::Menu>>>>;

struct Row {
    id: String,
    parent: Option<String>,
    row: gtk4::ListBoxRow,
    mark: gtk4::Label,
    name: gtk4::Label,
    status: StatusSlot,
    message: gtk4::Label,
    place: gtk4::Label,
    agents: gtk4::Label,
    metadata: gtk4::Box,
    disclosure: gtk4::Button,
    close: gtk4::Button,
    popover: gtk4::PopoverMenu,
    tasks: gtk4::Box,
    chips: Vec<TaskChipWidget>,
    chip_summary: String,
    /// Kept so the 30s tick can re-render time-derived text.
    summary: Option<WsSummary>,
    child_count: usize,
    child_needs_count: usize,
    child_finished_count: usize,
    is_collapsed: bool,
}

impl Drop for Row {
    fn drop(&mut self) {
        if self.popover.parent().is_some() {
            self.popover.unparent();
        }
    }
}

impl Row {
    fn new(
        id: &str,
        list: &gtk4::ListBox,
        on_close: &CloseCallback,
        on_toggle: &ToggleCallback,
        menu_builder: &MenuBuilderCallback,
    ) -> Row {
        let label = |classes: &[&str]| {
            let l = gtk4::Label::new(None);
            l.set_xalign(0.0);
            l.set_ellipsize(gtk4::pango::EllipsizeMode::End);
            for c in classes {
                l.add_css_class(c);
            }
            l
        };
        let name = label(&["workspace-name"]);
        name.set_hexpand(true);
        let message = label(&["workspace-message"]);
        message.set_hexpand(true);
        let tasks = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
        tasks.add_css_class("workspace-tasks");
        tasks.set_valign(gtk4::Align::Center);
        tasks.set_visible(false);
        let place = label(&["workspace-meta"]);
        place.set_ellipsize(gtk4::pango::EllipsizeMode::Middle);
        place.set_hexpand(true);
        let agents = label(&["workspace-meta"]);
        agents.set_halign(gtk4::Align::End);
        let mark = mark();
        let status = StatusSlot::new();
        let close = gtk4::Button::from_icon_name("window-close-symbolic");
        close.add_css_class("flat");
        close.add_css_class("circular");
        close.add_css_class("row-close");
        close.set_halign(gtk4::Align::End);
        close.set_valign(gtk4::Align::Center);
        let close_id = id.to_string();
        let on_close = Rc::clone(on_close);
        close.connect_clicked(move |_| {
            if let Some(cb) = on_close.borrow().as_ref() {
                cb(close_id.clone());
            }
        });

        let disclosure = gtk4::Button::new();
        disclosure.add_css_class("flat");
        disclosure.add_css_class("workspace-disclosure");
        disclosure.set_valign(gtk4::Align::Center);
        disclosure.set_visible(false);
        let toggle_id = id.to_string();
        let on_toggle = Rc::clone(on_toggle);
        disclosure.connect_clicked(move |_| {
            if let Some(cb) = on_toggle.borrow().as_ref() {
                cb(toggle_id.clone());
            }
        });

        let grid = gtk4::Grid::new();
        grid.set_column_spacing(8);
        grid.set_row_spacing(4);
        grid.add_css_class("workspace-row");
        let title = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
        title.append(&mark);
        title.append(&name);
        grid.attach(&title, 0, 0, 1, 1);
        grid.attach(&status.widget, 1, 0, 1, 1);
        grid.attach(&close, 2, 0, 1, 1);
        let activity = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
        activity.append(&tasks);
        activity.append(&message);
        grid.attach(&activity, 0, 1, 3, 1);
        let metadata = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
        metadata.append(&disclosure);
        metadata.append(&place);
        metadata.append(&agents);
        grid.attach(&metadata, 0, 2, 3, 1);

        let row = gtk4::ListBoxRow::new();
        row.set_widget_name(id);
        row.set_child(Some(&grid));

        let popover = gtk4::PopoverMenu::from_model(None::<&gio::MenuModel>);
        popover.set_parent(&row);
        // Anchored at the pointer, like any context menu: no arrow.
        popover.set_has_arrow(false);

        // Secondary click (button 3) context menu
        let click_gesture = gtk4::GestureClick::new();
        click_gesture.set_button(3);
        let list_weak = list.downgrade();
        let popover_click = popover.clone();
        let row_click = row.clone();
        let id_click = id.to_string();
        let menu_builder_click = Rc::clone(menu_builder);
        click_gesture.connect_pressed(move |g, _n_press, x, y| {
            g.set_state(gtk4::EventSequenceState::Claimed);
            // Selecting activates the workspace (`on_select`), so every
            // `win.*` item acts on this row.
            if let Some(list) = list_weak.upgrade() {
                list.select_row(Some(&row_click));
            }
            if let Some(ref builder) = *menu_builder_click.borrow() {
                let menu = builder(&id_click);
                popover_click.set_menu_model(Some(&menu));
            }
            let rect = gtk4::gdk::Rectangle::new(x as i32, y as i32, 1, 1);
            popover_click.set_pointing_to(Some(&rect));
            popover_click.popup();
        });
        row.add_controller(click_gesture);

        // Keyboard context menu: Menu key or Shift+F10
        let key_controller = gtk4::EventControllerKey::new();
        let list_weak = list.downgrade();
        let popover_key = popover.clone();
        let row_key = row.clone();
        let id_key = id.to_string();
        let menu_builder_key = Rc::clone(menu_builder);
        key_controller.connect_key_pressed(move |_, keyval, _keycode, state| {
            let is_menu = keyval == gtk4::gdk::Key::Menu;
            let is_shift_f10 = keyval == gtk4::gdk::Key::F10
                && state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
            if is_menu || is_shift_f10 {
                if let Some(list) = list_weak.upgrade() {
                    list.select_row(Some(&row_key));
                }
                if let Some(ref builder) = *menu_builder_key.borrow() {
                    let menu = builder(&id_key);
                    popover_key.set_menu_model(Some(&menu));
                }
                popover_key.set_pointing_to(None);
                popover_key.popup();
                return gtk4::glib::Propagation::Stop;
            }
            gtk4::glib::Propagation::Proceed
        });
        row.add_controller(key_controller);

        Row {
            id: id.to_string(),
            parent: None,
            row,
            mark,
            name,
            status,
            message,
            place,
            agents,
            metadata,
            disclosure,
            close,
            popover,
            tasks,
            chips: Vec::new(),
            chip_summary: String::new(),
            summary: None,
            child_count: 0,
            child_needs_count: 0,
            child_finished_count: 0,
            is_collapsed: false,
        }
    }

    /// Reconcile worker chips on this row. Hidden when the workspace
    /// has no task panes, so ordinary rows keep their three lines.
    fn set_chips(&mut self, views: &[TaskChipView]) {
        while self.chips.len() < views.len() {
            let chip = TaskChipWidget::new();
            self.tasks.append(&chip.root);
            self.chips.push(chip);
        }
        for (i, chip) in self.chips.iter().enumerate() {
            chip.set(views.get(i));
        }
        self.tasks.set_visible(!views.is_empty());
        self.chip_summary = views
            .iter()
            .map(|view| view.accessible_name.clone())
            .collect::<Vec<_>>()
            .join("; ");
        self.refresh_time();
    }

    fn update(&mut self, s: WsSummary) {
        self.parent = s.parent.clone();
        if s.group_needs_you {
            self.row.add_css_class(NEEDS_YOU);
        } else {
            self.row.remove_css_class(NEEDS_YOU);
        }
        if s.parent.is_some() && s.finished {
            self.row.add_css_class("workspace-finished");
        } else {
            self.row.remove_css_class("workspace-finished");
        }
        // Same-name workspaces are otherwise indistinguishable rows;
        // the unique handle tells them apart (spec 001 amendment).
        match &s.disambiguator {
            Some(handle) => self.name.set_text(&format!("{} · {handle}", s.name)),
            None => self.name.set_text(&s.name),
        }
        self.name.set_tooltip_text(Some(&self.name.text()));
        set_mark(&self.mark, &self.id, &s.name);
        self.place.set_text(&s.place);
        self.place.set_tooltip_text(Some(&s.place));
        self.agents.set_text(&s.agents);
        self.agents.set_tooltip_text(Some(&s.agents));
        self.close
            .set_tooltip_text(Some(&format!("Close {}", s.name)));
        self.close
            .update_property(&[gtk4::accessible::Property::Label(&format!(
                "Close workspace {}",
                s.name
            ))]);
        self.summary = Some(s);
        self.refresh_time();
    }

    fn set_child_mode(&mut self, is_child: bool) {
        if is_child {
            self.row.add_css_class("workspace-child");
            self.mark.set_visible(false);
            self.metadata.set_visible(false);
            if let Some(s) = &self.summary {
                let meta_tip = match (s.place.is_empty(), s.agents.is_empty()) {
                    (false, false) => format!("{} · {}", s.place, s.agents),
                    (false, true) => s.place.clone(),
                    (true, false) => s.agents.clone(),
                    (true, true) => String::new(),
                };
                self.row.set_tooltip_text(if meta_tip.is_empty() {
                    None
                } else {
                    Some(&meta_tip)
                });
            }
        } else {
            self.row.remove_css_class("workspace-child");
            self.row.remove_css_class("workspace-finished");
            self.mark.set_visible(true);
            self.metadata.set_visible(true);
            self.row.set_tooltip_text(None);
        }
    }

    fn update_disclosure(
        &mut self,
        count: usize,
        needs_count: usize,
        finished_count: usize,
        is_collapsed: bool,
    ) {
        self.child_count = count;
        self.child_needs_count = needs_count;
        self.child_finished_count = finished_count;
        self.is_collapsed = is_collapsed;
        self.render_disclosure();
    }

    fn set_collapsed(&mut self, is_collapsed: bool) {
        self.is_collapsed = is_collapsed;
        self.render_disclosure();
    }

    fn render_disclosure(&self) {
        if let Some(label) = disclosure_label(
            self.child_count,
            self.child_needs_count,
            self.child_finished_count,
            self.is_collapsed,
        ) {
            self.disclosure.set_visible(true);
            self.disclosure.set_label(&label);
            let action_str = if self.is_collapsed {
                "Expand"
            } else {
                "Collapse"
            };
            // "Collapse 3 tasks · 2 done": the visible text minus the arrow.
            let counts = label.split_once(' ').map_or("", |(_, rest)| rest);
            let acc_label = format!("{action_str} {counts}");
            self.disclosure
                .update_property(&[gtk4::accessible::Property::Label(&acc_label)]);
        } else {
            self.disclosure.set_visible(false);
        }
    }

    fn refresh_time(&self) {
        let Some(s) = &self.summary else { return };
        let headline = s.headline(Utc::now());
        let description = [
            self.name.text().to_string(),
            self.chip_summary.clone(),
            status::attention_tooltip(s.attention).to_string(),
            headline.clone(),
            s.place.clone(),
            s.agents.clone(),
        ]
        .into_iter()
        .filter(|text| !text.is_empty())
        .collect::<Vec<_>>()
        .join(", ");
        self.row
            .update_property(&[gtk4::accessible::Property::Label(&description)]);
        if self.message.text() != headline {
            self.message.set_text(&headline);
            self.message.set_tooltip_text(Some(&headline));
        }
        let time = s.last_activity.map(time_ago).unwrap_or_default();
        // Attention dates from when it was raised; lifecycle from its change.
        let began = if s.attention == Attention::None {
            s.lifecycle_since
        } else {
            s.attention_since
        };
        let age = began.map(time_ago).unwrap_or_default();
        self.status.set(s.lifecycle, s.attention, &time, &age);
    }
}

/// Row class the section headers key on.
const NEEDS_YOU: &str = "needs-you";

fn apply_section_header(row: &gtk4::ListBoxRow, above: Option<&gtk4::ListBoxRow>) {
    let header = section_header(
        above.map(|r| r.has_css_class(NEEDS_YOU)),
        row.has_css_class(NEEDS_YOU),
    );
    let widget: Option<gtk4::Widget> = match header {
        None => None,
        Some(SectionHeader::NeedsYou) => {
            let label = gtk4::Label::new(Some("Needs you"));
            label.set_xalign(0.0);
            label.add_css_class("sidebar-section");
            Some(label.upcast())
        }
        Some(SectionHeader::Rest) => {
            let rule = gtk4::Separator::new(gtk4::Orientation::Horizontal);
            rule.add_css_class("sidebar-section-rule");
            Some(rule.upcast())
        }
    };
    // Keep an equivalent header rather than churn widgets on refresh.
    let same = match (&widget, row.header()) {
        (None, None) => true,
        (Some(new), Some(old)) => new.type_() == old.type_(),
        _ => false,
    };
    if !same {
        row.set_header(widget.as_ref());
    }
}

type SelectCallback = Rc<RefCell<Option<Box<dyn Fn(String)>>>>;
type CloseCallback = Rc<RefCell<Option<Box<dyn Fn(String)>>>>;
type ToggleCallback = Rc<RefCell<Option<Box<dyn Fn(String)>>>>;

pub struct Sidebar {
    pub widget: gtk4::ScrolledWindow,
    list: gtk4::ListBox,
    rows: Rc<RefCell<Vec<Row>>>,
    collapsed_roots: Rc<RefCell<std::collections::HashSet<String>>>,
    on_select: SelectCallback,
    on_close: CloseCallback,
    on_toggle: ToggleCallback,
    menu_builder: MenuBuilderCallback,
}

impl Sidebar {
    pub fn new() -> Sidebar {
        let list = gtk4::ListBox::new();
        list.add_css_class("navigation-sidebar");
        list.set_selection_mode(gtk4::SelectionMode::Single);
        list.set_header_func(apply_section_header);
        let on_select: SelectCallback = Rc::new(RefCell::new(None));
        let on_close: CloseCallback = Rc::new(RefCell::new(None));
        let on_toggle: ToggleCallback = Rc::new(RefCell::new(None));
        let menu_builder: MenuBuilderCallback = Rc::new(RefCell::new(None));
        let rows: Rc<RefCell<Vec<Row>>> = Rc::new(RefCell::new(Vec::new()));
        let collapsed_roots: Rc<RefCell<std::collections::HashSet<String>>> =
            Rc::new(RefCell::new(std::collections::HashSet::new()));
        {
            let on_select = Rc::clone(&on_select);
            list.connect_row_selected(move |_, row| {
                if let (Some(row), Some(cb)) = (row, on_select.borrow().as_ref()) {
                    cb(row.widget_name().to_string());
                }
            });
        }
        {
            let rows = Rc::clone(&rows);
            let collapsed_roots = Rc::clone(&collapsed_roots);
            let list_weak = list.downgrade();
            *on_toggle.borrow_mut() = Some(Box::new(move |root_id: String| {
                let is_collapsed = {
                    let mut collapsed = collapsed_roots.borrow_mut();
                    if collapsed.contains(&root_id) {
                        collapsed.remove(&root_id);
                        false
                    } else {
                        collapsed.insert(root_id.clone());
                        true
                    }
                };
                for r in rows.borrow_mut().iter_mut() {
                    if r.parent.as_deref() == Some(&root_id) {
                        r.row.set_visible(!is_collapsed);
                    }
                    if r.id == root_id {
                        r.set_collapsed(is_collapsed);
                    }
                }
                if let Some(list) = list_weak.upgrade() {
                    list.invalidate_headers();
                }
            }));
        }
        let widget = gtk4::ScrolledWindow::new();
        widget.set_child(Some(&list));
        widget.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
        widget.set_vexpand(true);
        Sidebar {
            widget,
            list,
            rows,
            collapsed_roots,
            on_select,
            on_close,
            on_toggle,
            menu_builder,
        }
    }

    pub fn set_menu_builder<F: Fn(&str) -> gio::Menu + 'static>(&self, cb: F) {
        *self.menu_builder.borrow_mut() = Some(Box::new(cb));
    }

    pub fn set_on_select(&self, cb: impl Fn(String) + 'static) {
        *self.on_select.borrow_mut() = Some(Box::new(cb));
    }

    pub fn set_on_close(&self, cb: impl Fn(String) + 'static) {
        *self.on_close.borrow_mut() = Some(Box::new(cb));
    }

    /// Reconcile rows with `items` (priority order), updating in
    /// place. Moves remove + re-insert rows, which drops GTK's selection
    /// on the moved row — so snapshot the selected workspace and restore
    /// it after: the highlight tracks the workspace, not the row index.
    /// The echo is harmless (`on_select` skips the already-active one).
    pub fn update(&self, items: Vec<WsSummary>) {
        let selected = self
            .list
            .selected_row()
            .map(|r| r.widget_name().to_string());
        let mut rows = self.rows.borrow_mut();
        rows.retain(|r| {
            let keep = items.iter().any(|s| s.id == r.id);
            if !keep {
                self.list.remove(&r.row);
            }
            keep
        });
        for (i, item) in items.iter().enumerate() {
            match rows.iter().position(|r| r.id == item.id) {
                Some(pos) if pos == i => rows[i].update(item.clone()),
                Some(pos) => {
                    let mut row = rows.remove(pos);
                    self.list.remove(&row.row);
                    self.list.insert(&row.row, i as i32);
                    row.update(item.clone());
                    rows.insert(i, row);
                }
                None => {
                    let mut row = Row::new(
                        &item.id,
                        &self.list,
                        &self.on_close,
                        &self.on_toggle,
                        &self.menu_builder,
                    );
                    row.update(item.clone());
                    self.list.insert(&row.row, i as i32);
                    rows.insert(i, row);
                }
            }
        }

        let mut child_counts: std::collections::HashMap<String, usize> =
            std::collections::HashMap::new();
        let mut child_needs_counts: std::collections::HashMap<String, usize> =
            std::collections::HashMap::new();
        let mut child_finished_counts: std::collections::HashMap<String, usize> =
            std::collections::HashMap::new();
        for item in &items {
            if let Some(parent_id) = &item.parent {
                if item.finished {
                    *child_finished_counts.entry(parent_id.clone()).or_default() += 1;
                } else {
                    *child_counts.entry(parent_id.clone()).or_default() += 1;
                    if needs_you(item.attention) {
                        *child_needs_counts.entry(parent_id.clone()).or_default() += 1;
                    }
                }
            }
        }

        self.collapsed_roots
            .borrow_mut()
            .retain(|id| child_counts.contains_key(id) || child_finished_counts.contains_key(id));

        let collapsed = self.collapsed_roots.borrow();
        for row in rows.iter_mut() {
            let is_child = row.parent.is_some();
            row.set_child_mode(is_child);
            if let Some(parent_id) = &row.parent {
                let is_hidden = collapsed.contains(parent_id);
                row.row.set_visible(!is_hidden);
            } else {
                row.row.set_visible(true);
                let count = child_counts.get(&row.id).copied().unwrap_or(0);
                let needs = child_needs_counts.get(&row.id).copied().unwrap_or(0);
                let finished = child_finished_counts.get(&row.id).copied().unwrap_or(0);
                let is_collapsed = collapsed.contains(&row.id);
                row.update_disclosure(count, needs, finished, is_collapsed);
            }
        }
        drop(collapsed);
        drop(rows);

        if let Some(id) = selected {
            if self.rows.borrow().iter().any(|r| r.id == id) {
                self.select(&id);
            }
        }
        // Section membership can change without a move.
        self.list.invalidate_headers();
    }

    /// Worker chips for workspaces that own task panes. Rows with no
    /// entry hide the chip box. Updates the existing chips in place.
    pub fn set_task_chips(
        &self,
        by_workspace: &std::collections::HashMap<String, Vec<TaskChipView>>,
    ) {
        let empty = Vec::new();
        for row in self.rows.borrow_mut().iter_mut() {
            row.set_chips(by_workspace.get(&row.id).unwrap_or(&empty));
        }
    }

    /// Re-render relative times ("now" → "2m") between server events.
    pub fn refresh_times(&self) {
        for r in self.rows.borrow().iter() {
            r.refresh_time();
        }
    }

    pub fn select(&self, ws_id: &str) {
        let mut rows = self.rows.borrow_mut();
        if let Some(pos) = rows.iter().position(|r| r.id == ws_id) {
            if let Some(parent_id) = rows[pos].parent.clone() {
                if self.collapsed_roots.borrow().contains(&parent_id) {
                    self.collapsed_roots.borrow_mut().remove(&parent_id);
                    for r in rows.iter_mut() {
                        if r.parent.as_deref() == Some(&parent_id) {
                            r.row.set_visible(true);
                        }
                        if r.id == parent_id {
                            r.set_collapsed(false);
                        }
                    }
                    self.list.invalidate_headers();
                }
            }
            if !rows[pos].row.is_selected() {
                self.list.select_row(Some(&rows[pos].row));
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;
    use signaltty_core::PtySize;

    fn summary(
        name: &str,
        lifecycle: Lifecycle,
        attention: Attention,
        minutes_ago: Option<i64>,
    ) -> WsSummary {
        WsSummary {
            id: name.to_string(),
            name: name.to_string(),
            disambiguator: None,
            parent: None,
            finished: false,
            group_needs_you: false,
            created_at: Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap(),
            lifecycle,
            attention,
            message: None,
            lifecycle_since: None,
            attention_since: None,
            last_run_secs: None,
            place: String::new(),
            agents: String::new(),
            last_activity: minutes_ago.map(|m| {
                Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap() - chrono::Duration::minutes(m)
            }),
        }
    }

    fn names(items: &[WsSummary]) -> Vec<&str> {
        items.iter().map(|s| s.name.as_str()).collect()
    }

    #[test]
    fn sections_appear_only_around_rows_that_need_you() {
        use SectionHeader::*;
        let headers = |needs: &[bool]| -> Vec<Option<SectionHeader>> {
            needs
                .iter()
                .enumerate()
                .map(|(i, n)| section_header(i.checked_sub(1).map(|j| needs[j]), *n))
                .collect()
        };
        assert_eq!(
            headers(&[true, true, false, false]),
            [Some(NeedsYou), None, Some(Rest), None]
        );
        assert_eq!(headers(&[false, false]), [None, None], "calm → no headers");
        assert_eq!(headers(&[true]), [Some(NeedsYou)]);
        assert!(needs_you(Attention::Warning));
        assert!(needs_you(Attention::PermissionRequired));
        assert!(!needs_you(Attention::Unread));
    }

    #[test]
    fn attention_severity_beats_lifecycle() {
        let mut items = vec![
            summary(
                "blocked-quiet",
                Lifecycle::Blocked,
                Attention::None,
                Some(0),
            ),
            summary("idle-error", Lifecycle::Idle, Attention::Error, Some(60)),
            summary("idle-unread", Lifecycle::Idle, Attention::Unread, Some(0)),
        ];
        sort_summaries(&mut items);
        assert_eq!(
            names(&items),
            ["idle-error", "idle-unread", "blocked-quiet"]
        );
    }

    #[test]
    fn lifecycle_follows_directive_order_within_equal_attention() {
        let mut items = vec![
            summary("idle", Lifecycle::Idle, Attention::None, Some(0)),
            summary("working", Lifecycle::Working, Attention::None, Some(0)),
            summary("done", Lifecycle::Done, Attention::None, Some(0)),
            summary("blocked", Lifecycle::Blocked, Attention::None, Some(0)),
        ];
        sort_summaries(&mut items);
        assert_eq!(names(&items), ["blocked", "done", "working", "idle"]);
    }

    #[test]
    fn creation_then_name_break_ties_deterministically() {
        let t0 = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        let at = |name: &str, created_mins_ago: i64| WsSummary {
            created_at: t0 - chrono::Duration::minutes(created_mins_ago),
            ..summary(name, Lifecycle::Idle, Attention::None, Some(1))
        };
        // Newer workspaces first; names break created ties.
        let mut items = vec![at("b-old", 30), at("a-new", 1), at("c-new", 1)];
        sort_summaries(&mut items);
        assert_eq!(names(&items), ["a-new", "c-new", "b-old"]);
        let mut again = vec![at("b-old", 30), at("c-new", 1), at("a-new", 1)];
        sort_summaries(&mut again);
        assert_eq!(names(&again), names(&items), "order input-independent");
    }

    #[test]
    fn order_survives_activity_churn() {
        // Regression test for rows flapping under the pointer: agent events
        // rewrite last_activity constantly, and the order must not follow —
        // severity and rank float urgent work instead.
        let t0 = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        let at = |name: &str, created_mins_ago: i64, active_mins_ago: i64| WsSummary {
            created_at: t0 - chrono::Duration::minutes(created_mins_ago),
            ..summary(
                name,
                Lifecycle::Working,
                Attention::None,
                Some(active_mins_ago),
            )
        };
        let mut items = vec![at("a", 60, 50), at("b", 30, 1)];
        sort_summaries(&mut items);
        assert_eq!(names(&items), ["b", "a"]);
        // Fresh tool events on `a` (activity now) must not move it.
        let mut churned = vec![at("a", 60, 0), at("b", 30, 1)];
        sort_summaries(&mut churned);
        assert_eq!(
            names(&churned),
            ["b", "a"],
            "activity churn must not reorder"
        );
    }

    #[test]
    fn children_follow_their_root() {
        let mut r1 = summary("root1", Lifecycle::Idle, Attention::None, Some(0));
        r1.created_at = Utc.with_ymd_and_hms(2026, 9, 28, 10, 0, 0).unwrap();
        let mut c1_1 = summary("child1-1", Lifecycle::Working, Attention::None, Some(0));
        c1_1.parent = Some("root1".into());
        let mut c1_2 = summary("child1-2", Lifecycle::Done, Attention::None, Some(0));
        c1_2.parent = Some("root1".into());

        let mut r2 = summary("root2", Lifecycle::Idle, Attention::None, Some(0));
        r2.created_at = Utc.with_ymd_and_hms(2026, 9, 28, 11, 0, 0).unwrap();

        // Without grouping, r2 would sort before r1 (newer created_at).
        // With grouping, root1's best lifecycle rank is Done (rank 3), beating root2's Idle (rank 1).
        let mut items = vec![r2, r1, c1_2, c1_1];
        sort_summaries(&mut items);
        assert_eq!(names(&items), ["root1", "child1-2", "child1-1", "root2"]);
    }

    #[test]
    fn root_order_uses_group_severity_floating_calm_root() {
        let calm_root = summary("calm-root", Lifecycle::Idle, Attention::None, Some(0));
        let mut needy_child = summary(
            "needy-child",
            Lifecycle::Working,
            Attention::PermissionRequired,
            Some(0),
        );
        needy_child.parent = Some("calm-root".into());

        let other_root = summary("other-root", Lifecycle::Blocked, Attention::None, Some(0));

        let mut items = vec![other_root, calm_root, needy_child];
        sort_summaries(&mut items);
        assert_eq!(names(&items), ["calm-root", "needy-child", "other-root"]);
        assert!(items[0].group_needs_you);
        assert!(items[1].group_needs_you);
        assert!(!items[2].group_needs_you);
    }

    #[test]
    fn orphan_child_becomes_root() {
        let mut orphan = summary("orphan", Lifecycle::Idle, Attention::None, Some(0));
        orphan.parent = Some("missing-parent".into());
        let normal = summary("normal", Lifecycle::Idle, Attention::None, Some(0));

        let mut items = vec![orphan, normal];
        sort_summaries(&mut items);
        assert_eq!(items[0].parent, None);
        assert_eq!(items[1].parent, None);
    }

    #[test]
    fn nested_parent_chain_flattens_to_root() {
        let root = summary("root", Lifecycle::Idle, Attention::None, Some(0));
        let mut child = summary("child", Lifecycle::Idle, Attention::None, Some(0));
        child.parent = Some("root".into());
        let mut grandchild = summary("grandchild", Lifecycle::Idle, Attention::None, Some(0));
        grandchild.parent = Some("child".into());

        let mut items = vec![grandchild, child, root];
        sort_summaries(&mut items);
        assert_eq!(items[0].name, "root");
        assert_eq!(items[0].parent, None);
        assert_eq!(items[1].parent, Some("root".into()));
        assert_eq!(items[2].parent, Some("root".into()));
    }

    #[test]
    fn cycle_terminates() {
        let mut a = summary("a", Lifecycle::Idle, Attention::None, Some(0));
        a.parent = Some("b".into());
        let mut b = summary("b", Lifecycle::Idle, Attention::None, Some(0));
        b.parent = Some("a".into());

        let mut items = vec![a, b];
        sort_summaries(&mut items);
        assert_eq!(items.len(), 2);
        assert_eq!(items[0].parent, None);
        assert_eq!(items[1].parent, None);
    }

    #[test]
    fn monogram_takes_word_initials_or_two_letters() {
        assert_eq!(monogram("api-server"), "AS");
        assert_eq!(monogram("signaltty gui app"), "SG");
        assert_eq!(monogram("simone"), "SI");
        assert_eq!(monogram("x"), "X");
        assert_eq!(monogram("--"), "?");
        assert_eq!(monogram("èco"), "ÈC");
    }

    #[test]
    fn mark_tint_is_stable_and_in_range() {
        assert_eq!(mark_tint("ws_a"), mark_tint("ws_a"));
        let tints: std::collections::HashSet<_> =
            (0..32).map(|i| mark_tint(&format!("ws_{i}"))).collect();
        assert!(tints.iter().all(|t| *t < MARK_TINTS));
        assert_eq!(tints.len(), MARK_TINTS, "ids spread over every tint");
    }

    #[test]
    fn headline_prefers_message_then_run_state_then_lifecycle() {
        let now = Utc::now();
        let mut s = summary("ws", Lifecycle::Done, Attention::None, Some(0));
        assert_eq!(s.headline(now), "Done", "no timing → plain word");
        s.last_run_secs = Some(130);
        assert_eq!(s.headline(now), "Worked for 2m");
        s.message = Some("Bash(cargo test)".into());
        assert_eq!(s.headline(now), "Bash(cargo test)");
    }

    fn workspace() -> Workspace {
        let now = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        Workspace {
            id: "ws".into(),
            name: "ws".into(),
            handle: "ws".into(),
            cwd: "/tmp".into(),
            git: Default::default(),
            tabs: vec![],
            active_tab_id: None,
            auto_resume: false,
            created_at: now,
            updated_at: now,
        }
    }

    fn pane(lifecycle: Lifecycle, since: Option<DateTime<Utc>>) -> Pane {
        let now = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        let mut p = Pane::new(
            "ws".into(),
            "tab".into(),
            "/tmp".into(),
            vec!["sh".into()],
            PtySize::default(),
            now,
        );
        p.lifecycle = lifecycle;
        p.lifecycle_since = since;
        p
    }

    #[test]
    fn lead_pane_is_the_longest_running_of_the_rolled_up_state() {
        let t0 = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        let ago = |m| Some(t0 - chrono::Duration::minutes(m));
        let panes = vec![
            pane(Lifecycle::Working, ago(1)),
            pane(Lifecycle::Working, ago(12)),
            pane(Lifecycle::Idle, ago(60)),
        ];
        let s = summarize(&workspace(), &panes);
        assert_eq!(s.lifecycle, Lifecycle::Working);
        assert_eq!(s.lifecycle_since, ago(12));
        assert_eq!(s.headline(t0), "Working for 12m…");
    }

    #[test]
    fn attention_age_comes_from_the_gate_not_later_activity() {
        let t0 = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        let mut gate = pane(Lifecycle::Blocked, None);
        gate.attention = Attention::PermissionRequired;
        gate.attention_since = Some(t0 - chrono::Duration::minutes(10));
        let mut chatty = pane(Lifecycle::Working, None);
        chatty.last_activity_at = t0;
        let s = summarize(&workspace(), &[gate, chatty]);
        assert_eq!(s.attention, Attention::PermissionRequired);
        assert_eq!(s.attention_since, Some(t0 - chrono::Duration::minutes(10)));
    }

    #[test]
    fn untimed_legacy_pane_never_shadows_a_timed_sibling() {
        let t0 = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        let panes = vec![
            pane(Lifecycle::Working, None),
            pane(Lifecycle::Working, Some(t0 - chrono::Duration::minutes(12))),
        ];
        let s = summarize(&workspace(), &panes);
        assert_eq!(
            s.headline(t0),
            "Working for 12m…",
            "`None` timing must sort last, not win `min_by_key`"
        );
    }

    #[test]
    fn restored_working_pane_cannot_supply_a_live_siblings_elapsed_time() {
        let now = Utc.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
        let mut restored = pane(
            Lifecycle::Working,
            Some(now - chrono::Duration::minutes(45)),
        );
        restored.live = signaltty_core::LiveState::Exited { code: None };
        restored.restore_state = signaltty_core::RestoreState::Restored;
        let running = pane(Lifecycle::Working, Some(now - chrono::Duration::minutes(2)));
        let summary = summarize(&workspace(), &[restored.clone(), running]);
        assert_eq!(summary.lifecycle, Lifecycle::Working);
        assert_eq!(summary.headline(now), "Working for 2m…");

        let stopped = summarize(&workspace(), &[restored]);
        assert_eq!(stopped.lifecycle, Lifecycle::Exited);
        assert_eq!(stopped.headline(now), "Exited");
    }

    #[test]
    fn sort_summaries_finished_children_sort_after_unfinished_siblings() {
        let root = summary("root", Lifecycle::Idle, Attention::None, Some(0));

        let mut child_unfinished_working = summary(
            "child-unfin-work",
            Lifecycle::Working,
            Attention::None,
            Some(0),
        );
        child_unfinished_working.parent = Some("root".into());
        child_unfinished_working.finished = false;

        let mut child_finished_working = summary(
            "child-fin-work",
            Lifecycle::Working,
            Attention::None,
            Some(0),
        );
        child_finished_working.parent = Some("root".into());
        child_finished_working.finished = true;

        let mut child_unfinished_idle = summary(
            "child-unfin-idle",
            Lifecycle::Idle,
            Attention::None,
            Some(0),
        );
        child_unfinished_idle.parent = Some("root".into());
        child_unfinished_idle.finished = false;

        let mut child_finished_idle =
            summary("child-fin-idle", Lifecycle::Idle, Attention::None, Some(0));
        child_finished_idle.parent = Some("root".into());
        child_finished_idle.finished = true;

        let mut items = vec![
            child_finished_working,
            child_unfinished_idle,
            root,
            child_finished_idle,
            child_unfinished_working,
        ];
        sort_summaries(&mut items);

        // Group ordering: root is at 0, followed by children
        assert_eq!(items[0].name, "root");
        // Unfinished children sort before finished children:
        // Among unfinished: child-unfin-work (Working) ranks before child-unfin-idle (Idle)
        assert_eq!(items[1].name, "child-unfin-work");
        assert_eq!(items[2].name, "child-unfin-idle");
        // Among finished: child-fin-work (Working) ranks before child-fin-idle (Idle)
        assert_eq!(items[3].name, "child-fin-work");
        assert_eq!(items[4].name, "child-fin-idle");
    }

    #[test]
    fn disclosure_label_formatting() {
        // No tasks -> None
        assert_eq!(disclosure_label(0, 0, 0, false), None);
        assert_eq!(disclosure_label(0, 0, 0, true), None);

        // Only active
        assert_eq!(
            disclosure_label(1, 0, 0, false),
            Some("▾ 1 task".to_string())
        );
        assert_eq!(
            disclosure_label(3, 0, 0, true),
            Some("▸ 3 tasks".to_string())
        );

        // Active with needs you
        assert_eq!(
            disclosure_label(3, 1, 0, false),
            Some("▾ 3 tasks · 1 need you".to_string())
        );
        assert_eq!(
            disclosure_label(3, 2, 0, true),
            Some("▸ 3 tasks · 2 need you".to_string())
        );

        // Active with finished
        assert_eq!(
            disclosure_label(3, 0, 2, false),
            Some("▾ 3 tasks · 2 done".to_string())
        );

        // Active with needs you and finished
        assert_eq!(
            disclosure_label(3, 1, 2, false),
            Some("▾ 3 tasks · 1 need you · 2 done".to_string())
        );

        // Only finished (0 active)
        assert_eq!(
            disclosure_label(0, 0, 2, false),
            Some("▾ 2 done".to_string())
        );
        assert_eq!(
            disclosure_label(0, 0, 1, true),
            Some("▸ 1 done".to_string())
        );
    }
}