use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use crate::ai::agent::conversation::AIConversationId; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::terminal::model::session::active_session::ActiveSession; use warpui::{ AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle, WindowId, }; /// Contains the handles needed to track an active agent view. struct ActiveAgentViewHandles { controller: WeakModelHandle, active_session: WeakModelHandle, } #[derive(Clone)] pub enum ActiveAgentViewsEvent { /// A conversation was closed (exited from the agent view or its pane was removed). ConversationClosed { conversation_id: AIConversationId }, /// A conversation was entered within a terminal view. TerminalViewFocused, /// An ambient agent session was opened in a tab. AmbientSessionOpened { #[allow(dead_code)] task_id: AmbientAgentTaskId, }, /// An ambient agent session tab was closed. AmbientSessionClosed { #[allow(dead_code)] task_id: AmbientAgentTaskId, }, /// A window was closed and its focused state was removed. WindowClosed, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ConversationOrTaskId { ConversationId(AIConversationId), TaskId(AmbientAgentTaskId), } impl ConversationOrTaskId { pub fn conversation_id(&self) -> Option { match self { ConversationOrTaskId::ConversationId(conversation_id) => Some(*conversation_id), ConversationOrTaskId::TaskId(..) => None, } } } /// State of the focused terminal view and the active conversation in that terminal view. #[derive(Clone)] struct FocusedTerminalState { #[cfg_attr(target_family = "wasm", allow(dead_code))] focused_terminal_id: EntityId, active_conversation_id: Option, } /// ActiveAgentViewsModel tracks which agent conversations are currently "active" - meaning either: /// - An interactive conversation whose agent view is expanded in a pane /// - An ambient conversation that is open in a tab /// This model also tracks which conversation is focused (i.e. active in the currently focused pane). pub struct ActiveAgentViewsModel { /// Per-window focused terminal state, keyed by WindowId. focused_terminal_states: HashMap, last_focused_terminal_state: Option, /// Map from terminal_view_id to agent view handles (for interactive conversations). agent_view_handles: HashMap, /// Map from terminal_view_id to ambient task ID (for open ambient sessions). ambient_sessions: HashMap, /// Tracks when each conversation was last opened/focused for sorting purposes. last_opened_times: HashMap>, } impl Entity for ActiveAgentViewsModel { type Event = ActiveAgentViewsEvent; } impl SingletonEntity for ActiveAgentViewsModel {} impl ActiveAgentViewsModel { pub fn new() -> Self { Self { focused_terminal_states: HashMap::new(), last_focused_terminal_state: None, agent_view_handles: HashMap::new(), ambient_sessions: HashMap::new(), last_opened_times: HashMap::new(), } } /// Register an agent view controller to track when the agent view is entered/exited. #[cfg_attr(target_family = "wasm", allow(dead_code))] pub fn register_agent_view_controller( &mut self, controller: &ModelHandle, active_session: &ModelHandle, terminal_view_id: EntityId, ctx: &mut ModelContext, ) { // Skip registering this controller if it is already registered. if let Some(existing) = self.agent_view_handles.get(&terminal_view_id) { if existing .controller .upgrade(ctx) .is_some_and(|c| c.id() == controller.id()) { return; } } self.agent_view_handles.insert( terminal_view_id, ActiveAgentViewHandles { controller: controller.downgrade(), active_session: active_session.downgrade(), }, ); ctx.subscribe_to_model(controller, move |model, event, ctx| match event { AgentViewControllerEvent::EnteredAgentView { conversation_id, .. } => { let conv_id = ConversationOrTaskId::ConversationId(*conversation_id); model.last_opened_times.insert(conv_id, Utc::now()); // Update the focused conversation in whichever window owns this terminal view. // We ignore agent view changes if we are focused on an ambient conversation, // as ambient conversation navigation operates at the task level instead of the conversation level. for focused_terminal_state in model.focused_terminal_states.values_mut() { if focused_terminal_state.focused_terminal_id == terminal_view_id && !matches!( focused_terminal_state.active_conversation_id, Some(ConversationOrTaskId::TaskId(_)) ) { focused_terminal_state.active_conversation_id = Some(conv_id); } } // Emit so subscribers can move this conversation to the Active section. ctx.emit(ActiveAgentViewsEvent::TerminalViewFocused); } AgentViewControllerEvent::ExitedAgentView { conversation_id, .. } => { model .last_opened_times .remove(&ConversationOrTaskId::ConversationId(*conversation_id)); // Clear the focused conversation in whichever window owns this terminal view. for state in model.focused_terminal_states.values_mut() { if state.focused_terminal_id == terminal_view_id && !matches!( state.active_conversation_id, Some(ConversationOrTaskId::TaskId(_)) ) { state.active_conversation_id = None; } } // Emit so subscribers can move this conversation to the Past section. ctx.emit(ActiveAgentViewsEvent::ConversationClosed { conversation_id: *conversation_id, }); } _ => {} }); } /// Unregister an agent view controller /// (called when the controller's terminal pane is hidden or closed). #[cfg_attr(target_family = "wasm", allow(dead_code))] pub fn unregister_agent_view_controller( &mut self, terminal_pane_id: EntityId, ctx: &mut ModelContext, ) { if let Some(handles) = self.agent_view_handles.remove(&terminal_pane_id) { let closed_conversation_id = handles .controller .upgrade(ctx) .and_then(|c| c.as_ref(ctx).agent_view_state().active_conversation_id()); // If the focused terminal is the one being unregistered, clear the focused state. self.focused_terminal_states .retain(|_, state| state.focused_terminal_id != terminal_pane_id); if self .last_focused_terminal_state .as_ref() .is_some_and(|state| state.focused_terminal_id == terminal_pane_id) { self.last_focused_terminal_state = None; } if let Some(conversation_id) = closed_conversation_id { ctx.emit(ActiveAgentViewsEvent::ConversationClosed { conversation_id }); } } } pub fn handle_pane_focus_change( &mut self, window_id: WindowId, focused_terminal_view_id: Option, focused_task_id: Option, ctx: &mut ModelContext, ) { let old_focused = self.get_focused_conversation(window_id); if let Some(terminal_view_id) = focused_terminal_view_id { // Task ID takes precedence if viewing a shared ambient agent session. let active_conversation_id = if let Some(task_id) = focused_task_id { Some(ConversationOrTaskId::TaskId(task_id)) } else { self.agent_view_handles .get(&terminal_view_id) .and_then(|handles| handles.controller.upgrade(ctx)) .and_then(|controller| { controller .as_ref(ctx) .agent_view_state() .active_conversation_id() }) .map(ConversationOrTaskId::ConversationId) }; let new_state = FocusedTerminalState { focused_terminal_id: terminal_view_id, active_conversation_id, }; self.last_focused_terminal_state = Some(new_state.clone()); self.focused_terminal_states.insert(window_id, new_state); } else { self.focused_terminal_states.remove(&window_id); } if old_focused != self.get_focused_conversation(window_id) { ctx.emit(ActiveAgentViewsEvent::TerminalViewFocused); } } /// Get the focused conversation for a specific window. /// Returns None if the window doesn't have an active agent view or ambient conversation. pub fn get_focused_conversation(&self, window_id: WindowId) -> Option { self.focused_terminal_states .get(&window_id) .and_then(|state| state.active_conversation_id) } /// Get the last focused terminal view id (persisted across non-terminal focus changes). pub fn get_last_focused_terminal_id(&self) -> Option { self.last_focused_terminal_state .as_ref() .map(|state| state.focused_terminal_id) } /// Returns the focused conversation ID if it's a new/empty conversation view. /// Only returns Some if the focused agent view was just created to start a new /// conversation (i.e. has no exchanges yet). pub fn maybe_get_focused_new_conversation( &self, window_id: WindowId, ctx: &AppContext, ) -> Option { let state = self.focused_terminal_states.get(&window_id)?; let terminal_id = state.focused_terminal_id; let is_new = self .agent_view_handles .get(&terminal_id) .and_then(|handles| handles.controller.upgrade(ctx)) .map(|c| c.as_ref(ctx).agent_view_state().is_new()) .unwrap_or(false); if is_new { match state.active_conversation_id { Some(ConversationOrTaskId::ConversationId(id)) => Some(id), _ => None, } } else { None } } /// Remove the focused state for a window /// (called when said window is closed and cleaned up from the undo stack). pub fn remove_focused_state_for_window( &mut self, window_id: WindowId, ctx: &mut ModelContext, ) { if self.focused_terminal_states.remove(&window_id).is_some() { ctx.emit(ActiveAgentViewsEvent::WindowClosed); } } /// Register an ambient session (open in a tab). #[cfg_attr(target_family = "wasm", allow(dead_code))] pub fn register_ambient_session( &mut self, terminal_view_id: EntityId, task_id: AmbientAgentTaskId, ctx: &mut ModelContext, ) { let existing = self.ambient_sessions.insert(terminal_view_id, task_id); if existing != Some(task_id) { self.last_opened_times .insert(ConversationOrTaskId::TaskId(task_id), Utc::now()); ctx.emit(ActiveAgentViewsEvent::AmbientSessionOpened { task_id }); } } /// Unregister an ambient session when the tab is closed. #[cfg_attr(target_family = "wasm", allow(dead_code))] pub fn unregister_ambient_session( &mut self, terminal_view_id: EntityId, ctx: &mut ModelContext, ) { if let Some(task_id) = self.ambient_sessions.remove(&terminal_view_id) { self.last_opened_times .remove(&ConversationOrTaskId::TaskId(task_id)); ctx.emit(ActiveAgentViewsEvent::AmbientSessionClosed { task_id }); } } /// Returns the terminal view ID for a conversation if it's currently active /// (i.e., has an expanded agent view in some pane). pub fn terminal_view_id_for_conversation( &self, conversation_id: AIConversationId, ctx: &AppContext, ) -> Option { self.agent_view_handles .iter() .find_map(|(terminal_view_id, handles)| { let controller = handles.controller.upgrade(ctx)?; controller .as_ref(ctx) .agent_view_state() .active_conversation_id() .is_some_and(|id| id == conversation_id) .then_some(*terminal_view_id) }) } /// Returns true if the conversation is currently open /// (i.e., has an expanded agent view in some pane). pub fn is_conversation_open( &self, conversation_id: AIConversationId, ctx: &AppContext, ) -> bool { self.terminal_view_id_for_conversation(conversation_id, ctx) .is_some() } /// Returns the active session for a conversation if it's currently active /// (i.e., has an expanded agent view). pub fn get_active_session_for_conversation( &self, conversation_id: AIConversationId, ctx: &AppContext, ) -> Option> { for handles in self.agent_view_handles.values() { let Some(controller) = handles.controller.upgrade(ctx) else { continue; }; let is_active = controller .as_ref(ctx) .agent_view_state() .active_conversation_id() .is_some_and(|id| id == conversation_id); if is_active { return handles.active_session.upgrade(ctx); } } None } /// Returns the controller for a conversation if it's currently active /// (i.e., has an expanded agent view). pub fn get_controller_for_conversation( &self, conversation_id: AIConversationId, ctx: &AppContext, ) -> Option> { for handles in self.agent_view_handles.values() { if let Some(controller) = handles.controller.upgrade(ctx) { let is_active = controller .as_ref(ctx) .agent_view_state() .active_conversation_id() .is_some_and(|id| id == conversation_id); if is_active { return Some(controller); } } } None } /// Returns the last opened time for a conversation, used for sorting active conversations. pub fn get_last_opened_time(&self, id: &ConversationOrTaskId) -> Option> { self.last_opened_times.get(id).copied() } /// Returns the terminal view ID that has an active ambient session with the given task ID. pub fn get_terminal_view_id_for_ambient_task( &self, task_id: AmbientAgentTaskId, ) -> Option { self.ambient_sessions .iter() .find_map(|(view_id, id)| (*id == task_id).then_some(*view_id)) } /// Returns the terminal view ID that has an active conversation with the given ID. pub fn get_terminal_view_id_for_conversation( &self, conversation_id: AIConversationId, ctx: &AppContext, ) -> Option { for (terminal_view_id, handles) in &self.agent_view_handles { let Some(controller) = handles.controller.upgrade(ctx) else { continue; }; let is_active = controller .as_ref(ctx) .agent_view_state() .active_conversation_id() .is_some_and(|id| id == conversation_id); if is_active { return Some(*terminal_view_id); } } None } /// Get all currently active conversation IDs. /// A conversation is active if it is open and a query has been sent since it was last opened. /// New (empty) conversations and ambient sessions are always considered active when open. pub fn get_all_active_conversation_ids( &self, ctx: &AppContext, ) -> HashSet { let history_model = BlocklistAIHistoryModel::as_ref(ctx); let mut ids = HashSet::new(); for handles in self.agent_view_handles.values() { if let Some(controller) = handles.controller.upgrade(ctx) { let state = controller.as_ref(ctx).agent_view_state(); if let Some(conversation_id) = state.active_conversation_id() { let Some(conversation) = history_model.conversation(&conversation_id) else { continue; }; if !conversation.is_entirely_passive() && state.was_conversation_modified_since_opening(history_model) { ids.insert(ConversationOrTaskId::ConversationId(conversation_id)); } } } } // Ambient sessions are always considered active when open. for task_id in self.ambient_sessions.values() { ids.insert(ConversationOrTaskId::TaskId(*task_id)); } ids } /// Get all currently open conversation IDs. /// A conversation is considered open if it is in an expanded agent view. pub fn get_all_open_conversation_ids(&self, ctx: &AppContext) -> HashSet { let mut ids = HashSet::new(); // Collect from interactive agent views (expanded). for handles in self.agent_view_handles.values() { if let Some(controller) = handles.controller.upgrade(ctx) { if let Some(conversation_id) = controller .as_ref(ctx) .agent_view_state() .active_conversation_id() { ids.insert(ConversationOrTaskId::ConversationId(conversation_id)); } } } // Collect from ambient sessions (open in tabs) for task_id in self.ambient_sessions.values() { ids.insert(ConversationOrTaskId::TaskId(*task_id)); } ids } } #[cfg(test)] #[path = "active_agent_views_model_tests.rs"] mod tests;