510 lines
19 KiB
Rust
510 lines
19 KiB
Rust
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<AgentViewController>,
|
|
active_session: WeakModelHandle<ActiveSession>,
|
|
}
|
|
|
|
#[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<AIConversationId> {
|
|
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<ConversationOrTaskId>,
|
|
}
|
|
|
|
/// 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<WindowId, FocusedTerminalState>,
|
|
last_focused_terminal_state: Option<FocusedTerminalState>,
|
|
/// Map from terminal_view_id to agent view handles (for interactive conversations).
|
|
agent_view_handles: HashMap<EntityId, ActiveAgentViewHandles>,
|
|
/// Map from terminal_view_id to ambient task ID (for open ambient sessions).
|
|
ambient_sessions: HashMap<EntityId, AmbientAgentTaskId>,
|
|
/// Tracks when each conversation was last opened/focused for sorting purposes.
|
|
last_opened_times: HashMap<ConversationOrTaskId, DateTime<Utc>>,
|
|
}
|
|
|
|
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<AgentViewController>,
|
|
active_session: &ModelHandle<ActiveSession>,
|
|
terminal_view_id: EntityId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
// 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<Self>,
|
|
) {
|
|
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<EntityId>,
|
|
focused_task_id: Option<AmbientAgentTaskId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
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<ConversationOrTaskId> {
|
|
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<EntityId> {
|
|
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<AIConversationId> {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<EntityId> {
|
|
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<ModelHandle<ActiveSession>> {
|
|
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<ModelHandle<AgentViewController>> {
|
|
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<DateTime<Utc>> {
|
|
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<EntityId> {
|
|
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<EntityId> {
|
|
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<ConversationOrTaskId> {
|
|
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<ConversationOrTaskId> {
|
|
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;
|