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;
const PREVIEW_CAP: usize = 80;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConvBall {
pub id: String,
pub state: Option<JoinState>,
pub title: Option<String>,
pub badge: Option<String>,
}
#[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,
pub ball: Option<ConvBall>,
}
impl ConvRow {
pub fn has_children(&self) -> bool {
self.members > 1
}
}
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();
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()
}
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()
}
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
}
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),
}
}
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)
}
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();
let root = members.first().copied();
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)
}
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)
})
}
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;