From 4adb3324ef9e517485148547d8b5dd2c45c80711 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 04:10:08 -0700 Subject: [PATCH] feat: add ecc2 context graph dashboard view --- ecc2/src/tui/app.rs | 4 + ecc2/src/tui/dashboard.rs | 607 +++++++++++++++++++++++++++++++++++--- 2 files changed, 577 insertions(+), 34 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 78da92b0..46f3cffb 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -98,9 +98,13 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(), (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, + (_, KeyCode::Char('K')) => dashboard.toggle_context_graph_mode(), (_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(), (_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(), (_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(), + (_, KeyCode::Char('E')) if dashboard.is_context_graph_mode() => { + dashboard.cycle_graph_entity_filter() + } (_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 3bf6ce12..3a3a240b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -117,6 +117,7 @@ pub struct Dashboard { selected_git_patch_hunk_offsets_split: Vec, selected_git_patch_hunk: usize, output_mode: OutputMode, + graph_entity_filter: GraphEntityFilter, output_filter: OutputFilter, output_time_filter: OutputTimeFilter, timeline_event_filter: TimelineEventFilter, @@ -182,12 +183,22 @@ enum Pane { enum OutputMode { SessionOutput, Timeline, + ContextGraph, WorktreeDiff, ConflictProtocol, GitStatus, GitPatch, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphEntityFilter { + All, + Decisions, + Files, + Functions, + Sessions, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DiffViewMode { Split, @@ -246,6 +257,12 @@ struct SearchMatch { line_index: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct GraphDisplayLine { + session_id: String, + text: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PrPromptSpec { title: String, @@ -535,6 +552,7 @@ impl Dashboard { selected_git_patch_hunk_offsets_split: Vec::new(), selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, + graph_entity_filter: GraphEntityFilter::All, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, timeline_event_filter: TimelineEventFilter::All, @@ -817,6 +835,21 @@ impl Dashboard { }; (self.output_title(), content) } + OutputMode::ContextGraph => { + let lines = self.visible_graph_lines(); + let content = if lines.is_empty() { + Text::from(self.empty_graph_message()) + } else if self.search_query.is_some() { + self.render_searchable_graph(&lines) + } else { + Text::from( + lines.into_iter() + .map(|line| Line::from(line.text)) + .collect::>(), + ) + }; + (self.output_title(), content) + } OutputMode::WorktreeDiff => { let content = if let Some(patch) = self.selected_diff_patch.as_ref() { build_unified_diff_text(patch, self.theme_palette()) @@ -924,6 +957,25 @@ impl Dashboard { ); } + if self.output_mode == OutputMode::ContextGraph { + let scope = self.search_scope.title_suffix(); + let filter = self.graph_entity_filter.title_suffix(); + let time = self.output_time_filter.title_suffix(); + if let Some(input) = self.search_input.as_ref() { + return format!(" Graph{scope}{filter}{time} /{input}_ "); + } + if let Some(query) = self.search_query.as_ref() { + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + return format!(" Graph{scope}{filter}{time} /{query} {current}/{total} "); + } + return format!(" Graph{scope}{filter}{time} "); + } + if self.output_mode == OutputMode::WorktreeDiff { return format!( " Diff{}{} ", @@ -1116,6 +1168,34 @@ impl Dashboard { } } + fn empty_graph_message(&self) -> &'static str { + match ( + self.search_scope, + self.graph_entity_filter, + self.output_time_filter, + ) { + (SearchScope::SelectedSession, GraphEntityFilter::All, OutputTimeFilter::AllTime) => { + "No graph entities for this session yet." + } + (_, GraphEntityFilter::Decisions, OutputTimeFilter::AllTime) => { + "No decision graph entities in the current scope yet." + } + (_, GraphEntityFilter::Files, OutputTimeFilter::AllTime) => { + "No file graph entities in the current scope yet." + } + (_, GraphEntityFilter::Functions, OutputTimeFilter::AllTime) => { + "No function graph entities in the current scope yet." + } + (_, GraphEntityFilter::Sessions, OutputTimeFilter::AllTime) => { + "No session graph entities in the current scope yet." + } + (SearchScope::AllSessions, GraphEntityFilter::All, OutputTimeFilter::AllTime) => { + "No graph entities across all sessions yet." + } + (_, _, _) => "No graph entities in the selected filter/time range.", + } + } + fn render_searchable_output(&self, lines: &[&OutputLine]) -> Text<'static> { let Some(query) = self.search_query.as_deref() else { return Text::from( @@ -1147,6 +1227,39 @@ impl Dashboard { self.theme_palette(), ) }) + .collect::>(), + ) + } + + fn render_searchable_graph(&self, lines: &[GraphDisplayLine]) -> Text<'static> { + let Some(query) = self.search_query.as_deref() else { + return Text::from( + lines + .iter() + .map(|line| Line::from(line.text.clone())) + .collect::>(), + ); + }; + + let active_match = self.search_matches.get(self.selected_search_match); + + Text::from( + lines + .iter() + .enumerate() + .map(|(index, line)| { + highlight_output_line( + &line.text, + query, + active_match + .map(|search_match| { + search_match.session_id == line.session_id + && search_match.line_index == index + }) + .unwrap_or(false), + self.theme_palette(), + ) + }) .collect::>(), ) } @@ -1380,10 +1493,11 @@ impl Dashboard { " I Jump to the next unread approval/conflict target session".to_string(), " g Auto-dispatch unread handoffs across lead sessions".to_string(), " G Dispatch then rebalance backlog across lead teams".to_string(), + " K Toggle selected-session context graph view".to_string(), " h Collapse the focused non-session pane".to_string(), " H Restore all collapsed panes".to_string(), " y Toggle selected-session timeline view".to_string(), - " E Cycle timeline event filter".to_string(), + " E Cycle timeline event filter or graph entity filter".to_string(), " v Toggle selected worktree diff or selected-file patch in output pane" .to_string(), " z Toggle selected worktree git status in output pane".to_string(), @@ -1396,7 +1510,7 @@ impl Dashboard { .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), " f Cycle output or timeline time range between all/15m/1h/24h".to_string(), - " A Toggle search or timeline scope between selected session and all sessions" + " A Toggle search, graph, or timeline scope between selected session and all sessions" .to_string(), " o Toggle search agent filter between all agents and selected agent type" .to_string(), @@ -1430,7 +1544,7 @@ impl Dashboard { " k/↑ Scroll up".to_string(), " [ or ] Focus previous/next delegate in lead Metrics board".to_string(), " Enter Open focused delegate from lead Metrics board".to_string(), - " / Search current session output".to_string(), + " / Search session output or graph lines".to_string(), " n/N Next/previous search match when search is active".to_string(), " Esc Clear active search or cancel search input".to_string(), " +/= Increase pane size and persist it".to_string(), @@ -2113,6 +2227,11 @@ impl Dashboard { self.reset_output_view(); self.set_operator_note("showing session output".to_string()); } + OutputMode::ContextGraph => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } OutputMode::ConflictProtocol => { self.output_mode = OutputMode::SessionOutput; self.reset_output_view(); @@ -3057,6 +3176,10 @@ impl Dashboard { self.search_query.is_some() } + pub fn is_context_graph_mode(&self) -> bool { + self.output_mode == OutputMode::ContextGraph + } + pub fn has_active_completion_popup(&self) -> bool { self.active_completion_popup.is_some() } @@ -3092,9 +3215,27 @@ impl Dashboard { return; } + if self.output_mode == OutputMode::ContextGraph { + self.search_scope = self.search_scope.next(); + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + + if self.search_query.is_some() { + self.set_operator_note(format!( + "graph scope set to {} | {} match(es)", + self.search_scope.label(), + self.search_matches.len() + )); + } else { + self.set_operator_note(format!("graph scope set to {}", self.search_scope.label())); + } + return; + } + if self.output_mode != OutputMode::SessionOutput { self.set_operator_note( - "scope toggle is only available in session output or timeline view".to_string(), + "scope toggle is only available in session output, graph, or timeline view" + .to_string(), ); return; } @@ -3154,13 +3295,20 @@ impl Dashboard { return; } - if self.output_mode != OutputMode::SessionOutput { - self.set_operator_note("search is only available in session output view".to_string()); + if !matches!(self.output_mode, OutputMode::SessionOutput | OutputMode::ContextGraph) { + self.set_operator_note( + "search is only available in session output or graph view".to_string(), + ); return; } self.search_input = Some(self.search_query.clone().unwrap_or_default()); - self.set_operator_note("search mode | type a query and press Enter".to_string()); + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; + self.set_operator_note(format!("{mode} mode | type a query and press Enter")); } pub fn push_input_char(&mut self, ch: char) { @@ -3327,10 +3475,20 @@ impl Dashboard { self.search_query = Some(query.clone()); self.recompute_search_matches(); if self.search_matches.is_empty() { - self.set_operator_note(format!("search /{query} found no matches")); + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; + self.set_operator_note(format!("{mode} /{query} found no matches")); } else { + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; self.set_operator_note(format!( - "search /{query} matched {} line(s) across {} session(s) | n/N navigate matches", + "{mode} /{query} matched {} line(s) across {} session(s) | n/N navigate matches", self.search_matches.len(), self.search_match_session_count() )); @@ -3551,7 +3709,12 @@ impl Dashboard { self.search_matches.clear(); self.selected_search_match = 0; if had_query || had_input { - self.set_operator_note("cleared output search".to_string()); + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "output search" + }; + self.set_operator_note(format!("cleared {mode}")); } } @@ -3601,23 +3764,27 @@ impl Dashboard { pub fn cycle_output_time_filter(&mut self) { if !matches!( self.output_mode, - OutputMode::SessionOutput | OutputMode::Timeline + OutputMode::SessionOutput | OutputMode::Timeline | OutputMode::ContextGraph ) { self.set_operator_note( - "time filters are only available in session output or timeline view".to_string(), + "time filters are only available in session output, graph, or timeline view" + .to_string(), ); return; } self.output_time_filter = self.output_time_filter.next(); - if self.output_mode == OutputMode::SessionOutput { + if matches!( + self.output_mode, + OutputMode::SessionOutput | OutputMode::ContextGraph + ) { self.recompute_search_matches(); } self.sync_output_scroll(self.last_output_height.max(1)); - let note_prefix = if self.output_mode == OutputMode::Timeline { - "timeline range" - } else { - "output time filter" + let note_prefix = match self.output_mode { + OutputMode::Timeline => "timeline range", + OutputMode::ContextGraph => "graph range", + _ => "output time filter", }; self.set_operator_note(format!( "{note_prefix} set to {}", @@ -3641,6 +3808,41 @@ impl Dashboard { )); } + pub fn toggle_context_graph_mode(&mut self) { + match self.output_mode { + OutputMode::ContextGraph => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + _ => { + self.output_mode = OutputMode::ContextGraph; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = 0; + self.recompute_search_matches(); + self.set_operator_note("showing selected session context graph".to_string()); + } + } + } + + pub fn cycle_graph_entity_filter(&mut self) { + if self.output_mode != OutputMode::ContextGraph { + self.set_operator_note( + "graph entity filters are only available in context graph view".to_string(), + ); + return; + } + + self.graph_entity_filter = self.graph_entity_filter.next(); + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note(format!( + "graph filter set to {}", + self.graph_entity_filter.label() + )); + } + pub fn toggle_auto_dispatch_policy(&mut self) { self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; match self.cfg.save() { @@ -4842,6 +5044,97 @@ impl Dashboard { .unwrap_or_default() } + fn visible_graph_lines(&self) -> Vec { + let session_scope = match self.search_scope { + SearchScope::SelectedSession => self.selected_session_id(), + SearchScope::AllSessions => None, + }; + let entity_type = self.graph_entity_filter.entity_type(); + let entities = self + .db + .list_context_entities(session_scope, entity_type, 48) + .unwrap_or_default(); + let show_session_label = self.search_scope == SearchScope::AllSessions; + + entities + .into_iter() + .filter(|entity| self.output_time_filter.matches_timestamp(entity.updated_at)) + .flat_map(|entity| self.graph_lines_for_entity(entity, show_session_label)) + .collect() + } + + fn graph_lines_for_entity( + &self, + entity: crate::session::ContextGraphEntity, + show_session_label: bool, + ) -> Vec { + let session_id = entity.session_id.clone().unwrap_or_default(); + let session_label = if show_session_label { + if session_id.is_empty() { + "global ".to_string() + } else { + format!("{} ", format_session_id(&session_id)) + } + } else { + String::new() + }; + let entity_title = format!( + "[{}] {}{:<8} {}", + entity.updated_at.format("%H:%M:%S"), + session_label, + entity.entity_type, + entity.name + ); + let mut lines = vec![GraphDisplayLine { + session_id: session_id.clone(), + text: entity_title, + }]; + + if let Some(path) = entity.path.as_ref() { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!(" path {}", truncate_for_dashboard(path, 96)), + }); + } + + if !entity.summary.trim().is_empty() { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!( + " summary {}", + truncate_for_dashboard(&entity.summary, 96) + ), + }); + } + + if let Ok(Some(detail)) = self.db.get_context_entity_detail(entity.id, 2) { + for relation in detail.outgoing { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!( + " -> {} {}:{}", + relation.relation_type, + relation.to_entity_type, + truncate_for_dashboard(&relation.to_entity_name, 72) + ), + }); + } + for relation in detail.incoming { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!( + " <- {} {}:{}", + relation.relation_type, + relation.from_entity_type, + truncate_for_dashboard(&relation.from_entity_name, 72) + ), + }); + } + } + + lines + } + fn visible_git_status_lines(&self) -> Vec> { self.selected_git_status_entries .iter() @@ -5066,22 +5359,34 @@ impl Dashboard { return; }; - self.search_matches = self - .search_target_session_ids() - .into_iter() - .flat_map(|session_id| { - self.visible_output_lines_for_session(session_id) - .into_iter() - .enumerate() - .filter_map(|(index, line)| { - regex.is_match(&line.text).then_some(SearchMatch { - session_id: session_id.to_string(), - line_index: index, - }) + self.search_matches = if self.output_mode == OutputMode::ContextGraph { + self.visible_graph_lines() + .into_iter() + .enumerate() + .filter_map(|(index, line)| { + regex.is_match(&line.text).then_some(SearchMatch { + session_id: line.session_id, + line_index: index, }) - .collect::>() - }) - .collect(); + }) + .collect() + } else { + self.search_target_session_ids() + .into_iter() + .flat_map(|session_id| { + self.visible_output_lines_for_session(session_id) + .into_iter() + .enumerate() + .filter_map(|(index, line)| { + regex.is_match(&line.text).then_some(SearchMatch { + session_id: session_id.to_string(), + line_index: index, + }) + }) + .collect::>() + }) + .collect() + }; if self.search_matches.is_empty() { self.selected_search_match = 0; @@ -5100,7 +5405,9 @@ impl Dashboard { return; }; - if self.selected_session_id() != Some(search_match.session_id.as_str()) { + if !search_match.session_id.is_empty() + && self.selected_session_id() != Some(search_match.session_id.as_str()) + { self.sync_selection_by_id(Some(&search_match.session_id)); self.sync_selected_output(); self.sync_selected_diff(); @@ -5126,8 +5433,13 @@ impl Dashboard { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; format!( - "search /{query} match {current}/{total} | {}", + "{mode} /{query} match {current}/{total} | {}", self.search_scope.label() ) } @@ -5135,6 +5447,7 @@ impl Dashboard { fn search_match_session_count(&self) -> usize { self.search_matches .iter() + .filter(|search_match| !search_match.session_id.is_empty()) .map(|search_match| search_match.session_id.as_str()) .collect::>() .len() @@ -5224,6 +5537,8 @@ impl Dashboard { self.active_patch_text() .map(|patch| patch.lines().count()) .unwrap_or(0) + } else if self.output_mode == OutputMode::ContextGraph { + self.visible_graph_lines().len() } else if self.output_mode == OutputMode::Timeline { self.visible_timeline_lines().len() } else { @@ -6705,6 +7020,48 @@ impl TimelineEventFilter { } } +impl GraphEntityFilter { + fn next(self) -> Self { + match self { + Self::All => Self::Decisions, + Self::Decisions => Self::Files, + Self::Files => Self::Functions, + Self::Functions => Self::Sessions, + Self::Sessions => Self::All, + } + } + + fn entity_type(self) -> Option<&'static str> { + match self { + Self::All => None, + Self::Decisions => Some("decision"), + Self::Files => Some("file"), + Self::Functions => Some("function"), + Self::Sessions => Some("session"), + } + } + + fn label(self) -> &'static str { + match self { + Self::All => "all entities", + Self::Decisions => "decisions", + Self::Files => "files", + Self::Functions => "functions", + Self::Sessions => "sessions", + } + } + + fn title_suffix(self) -> &'static str { + match self { + Self::All => "", + Self::Decisions => " decisions", + Self::Files => " files", + Self::Functions => " functions", + Self::Sessions => " sessions", + } + } +} + impl TimelineEventType { fn label(self) -> &'static str { match self { @@ -9583,6 +9940,187 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ assert!(rendered.contains("tool git")); } + #[test] + fn toggle_context_graph_mode_renders_selected_session_entities_and_relations() -> Result<()> { + let session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + + let file = dashboard.db.upsert_context_entity( + Some(&session.id), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "dashboard renderer", + &std::collections::BTreeMap::new(), + )?; + let function = dashboard.db.upsert_context_entity( + Some(&session.id), + "function", + "render_output", + None, + "renders the output pane", + &std::collections::BTreeMap::new(), + )?; + dashboard.db.upsert_context_relation( + Some(&session.id), + file.id, + function.id, + "contains", + "output rendering path", + )?; + + dashboard.toggle_context_graph_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::ContextGraph); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected session context graph") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Graph")); + assert!(rendered.contains("dashboard.rs")); + assert!(rendered.contains("summary dashboard renderer")); + assert!(rendered.contains("-> contains function:render_output")); + Ok(()) + } + + #[test] + fn cycle_graph_entity_filter_limits_rendered_entities() -> Result<()> { + let session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + dashboard.db.insert_decision( + &session.id, + "Use sqlite graph sync", + &[], + "Keeps shared memory queryable", + )?; + dashboard.db.upsert_context_entity( + Some(&session.id), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "dashboard renderer", + &std::collections::BTreeMap::new(), + )?; + + dashboard.toggle_context_graph_mode(); + dashboard.cycle_graph_entity_filter(); + + assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Decisions); + assert_eq!(dashboard.output_title(), " Graph decisions "); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Use sqlite graph sync")); + assert!(!rendered.contains("dashboard.rs")); + + dashboard.cycle_graph_entity_filter(); + assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Files); + assert_eq!(dashboard.output_title(), " Graph files "); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("dashboard.rs")); + assert!(!rendered.contains("Use sqlite graph sync")); + Ok(()) + } + + #[test] + fn graph_scope_all_sessions_renders_cross_session_entities() -> Result<()> { + let focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let review = sample_session( + "review-87654321", + "reviewer", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&review)?; + dashboard.db.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?; + dashboard.db.insert_decision(&review.id, "Beta graph path", &[], "review path")?; + + dashboard.toggle_context_graph_mode(); + dashboard.toggle_search_scope(); + + assert_eq!(dashboard.search_scope, SearchScope::AllSessions); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("graph scope set to all sessions") + ); + assert_eq!(dashboard.output_title(), " Graph all sessions "); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("focus-12")); + assert!(rendered.contains("review-8")); + assert!(rendered.contains("Alpha graph path")); + assert!(rendered.contains("Beta graph path")); + Ok(()) + } + + #[test] + fn graph_search_matches_and_switches_selected_session() -> Result<()> { + let focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let review = sample_session( + "review-87654321", + "reviewer", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&review)?; + dashboard.db.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?; + dashboard.db.insert_decision(&review.id, "alpha remote graph", &[], "review path")?; + + dashboard.toggle_context_graph_mode(); + dashboard.toggle_search_scope(); + dashboard.begin_search(); + for ch in "alpha.*".chars() { + dashboard.push_input_char(ch); + } + dashboard.submit_search(); + + assert_eq!(dashboard.search_matches.len(), 2); + let first_session = dashboard.selected_session_id().map(str::to_string); + dashboard.next_search_match(); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("graph search /alpha.* match 2/2 | all sessions") + ); + assert_ne!(dashboard.selected_session_id().map(str::to_string), first_session); + Ok(()) + } + #[test] fn worktree_diff_columns_split_removed_and_added_lines() { let patch = "\ @@ -13343,6 +13881,7 @@ diff --git a/src/lib.rs b/src/lib.rs selected_git_patch_hunk_offsets_split: Vec::new(), selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, + graph_entity_filter: GraphEntityFilter::All, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, timeline_event_filter: TimelineEventFilter::All,