From a0f69cec9231aa61474101b26903a85b5dade09e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:27:17 -0700 Subject: [PATCH] feat(ecc2): surface per-file session activity --- ecc2/src/session/mod.rs | 9 +++ ecc2/src/session/store.rs | 112 +++++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 99 ++++++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 653ca1bc..086a7d53 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -127,3 +127,12 @@ pub struct SessionMessage { pub read: bool, pub timestamp: DateTime, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FileActivityEntry { + pub session_id: String, + pub tool_name: String, + pub path: String, + pub summary: String, + pub timestamp: DateTime, +} diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index aaf2e38e..061c96d6 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -11,7 +11,7 @@ use crate::config::Config; use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; -use super::{Session, SessionMessage, SessionMetrics, SessionState}; +use super::{FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState}; pub struct StateStore { conn: Connection, @@ -1480,6 +1480,72 @@ impl StateStore { total, }) } + + pub fn list_file_activity( + &self, + session_id: &str, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json + FROM tool_log + WHERE session_id = ?1 + AND file_paths_json IS NOT NULL + AND file_paths_json != '[]' + ORDER BY timestamp DESC, id DESC", + )?; + + let rows = stmt + .query_map(rusqlite::params![session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?.unwrap_or_default(), + row.get::<_, Option>(3)?.unwrap_or_default(), + row.get::<_, String>(4)?, + row.get::<_, Option>(5)? + .unwrap_or_else(|| "[]".to_string()), + )) + })? + .collect::, _>>()?; + + let mut events = Vec::new(); + for (session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json) in + rows + { + let Ok(paths) = serde_json::from_str::>(&file_paths_json) else { + continue; + }; + let occurred_at = chrono::DateTime::parse_from_rfc3339(×tamp) + .unwrap_or_default() + .with_timezone(&chrono::Utc); + let summary = if output_summary.trim().is_empty() { + input_summary + } else { + output_summary + }; + + for path in paths { + let path = path.trim().to_string(); + if path.is_empty() { + continue; + } + + events.push(FileActivityEntry { + session_id: session_id.clone(), + tool_name: tool_name.clone(), + path, + summary: summary.clone(), + timestamp: occurred_at, + }); + if events.len() >= limit { + return Ok(events); + } + } + } + + Ok(events) + } } #[cfg(test)] @@ -1702,6 +1768,50 @@ mod tests { Ok(()) } + #[test] + fn list_file_activity_expands_logged_file_paths() -> Result<()> { + let tempdir = TestDir::new("store-file-activity")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "sync tools".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, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\",\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let activity = db.list_file_activity("session-1", 10)?; + assert_eq!(activity.len(), 3); + assert_eq!(activity[0].tool_name, "Write"); + assert_eq!(activity[0].path, "README.md"); + assert_eq!(activity[1].path, "src/lib.rs"); + assert_eq!(activity[2].tool_name, "Read"); + assert_eq!(activity[2].path, "src/lib.rs"); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 63ba8984..9bf315b9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -20,7 +20,7 @@ use crate::session::output::{ OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, }; use crate::session::store::{DaemonActivity, StateStore}; -use crate::session::{Session, SessionMessage, SessionState}; +use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState}; use crate::worktree; #[cfg(test)] @@ -3482,13 +3482,24 @@ impl Dashboard { }); } - if session.metrics.files_changed > 0 { + let file_activity = self + .db + .list_file_activity(&session.id, 64) + .unwrap_or_default(); + if file_activity.is_empty() && session.metrics.files_changed > 0 { events.push(TimelineEvent { occurred_at: session.updated_at, session_id: session.id.clone(), event_type: TimelineEventType::FileChange, summary: format!("files touched {}", session.metrics.files_changed), }); + } else { + events.extend(file_activity.into_iter().map(|entry| TimelineEvent { + occurred_at: entry.timestamp, + session_id: session.id.clone(), + event_type: TimelineEventType::FileChange, + summary: file_activity_summary(&entry), + })); } let messages = self @@ -4125,6 +4136,20 @@ impl Dashboard { "Tools {} | Files {}", metrics.tool_calls, metrics.files_changed, )); + let recent_file_activity = self + .db + .list_file_activity(&session.id, 5) + .unwrap_or_default(); + if !recent_file_activity.is_empty() { + lines.push("Recent file activity".to_string()); + for entry in recent_file_activity { + lines.push(format!( + "- {} {}", + self.short_timestamp(&entry.timestamp.to_rfc3339()), + file_activity_summary(&entry) + )); + } + } lines.push(format!( "Cost ${:.4} | Duration {}s", metrics.cost_usd, metrics.duration_secs @@ -5372,6 +5397,31 @@ fn session_state_color(state: &SessionState) -> Color { } } +fn file_activity_summary(entry: &FileActivityEntry) -> String { + format!( + "{} {}", + file_activity_verb(&entry.tool_name), + truncate_for_dashboard(&entry.path, 72) + ) +} + +fn file_activity_verb(tool_name: &str) -> &'static str { + let tool_name = tool_name.trim().to_ascii_lowercase(); + if tool_name.contains("read") { + "read" + } else if tool_name.contains("write") { + "write" + } else if tool_name.contains("edit") { + "edit" + } else if tool_name.contains("delete") || tool_name.contains("remove") { + "delete" + } else if tool_name.contains("move") || tool_name.contains("rename") { + "move" + } else { + "touch" + } +} + fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) -> String { if !outcome.auto_terminated_sessions.is_empty() { return format!( @@ -6017,6 +6067,51 @@ mod tests { assert!(!rendered.contains("files touched 1")); } + #[test] + fn timeline_and_metrics_render_recent_file_activity_details() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-file-activity-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let now = Utc::now(); + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.created_at = now - chrono::Duration::hours(2); + session.updated_at = now - chrono::Duration::minutes(5); + + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + + let metrics_path = root.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + dashboard.db.sync_tool_activity_metrics(&metrics_path)?; + dashboard.sync_from_store(); + + dashboard.toggle_timeline_mode(); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("read src/lib.rs")); + assert!(rendered.contains("write README.md")); + assert!(!rendered.contains("files touched 2")); + + let metrics_text = dashboard.selected_session_metrics_text(); + assert!(metrics_text.contains("Recent file activity")); + assert!(metrics_text.contains("write README.md")); + assert!(metrics_text.contains("read src/lib.rs")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn timeline_time_filter_hides_old_events() { let now = Utc::now();