yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The conversation-list view-model (DESIGN §11 altitude 0, §15 Z9).
//!
//! A **conversation** is a root agent in the focused workspace (§1); its
//! subtree (the §2.3 hyphenated descent) rides with it. One row per root:
//! state badge (aggregated over the subtree — InFlight > Live > the root's
//! settled state), first-line preview, age, and the streaming pulse while any
//! member is in flight. Sort: **attention > running > recency** (§6/§11).
//! Pure over the injected agent snapshot + the seen closure; the shell paints
//! [`ConvRow`]s and renders the center's descent tree from [`members`].

use crate::attention;
use crate::git_tree::{Agent, AgentState, DescentRow, descent_order};
use crate::projects::join::JoinState;
use crate::ui_state::SeenKind;

pub mod group;

/// First-line preview cap (characters); the row is a glance, not a transcript.
const PREVIEW_CAP: usize = 80;

/// The conversation's associated start-flow ball (DESIGN §3.2, §3.3, §3.5): the
/// `id` is the goal stamp — source 1, the *only* per-conversation attribution
/// that exists (§3.2: agent-picked balls bind at the workspace level, not the
/// conversation). The join facts (`state`/`title`/`badge`) come from the §3.5
/// claimant join and are `None` when no live/closed ball matches the stamped id
/// here (project unfetched, or a stray id) — the badge still renders from source 1.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConvBall {
    pub id: String,
    pub state: Option<JoinState>,
    pub title: Option<String>,
    pub badge: Option<String>,
}

/// One conversation row (§11): the root agent id, the aggregated state badge
/// (+ §10 uncertainty), the first-line preview, seconds since the subtree's
/// last activity, the streaming pulse, the subtree attention count, and the
/// member count (root + descendants).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConvRow {
    pub root_id: String,
    pub state: AgentState,
    pub uncertain: bool,
    pub preview: String,
    pub age_secs: i64,
    pub streaming: bool,
    pub attention: usize,
    pub members: usize,
    /// The conversation's start-flow ball (§3.3), derived from the root's goal
    /// stamp and coloured by the §3.5 join. `None` for a bare/path conversation.
    pub ball: Option<ConvBall>,
}

impl ConvRow {
    /// Whether the conversation has children — the §11 gate for rendering the
    /// descent tree in the center (a lone root shows transcript only).
    pub fn has_children(&self) -> bool {
        self.members > 1
    }
}

/// Build the §11 conversation list for one workspace's agents. `ws` is the
/// seen-key path (§4.1) and `now_unix` the caller's wall clock (the age input
/// — injected so the derivation stays pure).
pub fn build(
    agents: &[Agent],
    ws: &str,
    seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool,
    now_unix: i64,
    ball: &dyn Fn(&str) -> ConvBall,
) -> Vec<ConvRow> {
    let mut rows: Vec<(i64, ConvRow)> = conversations(agents)
        .into_iter()
        .map(|subtree| row(agents, &subtree, ws, seen, now_unix, ball))
        .collect();
    // §11 sort: attention > running > recency (latest activity first), then
    // root id for a deterministic tail (I9).
    rows.sort_by(|(ta, a), (tb, b)| {
        let rank = |r: &ConvRow| (r.attention == 0, !running(r.state));
        rank(a)
            .cmp(&rank(b))
            .then_with(|| tb.cmp(ta))
            .then_with(|| a.root_id.cmp(&b.root_id))
    });
    rows.into_iter().map(|(_, r)| r).collect()
}

/// The conversation's rendered subtree (root first, §2.3 descent order) — the
/// center's descent-tree source. Empty when `root_id` is not a root here.
pub fn members(agents: &[Agent], root_id: &str) -> Vec<DescentRow> {
    conversations(agents)
        .into_iter()
        .find(|subtree| {
            subtree
                .first()
                .and_then(|r| agents.get(r.index))
                .is_some_and(|a| a.agent_id == root_id)
        })
        .unwrap_or_default()
}

/// The conversation root an agent belongs to — the selected member's
/// conversation identity (§11 center header). `None` for an unknown id.
pub fn root_of(agents: &[Agent], agent_id: &str) -> Option<String> {
    for subtree in conversations(agents) {
        let root = subtree.first().and_then(|r| agents.get(r.index))?;
        if subtree
            .iter()
            .filter_map(|r| agents.get(r.index))
            .any(|a| a.agent_id == agent_id)
        {
            return Some(root.agent_id.clone());
        }
    }
    None
}

/// A compact age label for a row: `42s`, `7m`, `3h`, `2d`. Negative (clock
/// skew) clamps to `0s`.
pub fn age_label(age_secs: i64) -> String {
    let s = age_secs.max(0);
    match s {
        0..=59 => format!("{s}s"),
        60..=3599 => format!("{}m", s / 60),
        3600..=86399 => format!("{}h", s / 3600),
        _ => format!("{}d", s / 86400),
    }
}

/// Segment the descent order into per-conversation subtrees: each depth-0 row
/// starts a conversation, its descendants follow it (pre-order, §2.3).
fn conversations(agents: &[Agent]) -> Vec<Vec<DescentRow>> {
    let mut out: Vec<Vec<DescentRow>> = Vec::new();
    for r in descent_order(agents) {
        if r.depth == 0 {
            out.push(vec![r]);
        } else if let Some(current) = out.last_mut() {
            current.push(r);
        }
    }
    out
}

fn running(state: AgentState) -> bool {
    matches!(state, AgentState::Live | AgentState::InFlight)
}

/// Project one subtree to its row, returning `(last_active, row)` — the sort
/// key rides beside the row so the age is derived once.
fn row(
    agents: &[Agent],
    subtree: &[DescentRow],
    ws: &str,
    seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool,
    now_unix: i64,
    ball: &dyn Fn(&str) -> ConvBall,
) -> (i64, ConvRow) {
    let members: Vec<&Agent> = subtree.iter().filter_map(|r| agents.get(r.index)).collect();
    // `conversations` only emits non-empty subtrees; a filtered-empty one
    // yields a harmless placeholder root reference via `first`.
    let root = members.first().copied();
    // The per-conversation ball is the *root's* goal stamp (§3.2): a child's
    // goal.md is its own sub-task, never a yog ball attribution.
    let conv_ball = root.and_then(|a| a.goal_ball.as_deref()).map(ball);
    let (state, uncertain) = agg_state(&members, root);
    let last_active = members
        .iter()
        .map(|a| a.tip_timestamp_unix)
        .max()
        .unwrap_or(0);
    let attention_count = members
        .iter()
        .filter(|a| attention::attention(a, ws, seen).any())
        .count();
    let row = ConvRow {
        root_id: root.map(|a| a.agent_id.clone()).unwrap_or_default(),
        state,
        uncertain,
        preview: preview(root),
        age_secs: (now_unix - last_active).max(0),
        streaming: members.iter().any(|a| a.state == AgentState::InFlight),
        attention: attention_count,
        members: members.len(),
        ball: conv_ball,
    };
    (last_active, row)
}

/// The aggregated badge state (§11): InFlight if any member streams, else Live
/// if any member holds a driver, else the root's settled state. The
/// uncertainty flag rides with whichever agent decided.
fn agg_state(members: &[&Agent], root: Option<&Agent>) -> (AgentState, bool) {
    for want in [AgentState::InFlight, AgentState::Live] {
        if let Some(a) = members.iter().find(|a| a.state == want) {
            return (want, a.state_uncertain);
        }
    }
    root.map_or((AgentState::Stopped, false), |a| {
        (a.state, a.state_uncertain)
    })
}

/// The row's first-line preview: the root's request preview, else its live
/// streaming text, else empty — first line only, capped at [`PREVIEW_CAP`].
fn preview(root: Option<&Agent>) -> String {
    let text = root
        .and_then(|a| a.preview.clone().or_else(|| a.streaming_text.clone()))
        .unwrap_or_default();
    text.lines()
        .next()
        .unwrap_or("")
        .chars()
        .take(PREVIEW_CAP)
        .collect()
}

#[cfg(test)]
mod tests;