diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 8ee2668e..b1b15388 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -102,6 +102,27 @@ pub struct SessionMetrics { pub cost_usd: f64, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionBoardMeta { + pub lane: String, + pub project: Option, + pub feature: Option, + pub issue: Option, + pub row_label: Option, + pub previous_lane: Option, + pub previous_row_label: Option, + pub column_index: i64, + pub row_index: i64, + pub stack_index: i64, + pub progress_percent: i64, + pub status_detail: Option, + pub movement_note: Option, + pub activity_kind: Option, + pub activity_note: Option, + pub handoff_backlog: i64, + pub conflict_signal: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMessage { pub id: i64, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 2cb906b8..434ad7c3 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -8,7 +8,7 @@ use std::time::Duration; use crate::observability::{ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; -use super::{Session, SessionMessage, SessionMetrics, SessionState}; +use super::{Session, SessionBoardMeta, SessionMessage, SessionMetrics, SessionState}; pub struct StateStore { conn: Connection, @@ -159,6 +159,28 @@ impl StateStore { timestamp TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS session_board ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + lane TEXT NOT NULL, + project TEXT, + feature TEXT, + issue TEXT, + row_label TEXT, + previous_lane TEXT, + previous_row_label TEXT, + column_index INTEGER NOT NULL DEFAULT 0, + row_index INTEGER NOT NULL DEFAULT 0, + stack_index INTEGER NOT NULL DEFAULT 0, + progress_percent INTEGER NOT NULL DEFAULT 0, + status_detail TEXT, + movement_note TEXT, + activity_kind TEXT, + activity_note TEXT, + handoff_backlog INTEGER NOT NULL DEFAULT 0, + conflict_signal TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS daemon_activity ( id INTEGER PRIMARY KEY CHECK(id = 1), last_dispatch_at TEXT, @@ -188,11 +210,16 @@ impl StateStore { CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read); CREATE INDEX IF NOT EXISTS idx_session_output_session ON session_output(session_id, id); + CREATE INDEX IF NOT EXISTS idx_session_board_lane ON session_board(lane); + CREATE INDEX IF NOT EXISTS idx_session_board_coords + ON session_board(column_index, row_index, stack_index); INSERT OR IGNORE INTO daemon_activity (id) VALUES (1); ", )?; self.ensure_session_columns()?; + self.ensure_session_board_columns()?; + self.refresh_session_board_meta()?; Ok(()) } @@ -343,6 +370,97 @@ impl StateStore { Ok(()) } + fn ensure_session_board_columns(&self) -> Result<()> { + if !self.has_column("session_board", "row_label")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN row_label TEXT", []) + .context("Failed to add row_label column to session_board table")?; + } + + if !self.has_column("session_board", "column_index")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN column_index INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add column_index column to session_board table")?; + } + + if !self.has_column("session_board", "row_index")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN row_index INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add row_index column to session_board table")?; + } + + if !self.has_column("session_board", "stack_index")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN stack_index INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add stack_index column to session_board table")?; + } + + if !self.has_column("session_board", "progress_percent")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN progress_percent INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add progress_percent column to session_board table")?; + } + + if !self.has_column("session_board", "status_detail")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN status_detail TEXT", []) + .context("Failed to add status_detail column to session_board table")?; + } + + if !self.has_column("session_board", "previous_lane")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN previous_lane TEXT", []) + .context("Failed to add previous_lane column to session_board table")?; + } + + if !self.has_column("session_board", "previous_row_label")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN previous_row_label TEXT", []) + .context("Failed to add previous_row_label column to session_board table")?; + } + + if !self.has_column("session_board", "movement_note")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN movement_note TEXT", []) + .context("Failed to add movement_note column to session_board table")?; + } + + if !self.has_column("session_board", "activity_note")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN activity_note TEXT", []) + .context("Failed to add activity_note column to session_board table")?; + } + + if !self.has_column("session_board", "activity_kind")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN activity_kind TEXT", []) + .context("Failed to add activity_kind column to session_board table")?; + } + + if !self.has_column("session_board", "handoff_backlog")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN handoff_backlog INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add handoff_backlog column to session_board table")?; + } + + Ok(()) + } + fn has_column(&self, table: &str, column: &str) -> Result { let pragma = format!("PRAGMA table_info({table})"); let mut stmt = self.conn.prepare(&pragma)?; @@ -374,6 +492,7 @@ impl StateStore { session.updated_at.to_rfc3339(), ], )?; + self.refresh_session_board_meta()?; Ok(()) } @@ -397,6 +516,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -433,6 +553,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -450,6 +571,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -547,6 +669,46 @@ impl StateStore { Ok(self.list_sessions()?.into_iter().next()) } + pub fn list_session_board_meta(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT session_id, lane, project, feature, issue, row_label, + previous_lane, previous_row_label, + column_index, row_index, stack_index, progress_percent, + status_detail, movement_note, activity_kind, activity_note, + handoff_backlog, conflict_signal + FROM session_board", + )?; + + let meta = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + SessionBoardMeta { + lane: row.get(1)?, + project: row.get(2)?, + feature: row.get(3)?, + issue: row.get(4)?, + row_label: row.get(5)?, + previous_lane: row.get(6)?, + previous_row_label: row.get(7)?, + column_index: row.get(8)?, + row_index: row.get(9)?, + stack_index: row.get(10)?, + progress_percent: row.get(11)?, + status_detail: row.get(12)?, + movement_note: row.get(13)?, + activity_kind: row.get(14)?, + activity_note: row.get(15)?, + handoff_backlog: row.get(16)?, + conflict_signal: row.get(17)?, + }, + )) + })? + .collect::, _>>()?; + + Ok(meta) + } + pub fn get_session(&self, id: &str) -> Result> { let sessions = self.list_sessions()?; Ok(sessions @@ -577,6 +739,94 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; + Ok(()) + } + + fn refresh_session_board_meta(&self) -> Result<()> { + self.conn.execute( + "DELETE FROM session_board + WHERE session_id NOT IN (SELECT id FROM sessions)", + [], + )?; + + let existing_meta = self.list_session_board_meta().unwrap_or_default(); + let sessions = self.list_sessions()?; + let board_meta = derive_board_meta_map(&sessions); + let now = chrono::Utc::now().to_rfc3339(); + + for session in sessions { + let mut meta = board_meta + .get(&session.id) + .cloned() + .unwrap_or_else(|| SessionBoardMeta { + lane: board_lane_for_state(&session.state).to_string(), + ..SessionBoardMeta::default() + }); + if let Some(previous) = existing_meta.get(&session.id) { + annotate_board_motion(&mut meta, previous); + } + if let Some((activity_kind, activity_note)) = + self.latest_task_handoff_activity(&session.id)? + { + meta.activity_kind = Some(activity_kind); + meta.activity_note = Some(activity_note); + } else { + meta.activity_kind = None; + meta.activity_note = None; + } + meta.handoff_backlog = self.unread_task_handoff_count(&session.id)? as i64; + self.conn.execute( + "INSERT INTO session_board ( + session_id, lane, project, feature, issue, row_label, + previous_lane, previous_row_label, + column_index, row_index, stack_index, progress_percent, + status_detail, movement_note, activity_kind, activity_note, + handoff_backlog, conflict_signal, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) + ON CONFLICT(session_id) DO UPDATE SET + lane = excluded.lane, + project = excluded.project, + feature = excluded.feature, + issue = excluded.issue, + row_label = excluded.row_label, + previous_lane = excluded.previous_lane, + previous_row_label = excluded.previous_row_label, + column_index = excluded.column_index, + row_index = excluded.row_index, + stack_index = excluded.stack_index, + progress_percent = excluded.progress_percent, + status_detail = excluded.status_detail, + movement_note = excluded.movement_note, + activity_kind = excluded.activity_kind, + activity_note = excluded.activity_note, + handoff_backlog = excluded.handoff_backlog, + conflict_signal = excluded.conflict_signal, + updated_at = excluded.updated_at", + rusqlite::params![ + session.id, + meta.lane, + meta.project, + meta.feature, + meta.issue, + meta.row_label, + meta.previous_lane, + meta.previous_row_label, + meta.column_index, + meta.row_index, + meta.stack_index, + meta.progress_percent, + meta.status_detail, + meta.movement_note, + meta.activity_kind, + meta.activity_note, + meta.handoff_backlog, + meta.conflict_signal, + now, + ], + )?; + } + Ok(()) } @@ -586,6 +836,7 @@ impl StateStore { VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![from, to, content, msg_type, chrono::Utc::now().to_rfc3339()], )?; + self.refresh_session_board_meta()?; Ok(()) } @@ -709,6 +960,10 @@ impl StateStore { rusqlite::params![session_id], )?; + if updated > 0 { + self.refresh_session_board_meta()?; + } + Ok(updated) } @@ -718,6 +973,10 @@ impl StateStore { rusqlite::params![message_id], )?; + if updated > 0 { + self.refresh_session_board_meta()?; + } + Ok(updated) } @@ -736,6 +995,69 @@ impl StateStore { .map_err(Into::into) } + fn latest_task_handoff_activity( + &self, + session_id: &str, + ) -> Result> { + let latest_handoff = self + .conn + .query_row( + "SELECT from_session, to_session, content + FROM messages + WHERE msg_type = 'task_handoff' + AND (from_session = ?1 OR to_session = ?1) + ORDER BY id DESC + LIMIT 1", + rusqlite::params![session_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + + Ok(latest_handoff.and_then(|(from_session, to_session, content)| { + let context = extract_task_handoff_context(&content)?; + let routing_suffix = routing_activity_suffix(&context); + + if session_id == to_session { + Some(( + "received".to_string(), + format!( + "Received from {}{}", + short_session_ref(&from_session), + routing_suffix + .map(|value| format!(" | {value}")) + .unwrap_or_default() + ), + )) + } else if session_id == from_session { + let (kind, base) = match routing_suffix { + Some("spawned") => ("spawned", format!("Spawned {}", short_session_ref(&to_session))), + Some("spawned fallback") => { + ("spawned_fallback", format!("Spawned fallback {}", short_session_ref(&to_session))) + } + _ => ("delegated", format!("Delegated to {}", short_session_ref(&to_session))), + }; + Some(( + kind.to_string(), + format!( + "{base}{}", + routing_suffix + .filter(|value| !value.starts_with("spawned")) + .map(|value| format!(" | {value}")) + .unwrap_or_default() + ), + )) + } else { + None + } + })) + } + pub fn daemon_activity(&self) -> Result { self.conn .query_row( @@ -1070,11 +1392,428 @@ impl StateStore { } } +fn board_lane_for_state(state: &SessionState) -> &'static str { + match state { + SessionState::Pending => "Inbox", + SessionState::Running => "In Progress", + SessionState::Idle => "Review", + SessionState::Completed => "Done", + SessionState::Failed => "Blocked", + SessionState::Stopped => "Stopped", + } +} + +fn derive_board_scope(session: &Session) -> (Option, Option, Option) { + let project = extract_labeled_scope(&session.task, &["project", "roadmap", "epic"]); + let feature = extract_labeled_scope(&session.task, &["feature", "workflow", "flow"]); + let issue = extract_issue_reference(&session.task); + (project, feature, issue) +} + +fn derive_board_meta_map(sessions: &[Session]) -> HashMap { + let conflict_signals = derive_board_conflict_signals(sessions); + let scopes = sessions + .iter() + .map(|session| (session.id.clone(), derive_board_scope(session))) + .collect::>(); + + let mut row_specs = scopes + .iter() + .map(|(session_id, (project, feature, issue))| { + let row_label = issue + .clone() + .or_else(|| feature.clone()) + .or_else(|| project.clone()) + .or_else(|| { + sessions + .iter() + .find(|session| &session.id == session_id) + .and_then(|session| session.worktree.as_ref()) + .map(|worktree| worktree.branch.clone()) + }) + .unwrap_or_else(|| "General".to_string()); + + let row_rank = if issue.is_some() { + 0 + } else if feature.is_some() { + 1 + } else if project.is_some() { + 2 + } else { + 3 + }; + + (session_id.clone(), row_label, row_rank) + }) + .collect::>(); + + row_specs.sort_by(|left, right| { + left.2 + .cmp(&right.2) + .then_with(|| left.1.to_ascii_lowercase().cmp(&right.1.to_ascii_lowercase())) + .then_with(|| left.0.cmp(&right.0)) + }); + + let mut row_indices = HashMap::new(); + let mut next_row_index = 0_i64; + for (_, row_label, row_rank) in &row_specs { + let key = (*row_rank, row_label.clone()); + if let std::collections::hash_map::Entry::Vacant(entry) = row_indices.entry(key) { + entry.insert(next_row_index); + next_row_index += 1; + } + } + + let mut stack_counts: HashMap<(i64, i64), i64> = HashMap::new(); + let mut board_meta = HashMap::new(); + + for session in sessions { + let (project, feature, issue) = scopes + .get(&session.id) + .cloned() + .unwrap_or((None, None, None)); + let (_, row_label, row_rank) = row_specs + .iter() + .find(|(session_id, _, _)| session_id == &session.id) + .cloned() + .unwrap_or_else(|| (session.id.clone(), "General".to_string(), 4)); + let column_index = board_column_index(&session.state); + let row_index = row_indices + .get(&(row_rank, row_label.clone())) + .copied() + .unwrap_or_default(); + let stack_index = { + let entry = stack_counts.entry((column_index, row_index)).or_insert(0); + let current = *entry; + *entry += 1; + current + }; + + board_meta.insert( + session.id.clone(), + SessionBoardMeta { + lane: board_lane_for_state(&session.state).to_string(), + project, + feature, + issue, + row_label: Some(row_label), + previous_lane: None, + previous_row_label: None, + column_index, + row_index, + stack_index, + progress_percent: derive_board_progress_percent(session), + status_detail: derive_board_status_detail(session), + movement_note: None, + activity_kind: None, + activity_note: None, + handoff_backlog: 0, + conflict_signal: conflict_signals.get(&session.id).cloned(), + }, + ); + } + + board_meta +} + +fn board_column_index(state: &SessionState) -> i64 { + match state { + SessionState::Pending => 0, + SessionState::Running => 1, + SessionState::Idle => 2, + SessionState::Failed => 3, + SessionState::Completed => 4, + SessionState::Stopped => 5, + } +} + +fn derive_board_progress_percent(session: &Session) -> i64 { + match session.state { + SessionState::Pending => 10, + SessionState::Running => { + if session.metrics.files_changed > 0 { + 60 + } else if session.worktree.is_some() || session.metrics.tool_calls > 0 { + 45 + } else { + 25 + } + } + SessionState::Idle => 85, + SessionState::Completed => 100, + SessionState::Failed => 65, + SessionState::Stopped => 0, + } +} + +fn derive_board_status_detail(session: &Session) -> Option { + let detail = match session.state { + SessionState::Pending => "Queued", + SessionState::Running => { + if session.metrics.files_changed > 0 { + "Actively editing" + } else if session.worktree.is_some() { + "Scoping" + } else { + "Booting" + } + } + SessionState::Idle => "Awaiting review", + SessionState::Completed => "Task complete", + SessionState::Failed => "Blocked by failure", + SessionState::Stopped => "Stopped", + }; + + Some(detail.to_string()) +} + +fn annotate_board_motion(current: &mut SessionBoardMeta, previous: &SessionBoardMeta) { + if previous.lane != current.lane { + current.previous_lane = Some(previous.lane.clone()); + current.previous_row_label = previous.row_label.clone(); + current.movement_note = Some(match current.lane.as_str() { + "Blocked" => "Blocked".to_string(), + "Done" => "Completed".to_string(), + _ => format!("Moved {} -> {}", previous.lane, current.lane), + }); + return; + } + + if previous.row_label != current.row_label { + let from = previous + .row_label + .clone() + .unwrap_or_else(|| "General".to_string()); + let to = current + .row_label + .clone() + .unwrap_or_else(|| "General".to_string()); + current.previous_lane = Some(previous.lane.clone()); + current.previous_row_label = previous.row_label.clone(); + current.movement_note = Some(format!("Retargeted {from} -> {to}")); + } +} + +fn extract_labeled_scope(task: &str, labels: &[&str]) -> Option { + let lowered = task.to_ascii_lowercase(); + + for label in labels { + if let Some(index) = lowered.find(label) { + let mut tail = task.get(index + label.len()..)?.trim_start_matches([ + ' ', ':', '-', '#', + ]); + if tail.is_empty() { + continue; + } + + if let Some((candidate, _)) = tail + .split_once('|') + .or_else(|| tail.split_once(';')) + .or_else(|| tail.split_once(',')) + .or_else(|| tail.split_once('\n')) + { + tail = candidate; + } + + let words = tail + .split_whitespace() + .take(4) + .collect::>() + .join(" ") + .trim() + .trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':' | '|')) + .to_string(); + + if !words.is_empty() { + return Some(words); + } + } + } + + None +} + +fn extract_issue_reference(task: &str) -> Option { + let tokens = task + .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | ':' | '(' | ')')) + .filter(|token| !token.is_empty()); + + for token in tokens { + if let Some(stripped) = token.strip_prefix('#') { + if !stripped.is_empty() && stripped.chars().all(|ch| ch.is_ascii_digit()) { + return Some(format!("#{stripped}")); + } + } + + if let Some((prefix, suffix)) = token.split_once('-') { + if !prefix.is_empty() + && !suffix.is_empty() + && prefix.chars().all(|ch| ch.is_ascii_uppercase()) + && suffix.chars().all(|ch| ch.is_ascii_digit()) + { + return Some(token.trim_matches('.').to_string()); + } + } + } + + None +} + +fn derive_board_conflict_signals(sessions: &[Session]) -> HashMap { + let active_sessions = sessions + .iter() + .filter(|session| { + matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) + }) + .collect::>(); + + let mut sessions_by_branch: HashMap> = HashMap::new(); + let mut sessions_by_task: HashMap> = HashMap::new(); + let mut sessions_by_scope: HashMap> = HashMap::new(); + + for session in active_sessions { + if let Some(worktree) = session.worktree.as_ref() { + sessions_by_branch + .entry(worktree.branch.clone()) + .or_default() + .push(session); + } + + sessions_by_task + .entry(session.task.trim().to_ascii_lowercase()) + .or_default() + .push(session); + + let (project, feature, issue) = derive_board_scope(session); + if let Some(scope) = issue + .or(feature) + .or(project) + .filter(|scope| !scope.is_empty()) + { + sessions_by_scope.entry(scope).or_default().push(session); + } + } + + let mut signals = HashMap::new(); + + for (branch, grouped_sessions) in sessions_by_branch { + if grouped_sessions.len() < 2 { + continue; + } + + for session in grouped_sessions { + append_conflict_signal( + &mut signals, + &session.id, + format!("Shared branch {branch}"), + ); + } + } + + for (task, grouped_sessions) in sessions_by_task { + if grouped_sessions.len() < 2 { + continue; + } + + for session in grouped_sessions { + append_conflict_signal( + &mut signals, + &session.id, + format!("Shared task {}", truncate_task_for_signal(&task)), + ); + } + } + + for (scope, grouped_sessions) in sessions_by_scope { + if grouped_sessions.len() < 2 { + continue; + } + + for session in grouped_sessions { + append_conflict_signal( + &mut signals, + &session.id, + format!("Shared scope {}", truncate_task_for_signal(&scope)), + ); + } + } + + signals +} + +fn append_conflict_signal( + signals: &mut HashMap, + session_id: &str, + next_signal: String, +) { + let entry = signals.entry(session_id.to_string()).or_default(); + if entry.is_empty() { + *entry = next_signal; + return; + } + + if !entry.split("; ").any(|existing| existing == next_signal) { + entry.push_str("; "); + entry.push_str(&next_signal); + } +} + +fn short_session_ref(session_id: &str) -> String { + if session_id.chars().count() <= 12 { + session_id.to_string() + } else { + session_id.chars().take(8).collect() + } +} + +fn routing_activity_suffix(context: &str) -> Option<&'static str> { + let normalized = context.to_ascii_lowercase(); + if normalized.contains("reused idle delegate") { + Some("reused idle") + } else if normalized.contains("reused active delegate") { + Some("reused active") + } else if normalized.contains("spawned fallback delegate") { + Some("spawned fallback") + } else if normalized.contains("spawned new delegate") { + Some("spawned") + } else { + None + } +} + +fn extract_task_handoff_context(content: &str) -> Option { + if let Some(crate::comms::MessageType::TaskHandoff { context, .. }) = crate::comms::parse(content) + { + return Some(context); + } + + let value: serde_json::Value = serde_json::from_str(content).ok()?; + value + .get("context") + .and_then(|context| context.as_str()) + .map(ToOwned::to_owned) +} + +fn truncate_task_for_signal(task: &str) -> String { + const LIMIT: usize = 28; + let trimmed = task.trim(); + let count = trimmed.chars().count(); + if count <= LIMIT { + trimmed.to_string() + } else { + format!("{}...", trimmed.chars().take(LIMIT - 3).collect::()) + } +} + #[cfg(test)] mod tests { use super::*; use chrono::{Duration as ChronoDuration, Utc}; use std::fs; + use crate::session::WorktreeInfo; struct TestDir { path: PathBuf, @@ -1172,6 +1911,211 @@ mod tests { Ok(()) } + #[test] + fn session_board_meta_is_backfilled_and_tracks_conflicts() -> Result<()> { + let tempdir = TestDir::new("store-board-meta")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "alpha-1".to_string(), + task: "Project Atlas feature board issue #1454".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: Some(WorktreeInfo { + path: PathBuf::from("/tmp/atlas-alpha"), + branch: "feat/shared".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + db.insert_session(&Session { + id: "beta-2".to_string(), + task: "Project Atlas feature board follow-up".to_string(), + agent_type: "codex".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Idle, + pid: None, + worktree: Some(WorktreeInfo { + path: PathBuf::from("/tmp/atlas-beta"), + branch: "feat/shared".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let board_meta = db.list_session_board_meta()?; + let alpha = board_meta.get("alpha-1").expect("alpha board meta"); + let beta = board_meta.get("beta-2").expect("beta board meta"); + + assert_eq!(alpha.lane, "In Progress"); + assert_eq!(beta.lane, "Review"); + assert_eq!(alpha.column_index, 1); + assert_eq!(beta.column_index, 2); + assert_eq!(alpha.project.as_deref(), Some("Atlas feature board issue")); + assert_eq!(alpha.feature.as_deref(), Some("board issue #1454")); + assert_eq!(alpha.issue.as_deref(), Some("#1454")); + assert_eq!(alpha.row_label.as_deref(), Some("#1454")); + assert!(beta.row_label.is_some()); + assert!(alpha.row_index <= beta.row_index); + assert_eq!(alpha.stack_index, 0); + assert_eq!(alpha.progress_percent, 45); + assert_eq!(beta.progress_percent, 85); + assert_eq!(alpha.status_detail.as_deref(), Some("Scoping")); + assert_eq!(beta.status_detail.as_deref(), Some("Awaiting review")); + assert!(alpha + .conflict_signal + .as_deref() + .unwrap_or_default() + .contains("Shared branch feat/shared")); + assert!(beta + .conflict_signal + .as_deref() + .unwrap_or_default() + .contains("Shared branch feat/shared")); + + Ok(()) + } + + #[test] + fn deleting_session_removes_board_metadata() -> Result<()> { + let tempdir = TestDir::new("store-board-delete")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + + db.insert_session(&build_session("gone", SessionState::Pending))?; + assert!(db.list_session_board_meta()?.contains_key("gone")); + + db.delete_session("gone")?; + assert!(!db.list_session_board_meta()?.contains_key("gone")); + + Ok(()) + } + + #[test] + fn session_board_meta_tracks_lane_motion() -> Result<()> { + let tempdir = TestDir::new("store-board-motion")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "moving-1".to_string(), + task: "Project Atlas feature observability issue #1454".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: Some(WorktreeInfo { + path: PathBuf::from("/tmp/moving-1"), + branch: "feat/atlas-board".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + db.update_state("moving-1", &SessionState::Idle)?; + + let board_meta = db.list_session_board_meta()?; + let moving = board_meta.get("moving-1").expect("moving board meta"); + assert_eq!(moving.lane, "Review"); + assert_eq!(moving.previous_lane.as_deref(), Some("In Progress")); + assert_eq!(moving.movement_note.as_deref(), Some("Moved In Progress -> Review")); + + Ok(()) + } + + #[test] + fn session_board_meta_tracks_latest_handoff_activity() -> Result<()> { + let tempdir = TestDir::new("store-board-activity")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "planner-1".to_string(), + task: "Coordinate Atlas implementation".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + db.insert_session(&Session { + id: "worker-1".to_string(), + task: "Implement Atlas issue #1454".to_string(), + agent_type: "codex".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Pending, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + db.send_message( + "planner-1", + "worker-1", + r#"{"task":"Implement Atlas issue #1454","context":"Assigned by planner-1 [claude] | cwd /tmp | reused idle delegate"}"#, + "task_handoff", + )?; + + let board_meta = db.list_session_board_meta()?; + assert_eq!( + board_meta + .get("planner-1") + .and_then(|meta| meta.activity_kind.as_deref()), + Some("delegated") + ); + assert_eq!( + board_meta + .get("planner-1") + .and_then(|meta| meta.activity_note.as_deref()), + Some("Delegated to worker-1 | reused idle") + ); + assert_eq!( + board_meta + .get("worker-1") + .and_then(|meta| meta.activity_kind.as_deref()), + Some("received") + ); + assert_eq!( + board_meta + .get("worker-1") + .and_then(|meta| meta.activity_note.as_deref()), + Some("Received from planner-1 | reused idle") + ); + assert_eq!( + board_meta + .get("worker-1") + .map(|meta| meta.handoff_backlog), + Some(1) + ); + + db.mark_messages_read("worker-1")?; + + let board_meta = db.list_session_board_meta()?; + assert_eq!( + board_meta + .get("worker-1") + .map(|meta| meta.handoff_backlog), + Some(0) + ); + + Ok(()) + } + #[test] fn append_output_line_keeps_latest_buffer_window() -> Result<()> { let tempdir = TestDir::new("store-output")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 4195a493..1a142da2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -14,7 +14,7 @@ use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; use crate::session::store::{DaemonActivity, StateStore}; -use crate::session::{Session, SessionMessage, SessionState}; +use crate::session::{Session, SessionBoardMeta, SessionMessage, SessionState}; use crate::worktree; #[cfg(test)] @@ -47,6 +47,7 @@ pub struct Dashboard { session_output_cache: HashMap>, unread_message_counts: HashMap, handoff_backlog_counts: HashMap, + board_meta_by_session: HashMap, worktree_health_by_session: HashMap, global_handoff_backlog_leads: usize, global_handoff_backlog_messages: usize, @@ -94,6 +95,7 @@ enum Pane { Sessions, Output, Metrics, + Board, Log, } @@ -168,6 +170,7 @@ impl Dashboard { session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), handoff_backlog_counts: HashMap::new(), + board_meta_by_session: HashMap::new(), worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, @@ -196,6 +199,7 @@ impl Dashboard { }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); dashboard.sync_handoff_backlog_counts(); + dashboard.sync_board_meta(); dashboard.sync_global_handoff_backlog(); dashboard.sync_selected_output(); dashboard.sync_selected_diff(); @@ -446,10 +450,18 @@ impl Dashboard { } fn render_metrics(&self, frame: &mut Frame, area: Rect) { + let side_pane = if self.selected_pane == Pane::Board { + Pane::Board + } else { + Pane::Metrics + }; let block = Block::default() .borders(Borders::ALL) - .title(" Metrics ") - .border_style(self.pane_border_style(Pane::Metrics)); + .title(match side_pane { + Pane::Board => " Board ", + _ => " Metrics ", + }) + .border_style(self.pane_border_style(side_pane)); let inner = block.inner(area); frame.render_widget(block, area); @@ -457,6 +469,14 @@ impl Dashboard { return; } + if side_pane == Pane::Board { + frame.render_widget( + Paragraph::new(self.board_text()).wrap(Wrap { trim: true }), + inner, + ); + return; + } + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -669,7 +689,7 @@ impl Dashboard { self.output_scroll_offset = self.output_scroll_offset.saturating_add(1); } } - Pane::Metrics => {} + Pane::Metrics | Pane::Board => {} Pane::Log => { self.output_follow = false; self.output_scroll_offset = self.output_scroll_offset.saturating_add(1); @@ -698,7 +718,7 @@ impl Dashboard { self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1); } - Pane::Metrics => {} + Pane::Metrics | Pane::Board => {} Pane::Log => { self.output_follow = false; self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1); @@ -1491,6 +1511,7 @@ impl Dashboard { } }; self.sync_handoff_backlog_counts(); + self.sync_board_meta(); self.sync_worktree_health_by_session(); self.sync_global_handoff_backlog(); self.sync_daemon_activity(); @@ -1561,6 +1582,16 @@ impl Dashboard { } } + fn sync_board_meta(&mut self) { + self.board_meta_by_session = match self.db.list_session_board_meta() { + Ok(meta) => meta, + Err(error) => { + tracing::warn!("Failed to refresh board metadata: {error}"); + HashMap::new() + } + }; + } + fn sync_worktree_health_by_session(&mut self) { self.worktree_health_by_session.clear(); for session in &self.sessions { @@ -2133,6 +2164,289 @@ impl Dashboard { } } + fn board_text(&self) -> String { + if self.sessions.is_empty() { + return "No sessions available.\n\nStart a session to populate the board.".to_string(); + } + + let mut lines = Vec::new(); + lines.push(format!("Board snapshot | {} sessions", self.sessions.len())); + + if let Some(session) = self.sessions.get(self.selected_session) { + let meta = self.board_meta_by_session.get(&session.id); + let branch = session_branch(session); + lines.push(format!( + "Focus {} {} | {} | {}{}", + board_presence_marker(session), + board_codename(session), + meta.map(|meta| meta.lane.as_str()) + .unwrap_or_else(|| board_lane_label(&session.state)), + format_session_id(&session.id), + if branch == "-" { + String::new() + } else { + format!(" | {branch}") + } + )); + lines.push(format!("Task {}", truncate_for_dashboard(&session.task, 48))); + if let Some(meta) = meta { + lines.push(format!( + "Progress {:>3}% {}", + meta.progress_percent, + board_progress_bar(meta.progress_percent) + )); + if let Some(status_detail) = meta.status_detail.as_ref() { + lines.push(format!("Status {status_detail}")); + } + if let Some(movement_note) = meta.movement_note.as_ref() { + lines.push(format!("Event {movement_note}")); + } + if meta.handoff_backlog > 0 { + lines.push(format!("Inbox {} handoff(s)", meta.handoff_backlog)); + } + if let Some(activity_note) = meta.activity_note.as_ref() { + lines.push(format!("Route {activity_note}")); + } + lines.push(format!( + "Coords C{} R{} S{}", + meta.column_index + 1, + meta.row_index + 1, + meta.stack_index + 1 + )); + if let Some(row_label) = meta.row_label.as_ref() { + lines.push(format!("Row {row_label}")); + } + if let Some(project) = meta.project.as_ref() { + lines.push(format!("Project {project}")); + } + if let Some(feature) = meta.feature.as_ref() { + lines.push(format!("Feature {feature}")); + } + if let Some(issue) = meta.issue.as_ref() { + lines.push(format!("Issue {issue}")); + } + } + } + + let overlap_risks = self.board_overlap_risks(); + if overlap_risks.is_empty() { + lines.push("Overlap risk clear".to_string()); + } else { + lines.push("Overlap risk".to_string()); + for risk in overlap_risks { + lines.push(format!("- {risk}")); + } + } + + let lanes = ["Inbox", "In Progress", "Review", "Blocked", "Done", "Stopped"]; + + for label in lanes { + let mut lane_sessions = self + .sessions + .iter() + .filter_map(|session| { + let lane = self + .board_meta_by_session + .get(&session.id) + .map(|meta| meta.lane.as_str()) + .unwrap_or_else(|| board_lane_label(&session.state)); + if lane == label { + Some((session, self.board_meta_by_session.get(&session.id))) + } else { + None + } + }) + .collect::>(); + if lane_sessions.is_empty() { + continue; + } + + let mut row_risks: HashMap<(i64, String), Vec> = HashMap::new(); + let mut row_backlogs: HashMap<(i64, String), i64> = HashMap::new(); + for (_, meta) in &lane_sessions { + let Some(meta) = meta else { + continue; + }; + let Some(conflict_signal) = meta.conflict_signal.as_ref() else { + let key = ( + meta.row_index, + meta.row_label + .clone() + .unwrap_or_else(|| "General".to_string()), + ); + if meta.handoff_backlog > 0 { + *row_backlogs.entry(key).or_default() += meta.handoff_backlog; + } + continue; + }; + let key = ( + meta.row_index, + meta.row_label + .clone() + .unwrap_or_else(|| "General".to_string()), + ); + let entry = row_risks.entry(key.clone()).or_default(); + for risk in conflict_signal.split("; ") { + if !entry.iter().any(|existing| existing == risk) { + entry.push(risk.to_string()); + } + } + if meta.handoff_backlog > 0 { + *row_backlogs.entry(key).or_default() += meta.handoff_backlog; + } + } + + lane_sessions.sort_by(|left, right| { + let left_meta = left.1.cloned().unwrap_or_default(); + let right_meta = right.1.cloned().unwrap_or_default(); + left_meta + .row_index + .cmp(&right_meta.row_index) + .then_with(|| left_meta.stack_index.cmp(&right_meta.stack_index)) + .then_with(|| left.0.id.cmp(&right.0.id)) + }); + + lines.push(String::new()); + lines.push(format!("{label} ({})", lane_sessions.len())); + let mut current_row: Option = None; + for (session, meta) in lane_sessions.into_iter().take(6) { + let meta = meta.cloned().unwrap_or_default(); + let row_label = meta + .row_label + .clone() + .unwrap_or_else(|| "General".to_string()); + if current_row.as_ref() != Some(&row_label) { + current_row = Some(row_label.clone()); + let row_key = (meta.row_index, row_label.clone()); + let row_conflict_summary = row_risks + .get(&row_key) + .filter(|risks| !risks.is_empty()) + .map(|risks| truncate_for_dashboard(&risks.join(" + "), 42)); + let row_backlog = row_backlogs.get(&row_key).copied().unwrap_or(0); + let row_pressure_summary = if row_backlog > 0 { + Some(format!("{} handoff(s)", row_backlog)) + } else { + None + }; + let row_marker = if row_conflict_summary.is_some() { + "!" + } else if row_pressure_summary.is_some() { + "+" + } else { + "-" + }; + lines.push(format!( + " {} Row {} | {}{}{}", + row_marker, + meta.row_index + 1, + row_label, + row_conflict_summary + .map(|summary| format!(" | {summary}")) + .unwrap_or_default(), + row_pressure_summary + .map(|summary| format!(" | {summary}")) + .unwrap_or_default() + )); + } + let branch = session_branch(session); + let branch_suffix = if branch == "-" { + String::new() + } else { + format!(" | {branch}") + }; + let activity_suffix = meta + .activity_note + .as_ref() + .map(|note| format!(" | {}", truncate_for_dashboard(note, 26))) + .unwrap_or_default(); + let backlog_suffix = if meta.handoff_backlog > 0 { + format!(" | inbox {}", meta.handoff_backlog) + } else { + String::new() + }; + let kind_marker = board_activity_marker(&meta); + lines.push(format!( + " {}{} {} {} {} [{}] {:>3}% {} | {}{}{}{}", + board_motion_marker(&meta), + kind_marker, + board_presence_marker(session), + board_codename(session), + format_session_id(&session.id), + session.agent_type, + meta.progress_percent, + board_progress_bar(meta.progress_percent), + truncate_for_dashboard( + meta.status_detail + .as_deref() + .unwrap_or(&session.task), + 18 + ), + activity_suffix, + backlog_suffix, + branch_suffix + )); + } + } + + lines.join("\n") + } + + fn board_overlap_risks(&self) -> Vec { + let mut risks = self + .board_meta_by_session + .values() + .filter_map(|meta| meta.conflict_signal.clone()) + .collect::>(); + if risks.is_empty() { + let mut duplicate_branches: HashMap> = HashMap::new(); + let mut duplicate_tasks: HashMap> = HashMap::new(); + + for session in self.sessions.iter().filter(|session| { + matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) + }) { + if let Some(worktree) = session.worktree.as_ref() { + duplicate_branches + .entry(worktree.branch.clone()) + .or_default() + .push(format_session_id(&session.id)); + } + + duplicate_tasks + .entry(session.task.to_ascii_lowercase()) + .or_default() + .push(format_session_id(&session.id)); + } + + for (branch, session_ids) in duplicate_branches { + if session_ids.len() > 1 { + risks.push(format!( + "{} sessions share branch {} ({})", + session_ids.len(), + branch, + session_ids.join(", ") + )); + } + } + + for (task, session_ids) in duplicate_tasks { + if session_ids.len() > 1 { + risks.push(format!( + "{} sessions share task {} ({})", + session_ids.len(), + truncate_for_dashboard(&task, 24), + session_ids.join(", ") + )); + } + } + } + risks.sort(); + risks.dedup(); + risks + } + fn aggregate_cost_summary(&self) -> (String, Style) { let aggregate = self.aggregate_usage(); let mut text = if self.cfg.cost_budget_usd > 0.0 { @@ -2314,9 +2628,9 @@ impl Dashboard { fn visible_panes(&self) -> &'static [Pane] { match self.cfg.pane_layout { - PaneLayout::Grid => &[Pane::Sessions, Pane::Output, Pane::Metrics, Pane::Log], + PaneLayout::Grid => &[Pane::Sessions, Pane::Output, Pane::Metrics, Pane::Board, Pane::Log], PaneLayout::Horizontal | PaneLayout::Vertical => { - &[Pane::Sessions, Pane::Output, Pane::Metrics] + &[Pane::Sessions, Pane::Output, Pane::Metrics, Pane::Board] } } } @@ -2394,6 +2708,7 @@ impl Pane { Pane::Sessions => "Sessions", Pane::Output => "Output", Pane::Metrics => "Metrics", + Pane::Board => "Board", Pane::Log => "Log", } } @@ -2628,6 +2943,17 @@ fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { } } +fn board_lane_label(state: &SessionState) -> &'static str { + match state { + SessionState::Pending => "Inbox", + SessionState::Running => "In Progress", + SessionState::Idle => "Review", + SessionState::Completed => "Done", + SessionState::Failed => "Blocked", + SessionState::Stopped => "Stopped", + } +} + fn session_state_label(state: &SessionState) -> &'static str { match state { SessionState::Pending => "Pending", @@ -2654,6 +2980,25 @@ fn format_session_id(id: &str) -> String { id.chars().take(8).collect() } +fn board_codename(session: &Session) -> String { + const ADJECTIVES: &[&str] = &[ + "Amber", "Cinder", "Moss", "Nova", "Sable", "Slate", "Swift", "Talon", + ]; + const NOUNS: &[&str] = &[ + "Fox", "Kite", "Lynx", "Otter", "Rook", "Sprite", "Wisp", "Wolf", + ]; + + let seed = session + .id + .bytes() + .fold(0usize, |acc, byte| acc.wrapping_mul(33).wrapping_add(byte as usize)); + format!( + "{} {}", + ADJECTIVES[seed % ADJECTIVES.len()], + NOUNS[(seed / ADJECTIVES.len()) % NOUNS.len()] + ) +} + fn build_conflict_protocol( session_id: &str, worktree: &crate::session::WorktreeInfo, @@ -2714,6 +3059,44 @@ fn session_branch(session: &Session) -> String { .unwrap_or_else(|| "-".to_string()) } +fn board_progress_bar(progress_percent: i64) -> String { + let clamped = progress_percent.clamp(0, 100); + let filled = ((clamped + 9) / 10) as usize; + let empty = 10usize.saturating_sub(filled); + format!("[{}{}]", "#".repeat(filled), ".".repeat(empty)) +} + +fn board_presence_marker(session: &Session) -> String { + let codename = board_codename(session); + let initials = codename + .split_whitespace() + .filter_map(|part| part.chars().next()) + .take(2) + .collect::() + .to_ascii_uppercase(); + format!("@{initials}") +} + +fn board_motion_marker(meta: &SessionBoardMeta) -> &'static str { + match meta.movement_note.as_deref() { + Some("Blocked") => "x", + Some("Completed") => "*", + Some(note) if note.starts_with("Moved ") => ">", + Some(note) if note.starts_with("Retargeted ") => "~", + _ => ".", + } +} + +fn board_activity_marker(meta: &SessionBoardMeta) -> &'static str { + match meta.activity_kind.as_deref() { + Some("received") => "<", + Some("delegated") => ">", + Some("spawned") => "+", + Some("spawned_fallback") => "#", + _ => "", + } +} + fn format_duration(duration_secs: u64) -> String { let hours = duration_secs / 3600; let minutes = (duration_secs % 3600) / 60; @@ -2768,6 +3151,244 @@ mod tests { assert!(rendered.contains("done-876")); } + #[test] + fn board_pane_renders_lane_groups_and_focus_summary() { + let mut dashboard = test_dashboard( + vec![ + sample_session( + "run-12345678", + "planner", + SessionState::Running, + Some("feat/run"), + 128, + 15, + ), + sample_session( + "done-87654321", + "reviewer", + SessionState::Completed, + Some("release/v1"), + 2048, + 125, + ), + ], + 0, + ); + dashboard.selected_pane = Pane::Board; + + let board = dashboard.board_text(); + assert!(board.contains("Board snapshot")); + assert!(board.contains("Focus")); + assert!(board.contains("@")); + assert!(board.contains("Progress")); + assert!(board.contains("In Progress (1)")); + assert!(board.contains("Done (1)")); + assert!(board.contains("run-1234")); + assert!(board.contains("done-876")); + } + + #[test] + fn board_pane_surfaces_overlap_risk_for_shared_branch() { + let mut dashboard = test_dashboard( + vec![ + sample_session( + "alpha-12345678", + "claude", + SessionState::Running, + Some("feat/shared"), + 10, + 5, + ), + sample_session( + "beta-87654321", + "codex", + SessionState::Idle, + Some("feat/shared"), + 12, + 7, + ), + ], + 0, + ); + dashboard.selected_pane = Pane::Board; + + let board = dashboard.board_text(); + assert!(board.contains("Overlap risk")); + assert!(board.contains("share branch feat/shared")); + } + + #[test] + fn board_pane_uses_persisted_board_metadata() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc-board-meta-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let db = StateStore::open(&root.join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "atlas-12345678".to_string(), + task: "Project Atlas feature observability issue #1454".to_string(), + agent_type: "claude".to_string(), + state: SessionState::Running, + working_dir: PathBuf::from("/tmp"), + pid: None, + worktree: Some(WorktreeInfo { + path: PathBuf::from("/tmp/atlas"), + branch: "feat/atlas-board".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut dashboard = Dashboard::new(db, build_config(&root)); + dashboard.selected_pane = Pane::Board; + + let board = dashboard.board_text(); + assert!(board.contains("Coords C2 R1 S1")); + assert!(board.contains("Progress 45% [#####.....]")); + assert!(board.contains("Status Scoping")); + assert!(board.contains("Row #1454")); + assert!(board.contains("- Row 1 | #1454")); + assert!(board.contains("Project Atlas feature observability issue")); + assert!(board.contains("Feature observability issue #1454")); + assert!(board.contains("Issue #1454")); + + let _ = fs::remove_dir_all(&root); + Ok(()) + } + + #[test] + fn board_pane_surfaces_row_level_conflict_summary() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc-board-conflict-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let db = StateStore::open(&root.join("state.db"))?; + let now = Utc::now(); + + for (id, agent_type) in [("atlas-a", "claude"), ("atlas-b", "codex")] { + db.insert_session(&Session { + id: id.to_string(), + task: "Project Atlas feature observability issue #1454".to_string(), + agent_type: agent_type.to_string(), + state: SessionState::Running, + working_dir: PathBuf::from("/tmp"), + pid: None, + worktree: Some(WorktreeInfo { + path: PathBuf::from(format!("/tmp/{id}")), + branch: "feat/atlas-collision".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics { + files_changed: 1, + ..SessionMetrics::default() + }, + })?; + } + + let mut dashboard = Dashboard::new(db, build_config(&root)); + dashboard.selected_pane = Pane::Board; + + let board = dashboard.board_text(); + assert!(board.contains("! Row 1 | #1454 | Shared branch")); + + let _ = fs::remove_dir_all(&root); + Ok(()) + } + + #[test] + fn board_pane_surfaces_motion_event_and_marker() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc-board-motion-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let db = StateStore::open(&root.join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "atlas-moving".to_string(), + task: "Project Atlas feature observability issue #1454".to_string(), + agent_type: "claude".to_string(), + state: SessionState::Running, + working_dir: PathBuf::from("/tmp"), + pid: None, + worktree: Some(WorktreeInfo { + path: PathBuf::from("/tmp/atlas-moving"), + branch: "feat/atlas-board".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + db.update_state("atlas-moving", &SessionState::Idle)?; + + let mut dashboard = Dashboard::new(db, build_config(&root)); + dashboard.selected_pane = Pane::Board; + + let board = dashboard.board_text(); + assert!(board.contains("Event Moved In Progress -> Review")); + assert!(board.contains("> @")); + + let _ = fs::remove_dir_all(&root); + Ok(()) + } + + #[test] + fn board_pane_surfaces_routing_activity_note() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc-board-route-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let db = StateStore::open(&root.join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "planner-1".to_string(), + task: "Coordinate Atlas implementation".to_string(), + agent_type: "claude".to_string(), + state: SessionState::Running, + working_dir: PathBuf::from("/tmp"), + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + db.insert_session(&Session { + id: "worker-1".to_string(), + task: "Implement Atlas issue #1454".to_string(), + agent_type: "codex".to_string(), + state: SessionState::Pending, + working_dir: PathBuf::from("/tmp"), + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + db.send_message( + "planner-1", + "worker-1", + r#"{"task":"Implement Atlas issue #1454","context":"Assigned by planner-1 [claude] | cwd /tmp | reused idle delegate"}"#, + "task_handoff", + )?; + + let mut dashboard = Dashboard::new(db, build_config(&root)); + dashboard.selected_pane = Pane::Board; + dashboard.selected_session = 1; + + let board = dashboard.board_text(); + assert!(board.contains("+ Row 1 | #1454 | 1 handoff(s)")); + assert!(board.contains("Route Received from planner-1 | reused idle")); + assert!(board.contains(".< @")); + assert!(board.contains("Received from planner-1")); + assert!(board.contains("inbox 1")); + + let _ = fs::remove_dir_all(&root); + Ok(()) + } + #[test] fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() { let mut dashboard = test_dashboard( @@ -4180,6 +4801,8 @@ diff --git a/src/next.rs b/src/next.rs dashboard.next_pane(); dashboard.next_pane(); dashboard.next_pane(); + assert_eq!(dashboard.selected_pane, Pane::Board); + dashboard.next_pane(); assert_eq!(dashboard.selected_pane, Pane::Sessions); dashboard.cfg.pane_layout = PaneLayout::Grid; @@ -4187,6 +4810,7 @@ diff --git a/src/next.rs b/src/next.rs dashboard.next_pane(); dashboard.next_pane(); dashboard.next_pane(); + dashboard.next_pane(); assert_eq!(dashboard.selected_pane, Pane::Log); } @@ -4213,6 +4837,7 @@ diff --git a/src/next.rs b/src/next.rs session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), handoff_backlog_counts: HashMap::new(), + board_meta_by_session: HashMap::new(), worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0,