feat: add ecc2 approval queue sidebar

This commit is contained in:
Affaan Mustafa
2026-04-09 04:42:13 -07:00
parent cc5fe121bf
commit 1c27f7b29a
2 changed files with 279 additions and 12 deletions

View File

@@ -641,6 +641,51 @@ impl StateStore {
Ok(counts)
}
pub fn unread_approval_counts(&self) -> Result<HashMap<String, usize>> {
let mut stmt = self.conn.prepare(
"SELECT to_session, COUNT(*)
FROM messages
WHERE read = 0 AND msg_type IN ('query', 'conflict')
GROUP BY to_session",
)?;
let counts = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
})?
.collect::<Result<HashMap<_, _>, _>>()?;
Ok(counts)
}
pub fn unread_approval_queue(&self, limit: usize) -> Result<Vec<SessionMessage>> {
let mut stmt = self.conn.prepare(
"SELECT id, from_session, to_session, content, msg_type, read, timestamp
FROM messages
WHERE read = 0 AND msg_type IN ('query', 'conflict')
ORDER BY id ASC
LIMIT ?1",
)?;
let messages = stmt.query_map(rusqlite::params![limit as i64], |row| {
let timestamp: String = row.get(6)?;
Ok(SessionMessage {
id: row.get(0)?,
from_session: row.get(1)?,
to_session: row.get(2)?,
content: row.get(3)?,
msg_type: row.get(4)?,
read: row.get::<_, i64>(5)? != 0,
timestamp: chrono::DateTime::parse_from_rfc3339(&timestamp)
.unwrap_or_default()
.with_timezone(&chrono::Utc),
})
})?;
messages.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn unread_task_handoffs_for_session(
&self,
session_id: &str,
@@ -1274,6 +1319,53 @@ mod tests {
Ok(())
}
#[test]
fn approval_queue_counts_only_queries_and_conflicts() -> Result<()> {
let tempdir = TestDir::new("store-approval-queue")?;
let db = StateStore::open(&tempdir.path().join("state.db"))?;
db.insert_session(&build_session("planner", SessionState::Running))?;
db.insert_session(&build_session("worker", SessionState::Pending))?;
db.insert_session(&build_session("worker-2", SessionState::Pending))?;
db.send_message(
"planner",
"worker",
"{\"question\":\"Need operator approval\"}",
"query",
)?;
db.send_message(
"planner",
"worker",
"{\"file\":\"src/main.rs\",\"description\":\"Merge conflict\"}",
"conflict",
)?;
db.send_message(
"worker",
"planner",
"{\"summary\":\"Finished pass\",\"files_changed\":[]}",
"completed",
)?;
db.send_message(
"planner",
"worker-2",
"{\"task\":\"Review auth flow\",\"context\":\"Delegated from planner\"}",
"task_handoff",
)?;
let counts = db.unread_approval_counts()?;
assert_eq!(counts.get("worker"), Some(&2));
assert_eq!(counts.get("planner"), None);
assert_eq!(counts.get("worker-2"), None);
let queue = db.unread_approval_queue(10)?;
assert_eq!(queue.len(), 2);
assert_eq!(queue[0].msg_type, "query");
assert_eq!(queue[1].msg_type, "conflict");
Ok(())
}
#[test]
fn daemon_activity_round_trips_latest_passes() -> Result<()> {
let tempdir = TestDir::new("store-daemon-activity")?;

View File

@@ -55,6 +55,8 @@ pub struct Dashboard {
sessions: Vec<Session>,
session_output_cache: HashMap<String, Vec<OutputLine>>,
unread_message_counts: HashMap<String, usize>,
approval_queue_counts: HashMap<String, usize>,
approval_queue_preview: Vec<SessionMessage>,
handoff_backlog_counts: HashMap<String, usize>,
worktree_health_by_session: HashMap<String, worktree::WorktreeHealth>,
global_handoff_backlog_leads: usize,
@@ -229,6 +231,8 @@ impl Dashboard {
sessions,
session_output_cache: HashMap::new(),
unread_message_counts: HashMap::new(),
approval_queue_counts: HashMap::new(),
approval_queue_preview: Vec::new(),
handoff_backlog_counts: HashMap::new(),
worktree_health_by_session: HashMap::new(),
global_handoff_backlog_leads: 0,
@@ -358,22 +362,31 @@ impl Dashboard {
&self.worktree_health_by_session,
stabilized,
);
let mut overview_lines = vec![
summary_line(&summary),
attention_queue_line(&summary, stabilized),
approval_queue_line(&self.approval_queue_counts),
];
if let Some(preview) = approval_queue_preview_line(&self.approval_queue_preview) {
overview_lines.push(preview);
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(3)])
.constraints([
Constraint::Length(overview_lines.len() as u16),
Constraint::Min(3),
])
.split(inner_area);
frame.render_widget(
Paragraph::new(vec![
summary_line(&summary),
attention_queue_line(&summary, stabilized),
]),
chunks[0],
);
frame.render_widget(Paragraph::new(overview_lines), chunks[0]);
let rows = self.sessions.iter().map(|session| {
session_row(
session,
self.approval_queue_counts
.get(&session.id)
.copied()
.unwrap_or(0),
self.handoff_backlog_counts
.get(&session.id)
.copied()
@@ -381,7 +394,14 @@ impl Dashboard {
)
});
let header = Row::new([
"ID", "Agent", "State", "Branch", "Backlog", "Tokens", "Duration",
"ID",
"Agent",
"State",
"Branch",
"Approvals",
"Backlog",
"Tokens",
"Duration",
])
.style(Style::default().add_modifier(Modifier::BOLD));
let widths = [
@@ -389,6 +409,7 @@ impl Dashboard {
Constraint::Length(10),
Constraint::Length(10),
Constraint::Min(12),
Constraint::Length(10),
Constraint::Length(7),
Constraint::Length(8),
Constraint::Length(8),
@@ -2216,6 +2237,23 @@ impl Dashboard {
}
}
fn sync_approval_queue(&mut self) {
self.approval_queue_counts = match self.db.unread_approval_counts() {
Ok(counts) => counts,
Err(error) => {
tracing::warn!("Failed to refresh approval queue counts: {error}");
HashMap::new()
}
};
self.approval_queue_preview = match self.db.unread_approval_queue(3) {
Ok(messages) => messages,
Err(error) => {
tracing::warn!("Failed to refresh approval queue preview: {error}");
Vec::new()
}
};
}
fn sync_handoff_backlog_counts(&mut self) {
let limit = self.sessions.len().max(1);
self.handoff_backlog_counts.clear();
@@ -2308,6 +2346,7 @@ impl Dashboard {
fn sync_selected_messages(&mut self) {
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
self.selected_messages.clear();
self.sync_approval_queue();
return;
};
@@ -2337,6 +2376,8 @@ impl Dashboard {
Vec::new()
}
};
self.sync_approval_queue();
}
fn sync_selected_lineage(&mut self) {
@@ -3620,7 +3661,11 @@ impl SessionSummary {
}
}
fn session_row(session: &Session, unread_messages: usize) -> Row<'static> {
fn session_row(
session: &Session,
approval_requests: usize,
unread_messages: usize,
) -> Row<'static> {
Row::new(vec![
Cell::from(format_session_id(&session.id)),
Cell::from(session.agent_type.clone()),
@@ -3630,6 +3675,18 @@ fn session_row(session: &Session, unread_messages: usize) -> Row<'static> {
.add_modifier(Modifier::BOLD),
),
Cell::from(session_branch(session)),
Cell::from(if approval_requests == 0 {
"-".to_string()
} else {
approval_requests.to_string()
})
.style(if approval_requests == 0 {
Style::default()
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}),
Cell::from(if unread_messages == 0 {
"-".to_string()
} else {
@@ -3734,6 +3791,49 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta
Line::from(spans)
}
fn approval_queue_line(approval_queue_counts: &HashMap<String, usize>) -> Line<'static> {
let pending_sessions = approval_queue_counts.len();
let pending_items: usize = approval_queue_counts.values().sum();
if pending_items == 0 {
return Line::from(vec![
Span::styled(
"Approval queue clear",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" no unanswered queries or conflicts"),
]);
}
Line::from(vec![
Span::styled(
"Approval queue ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
summary_span("Pending", pending_items, Color::Yellow),
summary_span("Sessions", pending_sessions, Color::Yellow),
])
}
fn approval_queue_preview_line(messages: &[SessionMessage]) -> Option<Line<'static>> {
let message = messages.first()?;
let preview = truncate_for_dashboard(&comms::preview(&message.msg_type, &message.content), 72);
Some(Line::from(vec![
Span::raw("- "),
Span::styled(
format_session_id(&message.to_session),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" | "),
Span::raw(preview),
]))
}
fn truncate_for_dashboard(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
if trimmed.chars().count() <= max_chars {
@@ -3968,7 +4068,7 @@ mod tests {
#[test]
fn render_sessions_shows_summary_headers_and_selected_row() {
let dashboard = test_dashboard(
let mut dashboard = test_dashboard(
vec![
sample_session(
"run-12345678",
@@ -3989,6 +4089,16 @@ mod tests {
],
1,
);
dashboard.approval_queue_counts = HashMap::from([(String::from("run-12345678"), 2usize)]);
dashboard.approval_queue_preview = vec![SessionMessage {
id: 1,
from_session: "lead-12345678".to_string(),
to_session: "run-12345678".to_string(),
content: "{\"question\":\"Need approval to continue\"}".to_string(),
msg_type: "query".to_string(),
read: false,
timestamp: Utc::now(),
}];
let rendered = render_dashboard_text(dashboard, 180, 24);
assert!(rendered.contains("ID"));
@@ -3996,10 +4106,73 @@ mod tests {
assert!(rendered.contains("Total 2"));
assert!(rendered.contains("Running 1"));
assert!(rendered.contains("Completed 1"));
assert!(rendered.contains("Attention queue clear"));
assert!(rendered.contains("Approval queue"));
assert!(rendered.contains("done-876"));
}
#[test]
fn approval_queue_preview_line_uses_target_session_and_preview() {
let line = approval_queue_preview_line(&[SessionMessage {
id: 1,
from_session: "lead-12345678".to_string(),
to_session: "run-12345678".to_string(),
content: "{\"question\":\"Need approval to continue\"}".to_string(),
msg_type: "query".to_string(),
read: false,
timestamp: Utc::now(),
}])
.expect("approval preview line");
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(rendered.contains("run-123"));
assert!(rendered.contains("query"));
}
#[test]
fn sync_selected_messages_refreshes_approval_queue_after_marking_read() {
let sessions = vec![
sample_session(
"lead-12345678",
"planner",
SessionState::Running,
Some("ecc/lead"),
512,
42,
),
sample_session(
"worker-123456",
"reviewer",
SessionState::Idle,
Some("ecc/worker"),
64,
5,
),
];
let mut dashboard = test_dashboard(sessions, 1);
for session in &dashboard.sessions {
dashboard.db.insert_session(session).unwrap();
}
dashboard
.db
.send_message(
"lead-12345678",
"worker-123456",
"{\"question\":\"Need operator input\"}",
"query",
)
.unwrap();
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap();
dashboard.sync_selected_messages();
assert_eq!(dashboard.approval_queue_counts.get("worker-123456"), None);
assert!(dashboard.approval_queue_preview.is_empty());
}
#[test]
fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() {
let mut dashboard = test_dashboard(
@@ -6254,6 +6427,8 @@ diff --git a/src/next.rs b/src/next.rs
sessions,
session_output_cache: HashMap::new(),
unread_message_counts: HashMap::new(),
approval_queue_counts: HashMap::new(),
approval_queue_preview: Vec::new(),
handoff_backlog_counts: HashMap::new(),
worktree_health_by_session: HashMap::new(),
global_handoff_backlog_leads: 0,