diff --git a/src/acp_state.rs b/src/acp_state.rs index 0b86d68..73bda7e 100644 --- a/src/acp_state.rs +++ b/src/acp_state.rs @@ -4,10 +4,10 @@ use serde_json::Value; use crate::acp_client::DelegateModelOverrideInfo; use crate::command::{Command, SessionListRequest}; +use crate::delegates_state::DelegateLifecycleUpdate; use crate::diagnostics::LogLevel; use crate::domain::activity::{ - ActivityState, DelegateChildState, DelegateEntry, DelegateStats, DelegateStatus, - DelegationState, DelegationUpdate, + ActivityState, DelegateChildState, DelegateStatus, DelegationState, DelegationUpdate, }; use crate::domain::auth::{AuthProviderEntry, OAuthFlow, OAuthResult}; use crate::domain::chat::ChatEntry; @@ -233,7 +233,7 @@ impl crate::app::App { AcpAppEvent::ProfileAgents { profile_id, agents } => { if self.desired_agents_profile_id() == Some(profile_id.as_str()) { self.models.replace_profile_agents(profile_id, agents); - if self.parent_session_id.is_none() + if self.delegates.parent_session_id.is_none() && let (Some(session_id), Some(profile_id)) = ( self.sessions.session_id.as_deref(), self.models.agents_profile_id.as_deref(), @@ -623,6 +623,8 @@ impl crate::app::App { session_id: String, profile_id: Option, ) -> Vec { + self.delegates.parent_session_id = None; + self.delegates.pending_parent_session_id = None; self.sessions.session_id = Some(session_id.clone()); self.apply_session_profile_binding(&session_id, profile_id); self.sessions.agent_id = Some(agent_id); @@ -650,16 +652,16 @@ impl crate::app::App { profile_id: Option, ) -> Vec { self.activity = ActivityState::Idle; - self.parent_session_id = self.pending_parent_session_id.take().or_else(|| { - self.sessions - .session_parent_id(&session_id) - .map(str::to_owned) - }); + let discovered_parent = self + .sessions + .session_parent_id(&session_id) + .map(str::to_owned); + self.delegates.resolve_parent_session_id(discovered_parent); self.apply_session_profile_binding(&session_id, profile_id); self.sessions.session_id = Some(session_id.clone()); self.sessions.agent_id = Some(agent_id); self.reset_active_session_view(); - self.navigation.screen = if self.parent_session_id.is_some() { + self.navigation.screen = if self.delegates.parent_session_id.is_some() { Screen::Delegate } else { Screen::Chat @@ -668,7 +670,7 @@ impl crate::app::App { let mut commands = vec![Command::SetAgentMode { mode: self.sessions.agent_mode.clone(), }]; - if self.parent_session_id.is_none() + if self.delegates.parent_session_id.is_none() && let Some(profile_id) = self.current_session_profile_id().map(str::to_string) { if self.models.agents_profile_id.as_deref() == Some(profile_id.as_str()) { @@ -694,15 +696,8 @@ impl crate::app::App { self.undoable_turns.clear(); self.recent_prompt_text = None; self.suppress_turn_output = false; - if self.parent_session_id.is_none() { - self.delegate_entries.clear(); - self.pending_delegate_child_states.clear(); - self.pending_delegate_child_stats.clear(); - self.delegate_child_message_ids.clear(); - self.delegation_update_times.clear(); - self.delegation_result_summaries.clear(); - self.delegation_errors.clear(); - self.pending_delegate_tool_calls.clear(); + if self.delegates.parent_session_id.is_none() { + self.delegates.clear_for_root_session(); } self.composer.reset_for_session_switch(); self.last_compaction_token_estimate = None; @@ -722,13 +717,6 @@ impl crate::app::App { let Some(tool_call_id) = tool_call_id else { return; }; - if self - .delegate_entries - .iter() - .any(|entry| entry.delegate_tool_call_id.as_deref() == Some(tool_call_id)) - { - return; - } let target_agent_id = arguments .and_then(|value| value.get("target_agent_id")) .and_then(Value::as_str) @@ -738,154 +726,67 @@ impl crate::app::App { .and_then(Value::as_str) .unwrap_or_default() .to_string(); - self.delegate_entries.push(DelegateEntry { - delegation_id: format!("tool:{tool_call_id}"), - child_session_id: None, - delegate_tool_call_id: Some(tool_call_id.to_string()), - target_agent_id, - objective, - status: DelegateStatus::InProgress, - stats: DelegateStats::default(), - started_at: None, - ended_at: None, - child_state: DelegateChildState::None, - }); - self.invalidate_delegate_render_cache(); + if self + .delegates + .upsert_provisional_delegate(tool_call_id, target_agent_id, objective) + { + self.invalidate_delegate_render_cache(); + } } fn apply_acp_delegation_update(&mut self, update: DelegationUpdate) { if self.sessions.session_id.as_deref() != Some(update.session_id.as_str()) { return; } - let existing_index = self - .delegate_entries - .iter() - .position(|entry| entry.delegation_id == update.delegation_id); - if let Some(existing_timestamp) = self.delegation_update_times.get(&update.delegation_id) { - let incoming_rank = delegation_state_rank(update.state); - let existing_rank = existing_index - .map(|index| delegate_entry_lifecycle_rank(&self.delegate_entries[index])) - .unwrap_or(0); - if *existing_timestamp > update.updated_at - || (*existing_timestamp == update.updated_at && existing_rank > incoming_rank) - { - return; - } - } - let index = existing_index.or_else(|| { - update.tool_call_id.as_deref().and_then(|tool_call_id| { - self.delegate_entries - .iter() - .position(|entry| entry.delegate_tool_call_id.as_deref() == Some(tool_call_id)) - }) - }); let status = match update.state { DelegationState::Requested | DelegationState::Forked => DelegateStatus::InProgress, DelegationState::Completed => DelegateStatus::Completed, DelegationState::Failed => DelegateStatus::Failed, DelegationState::Cancelled => DelegateStatus::Cancelled, }; - let index = if let Some(index) = index { - index - } else { - self.delegate_entries.push(DelegateEntry { - delegation_id: update.delegation_id.clone(), - child_session_id: None, - delegate_tool_call_id: update.tool_call_id.clone(), - target_agent_id: Some(update.target_agent_id.clone()), - objective: update.objective.clone(), + let lifecycle_rank = delegation_state_rank(update.state); + if self + .delegates + .apply_lifecycle_update(DelegateLifecycleUpdate { + delegation_id: update.delegation_id, + tool_call_id: update.tool_call_id, + target_agent_id: update.target_agent_id, + objective: update.objective, + child_session_id: update.child_session_id, status, - stats: DelegateStats::default(), - started_at: Some(update.requested_at), - ended_at: None, - child_state: DelegateChildState::None, - }); - self.delegate_entries.len() - 1 - }; - let entry = &mut self.delegate_entries[index]; - entry.delegation_id = update.delegation_id.clone(); - if update.tool_call_id.is_some() { - entry.delegate_tool_call_id = update.tool_call_id.clone(); - } - entry.target_agent_id = Some(update.target_agent_id); - entry.objective = update.objective; - entry.status = status; - entry.started_at = Some(update.requested_at); - entry.ended_at = update.finished_at; - entry.child_session_id = update.child_session_id.clone(); - if status != DelegateStatus::InProgress { - entry.child_state = DelegateChildState::None; - } - self.delegation_update_times - .insert(update.delegation_id.clone(), update.updated_at); - match update.result_summary { - Some(summary) => { - self.delegation_result_summaries - .insert(update.delegation_id.clone(), summary); - } - None => { - self.delegation_result_summaries - .remove(&update.delegation_id); - } - } - match update.error { - Some(error) => { - self.delegation_errors - .insert(update.delegation_id.clone(), error); - } - None => { - self.delegation_errors.remove(&update.delegation_id); - } - } - if let Some(child_session_id) = update.child_session_id { - if let Some(stats) = self.pending_delegate_child_stats.remove(&child_session_id) { - self.delegate_entries[index].stats = stats; - } - if let Some(state) = self.pending_delegate_child_states.remove(&child_session_id) { - self.delegate_entries[index].child_state = state; - } + lifecycle_rank, + requested_at: update.requested_at, + finished_at: update.finished_at, + updated_at: update.updated_at, + result_summary: update.result_summary, + error: update.error, + }) + { + self.invalidate_delegate_render_cache(); } - self.invalidate_delegate_render_cache(); } fn apply_acp_delegate_child_update(&mut self, session_id: &str, update: &AcpSessionUpdate) { - let index = self - .delegate_entries - .iter() - .position(|entry| entry.child_session_id.as_deref() == Some(session_id)); - let mut state = index - .map(|index| self.delegate_entries[index].child_state.clone()) - .or_else(|| self.pending_delegate_child_states.get(session_id).cloned()) - .unwrap_or_default(); - let mut stats = index - .map(|index| self.delegate_entries[index].stats.clone()) - .or_else(|| self.pending_delegate_child_stats.get(session_id).cloned()) - .unwrap_or_default(); + let (mut state, mut stats) = self.delegates.child_snapshot(session_id); match update { AcpSessionUpdate::ToolCallStart { .. } => { stats.tool_calls = stats.tool_calls.saturating_add(1); state = DelegateChildState::OtherProgress; } AcpSessionUpdate::AssistantMessage { message_id, .. } => { - let should_increment = message_id.as_ref().is_none_or(|message_id| { - self.delegate_child_message_ids - .entry(session_id.to_string()) - .or_default() - .insert(message_id.clone()) - }); - if should_increment { + if self + .delegates + .record_child_message_id(session_id, message_id.as_deref(), true) + { stats.messages = stats.messages.saturating_add(1); } state = DelegateChildState::AssistantMessage; } AcpSessionUpdate::AssistantContentDelta { message_id, .. } => { - let should_increment = message_id.as_ref().is_some_and(|message_id| { - self.delegate_child_message_ids - .entry(session_id.to_string()) - .or_default() - .insert(message_id.clone()) - }); - if should_increment { + if self + .delegates + .record_child_message_id(session_id, message_id.as_deref(), false) + { stats.messages = stats.messages.saturating_add(1); } state = DelegateChildState::AssistantMessage; @@ -931,19 +832,11 @@ impl crate::app::App { | AcpSessionUpdate::Cancelled | AcpSessionUpdate::Finished { .. } => {} } - if let Some(index) = index { - if self.delegate_entries[index].stats != stats - || self.delegate_entries[index].child_state != state - { - self.delegate_entries[index].stats = stats; - self.delegate_entries[index].child_state = state; - self.invalidate_delegate_render_cache(); - } - } else if state != DelegateChildState::None || stats != DelegateStats::default() { - self.pending_delegate_child_states - .insert(session_id.to_string(), state); - self.pending_delegate_child_stats - .insert(session_id.to_string(), stats); + if self + .delegates + .apply_child_snapshot(session_id, state, stats) + { + self.invalidate_delegate_render_cache(); } } @@ -1900,14 +1793,6 @@ fn delegation_state_rank(state: DelegationState) -> u8 { } } -fn delegate_entry_lifecycle_rank(entry: &DelegateEntry) -> u8 { - match entry.status { - DelegateStatus::Completed | DelegateStatus::Failed | DelegateStatus::Cancelled => 3, - DelegateStatus::InProgress if entry.child_session_id.is_some() => 2, - DelegateStatus::InProgress => 1, - } -} - fn acp_content_to_string(value: &Value) -> String { match value { Value::String(s) => s.clone(), @@ -1925,7 +1810,9 @@ mod tests { use super::*; use crate::app::App; use crate::composer_state::{FileIndexEntryLite, MentionState}; - use crate::domain::activity::{PendingDelegateToolCall, SessionOp}; + use crate::domain::activity::{ + DelegateEntry, DelegateStats, PendingDelegateToolCall, SessionOp, + }; use crate::domain::mesh::{RemoteNodeInfo, RemoteSessionInfo}; use crate::domain::model::DelegateModelPreference; use crate::domain::session::{ @@ -2238,13 +2125,19 @@ mod tests { is_replay: false, }); - assert_eq!(app.delegate_entries.len(), 1); - assert_eq!(app.delegate_entries[0].delegation_id, "tool:call-1"); + assert_eq!(app.delegates.delegate_entries.len(), 1); + assert_eq!( + app.delegates.delegate_entries[0].delegation_id, + "tool:call-1" + ); assert_eq!( - app.delegate_entries[0].target_agent_id.as_deref(), + app.delegates.delegate_entries[0].target_agent_id.as_deref(), Some("coder") ); - assert_eq!(app.delegate_entries[0].status, DelegateStatus::InProgress); + assert_eq!( + app.delegates.delegate_entries[0].status, + DelegateStatus::InProgress + ); } #[test] @@ -2267,12 +2160,15 @@ mod tests { 110, ))); - assert_eq!(app.delegate_entries.len(), 1); - let entry = &app.delegate_entries[0]; + assert_eq!(app.delegates.delegate_entries.len(), 1); + let entry = &app.delegates.delegate_entries[0]; assert_eq!(entry.delegation_id, "delegation-1"); assert_eq!(entry.child_session_id.as_deref(), Some("child-1")); assert_eq!(entry.status, DelegateStatus::Completed); - assert_eq!(app.delegation_result_summaries["delegation-1"], "done"); + assert_eq!( + app.delegates.delegation_result_summaries["delegation-1"], + "done" + ); } #[test] @@ -2288,8 +2184,14 @@ mod tests { 120, ))); - assert_eq!(app.delegate_entries[0].status, DelegateStatus::Completed); - assert_eq!(app.delegation_result_summaries["delegation-1"], "done"); + assert_eq!( + app.delegates.delegate_entries[0].status, + DelegateStatus::Completed + ); + assert_eq!( + app.delegates.delegation_result_summaries["delegation-1"], + "done" + ); } #[test] @@ -2300,23 +2202,36 @@ mod tests { DelegationState::Completed, 120, ))); - assert_eq!(app.delegation_result_summaries["delegation-1"], "done"); - assert!(!app.delegation_errors.contains_key("delegation-1")); + assert_eq!( + app.delegates.delegation_result_summaries["delegation-1"], + "done" + ); + assert!(!app.delegates.delegation_errors.contains_key("delegation-1")); app.handle_acp_event(AcpAppEvent::DelegationUpdate(delegation_update( DelegationState::Failed, 130, ))); - assert_eq!(app.delegate_entries[0].status, DelegateStatus::Failed); - assert!(!app.delegation_result_summaries.contains_key("delegation-1")); - assert_eq!(app.delegation_errors["delegation-1"], "boom"); + assert_eq!( + app.delegates.delegate_entries[0].status, + DelegateStatus::Failed + ); + assert!( + !app.delegates + .delegation_result_summaries + .contains_key("delegation-1") + ); + assert_eq!(app.delegates.delegation_errors["delegation-1"], "boom"); app.handle_acp_event(AcpAppEvent::DelegationUpdate(delegation_update( DelegationState::Cancelled, 140, ))); - assert_eq!(app.delegate_entries[0].status, DelegateStatus::Cancelled); - assert!(!app.delegation_errors.contains_key("delegation-1")); + assert_eq!( + app.delegates.delegate_entries[0].status, + DelegateStatus::Cancelled + ); + assert!(!app.delegates.delegation_errors.contains_key("delegation-1")); } #[test] @@ -2328,7 +2243,7 @@ mod tests { session_id: "session-2".into(), updates: vec![valid_update.clone()], }); - assert!(app.delegate_entries.is_empty()); + assert!(app.delegates.delegate_entries.is_empty()); let mut wrong_parent_update = valid_update.clone(); wrong_parent_update.session_id = "session-2".into(); @@ -2337,14 +2252,17 @@ mod tests { updates: vec![wrong_parent_update.clone()], }); app.handle_acp_event(AcpAppEvent::DelegationUpdate(wrong_parent_update)); - assert!(app.delegate_entries.is_empty()); + assert!(app.delegates.delegate_entries.is_empty()); app.handle_acp_event(AcpAppEvent::DelegationReplay { session_id: TEST_SESSION_ID.into(), updates: vec![valid_update], }); - assert_eq!(app.delegate_entries.len(), 1); - assert_eq!(app.delegate_entries[0].status, DelegateStatus::InProgress); + assert_eq!(app.delegates.delegate_entries.len(), 1); + assert_eq!( + app.delegates.delegate_entries[0].status, + DelegateStatus::InProgress + ); } #[test] @@ -2373,7 +2291,7 @@ mod tests { 110, ))); - let entry = &app.delegate_entries[0]; + let entry = &app.delegates.delegate_entries[0]; assert_eq!(entry.stats.tool_calls, 1); assert_eq!(entry.stats.messages, 1); assert_eq!(entry.child_state, DelegateChildState::AssistantMessage); @@ -2932,6 +2850,20 @@ mod tests { let mut app = App::new(); app.messages.push(ChatEntry::Error("stale".into())); app.streaming_content = "stale stream".into(); + app.delegates.parent_session_id = Some("old-parent".into()); + app.delegates.pending_parent_session_id = Some("staged-parent".into()); + app.delegates.delegate_entries.push(DelegateEntry { + delegation_id: "stale-delegate".into(), + child_session_id: Some("old-child".into()), + delegate_tool_call_id: None, + target_agent_id: Some("coder".into()), + objective: "stale".into(), + status: DelegateStatus::InProgress, + stats: DelegateStats::default(), + started_at: None, + ended_at: None, + child_state: DelegateChildState::None, + }); app.scroll_offset = 3; app.composer.input = "/mo".into(); app.composer.input_cursor = 3; @@ -2977,6 +2909,9 @@ mod tests { assert_eq!(app.sessions.session_id.as_deref(), Some("session-1")); assert_eq!(app.sessions.agent_id.as_deref(), Some("agent-1")); assert_eq!(app.navigation.screen, Screen::Chat); + assert_eq!(app.delegates.parent_session_id, None); + assert_eq!(app.delegates.pending_parent_session_id, None); + assert!(app.delegates.delegate_entries.is_empty()); assert!(app.messages.is_empty()); assert!(app.streaming_content.is_empty()); assert_eq!(app.scroll_offset, 0); @@ -3048,7 +2983,7 @@ mod tests { app.elicitation_ui = Some(crate::ui::ElicitationUiState::default()); app.session_stats.total_tool_calls = 2; seed_model_state(&mut app); - app.delegate_entries.push(DelegateEntry { + app.delegates.delegate_entries.push(DelegateEntry { delegation_id: "delegate-1".into(), child_session_id: Some("child".into()), delegate_tool_call_id: None, @@ -3067,7 +3002,7 @@ mod tests { profile_id: None, }); - assert_eq!(app.parent_session_id.as_deref(), Some("parent")); + assert_eq!(app.delegates.parent_session_id.as_deref(), Some("parent")); assert_eq!(app.navigation.screen, Screen::Delegate); assert!(app.messages.is_empty()); assert!(app.streaming_content.is_empty()); @@ -3080,7 +3015,7 @@ mod tests { assert!(app.recent_prompt_text.is_none()); assert!(app.elicitation.is_none()); assert_eq!(app.session_stats.total_tool_calls, 0); - assert_eq!(app.delegate_entries.len(), 1); + assert_eq!(app.delegates.delegate_entries.len(), 1); assert_seeded_model_state(&app); assert!(matches!( replies.as_slice(), @@ -3091,8 +3026,8 @@ mod tests { #[test] fn native_session_loaded_root_clears_delegate_state() { let mut app = App::new(); - app.parent_session_id = Some("old-parent".into()); - app.delegate_entries.push(DelegateEntry { + app.delegates.parent_session_id = Some("old-parent".into()); + app.delegates.delegate_entries.push(DelegateEntry { delegation_id: "delegate-1".into(), child_session_id: None, delegate_tool_call_id: None, @@ -3104,9 +3039,11 @@ mod tests { ended_at: None, child_state: DelegateChildState::None, }); - app.pending_delegate_child_states + app.delegates + .pending_delegate_child_states .insert("child".into(), DelegateChildState::OtherProgress); - app.pending_delegate_tool_calls + app.delegates + .pending_delegate_tool_calls .push(PendingDelegateToolCall { tool_call_id: "tool-1".into(), target_agent_id: None, @@ -3121,11 +3058,11 @@ mod tests { profile_id: None, }); - assert_eq!(app.parent_session_id, None); + assert_eq!(app.delegates.parent_session_id, None); assert_eq!(app.navigation.screen, Screen::Chat); - assert!(app.delegate_entries.is_empty()); - assert!(app.pending_delegate_child_states.is_empty()); - assert!(app.pending_delegate_tool_calls.is_empty()); + assert!(app.delegates.delegate_entries.is_empty()); + assert!(app.delegates.pending_delegate_child_states.is_empty()); + assert!(app.delegates.pending_delegate_tool_calls.is_empty()); assert_eq!(app.mesh.mesh_node_count, Some(2)); assert_eq!(app.mesh.mesh_invite_ttl, "48h"); } diff --git a/src/app.rs b/src/app.rs index 0d59915..a4662ac 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,14 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::time::{Duration, Instant}; use crate::auth_state::AuthState; use crate::command::Command; use crate::composer_state::ComposerState; use crate::connection_state::{ConnState, ConnectionState}; +use crate::delegates_state::DelegatesState; use crate::diagnostics::{AppLogEntry, DiagnosticsState, LogLevel}; use crate::domain::activity::{ - ActivityState, DelegateChildState, DelegateEntry, DelegateStats, PendingDelegateToolCall, - SessionOp, SessionStatsLite, + ActivityState, DelegateChildState, DelegateStats, SessionOp, SessionStatsLite, }; use crate::domain::chat::{ChatEntry, format_outcome_labels}; use crate::domain::elicitation::ElicitationState; @@ -211,8 +211,7 @@ pub struct App { // sessions pub(crate) sessions: SessionsState, - /// Last rendered visible row count for the delegates tab in the popup. - pub delegate_popup_visible_rows: usize, + pub(crate) delegates: DelegatesState, // chat pub messages: Vec, @@ -281,27 +280,6 @@ pub struct App { // auth popup state pub(crate) auth: AuthState, - // delegate session listing (built from event stream) - pub delegate_entries: Vec, - pub delegate_cursor: usize, - pub delegate_filter: String, - /// Parent session ID (set when viewing a delegate child session). - pub parent_session_id: Option, - /// Staging field: set by delegate popup before LoadSession, consumed by session_loaded. - pub pending_parent_session_id: Option, - /// Set after DelegationCompleted/DelegationFailed; consumed by the next - /// UserMessageStored to suppress the noisy batch-result message. - pub suppress_delegation_result: bool, - /// Child-session state observed before a delegation entry can be linked. - pub pending_delegate_child_states: HashMap, - pub pending_delegate_child_stats: HashMap, - pub delegate_child_message_ids: HashMap>, - /// Latest lifecycle timestamp and bounded terminal metadata by delegation ID. - pub delegation_update_times: HashMap, - pub delegation_result_summaries: HashMap, - pub delegation_errors: HashMap, - /// Parent delegate ToolCallStart records awaiting DelegationRequested linkage. - pub pending_delegate_tool_calls: Vec, /// While a reverted frontier turn is being suppressed, ignore any /// follow-up assistant/tool/cancelled events until a new prompt arrives. pub suppress_turn_output: bool, @@ -349,7 +327,7 @@ impl App { Self { navigation: NavigationState::new(), sessions: SessionsState::new(), - delegate_popup_visible_rows: 0, + delegates: DelegatesState::new(), messages: Vec::new(), pending_prompt_seq: 0, composer: ComposerState::new(), @@ -384,19 +362,6 @@ impl App { hl: Highlighter::new(), card_cache: CardCache::new(), auth: AuthState::new(), - delegate_entries: Vec::new(), - delegate_cursor: 0, - delegate_filter: String::new(), - parent_session_id: None, - pending_parent_session_id: None, - suppress_delegation_result: false, - pending_delegate_child_states: HashMap::new(), - pending_delegate_child_stats: HashMap::new(), - delegate_child_message_ids: HashMap::new(), - delegation_update_times: HashMap::new(), - delegation_result_summaries: HashMap::new(), - delegation_errors: HashMap::new(), - pending_delegate_tool_calls: Vec::new(), suppress_turn_output: false, tick: 0, should_quit: false, @@ -1374,7 +1339,7 @@ mod reasoning_effort_tests { #[cfg(test)] mod delegate_entry_tests { use super::*; - use crate::domain::activity::DelegateStatus; + use crate::domain::activity::{DelegateEntry, DelegateStatus}; fn make_entry(delegation_id: &str, objective: &str, status: DelegateStatus) -> DelegateEntry { DelegateEntry { @@ -1396,28 +1361,28 @@ mod delegate_entry_tests { #[test] fn visible_entries_empty_when_no_entries() { let app = App::new(); - assert!(app.visible_delegate_entries().is_empty()); + assert!(app.delegates.visible_entries().is_empty()); } #[test] fn visible_entries_returns_all_when_no_filter() { let mut app = App::new(); - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ make_entry("d1", "Build feature", DelegateStatus::Completed), make_entry("d2", "Fix tests", DelegateStatus::InProgress), ]; - assert_eq!(app.visible_delegate_entries().len(), 2); + assert_eq!(app.delegates.visible_entries().len(), 2); } #[test] fn visible_entries_filters_by_objective() { let mut app = App::new(); - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ make_entry("d1", "Build feature", DelegateStatus::Completed), make_entry("d2", "Fix tests", DelegateStatus::InProgress), ]; - app.delegate_filter = "build".into(); - let entries = app.visible_delegate_entries(); + app.delegates.delegate_filter = "build".into(); + let entries = app.delegates.visible_entries(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].delegation_id, "d1"); } @@ -1425,12 +1390,12 @@ mod delegate_entry_tests { #[test] fn visible_entries_filters_by_delegation_id() { let mut app = App::new(); - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ make_entry("abc123", "Build feature", DelegateStatus::Completed), make_entry("xyz789", "Fix tests", DelegateStatus::InProgress), ]; - app.delegate_filter = "xyz".into(); - let entries = app.visible_delegate_entries(); + app.delegates.delegate_filter = "xyz".into(); + let entries = app.delegates.visible_entries(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].delegation_id, "xyz789"); } @@ -1438,7 +1403,7 @@ mod delegate_entry_tests { #[test] fn visible_entries_filters_by_target_agent() { let mut app = App::new(); - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "d1".into(), child_session_id: None, @@ -1464,8 +1429,8 @@ mod delegate_entry_tests { child_state: DelegateChildState::None, }, ]; - app.delegate_filter = "planner".into(); - let entries = app.visible_delegate_entries(); + app.delegates.delegate_filter = "planner".into(); + let entries = app.delegates.visible_entries(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].delegation_id, "d1"); } @@ -1473,9 +1438,10 @@ mod delegate_entry_tests { #[test] fn visible_entries_filter_is_case_insensitive() { let mut app = App::new(); - app.delegate_entries = vec![make_entry("d1", "Build Feature", DelegateStatus::Completed)]; - app.delegate_filter = "BUILD".into(); - assert_eq!(app.visible_delegate_entries().len(), 1); + app.delegates.delegate_entries = + vec![make_entry("d1", "Build Feature", DelegateStatus::Completed)]; + app.delegates.delegate_filter = "BUILD".into(); + assert_eq!(app.delegates.visible_entries().len(), 1); } // ── delegation event processing ─────────────────────────────────────────── diff --git a/src/delegates_state.rs b/src/delegates_state.rs new file mode 100644 index 0000000..19628d4 --- /dev/null +++ b/src/delegates_state.rs @@ -0,0 +1,739 @@ +use std::collections::{HashMap, HashSet}; + +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; + +use crate::domain::activity::{ + DelegateChildState, DelegateEntry, DelegateStats, DelegateStatus, PendingDelegateToolCall, +}; + +pub(crate) struct DelegateLifecycleUpdate { + pub(crate) delegation_id: String, + pub(crate) tool_call_id: Option, + pub(crate) target_agent_id: String, + pub(crate) objective: String, + pub(crate) child_session_id: Option, + pub(crate) status: DelegateStatus, + pub(crate) lifecycle_rank: u8, + pub(crate) requested_at: i64, + pub(crate) finished_at: Option, + pub(crate) updated_at: i64, + pub(crate) result_summary: Option, + pub(crate) error: Option, +} + +pub(crate) struct DelegatesState { + pub(crate) delegate_popup_visible_rows: usize, + pub(crate) delegate_entries: Vec, + pub(crate) delegate_cursor: usize, + pub(crate) delegate_filter: String, + pub(crate) parent_session_id: Option, + pub(crate) pending_parent_session_id: Option, + pub(crate) suppress_delegation_result: bool, + pub(crate) pending_delegate_child_states: HashMap, + pub(crate) pending_delegate_child_stats: HashMap, + pub(crate) delegate_child_message_ids: HashMap>, + pub(crate) delegation_update_times: HashMap, + pub(crate) delegation_result_summaries: HashMap, + pub(crate) delegation_errors: HashMap, + pub(crate) pending_delegate_tool_calls: Vec, +} + +impl DelegatesState { + pub(crate) fn new() -> Self { + Self { + delegate_popup_visible_rows: 0, + delegate_entries: Vec::new(), + delegate_cursor: 0, + delegate_filter: String::new(), + parent_session_id: None, + pending_parent_session_id: None, + suppress_delegation_result: false, + pending_delegate_child_states: HashMap::new(), + pending_delegate_child_stats: HashMap::new(), + delegate_child_message_ids: HashMap::new(), + delegation_update_times: HashMap::new(), + delegation_result_summaries: HashMap::new(), + delegation_errors: HashMap::new(), + pending_delegate_tool_calls: Vec::new(), + } + } + + pub(crate) fn visible_entries(&self) -> Vec<&DelegateEntry> { + if self.delegate_filter.is_empty() { + return self.delegate_entries.iter().collect(); + } + let matcher = SkimMatcherV2::default(); + let query = self.delegate_filter.to_lowercase(); + let mut scored: Vec<(i64, &DelegateEntry)> = self + .delegate_entries + .iter() + .filter_map(|entry| { + [ + matcher.fuzzy_match(&entry.objective, &query), + matcher.fuzzy_match(&entry.delegation_id, &query), + matcher.fuzzy_match(entry.target_agent_id.as_deref().unwrap_or(""), &query), + ] + .into_iter() + .flatten() + .max() + .map(|score| (score, entry)) + }) + .collect(); + scored.sort_by_key(|item| std::cmp::Reverse(item.0)); + scored.into_iter().map(|(_, entry)| entry).collect() + } + + pub(crate) fn selected_entry(&self) -> Option<&DelegateEntry> { + self.visible_entries().get(self.delegate_cursor).copied() + } + + pub(crate) fn reset_popup(&mut self) { + self.delegate_cursor = 0; + self.delegate_filter.clear(); + } + + pub(crate) fn move_cursor_up(&mut self) { + self.delegate_cursor = self.delegate_cursor.saturating_sub(1); + } + + pub(crate) fn move_cursor_down(&mut self) { + let max = self.visible_entries().len().saturating_sub(1); + self.delegate_cursor = self.delegate_cursor.saturating_add(1).min(max); + } + + pub(crate) fn move_cursor_page(&mut self, down: bool) { + let step = self.delegate_popup_visible_rows.saturating_sub(1).max(1); + if down { + let max = self.visible_entries().len().saturating_sub(1); + self.delegate_cursor = self.delegate_cursor.saturating_add(step).min(max); + } else { + self.delegate_cursor = self.delegate_cursor.saturating_sub(step); + } + } + + pub(crate) fn filter_insert(&mut self, character: char) { + self.delegate_filter.push(character); + self.delegate_cursor = 0; + } + + pub(crate) fn filter_backspace(&mut self) { + self.delegate_filter.pop(); + self.delegate_cursor = 0; + } + + pub(crate) fn upsert_provisional_delegate( + &mut self, + tool_call_id: &str, + target_agent_id: Option, + objective: String, + ) -> bool { + if self + .delegate_entries + .iter() + .any(|entry| entry.delegate_tool_call_id.as_deref() == Some(tool_call_id)) + { + return false; + } + self.delegate_entries.push(DelegateEntry { + delegation_id: format!("tool:{tool_call_id}"), + child_session_id: None, + delegate_tool_call_id: Some(tool_call_id.to_string()), + target_agent_id: target_agent_id.clone(), + objective: objective.clone(), + status: DelegateStatus::InProgress, + stats: DelegateStats::default(), + started_at: None, + ended_at: None, + child_state: DelegateChildState::None, + }); + self.pending_delegate_tool_calls + .push(PendingDelegateToolCall { + tool_call_id: tool_call_id.to_string(), + target_agent_id, + objective, + }); + true + } + + pub(crate) fn apply_lifecycle_update(&mut self, update: DelegateLifecycleUpdate) -> bool { + let DelegateLifecycleUpdate { + delegation_id, + mut tool_call_id, + target_agent_id, + objective, + child_session_id, + status, + lifecycle_rank: incoming_lifecycle_rank, + requested_at, + finished_at, + updated_at, + result_summary, + error, + } = update; + let existing_index = self + .delegate_entries + .iter() + .position(|entry| entry.delegation_id == delegation_id); + if let Some(existing_timestamp) = self.delegation_update_times.get(&delegation_id) { + let existing_rank = existing_index + .map(|index| entry_lifecycle_rank(&self.delegate_entries[index])) + .unwrap_or(0); + if *existing_timestamp > updated_at + || (*existing_timestamp == updated_at && existing_rank > incoming_lifecycle_rank) + { + return false; + } + } + + if tool_call_id.is_none() { + tool_call_id = self + .take_pending_tool_call(Some(&target_agent_id), Some(&objective)) + .map(|pending| pending.tool_call_id); + } else if let Some(id) = tool_call_id.as_deref() { + self.pending_delegate_tool_calls + .retain(|pending| pending.tool_call_id != id); + } + + let index = existing_index.or_else(|| { + tool_call_id.as_deref().and_then(|id| { + self.delegate_entries + .iter() + .position(|entry| entry.delegate_tool_call_id.as_deref() == Some(id)) + }) + }); + let index = if let Some(index) = index { + index + } else { + self.delegate_entries.push(DelegateEntry { + delegation_id: delegation_id.clone(), + child_session_id: None, + delegate_tool_call_id: tool_call_id.clone(), + target_agent_id: Some(target_agent_id.clone()), + objective: objective.clone(), + status, + stats: DelegateStats::default(), + started_at: Some(requested_at), + ended_at: None, + child_state: DelegateChildState::None, + }); + self.delegate_entries.len() - 1 + }; + + let entry = &mut self.delegate_entries[index]; + entry.delegation_id = delegation_id.clone(); + if tool_call_id.is_some() { + entry.delegate_tool_call_id = tool_call_id; + } + entry.target_agent_id = Some(target_agent_id); + entry.objective = objective; + entry.status = status; + entry.started_at = Some(requested_at); + entry.ended_at = finished_at; + entry.child_session_id = child_session_id.clone(); + if status != DelegateStatus::InProgress { + entry.child_state = DelegateChildState::None; + } + + self.delegation_update_times + .insert(delegation_id.clone(), updated_at); + replace_optional_map_value( + &mut self.delegation_result_summaries, + &delegation_id, + result_summary, + ); + replace_optional_map_value(&mut self.delegation_errors, &delegation_id, error); + + if let Some(child_session_id) = child_session_id { + if let Some(stats) = self.pending_delegate_child_stats.remove(&child_session_id) { + self.delegate_entries[index].stats = stats; + } + if let Some(state) = self.pending_delegate_child_states.remove(&child_session_id) { + self.delegate_entries[index].child_state = state; + } + } + true + } + + pub(crate) fn child_snapshot(&self, session_id: &str) -> (DelegateChildState, DelegateStats) { + let index = self + .delegate_entries + .iter() + .position(|entry| entry.child_session_id.as_deref() == Some(session_id)); + let state = index + .map(|index| self.delegate_entries[index].child_state.clone()) + .or_else(|| self.pending_delegate_child_states.get(session_id).cloned()) + .unwrap_or_default(); + let stats = index + .map(|index| self.delegate_entries[index].stats.clone()) + .or_else(|| self.pending_delegate_child_stats.get(session_id).cloned()) + .unwrap_or_default(); + (state, stats) + } + + pub(crate) fn record_child_message_id( + &mut self, + session_id: &str, + message_id: Option<&str>, + increment_without_id: bool, + ) -> bool { + match message_id { + Some(message_id) => self + .delegate_child_message_ids + .entry(session_id.to_string()) + .or_default() + .insert(message_id.to_string()), + None => increment_without_id, + } + } + + pub(crate) fn apply_child_snapshot( + &mut self, + session_id: &str, + state: DelegateChildState, + stats: DelegateStats, + ) -> bool { + if let Some(index) = self + .delegate_entries + .iter() + .position(|entry| entry.child_session_id.as_deref() == Some(session_id)) + { + if self.delegate_entries[index].stats != stats + || self.delegate_entries[index].child_state != state + { + self.delegate_entries[index].stats = stats; + self.delegate_entries[index].child_state = state; + return true; + } + } else if state != DelegateChildState::None || stats != DelegateStats::default() { + self.pending_delegate_child_states + .insert(session_id.to_string(), state); + self.pending_delegate_child_stats + .insert(session_id.to_string(), stats); + } + false + } + + pub(crate) fn stage_parent_for_child_navigation( + &mut self, + current_parent_session_id: Option, + current_session_id: Option, + ) { + self.pending_parent_session_id = current_parent_session_id.or(current_session_id); + } + + pub(crate) fn resolve_parent_session_id(&mut self, discovered_parent: Option) { + self.parent_session_id = self.pending_parent_session_id.take().or(discovered_parent); + } + + pub(crate) fn clear_for_root_session(&mut self) { + self.delegate_entries.clear(); + self.pending_delegate_child_states.clear(); + self.pending_delegate_child_stats.clear(); + self.delegate_child_message_ids.clear(); + self.delegation_update_times.clear(); + self.delegation_result_summaries.clear(); + self.delegation_errors.clear(); + self.pending_delegate_tool_calls.clear(); + } + + fn take_pending_tool_call( + &mut self, + target_agent_id: Option<&str>, + objective: Option<&str>, + ) -> Option { + let index = self + .pending_delegate_tool_calls + .iter() + .position(|pending| { + target_agent_id + .is_none_or(|agent| pending.target_agent_id.as_deref() == Some(agent)) + && objective.is_none_or(|value| pending.objective == value) + })?; + Some(self.pending_delegate_tool_calls.remove(index)) + } +} + +fn replace_optional_map_value( + values: &mut HashMap, + key: &str, + value: Option, +) { + if let Some(value) = value { + values.insert(key.to_string(), value); + } else { + values.remove(key); + } +} + +fn lifecycle_rank(status: DelegateStatus, child_session_id: Option<&str>) -> u8 { + match status { + DelegateStatus::Completed | DelegateStatus::Failed | DelegateStatus::Cancelled => 3, + DelegateStatus::InProgress if child_session_id.is_some() => 2, + DelegateStatus::InProgress => 1, + } +} + +fn entry_lifecycle_rank(entry: &DelegateEntry) -> u8 { + lifecycle_rank(entry.status, entry.child_session_id.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry( + delegation_id: &str, + objective: &str, + agent: Option<&str>, + status: DelegateStatus, + ) -> DelegateEntry { + DelegateEntry { + delegation_id: delegation_id.to_string(), + child_session_id: None, + delegate_tool_call_id: None, + target_agent_id: agent.map(str::to_string), + objective: objective.to_string(), + status, + stats: DelegateStats::default(), + started_at: None, + ended_at: None, + child_state: DelegateChildState::None, + } + } + + fn apply( + state: &mut DelegatesState, + delegation_id: &str, + status: DelegateStatus, + updated_at: i64, + child_session_id: Option<&str>, + terminal: (Option<&str>, Option<&str>), + ) -> bool { + state.apply_lifecycle_update(DelegateLifecycleUpdate { + delegation_id: delegation_id.to_string(), + tool_call_id: None, + target_agent_id: "coder".to_string(), + objective: "build feature".to_string(), + child_session_id: child_session_id.map(str::to_string), + status, + lifecycle_rank: lifecycle_rank(status, child_session_id), + requested_at: 10, + finished_at: (status != DelegateStatus::InProgress).then_some(20), + updated_at, + result_summary: terminal.0.map(str::to_string), + error: terminal.1.map(str::to_string), + }) + } + + #[test] + fn constructor_uses_all_fourteen_exact_defaults() { + let state = DelegatesState::new(); + + assert_eq!(state.delegate_popup_visible_rows, 0); + assert!(state.delegate_entries.is_empty()); + assert_eq!(state.delegate_cursor, 0); + assert!(state.delegate_filter.is_empty()); + assert_eq!(state.parent_session_id, None); + assert_eq!(state.pending_parent_session_id, None); + assert!(!state.suppress_delegation_result); + assert!(state.pending_delegate_child_states.is_empty()); + assert!(state.pending_delegate_child_stats.is_empty()); + assert!(state.delegate_child_message_ids.is_empty()); + assert!(state.delegation_update_times.is_empty()); + assert!(state.delegation_result_summaries.is_empty()); + assert!(state.delegation_errors.is_empty()); + assert!(state.pending_delegate_tool_calls.is_empty()); + } + + #[test] + fn filtering_is_case_insensitive_across_objective_id_and_agent() { + let mut state = DelegatesState::new(); + state.delegate_entries = vec![ + entry( + "DEL-ONE", + "Build Feature", + Some("Coder"), + DelegateStatus::InProgress, + ), + entry( + "del-two", + "Write docs", + Some("Planner"), + DelegateStatus::Completed, + ), + ]; + + for query in ["BUILD", "del-one", "CODER"] { + state.delegate_filter = query.to_string(); + assert_eq!(state.visible_entries().len(), 1); + assert_eq!(state.visible_entries()[0].delegation_id, "DEL-ONE"); + } + } + + #[test] + fn selection_cursor_page_filter_and_popup_reset_preserve_filtered_semantics() { + let mut state = DelegatesState::new(); + state.delegate_entries = (0..7) + .map(|index| { + entry( + &format!("d{index}"), + if index % 2 == 0 { "docs" } else { "code" }, + None, + DelegateStatus::InProgress, + ) + }) + .collect(); + state.delegate_popup_visible_rows = 4; + + state.move_cursor_down(); + state.move_cursor_page(true); + assert_eq!(state.delegate_cursor, 4); + state.move_cursor_page(false); + assert_eq!(state.delegate_cursor, 1); + state.filter_insert('d'); + state.filter_insert('o'); + assert_eq!(state.delegate_cursor, 0); + state.move_cursor_down(); + assert_eq!(state.selected_entry().unwrap().objective, "docs"); + state.filter_backspace(); + assert_eq!(state.delegate_cursor, 0); + state.reset_popup(); + assert!(state.delegate_filter.is_empty()); + assert_eq!(state.delegate_cursor, 0); + } + + #[test] + fn page_movement_falls_back_to_one_when_visible_rows_are_unknown() { + let mut state = DelegatesState::new(); + state.delegate_entries = vec![ + entry("d1", "one", None, DelegateStatus::InProgress), + entry("d2", "two", None, DelegateStatus::InProgress), + ]; + + state.move_cursor_page(true); + assert_eq!(state.delegate_cursor, 1); + state.move_cursor_page(false); + assert_eq!(state.delegate_cursor, 0); + } + + #[test] + fn provisional_entries_deduplicate_and_reconcile_by_tool_id() { + let mut state = DelegatesState::new(); + assert!(state.upsert_provisional_delegate( + "tool-1", + Some("coder".to_string()), + "build feature".to_string(), + )); + assert!(!state.upsert_provisional_delegate("tool-1", None, String::new())); + assert_eq!(state.delegate_entries.len(), 1); + assert_eq!(state.pending_delegate_tool_calls.len(), 1); + + assert!(state.apply_lifecycle_update(DelegateLifecycleUpdate { + delegation_id: "delegation-1".to_string(), + tool_call_id: Some("tool-1".to_string()), + target_agent_id: "coder".to_string(), + objective: "build feature".to_string(), + child_session_id: None, + status: DelegateStatus::InProgress, + lifecycle_rank: 1, + requested_at: 10, + finished_at: None, + updated_at: 10, + result_summary: None, + error: None, + })); + assert_eq!(state.delegate_entries.len(), 1); + assert_eq!(state.delegate_entries[0].delegation_id, "delegation-1"); + assert!(state.pending_delegate_tool_calls.is_empty()); + } + + #[test] + fn pending_tool_call_attaches_by_narrow_agent_and_objective_values() { + let mut state = DelegatesState::new(); + state.upsert_provisional_delegate( + "tool-1", + Some("coder".to_string()), + "build feature".to_string(), + ); + + assert!(apply( + &mut state, + "delegation-1", + DelegateStatus::InProgress, + 10, + None, + (None, None), + )); + assert_eq!( + state.delegate_entries[0].delegate_tool_call_id.as_deref(), + Some("tool-1") + ); + assert!(state.pending_delegate_tool_calls.is_empty()); + } + + #[test] + fn lifecycle_rejects_stale_and_equal_timestamp_regressions() { + let mut state = DelegatesState::new(); + assert!(apply( + &mut state, + "d1", + DelegateStatus::Completed, + 20, + Some("child"), + (Some("done"), None), + )); + assert!(!apply( + &mut state, + "d1", + DelegateStatus::InProgress, + 19, + None, + (None, None), + )); + assert!(!apply( + &mut state, + "d1", + DelegateStatus::InProgress, + 20, + Some("child"), + (None, None), + )); + assert_eq!(state.delegate_entries[0].status, DelegateStatus::Completed); + assert_eq!(state.delegation_result_summaries["d1"], "done"); + } + + #[test] + fn lifecycle_summary_and_error_values_replace_and_remove_exactly() { + let mut state = DelegatesState::new(); + apply( + &mut state, + "d1", + DelegateStatus::Completed, + 10, + None, + (Some("done"), None), + ); + assert_eq!(state.delegation_result_summaries["d1"], "done"); + assert!(!state.delegation_errors.contains_key("d1")); + + apply( + &mut state, + "d1", + DelegateStatus::Failed, + 11, + None, + (None, Some("boom")), + ); + assert!(!state.delegation_result_summaries.contains_key("d1")); + assert_eq!(state.delegation_errors["d1"], "boom"); + + apply( + &mut state, + "d1", + DelegateStatus::Cancelled, + 12, + None, + (None, None), + ); + assert!(!state.delegation_errors.contains_key("d1")); + } + + #[test] + fn child_updates_before_linkage_attach_stats_and_pending_elicitation() { + let mut state = DelegatesState::new(); + let pending = DelegateChildState::PendingElicitation { + elicitation_id: "elic-1".to_string(), + message: "Need approval".to_string(), + requested_schema: serde_json::json!({"type": "object"}), + source: "builtin:question".to_string(), + }; + let stats = DelegateStats { + tool_calls: 2, + messages: 1, + ..DelegateStats::default() + }; + assert!(!state.apply_child_snapshot("child", pending.clone(), stats.clone())); + + apply( + &mut state, + "d1", + DelegateStatus::InProgress, + 10, + Some("child"), + (None, None), + ); + assert_eq!(state.delegate_entries[0].child_state, pending); + assert_eq!(state.delegate_entries[0].stats, stats); + assert!(state.pending_delegate_child_states.is_empty()); + assert!(state.pending_delegate_child_stats.is_empty()); + } + + #[test] + fn duplicate_child_assistant_message_ids_are_suppressed() { + let mut state = DelegatesState::new(); + assert!(state.record_child_message_id("child", Some("m1"), true)); + assert!(!state.record_child_message_id("child", Some("m1"), true)); + assert!(state.record_child_message_id("child", None, true)); + assert!(!state.record_child_message_id("child", None, false)); + } + + #[test] + fn root_clear_and_child_load_preservation_keep_existing_asymmetry() { + let mut state = DelegatesState::new(); + state + .delegate_entries + .push(entry("d1", "task", None, DelegateStatus::InProgress)); + state + .pending_delegate_child_states + .insert("child".to_string(), DelegateChildState::OtherProgress); + state + .delegate_child_message_ids + .insert("child".to_string(), HashSet::from(["m1".to_string()])); + state.delegation_update_times.insert("d1".to_string(), 10); + state + .delegation_result_summaries + .insert("d1".to_string(), "done".to_string()); + state + .delegation_errors + .insert("d1".to_string(), "boom".to_string()); + state.delegate_filter = "stale".to_string(); + state.delegate_cursor = 3; + state.delegate_popup_visible_rows = 5; + state.parent_session_id = Some("parent".to_string()); + + let entries_before = state.delegate_entries.clone(); + if state.parent_session_id.is_none() { + state.clear_for_root_session(); + } + assert_eq!(state.delegate_entries, entries_before); + + state.parent_session_id = None; + state.clear_for_root_session(); + assert!(state.delegate_entries.is_empty()); + assert!(state.pending_delegate_child_states.is_empty()); + assert!(state.delegate_child_message_ids.is_empty()); + assert!(state.delegation_update_times.is_empty()); + assert!(state.delegation_result_summaries.is_empty()); + assert!(state.delegation_errors.is_empty()); + assert_eq!(state.delegate_filter, "stale"); + assert_eq!(state.delegate_cursor, 3); + assert_eq!(state.delegate_popup_visible_rows, 5); + } + + #[test] + fn sibling_parent_staging_prefers_real_parent_and_survives_resolution() { + let mut state = DelegatesState::new(); + state.stage_parent_for_child_navigation( + Some("root".to_string()), + Some("child-a".to_string()), + ); + state.resolve_parent_session_id(Some("catalog-parent".to_string())); + assert_eq!(state.parent_session_id.as_deref(), Some("root")); + assert_eq!(state.pending_parent_session_id, None); + + state.stage_parent_for_child_navigation(None, Some("root".to_string())); + state.resolve_parent_session_id(None); + assert_eq!(state.parent_session_id.as_deref(), Some("root")); + } +} diff --git a/src/handlers.rs b/src/handlers.rs index 471d0e3..bb6b2c5 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -739,7 +739,7 @@ pub(crate) fn handle_chord( if !can_send_server_commands(app) { return Ok(()); } - if let Some(parent_sid) = app.parent_session_id.clone() { + if let Some(parent_sid) = app.delegates.parent_session_id.clone() { send_load_session_commands( cmd_tx, parent_sid, @@ -1174,7 +1174,7 @@ fn handle_delegate_view_key( } KeyCode::Esc => { // Go back to parent session. - if let Some(parent_sid) = app.parent_session_id.clone() { + if let Some(parent_sid) = app.delegates.parent_session_id.clone() { send_load_session_commands( cmd_tx, parent_sid, @@ -1238,39 +1238,24 @@ pub(crate) fn apply_delegate_popup_key( KeyCode::Esc => { app.navigation.popup = Popup::None; } - KeyCode::Up => { - app.delegate_cursor = app.delegate_cursor.saturating_sub(1); - } - KeyCode::Down => { - let max = app.visible_delegate_entries().len().saturating_sub(1); - app.delegate_cursor = (app.delegate_cursor + 1).min(max); - } - KeyCode::PageUp => { - let step = popup_page_step(app.delegate_popup_visible_rows); - app.delegate_cursor = app.delegate_cursor.saturating_sub(step); - } - KeyCode::PageDown => { - let max = app.visible_delegate_entries().len().saturating_sub(1); - let step = popup_page_step(app.delegate_popup_visible_rows); - app.delegate_cursor = app.delegate_cursor.saturating_add(step).min(max); - } + KeyCode::Up => app.delegates.move_cursor_up(), + KeyCode::Down => app.delegates.move_cursor_down(), + KeyCode::PageUp => app.delegates.move_cursor_page(false), + KeyCode::PageDown => app.delegates.move_cursor_page(true), KeyCode::Enter => { - let selected = app - .visible_delegate_entries() - .get(app.delegate_cursor) - .map(|entry| { - ( - entry.child_session_id.clone(), - entry.target_agent_id.clone(), - ) - }); + let selected = app.delegates.selected_entry().map(|entry| { + ( + entry.child_session_id.clone(), + entry.target_agent_id.clone(), + ) + }); if let Some((child_session_id, target_agent_id)) = selected { if let Some(sid) = child_session_id { // Use the real parent when navigating between siblings. - app.pending_parent_session_id = app - .parent_session_id - .clone() - .or_else(|| app.sessions.session_id.clone()); + app.delegates.stage_parent_for_child_navigation( + app.delegates.parent_session_id.clone(), + app.sessions.session_id.clone(), + ); app.navigation.popup = Popup::None; return SessionKeyAction::LoadSession { session_id: sid, @@ -1286,14 +1271,8 @@ pub(crate) fn apply_delegate_popup_key( } } } - KeyCode::Backspace => { - app.delegate_filter.pop(); - app.delegate_cursor = 0; - } - KeyCode::Char(c) => { - app.delegate_filter.push(c); - app.delegate_cursor = 0; - } + KeyCode::Backspace => app.delegates.filter_backspace(), + KeyCode::Char(c) => app.delegates.filter_insert(c), _ => {} } SessionKeyAction::None @@ -2402,7 +2381,7 @@ pub(crate) fn handle_model_popup_key( { app.models .set_delegate_model_preference(&profile_id, &agent_id, &model); - if app.parent_session_id.is_none() + if app.delegates.parent_session_id.is_none() && let Some(session_id) = app.sessions.session_id.clone() { cmd_tx.send(Command::SetDelegateModel { @@ -2434,7 +2413,7 @@ pub(crate) fn handle_model_popup_key( { app.models .clear_delegate_model_preference(&profile_id, &agent_id); - if app.parent_session_id.is_none() + if app.delegates.parent_session_id.is_none() && let Some(session_id) = app.sessions.session_id.clone() { cmd_tx.send(Command::SetDelegateModel { @@ -3279,7 +3258,7 @@ mod model_popup_tests { let mut app = App::new(); app.navigation.popup = Popup::ModelSelect; app.sessions.session_id = Some("child".into()); - app.parent_session_id = Some("parent".into()); + app.delegates.parent_session_id = Some("parent".into()); app.profiles.active_profile_id = Some("profile".into()); app.profiles .bind_session_profile("child".into(), "profile".into()); @@ -3511,7 +3490,7 @@ mod model_popup_tests { let mut app = App::new(); app.sessions.agent_id = Some("planner".into()); app.navigation.popup = Popup::SessionSelect; - app.delegate_entries.push(DelegateEntry { + app.delegates.delegate_entries.push(DelegateEntry { delegation_id: "del-1".into(), child_session_id: Some("child-1".into()), delegate_tool_call_id: None, diff --git a/src/lib.rs b/src/lib.rs index f956863..5e7983c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ mod command; mod composer_state; mod config; mod connection_state; +mod delegates_state; mod diagnostics; mod domain; mod handlers; diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index d615059..e110a25 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -2863,7 +2863,7 @@ mod delegate_popup_key_tests { app.sessions.session_id = Some("parent-1".into()); app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ make_entry("d1", "Build feature", Some("child-1")), make_entry("d2", "Fix tests", Some("child-2")), make_entry("d3", "Write docs", Some("child-3")), @@ -2875,52 +2875,52 @@ mod delegate_popup_key_tests { fn delegate_navigation_clamps_cursor_within_bounds() { let mut app = setup_delegate_app(); apply_delegate_popup_key(&mut app, KeyCode::Up); - assert_eq!(app.delegate_cursor, 0); + assert_eq!(app.delegates.delegate_cursor, 0); apply_delegate_popup_key(&mut app, KeyCode::Down); apply_delegate_popup_key(&mut app, KeyCode::Down); apply_delegate_popup_key(&mut app, KeyCode::Down); - assert_eq!(app.delegate_cursor, 2); + assert_eq!(app.delegates.delegate_cursor, 2); apply_delegate_popup_key(&mut app, KeyCode::Up); - assert_eq!(app.delegate_cursor, 1); + assert_eq!(app.delegates.delegate_cursor, 1); } #[test] fn delegate_page_down_uses_visible_rows_with_overlap() { let mut app = setup_delegate_app(); - app.delegate_entries.extend([ + app.delegates.delegate_entries.extend([ make_entry("d4", "Check logs", Some("child-4")), make_entry("d5", "Refactor code", Some("child-5")), make_entry("d6", "Polish UI", Some("child-6")), make_entry("d7", "Ship release", Some("child-7")), ]); - app.delegate_popup_visible_rows = 4; + app.delegates.delegate_popup_visible_rows = 4; apply_delegate_popup_key(&mut app, KeyCode::PageDown); - assert_eq!(app.delegate_cursor, 3); + assert_eq!(app.delegates.delegate_cursor, 3); apply_delegate_popup_key(&mut app, KeyCode::PageDown); - assert_eq!(app.delegate_cursor, 6); + assert_eq!(app.delegates.delegate_cursor, 6); } #[test] fn delegate_page_up_uses_visible_rows_with_overlap() { let mut app = setup_delegate_app(); - app.delegate_entries.extend([ + app.delegates.delegate_entries.extend([ make_entry("d4", "Check logs", Some("child-4")), make_entry("d5", "Refactor code", Some("child-5")), make_entry("d6", "Polish UI", Some("child-6")), make_entry("d7", "Ship release", Some("child-7")), ]); - app.delegate_popup_visible_rows = 4; - app.delegate_cursor = 6; + app.delegates.delegate_popup_visible_rows = 4; + app.delegates.delegate_cursor = 6; apply_delegate_popup_key(&mut app, KeyCode::PageUp); - assert_eq!(app.delegate_cursor, 3); + assert_eq!(app.delegates.delegate_cursor, 3); apply_delegate_popup_key(&mut app, KeyCode::PageUp); - assert_eq!(app.delegate_cursor, 0); + assert_eq!(app.delegates.delegate_cursor, 0); } #[test] @@ -2928,16 +2928,16 @@ mod delegate_popup_key_tests { let mut app = setup_delegate_app(); apply_delegate_popup_key(&mut app, KeyCode::PageDown); - assert_eq!(app.delegate_cursor, 1); + assert_eq!(app.delegates.delegate_cursor, 1); apply_delegate_popup_key(&mut app, KeyCode::PageUp); - assert_eq!(app.delegate_cursor, 0); + assert_eq!(app.delegates.delegate_cursor, 0); } #[test] fn delegate_enter_loads_selected_child_session() { let mut app = setup_delegate_app(); - app.delegate_cursor = 1; + app.delegates.delegate_cursor = 1; let action = apply_delegate_popup_key(&mut app, KeyCode::Enter); assert_eq!( action, @@ -2955,7 +2955,7 @@ mod delegate_popup_key_tests { let mut app = App::new(); app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; - app.delegate_entries = vec![DelegateEntry { + app.delegates.delegate_entries = vec![DelegateEntry { delegation_id: "d1".into(), child_session_id: None, delegate_tool_call_id: None, @@ -2975,18 +2975,18 @@ mod delegate_popup_key_tests { #[test] fn delegate_filter_updates_cursor_and_loads_filtered_result() { let mut app = setup_delegate_app(); - app.delegate_cursor = 2; + app.delegates.delegate_cursor = 2; for c in "docs".chars() { apply_delegate_popup_key(&mut app, KeyCode::Char(c)); } - assert_eq!(app.delegate_filter, "docs"); - assert_eq!(app.delegate_cursor, 0); - assert_eq!(app.visible_delegate_entries().len(), 1); - assert_eq!(app.visible_delegate_entries()[0].delegation_id, "d3"); + assert_eq!(app.delegates.delegate_filter, "docs"); + assert_eq!(app.delegates.delegate_cursor, 0); + assert_eq!(app.delegates.visible_entries().len(), 1); + assert_eq!(app.delegates.visible_entries()[0].delegation_id, "d3"); apply_delegate_popup_key(&mut app, KeyCode::Backspace); - assert_eq!(app.delegate_filter, "doc"); - assert_eq!(app.delegate_cursor, 0); + assert_eq!(app.delegates.delegate_filter, "doc"); + assert_eq!(app.delegates.delegate_cursor, 0); let action = apply_delegate_popup_key(&mut app, KeyCode::Enter); assert_eq!( @@ -3002,8 +3002,8 @@ mod delegate_popup_key_tests { #[test] fn delegate_enter_loads_awaiting_input_child_session() { let mut app = setup_delegate_app(); - app.delegate_entries[0].status = DelegateStatus::InProgress; - app.delegate_entries[0].child_state = DelegateChildState::PendingElicitation { + app.delegates.delegate_entries[0].status = DelegateStatus::InProgress; + app.delegates.delegate_entries[0].child_state = DelegateChildState::PendingElicitation { elicitation_id: "elic-1".into(), message: "Need approval".into(), requested_schema: serde_json::json!({ "properties": {} }), @@ -3033,7 +3033,7 @@ mod delegate_popup_key_tests { fn delegate_popup_enter_sets_parent_for_sibling_navigation() { let mut app = setup_delegate_app(); // Simulate being in a child session (parent_session_id is set). - app.parent_session_id = Some("parent-1".into()); + app.delegates.parent_session_id = Some("parent-1".into()); app.sessions.session_id = Some("child-old".into()); let action = apply_delegate_popup_key(&mut app, KeyCode::Enter); @@ -3042,7 +3042,7 @@ mod delegate_popup_key_tests { "enter must trigger LoadSession" ); assert_eq!( - app.pending_parent_session_id.as_deref(), + app.delegates.pending_parent_session_id.as_deref(), Some("parent-1"), "pending_parent must be the real parent, not the child session_id" ); diff --git a/src/session.rs b/src/session.rs index 8874044..4eb6e22 100644 --- a/src/session.rs +++ b/src/session.rs @@ -5,7 +5,6 @@ use fuzzy_matcher::skim::SkimMatcherV2; use crate::app::App; use crate::composer_state::FileIndexEntryLite; -use crate::domain::activity::DelegateEntry; use crate::navigation_state::Popup; impl App { @@ -18,36 +17,6 @@ impl App { } } - /// Flat list of delegate entries that match `delegate_filter`. - /// Built from the parent session's event stream (DelegationRequested / - /// SessionForked / DelegationCompleted / DelegationFailed events). - /// When the filter is empty every entry matches in original order. - /// When the filter is non-empty, results are sorted by fuzzy match score (best first). - pub fn visible_delegate_entries(&self) -> Vec<&DelegateEntry> { - if self.delegate_filter.is_empty() { - return self.delegate_entries.iter().collect(); - } - let matcher = SkimMatcherV2::default(); - let q = self.delegate_filter.to_lowercase(); - let mut scored: Vec<(i64, &DelegateEntry)> = self - .delegate_entries - .iter() - .filter_map(|e| { - let score = [ - matcher.fuzzy_match(&e.objective, &q), - matcher.fuzzy_match(&e.delegation_id, &q), - matcher.fuzzy_match(e.target_agent_id.as_deref().unwrap_or(""), &q), - ] - .into_iter() - .flatten() - .max(); - score.map(|s| (s, e)) - }) - .collect(); - scored.sort_by_key(|item| std::cmp::Reverse(item.0)); - scored.into_iter().map(|(_, e)| e).collect() - } - pub fn resolve_new_session_default_cwd(&self) -> Option { if let Some(active_session_id) = self.sessions.session_id.as_deref() { for group in &self.sessions.session_groups { @@ -80,8 +49,7 @@ impl App { pub fn open_delegate_popup(&mut self) { self.navigation.popup = Popup::SessionSelect; self.sessions.session_popup_tab = 1; - self.delegate_cursor = 0; - self.delegate_filter.clear(); + self.delegates.reset_popup(); } pub fn open_new_session_popup(&mut self) { diff --git a/src/ui/chat.rs b/src/ui/chat.rs index a660e3c..66783fc 100644 --- a/src/ui/chat.rs +++ b/src/ui/chat.rs @@ -311,11 +311,12 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { |tool_call_id: Option<&String>, sequential_idx: usize| -> Option<&DelegateEntry> { tool_call_id .and_then(|id| { - app.delegate_entries + app.delegates + .delegate_entries .iter() .find(|entry| entry.delegate_tool_call_id.as_deref() == Some(id.as_str())) }) - .or_else(|| app.delegate_entries.get(sequential_idx)) + .or_else(|| app.delegates.delegate_entries.get(sequential_idx)) }; let mut delegate_idx = app.messages[..start_idx] .iter() @@ -758,11 +759,11 @@ fn build_chat_header_spans(app: &App) -> (Vec>, Vec> )); } - if !app.delegate_entries.is_empty() { + if !app.delegates.delegate_entries.is_empty() { use crate::domain::activity::DelegateStatus; let (mut done, mut has_failed, mut has_running, mut awaiting_input) = (0usize, false, false, false); - for e in &app.delegate_entries { + for e in &app.delegates.delegate_entries { match e.status { DelegateStatus::Completed | DelegateStatus::Cancelled => done += 1, DelegateStatus::Failed => has_failed = true, @@ -770,7 +771,7 @@ fn build_chat_header_spans(app: &App) -> (Vec>, Vec> } awaiting_input |= e.awaiting_input(); } - let total = app.delegate_entries.len(); + let total = app.delegates.delegate_entries.len(); let style = if has_failed { Theme::error_on_dim() } else if has_running { @@ -822,7 +823,7 @@ fn build_chat_header_spans(app: &App) -> (Vec>, Vec> format!(" {} ", app.sessions.agent_mode), Theme::mode_badge(&app.sessions.agent_mode), )); - if app.parent_session_id.is_some() { + if app.delegates.parent_session_id.is_some() { left_spans.push(Span::styled(" \u{2b11} child ", Theme::status_accent())); } let profile_label = app.current_profile_label(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c6a8478..091a95f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -698,7 +698,7 @@ mod tests { assert!(!rendered.contains(ICON_DELEGATES)); // 2 completed + 1 running = "2/3" - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "d1".into(), child_session_id: Some("c1".into()), @@ -752,7 +752,7 @@ mod tests { app.sessions.agent_mode = "build".into(); app.models.current_provider = Some("anthropic".into()); app.models.current_model = Some("claude-sonnet".into()); - app.delegate_entries = vec![DelegateEntry { + app.delegates.delegate_entries = vec![DelegateEntry { delegation_id: "d1".into(), child_session_id: Some("c1".into()), delegate_tool_call_id: None, @@ -795,7 +795,7 @@ mod tests { app.sessions.agent_mode = "build".into(); app.models.current_provider = Some("anthropic".into()); app.models.current_model = Some("claude-sonnet".into()); - app.delegate_entries = vec![DelegateEntry { + app.delegates.delegate_entries = vec![DelegateEntry { delegation_id: "d1".into(), child_session_id: Some("c1".into()), delegate_tool_call_id: None, @@ -824,7 +824,7 @@ mod tests { app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; app.sessions.session_id = Some("parent".into()); - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "del-1".into(), child_session_id: Some("child-1".into()), @@ -886,8 +886,8 @@ mod tests { app.navigation.screen = Screen::Chat; app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; - app.delegate_cursor = 1; - app.delegate_entries = vec![DelegateEntry { + app.delegates.delegate_cursor = 1; + app.delegates.delegate_entries = vec![DelegateEntry { delegation_id: "del-1".into(), child_session_id: Some("child-1".into()), delegate_tool_call_id: None, @@ -934,7 +934,7 @@ mod tests { app.navigation.screen = Screen::Chat; app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "del-1".into(), child_session_id: None, @@ -1001,7 +1001,7 @@ mod tests { app.navigation.screen = Screen::Chat; app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; - app.delegate_entries = vec![DelegateEntry { + app.delegates.delegate_entries = vec![DelegateEntry { delegation_id: "del-1".into(), child_session_id: None, delegate_tool_call_id: None, @@ -1043,8 +1043,8 @@ mod tests { app.navigation.screen = Screen::Chat; app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; - app.delegate_cursor = 0; // keep first row selected; failed row remains unselected - app.delegate_entries = vec![ + app.delegates.delegate_cursor = 0; // keep first row selected; failed row remains unselected + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "del-0".into(), child_session_id: None, @@ -1091,8 +1091,8 @@ mod tests { app.navigation.screen = Screen::Chat; app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; - app.delegate_cursor = 1; - app.delegate_entries = vec![ + app.delegates.delegate_cursor = 1; + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "del-1".into(), child_session_id: None, @@ -1178,7 +1178,7 @@ mod tests { app.navigation.popup = Popup::SessionSelect; app.sessions.session_popup_tab = 1; app.sessions.session_id = Some("parent".into()); - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "del-1".into(), child_session_id: None, @@ -1239,7 +1239,7 @@ mod tests { is_error: false, detail: ToolDetail::Summary("(coder) Fix the bug".into()), }); - app.delegate_entries.push(DelegateEntry { + app.delegates.delegate_entries.push(DelegateEntry { delegation_id: "del-1".into(), child_session_id: None, delegate_tool_call_id: None, @@ -1374,7 +1374,7 @@ mod tests { is_error: false, detail: ToolDetail::Summary("(coder) Fix the bug".into()), }); - app.delegate_entries.push(DelegateEntry { + app.delegates.delegate_entries.push(DelegateEntry { delegation_id: "del-1".into(), child_session_id: Some("child-1".into()), delegate_tool_call_id: None, @@ -2362,7 +2362,7 @@ mod tests { "Fix live bug", )); // ACP has no delegation lifecycle updates; this fixture isolates row rendering. - app.delegate_entries.push(DelegateEntry { + app.delegates.delegate_entries.push(DelegateEntry { delegation_id: "del-1".into(), child_session_id: Some("child-1".into()), delegate_tool_call_id: Some("tool-delegate-1".into()), @@ -2511,7 +2511,7 @@ mod tests { let mut app = App::new(); app.messages .push(delegate_tool_call("tool-1", "coder", "Fix cache bug")); - app.delegate_entries.push(DelegateEntry { + app.delegates.delegate_entries.push(DelegateEntry { delegation_id: "del-1".into(), child_session_id: Some("child-1".into()), delegate_tool_call_id: Some("tool-1".into()), @@ -2528,7 +2528,7 @@ mod tests { assert!(!lines.iter().any(|line| line.contains("awaiting input"))); assert_eq!(app.card_cache.processed_messages, app.messages.len()); - app.delegate_entries[0].child_state = DelegateChildState::PendingElicitation { + app.delegates.delegate_entries[0].child_state = DelegateChildState::PendingElicitation { elicitation_id: "elic-1".into(), message: "Need approval".into(), requested_schema: serde_json::json!({ "properties": {} }), @@ -2561,7 +2561,7 @@ mod tests { build_message_cards(&mut app); app.messages .push(delegate_tool_call("tool-b", "coder", "Second task")); - app.delegate_entries = vec![ + app.delegates.delegate_entries = vec![ DelegateEntry { delegation_id: "del-b".into(), child_session_id: Some("child-b".into()), @@ -3029,7 +3029,7 @@ mod tests { allow_custom: true, }], ); - assert_eq!(app.parent_session_id.as_deref(), Some("parent")); + assert_eq!(app.delegates.parent_session_id.as_deref(), Some("parent")); assert!(app.elicitation.is_none()); assert!( matches!(app.messages.as_slice(), [ChatEntry::Elicitation { elicitation_id, outcome: None, .. }] if elicitation_id == "elic-1") @@ -3117,7 +3117,7 @@ mod tests { "parent-session", vec![user_update("parent prompt 1", "parent-user-1")], ); - app.pending_parent_session_id = Some("parent-session".into()); + app.delegates.pending_parent_session_id = Some("parent-session".into()); load_session(&mut app, "delegate-session", "agent-2"); replay_session( &mut app, diff --git a/src/ui/popups.rs b/src/ui/popups.rs index 9278cb0..f83dfd9 100644 --- a/src/ui/popups.rs +++ b/src/ui/popups.rs @@ -502,7 +502,8 @@ fn draw_session_tab_content(f: &mut Frame, app: &mut App, chunks: &std::rc::Rc<[ let is_active = app.sessions.session_id.as_deref() == Some(s.session_id.as_str()); - let is_parent = app.parent_session_id.as_deref() == Some(s.session_id.as_str()); + let is_parent = + app.delegates.parent_session_id.as_deref() == Some(s.session_id.as_str()); let marker_part = if is_active { " ● " } else if is_parent { @@ -688,8 +689,11 @@ fn draw_delegate_tab_content(f: &mut Frame, app: &mut App, chunks: &std::rc::Rc< // filter let avail = chunks[1].width.saturating_sub(2) as usize; - let (filter_display, filter_cur) = - scroll_input(&app.delegate_filter, app.delegate_filter.len(), avail); + let (filter_display, filter_cur) = scroll_input( + &app.delegates.delegate_filter, + app.delegates.delegate_filter.len(), + avail, + ); let filter_line = Line::from(vec![ Span::styled("> ", Theme::popup_title()), Span::styled(filter_display, Theme::popup_bg()), @@ -702,8 +706,8 @@ fn draw_delegate_tab_content(f: &mut Frame, app: &mut App, chunks: &std::rc::Rc< // delegate entry list (built from event stream) let visible_rows = chunks[3].height as usize; - app.delegate_popup_visible_rows = visible_rows; - let entries = app.visible_delegate_entries(); + app.delegates.delegate_popup_visible_rows = visible_rows; + let entries = app.delegates.visible_entries(); if entries.is_empty() { let list = List::new(vec![ListItem::new(Line::from(Span::styled( @@ -922,7 +926,10 @@ fn draw_delegate_tab_content(f: &mut Frame, app: &mut App, chunks: &std::rc::Rc< .style(Theme::popup_bg()) .row_highlight_style(Theme::selected()); - let selected_idx = app.delegate_cursor.min(entries.len().saturating_sub(1)); + let selected_idx = app + .delegates + .delegate_cursor + .min(entries.len().saturating_sub(1)); let offset = selected_idx.saturating_sub(visible_rows.saturating_sub(1)); let selected = Some(selected_idx); let mut state = TableState::default() @@ -932,7 +939,11 @@ fn draw_delegate_tab_content(f: &mut Frame, app: &mut App, chunks: &std::rc::Rc< } // hint - let selected_entry = entries.get(app.delegate_cursor.min(entries.len().saturating_sub(1))); + let selected_entry = entries.get( + app.delegates + .delegate_cursor + .min(entries.len().saturating_sub(1)), + ); let awaiting_selected = selected_entry.is_some_and(|entry| entry.awaiting_input()); let enter_help = if awaiting_selected { "open child to answer"