use crate::client::{FleetProjectGroupWire, ManagedActivityResponse, ManagedSessionSummary};
use crate::project::Project;
use crate::tui::coordinator::rows::session_short_id;
use crate::tui::project_ctl::state::{ActivityInfo, ProjectRow, SessionRow};
const RAW_PANE_TAIL_LINES: usize = 3;
pub(crate) fn project_to_row(p: &Project, group: Option<&FleetProjectGroupWire>) -> ProjectRow {
let (live_count, total_count) = match group {
Some(g) => (
g.sessions
.iter()
.filter(|s| matches!(s.state.as_str(), "active" | "provisioning"))
.count(),
g.sessions.len(),
),
None => (0, 0),
};
ProjectRow {
name: p.name.clone(),
repo_url: p.repo_url.clone(),
live_count,
total_count,
}
}
pub(crate) fn live_session_rows(sessions: Vec<ManagedSessionSummary>) -> Vec<SessionRow> {
sessions
.into_iter()
.filter(|s| {
!crate::session_manager::ManagedSessionState::from_wire(&s.state)
.is_some_and(|st| st.is_terminal())
})
.map(session_to_row)
.collect()
}
pub(crate) fn session_to_row(s: ManagedSessionSummary) -> SessionRow {
let short_id = session_short_id(&s.id);
SessionRow {
id: s.id,
short_id,
name: s.name,
branch: s.branch,
task: s.task,
state: s.state,
pending_decision: s.pending_decision,
proposed_default: s.proposed_default,
deliverable_id: s.deliverable_id,
unresumable: s.unresumable,
}
}
pub(crate) fn activity_from_response(
session_id: String,
resp: ManagedActivityResponse,
) -> ActivityInfo {
ActivityInfo {
session_id,
state: resp.state,
summary: resp.summary,
pending_decision: resp.pending_decision,
proposed_default: resp.proposed_default,
raw_pane_tail: tail_lines(&resp.raw_pane, RAW_PANE_TAIL_LINES),
stale: false,
}
}
fn tail_lines(text: &str, n: usize) -> Vec<String> {
let non_empty: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
let start = non_empty.len().saturating_sub(n);
non_empty[start..].iter().map(|l| l.to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn project(name: &str) -> Project {
Project {
name: name.to_string(),
repo_url: format!("https://github.com/acme/{name}"),
default_branch: "main".to_string(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
fn summary(id: &str, state: &str) -> ManagedSessionSummary {
ManagedSessionSummary {
id: id.to_string(),
name: format!("s-{id}"),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: Some("main".to_string()),
created_at: None,
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: Some("do the thing".to_string()),
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
attached: false,
slot: 0,
deleted: false,
}
}
#[test]
fn project_to_row_counts_live_and_total() {
let p = project("widget");
let group = FleetProjectGroupWire {
project_name: "widget".to_string(),
repo_url: p.repo_url.clone(),
sessions: vec![
summary("a1111111", "active"),
summary("b2222222", "provisioning"),
summary("c3333333", "stopped"),
],
};
let row = project_to_row(&p, Some(&group));
assert_eq!(row.live_count, 2);
assert_eq!(row.total_count, 3);
assert_eq!(row.name, "widget");
}
#[test]
fn project_to_row_missing_group_is_zeroed() {
let p = project("widget");
let row = project_to_row(&p, None);
assert_eq!(row.live_count, 0);
assert_eq!(row.total_count, 0);
}
#[test]
fn session_to_row_derives_short_id_and_copies_fields() {
let row = session_to_row(summary("4f9ca1b2ffff", "active"));
assert_eq!(row.short_id, "4f9ca1b2");
assert_eq!(row.id, "4f9ca1b2ffff");
assert_eq!(row.state, "active");
assert_eq!(row.branch.as_deref(), Some("main"));
assert_eq!(row.task.as_deref(), Some("do the thing"));
}
#[test]
fn session_to_row_carries_deliverable_id() {
let mut s = summary("4f9ca1b2ffff", "active");
s.deliverable_id = Some("11111111-1111-1111-1111-111111111111".to_string());
let row = session_to_row(s);
assert_eq!(
row.deliverable_id.as_deref(),
Some("11111111-1111-1111-1111-111111111111")
);
let none_row = session_to_row(summary("aaaaaaaabbbb", "active"));
assert!(none_row.deliverable_id.is_none());
}
#[test]
fn session_to_row_carries_unresumable_flag() {
let mut s = summary("dead0001", "stopped");
s.unresumable = true;
let row = session_to_row(s);
assert!(row.unresumable, "unresumable must copy through as true");
let healthy_row = session_to_row(summary("healthy1", "stopped"));
assert!(
!healthy_row.unresumable,
"a healthy session must copy through as false"
);
}
#[test]
fn live_session_rows_drops_decommissioned() {
let sessions = vec![
summary("a1111111", "active"),
summary("b2222222", "decommissioned"),
];
let rows = live_session_rows(sessions);
assert_eq!(
rows.len(),
1,
"decommissioned row must be dropped: {rows:?}"
);
assert_eq!(rows[0].id, "a1111111");
}
#[test]
fn live_session_rows_drops_deleted() {
let sessions = vec![
summary("a1111111", "active"),
summary("d2222222", "deleted"),
];
let rows = live_session_rows(sessions);
assert_eq!(rows.len(), 1, "deleted row must be dropped: {rows:?}");
assert_eq!(rows[0].id, "a1111111");
}
#[test]
fn live_session_rows_keeps_live_states() {
let sessions = vec![
summary("a1111111", "active"),
summary("b2222222", "provisioning"),
summary("c3333333", "stopped"),
summary("d4444444", "errored"),
];
let rows = live_session_rows(sessions);
assert_eq!(rows.len(), 4);
assert_eq!(
rows.iter().map(|r| r.state.as_str()).collect::<Vec<_>>(),
vec!["active", "provisioning", "stopped", "errored"]
);
}
#[test]
fn live_session_rows_same_name_replacement_is_clean() {
let mut old = summary("a1111111", "decommissioned");
old.name = "worker".to_string();
let mut replacement = summary("b2222222", "active");
replacement.name = "worker".to_string();
let rows = live_session_rows(vec![old, replacement]);
assert_eq!(
rows.len(),
1,
"expected exactly one surviving row: {rows:?}"
);
assert_eq!(rows[0].id, "b2222222");
assert_eq!(rows[0].name, "worker");
}
fn activity_response(raw_pane: &str) -> ManagedActivityResponse {
ManagedActivityResponse {
raw_pane: raw_pane.to_string(),
runtime_active: true,
pane_stale: false,
state: "working".to_string(),
summary: "running tests".to_string(),
confidence: 0.9,
cache_hit: false,
input_tokens: 0,
output_tokens: 0,
latency_ms: 12,
total_input_tokens: 0,
total_output_tokens: 0,
classification: None,
pending_decision: Some("write to ci.yml?".to_string()),
proposed_default: Some("yes".to_string()),
}
}
#[test]
fn activity_from_response_derives_fields_and_tail() {
let resp = activity_response("line1\nline2\nline3\nline4\n");
let info = activity_from_response("sess-1".to_string(), resp);
assert_eq!(info.session_id, "sess-1");
assert_eq!(info.state, "working");
assert_eq!(info.summary, "running tests");
assert_eq!(info.pending_decision.as_deref(), Some("write to ci.yml?"));
assert_eq!(info.proposed_default.as_deref(), Some("yes"));
assert_eq!(info.raw_pane_tail, vec!["line2", "line3", "line4"]);
assert!(!info.stale);
}
#[test]
fn activity_from_response_handles_short_pane() {
let resp = activity_response("only one line");
let info = activity_from_response("sess-1".to_string(), resp);
assert_eq!(info.raw_pane_tail, vec!["only one line"]);
}
#[test]
fn tail_lines_keeps_last_n_non_empty_lines() {
let text = "a\nb\n\nc\nd\n";
assert_eq!(tail_lines(text, 2), vec!["c", "d"]);
}
#[test]
fn tail_lines_handles_fewer_lines_than_requested() {
assert_eq!(tail_lines("only", 3), vec!["only"]);
assert_eq!(tail_lines("", 3), Vec::<String>::new());
}
}