diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index 1fbe81ffe5..1cbd9ae8ca 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -13,12 +13,12 @@ use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::ambient_agents::{AgentSource, AmbientAgentTask, AmbientAgentTaskState}; use crate::ai::artifacts::Artifact; use crate::ai::blocklist::{ - format_credits, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatusUpdate, + BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatusUpdate, }; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; use crate::ai::conversation_navigation::ConversationNavigationData; use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; -use crate::auth::{AuthStateProvider, UserUid}; +use crate::auth::AuthStateProvider; use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; use crate::server::cloud_objects::update_manager::{UpdateManager, UpdateManagerEvent}; use crate::server::ids::{ServerId, SyncId}; @@ -29,14 +29,12 @@ use crate::server::server_api::{ai::TaskListFilter, ServerApiProvider}; use crate::settings::AISettings; use crate::ui_components::icons::Icon; use crate::workspace::{RestoreConversationLayout, WorkspaceAction}; -use crate::workspaces::user_profiles::UserProfiles; use chrono::{DateTime, Utc}; use clap::ValueEnum; use futures::stream::AbortHandle; use instant::Instant; use itertools::Itertools; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use session_sharing_protocol::common::SessionId; use std::collections::{HashMap, HashSet}; use std::time::Duration; use warp_cli::agent::Harness; @@ -52,7 +50,6 @@ use warpui::{ SingletonEntity, WindowId, }; -const SESSION_EXPIRATION_TIME: chrono::Duration = chrono::Duration::weeks(1); const POLLING_INTERVAL: Duration = Duration::from_secs(30); const INITIAL_TASK_AMOUNT: i32 = 100; @@ -241,16 +238,6 @@ impl AgentManagementFilters { } } -/// Preference for which type of link/action to use for a conversation or task. -enum LinkPreference { - /// Use session link/action - Session, - /// Use conversation link/action - Conversation, - /// No link/action available - None, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum AgentRunDisplayStatus { /// Raw task-service lifecycle states. `from_task` only returns `TaskInProgress` while the @@ -460,425 +447,6 @@ pub struct ConversationMetadata { pub nav_data: ConversationNavigationData, } -/// ConversationOrTask is a wrapper around either conversation -/// or task data stored in the `AgentConversationsModel`. -/// -/// It provides a unified interface for reading data related to tasks and conversations. -pub enum ConversationOrTask<'a> { - Task(&'a AmbientAgentTask), - Conversation(&'a ConversationMetadata), -} - -#[allow(dead_code)] -impl ConversationOrTask<'_> { - pub fn title(&self, app: &AppContext) -> String { - match self { - ConversationOrTask::Task(task) => task.title.clone(), - ConversationOrTask::Conversation(metadata) => { - // We try to read the title from the history model first (that's the most up-to-date), - // but fall back to the one stored in the navigation data. - let history_model = BlocklistAIHistoryModel::as_ref(app); - history_model - .conversation(&metadata.nav_data.id) - .and_then(|conv| conv.title().clone()) - .unwrap_or(metadata.nav_data.title.clone()) - } - } - } - - /// Map to conversation status for the UI status display - pub fn status(&self, app: &AppContext) -> ConversationStatus { - match self { - ConversationOrTask::Task(task) => match &task.state { - AmbientAgentTaskState::Queued - | AmbientAgentTaskState::Pending - | AmbientAgentTaskState::Claimed - | AmbientAgentTaskState::InProgress => ConversationStatus::InProgress, - AmbientAgentTaskState::Succeeded => ConversationStatus::Success, - AmbientAgentTaskState::Cancelled => ConversationStatus::Cancelled, - AmbientAgentTaskState::Blocked => ConversationStatus::Blocked { - blocked_action: task - .status_message - .as_ref() - .map(|m| m.message.clone()) - .unwrap_or_else(|| "Task blocked".to_string()), - }, - AmbientAgentTaskState::Failed - | AmbientAgentTaskState::Error - | AmbientAgentTaskState::Unknown => ConversationStatus::Error, - }, - ConversationOrTask::Conversation(metadata) => { - let history_model = BlocklistAIHistoryModel::as_ref(app); - history_model - .conversation(&metadata.nav_data.id) - .map(|conv| conv.status().clone()) - .unwrap_or(ConversationStatus::Success) - } - } - } - - pub fn display_status(&self, app: &AppContext) -> AgentRunDisplayStatus { - match self { - ConversationOrTask::Task(task) => AgentRunDisplayStatus::from_task(task, app), - ConversationOrTask::Conversation(metadata) => { - let history_model = BlocklistAIHistoryModel::as_ref(app); - history_model - .conversation(&metadata.nav_data.id) - .map(|conv| AgentRunDisplayStatus::from_conversation_status(conv.status())) - .unwrap_or(AgentRunDisplayStatus::ConversationSucceeded) - } - } - } - - /// Grab the creator name from the task, or from the auth state if it is a conversation - pub fn creator_name(&self, app: &AppContext) -> Option { - match self { - ConversationOrTask::Task(task) => task.creator_display_name().or_else(|| { - // Fallback to the cached users in the UserProfiles singleton - let uid = task.creator.as_ref().map(|c| &c.uid)?; - let user_profiles = UserProfiles::as_ref(app); - user_profiles.displayable_identifier_for_uid(UserUid::new(uid)) - }), - ConversationOrTask::Conversation(_) => { - AuthStateProvider::as_ref(app).get().username_for_display() - } - } - } - - /// Grab the creator UID from the task, or from the auth state if it is a conversation - pub fn creator_uid(&self, app: &AppContext) -> Option { - match self { - ConversationOrTask::Task(task) => task.creator.as_ref().map(|c| c.uid.clone()), - ConversationOrTask::Conversation(_) => AuthStateProvider::as_ref(app) - .get() - .user_id() - .map(|uid| uid.to_string()), - } - } - - /// Returns the request usage for the task or conversation - pub(super) fn request_usage(&self, app: &AppContext) -> Option { - match self { - ConversationOrTask::Task(task) => task.credits_used(), - ConversationOrTask::Conversation(metadata) => { - let history_model = BlocklistAIHistoryModel::as_ref(app); - history_model - .conversation(&metadata.nav_data.id) - .map(|conv| conv.credits_spent()) - .or_else(|| { - history_model - .get_conversation_metadata(&metadata.nav_data.id) - .and_then(|m| m.credits_spent) - }) - } - } - } - - /// Formats the request usage for display. - pub fn display_request_usage(&self, app: &AppContext) -> Option { - self.request_usage(app).map(format_credits) - } - - pub fn last_updated(&self) -> DateTime { - match self { - ConversationOrTask::Task(task) => task.updated_at, - ConversationOrTask::Conversation(metadata) => metadata.nav_data.last_updated.into(), - } - } - - pub fn created_at(&self) -> DateTime { - match self { - ConversationOrTask::Task(task) => task.created_at, - ConversationOrTask::Conversation(metadata) => metadata.nav_data.last_updated.into(), - } - } - - /// Returns the session ID for tasks, if we have one. - pub fn session_id(&self) -> Option { - match self { - ConversationOrTask::Task(task) => { - task.session_id.as_deref().and_then(entry::parse_session_id) - } - ConversationOrTask::Conversation(_) => None, - } - } - - /// Returns the navigation data for local conversations, used for emitting the Navigate event. - pub fn navigation_data(&self) -> Option<&ConversationNavigationData> { - match self { - ConversationOrTask::Task(_) => None, - ConversationOrTask::Conversation(metadata) => Some(&metadata.nav_data), - } - } - - pub fn run_time(&self) -> Option { - match self { - // TODO this should really be done server-side - ConversationOrTask::Task(task) => { - let Some(duration) = task.run_time() else { - return Some("Not started".to_string()); - }; - if duration.num_minutes() < 1 { - Some(format!("{} seconds", duration.num_seconds())) - } else { - Some(format!("{} minutes", duration.num_minutes())) - } - } - // Local conversations don't currently track run time - ConversationOrTask::Conversation(_) => None, - } - } - - pub fn source(&self) -> Option<&AgentSource> { - match self { - ConversationOrTask::Task(task) => task.source.as_ref(), - ConversationOrTask::Conversation(_) => Some(&AgentSource::Interactive), - } - } - - pub fn environment_id(&self) -> Option<&str> { - match self { - ConversationOrTask::Task(task) => task - .agent_config_snapshot - .as_ref() - .and_then(|s| s.environment_id.as_deref()), - ConversationOrTask::Conversation(_) => None, - } - } - - /// Resolve the effective execution harness for this run. - pub fn harness(&self, app: &AppContext) -> Option { - match self { - ConversationOrTask::Task(task) => { - task.agent_config_snapshot.as_ref().and_then(|config| { - config - .harness - .as_ref() - .map(|h| h.harness_type) - .or(Some(Harness::Oz)) - }) - } - ConversationOrTask::Conversation(metadata) => BlocklistAIHistoryModel::as_ref(app) - .get_server_conversation_metadata(&metadata.nav_data.id) - .map(|m| Harness::from(m.harness)) - .or(Some(Harness::Oz)), - } - } - - /// Returns artifacts for the task or conversation. - pub fn artifacts(&self, app: &AppContext) -> Vec { - match self { - ConversationOrTask::Task(task) => task.artifacts.clone(), - ConversationOrTask::Conversation(metadata) => { - let history_model = BlocklistAIHistoryModel::as_ref(app); - history_model - .conversation(&metadata.nav_data.id) - .map(|conv| conv.artifacts().to_vec()) - .or_else(|| { - history_model - .get_conversation_metadata(&metadata.nav_data.id) - .map(|m| m.artifacts.clone()) - }) - .unwrap_or_default() - } - } - } - - /// Returns the preferred link type based on cloud conversations and session state. - fn link_preference(&self) -> LinkPreference { - match self { - ConversationOrTask::Task(task) => { - // Always open session link if there's a live session. - // Without cloud conversations, also open session link as long as it's not expired. - // With cloud conversations, even if the link is not expired, we load conversation - // data from graphql as long as the session isn't live. - if task.has_active_execution() - || (!FeatureFlag::CloudConversations.is_enabled() - && self.get_session_status() != Some(SessionStatus::Expired)) - { - LinkPreference::Session - } else if FeatureFlag::CloudConversations.is_enabled() { - LinkPreference::Conversation - } else { - LinkPreference::None - } - } - ConversationOrTask::Conversation(_) => LinkPreference::Conversation, - } - } - - /// Get a link to a session or conversation, depending on whether the cloud agent is running - pub fn session_or_conversation_link(&self, app: &AppContext) -> Option { - match self.link_preference() { - LinkPreference::Session => match self { - ConversationOrTask::Task(task) => task - .active_run_execution() - .session_link - .map(ToString::to_string), - ConversationOrTask::Conversation(_) => None, - }, - LinkPreference::Conversation => match self { - ConversationOrTask::Task(task) => task - .conversation_id() - .map(|id| ServerConversationToken::new(id.to_string()).conversation_link()), - ConversationOrTask::Conversation(conversation) => { - let history_model = BlocklistAIHistoryModel::as_ref(app); - history_model - .conversation(&conversation.nav_data.id) - .and_then(|c| c.server_conversation_token()) - .map(|t| t.conversation_link()) - .or_else(|| { - history_model - .get_conversation_metadata(&conversation.nav_data.id) - .and_then(|m| m.server_conversation_token.as_ref()) - .map(|t| t.conversation_link()) - }) - } - }, - LinkPreference::None => None, - } - } - - pub fn get_session_status(&self) -> Option { - // With cloud conversations, as long as the session link is populated, it is available - // If it's not, it's unavailable (no live session link and no conversation data in GCS) - if FeatureFlag::CloudConversations.is_enabled() { - return match self { - ConversationOrTask::Task(task) => { - if task.active_run_execution().session_link.is_some() { - Some(SessionStatus::Available) - } else { - Some(SessionStatus::Unavailable) - } - } - ConversationOrTask::Conversation(_) => None, - }; - } - match self { - ConversationOrTask::Task(task) => { - if task.active_run_execution().session_id.is_some() { - Some(SessionStatus::Available) - } else if (Utc::now() - task.created_at) > SESSION_EXPIRATION_TIME { - Some(SessionStatus::Expired) - } else { - Some(SessionStatus::Unavailable) - } - } - ConversationOrTask::Conversation(_) => None, - } - } - - /// Check if this item matches the current status filter. - fn matches_status(&self, status_filter: &StatusFilter, app: &AppContext) -> bool { - match status_filter { - StatusFilter::All => true, - StatusFilter::Working | StatusFilter::Done | StatusFilter::Failed => { - self.display_status(app).status_filter() == *status_filter - } - } - } - - /// Check if this item matches the artifact filter. - fn matches_artifact(&self, artifact_filter: &ArtifactFilter, app: &AppContext) -> bool { - artifacts_match_filter(&self.artifacts(app), artifact_filter) - } - - /// Check if this item matches the harness filter. - fn matches_harness(&self, harness_filter: &HarnessFilter, app: &AppContext) -> bool { - match harness_filter { - HarnessFilter::All => true, - HarnessFilter::Specific(h) => self.harness(app) == Some(*h), - } - } - - /// Check if this item matches the owner and creator filters. - fn matches_owner_and_creator( - &self, - owner_filter: &OwnerFilter, - creator_filter: &CreatorFilter, - app: &AppContext, - ) -> bool { - let current_user_id = AuthStateProvider::as_ref(app) - .get() - .user_id() - .map(|uid| uid.as_string()); - - // First check owner filter - let passes_owner = match owner_filter { - OwnerFilter::All => true, - OwnerFilter::PersonalOnly => match self { - ConversationOrTask::Task(_) => self.creator_uid(app) == current_user_id, - // Local conversations are always owned by the current user - ConversationOrTask::Conversation(_) => true, - }, - }; - - if !passes_owner { - return false; - } - - // We don't want to apply the creator filter if we are in the personal only view. - if matches!(owner_filter, OwnerFilter::PersonalOnly) { - return true; - } - - // Then check creator filter (only relevant when owner is "All") - match creator_filter { - CreatorFilter::All => true, - CreatorFilter::Specific { name, .. } => self.creator_name(app).as_ref() == Some(name), - } - } - - /// Returns the appropriate `WorkspaceAction` to dispatch when opening this item. - /// This encapsulates the decision logic for opening ambient agent sessions vs loading - /// cloud conversation data vs navigating to local conversations. - pub fn get_open_action( - &self, - restore_layout: Option, - app: &AppContext, - ) -> Option { - match self.link_preference() { - LinkPreference::Session => match self { - ConversationOrTask::Task(task) => { - self.session_id() - .map(|session_id| WorkspaceAction::OpenAmbientAgentSession { - session_id, - task_id: task.run_id(), - }) - } - ConversationOrTask::Conversation(_) => None, - }, - LinkPreference::Conversation => match self { - ConversationOrTask::Task(task) => task.conversation_id().map(|id| { - WorkspaceAction::OpenConversationTranscriptViewer { - conversation_id: ServerConversationToken::new(id.to_string()), - ambient_agent_task_id: Some(task.run_id()), - } - }), - ConversationOrTask::Conversation(metadata) => { - let is_active = ActiveAgentViewsModel::as_ref(app) - .is_conversation_open(metadata.nav_data.id, app); - let nav_data = &metadata.nav_data; - Some(WorkspaceAction::RestoreOrNavigateToConversation { - conversation_id: nav_data.id, - window_id: nav_data.window_id, - // Only try to navigate to the pane if the conversation is actually active. - // - // Otherwise, we should open in a new tab or pane according to the user's - // setting. - pane_view_locator: is_active - .then_some(nav_data.pane_view_locator) - .flatten(), - terminal_view_id: nav_data.terminal_view_id, - restore_layout, - }) - } - }, - LinkPreference::None => None, - } - } -} - pub(crate) fn artifacts_match_filter( artifacts: &[Artifact], artifact_filter: &ArtifactFilter, @@ -947,6 +515,8 @@ pub enum ConversationUpdateKind { prev_filter: StatusFilter, new_filter: StatusFilter, }, + /// Conversation metadata or capabilities changed. + MetadataChanged, } impl Entity for AgentConversationsModel { @@ -1458,19 +1028,7 @@ impl AgentConversationsModel { } } - fn conversation_ids_shadowed_by_tasks(&self, app: &AppContext) -> HashSet { - let history_model = BlocklistAIHistoryModel::as_ref(app); - self.tasks - .values() - .filter_map(|task| entry::conversation_id_shadowed_by_task(task, history_model)) - .collect() - } - /// Returns normalized, owned entries for agent management/navigation surfaces. - /// - /// This projection keeps task, local conversation, and cloud metadata identity together while - /// leaving the current `ConversationOrTask` call sites unchanged. - #[allow(dead_code)] pub fn get_entries( &self, filters: &AgentManagementFilters, @@ -1655,8 +1213,9 @@ impl AgentConversationsModel { .conversations .get(&conversation_id) .map(|metadata| &metadata.nav_data); - if entry.backing.has_loaded_conversation + if !entry.backing.has_cloud_data || entry.backing.has_local_persisted_data + || entry.backing.has_loaded_conversation || nav_data.is_some() { return Some(WorkspaceAction::RestoreOrNavigateToConversation { @@ -1816,89 +1375,16 @@ impl AgentConversationsModel { // doesn't change any ConversationNavigationData fields (title comes from // UpdateTaskDescription, last_updated uses exchange.start_time which is set at append time). | BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. } - | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } | BlocklistAIHistoryEvent::ConversationOwnershipTransferred { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } => {} - } - } - - /// Returns an iterator with all tasks and conversations with filters applied, sorted with the - /// most recently updated items first. - #[allow(dead_code)] - pub fn get_tasks_and_conversations( - &self, - filters: &AgentManagementFilters, - app: &AppContext, - ) -> impl Iterator> { - let conversation_ids_shadowed_by_tasks = self.conversation_ids_shadowed_by_tasks(app); - let owner_creator_filter = move |t: &ConversationOrTask| { - t.matches_owner_and_creator(&filters.owners, &filters.creator, app) - }; - - let status_filter = move |t: &ConversationOrTask| t.matches_status(&filters.status, app); - let source_filter = move |t: &ConversationOrTask| match &filters.source { - SourceFilter::All => true, - SourceFilter::Specific(s) => t.source() == Some(s), - }; - - let now = Utc::now(); - let created_cutoff = match filters.created_on { - CreatedOnFilter::All => None, - CreatedOnFilter::Last24Hours => Some(now - chrono::Duration::hours(24)), - CreatedOnFilter::Past3Days => Some(now - chrono::Duration::days(3)), - CreatedOnFilter::LastWeek => Some(now - chrono::Duration::days(7)), - }; - - let created_on_filter = move |t: &ConversationOrTask| match created_cutoff { - Some(cutoff) => t.created_at() >= cutoff, - None => true, - }; - - let artifact_filter_value = filters.artifact; - let artifact_filter = - move |t: &ConversationOrTask| t.matches_artifact(&artifact_filter_value, app); - - let environment_filter = move |t: &ConversationOrTask| match &filters.environment { - EnvironmentFilter::All => true, - EnvironmentFilter::NoEnvironment => t.environment_id().is_none(), - EnvironmentFilter::Specific(id) => t.environment_id() == Some(id.as_str()), - }; - - let harness_filter_value = filters.harness; - let harness_filter = - move |t: &ConversationOrTask| t.matches_harness(&harness_filter_value, app); - - let tasks_iter = self.tasks.values().map(ConversationOrTask::Task); - let conversations_iter = self - .conversations - .values() - .filter(move |conversation| { - // Prefer rendering the task row when both representations exist for the same local - // run. Task entries preserve task-specific affordances like source, runtime, - // session status, and ambient-session open behavior that the conversation row - // cannot express. - !conversation_ids_shadowed_by_tasks.contains(&conversation.nav_data.id) - }) - .map(ConversationOrTask::Conversation); - - tasks_iter - .chain(conversations_iter) - .filter(owner_creator_filter) - .filter(status_filter) - .filter(source_filter) - .filter(created_on_filter) - .filter(artifact_filter) - .filter(environment_filter) - .filter(harness_filter) - .sorted_by(|a, b| b.last_updated().cmp(&a.last_updated())) - } - - /// Get a task by its task ID - #[allow(dead_code)] - pub fn get_task(&self, task_id: &AmbientAgentTaskId) -> Option> { - self.tasks.get(task_id).map(ConversationOrTask::Task) + BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } => { + ctx.emit(AgentConversationsModelEvent::ConversationUpdated { + kind: ConversationUpdateKind::MetadataChanged, + }); + } + } } /// Get raw task data by task ID @@ -2003,16 +1489,6 @@ impl AgentConversationsModel { None } - /// Get a conversation by its AIConversationId - pub fn get_conversation( - &self, - conversation_id: &AIConversationId, - ) -> Option> { - self.conversations - .get(conversation_id) - .map(ConversationOrTask::Conversation) - } - /// Returns all (name, uid) pairs for creators of tasks in the model. /// /// We use this function to populate the available creator filter list @@ -2021,10 +1497,9 @@ impl AgentConversationsModel { let mut creators: Vec<(String, String)> = self .tasks .values() - .filter_map(|t| { - let wrapper = ConversationOrTask::Task(t); - let name = wrapper.creator_name(app)?; - let uid = wrapper.creator_uid(app)?; + .filter_map(|task| { + let name = entry::task_creator_name(task, app)?; + let uid = entry::task_creator_uid(task)?; Some((name, uid)) }) .collect(); diff --git a/app/src/ai/agent_conversations_model/entry.rs b/app/src/ai/agent_conversations_model/entry.rs index e44483b88f..400dce7ac5 100644 --- a/app/src/ai/agent_conversations_model/entry.rs +++ b/app/src/ai/agent_conversations_model/entry.rs @@ -5,18 +5,22 @@ use crate::ai::ambient_agents::{AgentSource, AmbientAgentTask, AmbientAgentTaskI use crate::ai::artifacts::Artifact; use crate::ai::blocklist::history_model::{AIConversationMetadata, BlocklistAIHistoryModel}; use crate::ai::conversation_navigation::ConversationNavigationData; -use crate::auth::AuthStateProvider; +use crate::auth::{AuthStateProvider, UserUid}; +use crate::workspaces::user_profiles::UserProfiles; use chrono::{DateTime, Utc}; use session_sharing_protocol::common::SessionId; use warp_cli::agent::Harness; +use warp_core::features::FeatureFlag; use warpui::{AppContext, SingletonEntity}; use super::{ artifacts_match_filter, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, - ConversationMetadata, ConversationOrTask, CreatedOnFilter, CreatorFilter, EnvironmentFilter, - HarnessFilter, OwnerFilter, SessionStatus, SourceFilter, StatusFilter, + ConversationMetadata, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter, + OwnerFilter, SessionStatus, SourceFilter, StatusFilter, }; +const SESSION_EXPIRATION_TIME: chrono::Duration = chrono::Duration::weeks(1); + /// Stable projection identity used by list and navigation surfaces. /// /// Task-backed rows use the ambient run ID even when they are attached to a local @@ -253,12 +257,125 @@ pub(super) fn conversation_id_shadowed_by_task( }) } +pub(super) fn task_creator_name(task: &AmbientAgentTask, app: &AppContext) -> Option { + task.creator_display_name().or_else(|| { + let uid = task.creator.as_ref().map(|creator| &creator.uid)?; + UserProfiles::as_ref(app).displayable_identifier_for_uid(UserUid::new(uid)) + }) +} + +pub(super) fn task_creator_uid(task: &AmbientAgentTask) -> Option { + task.creator.as_ref().map(|creator| creator.uid.clone()) +} + +fn current_user_name(app: &AppContext) -> Option { + AuthStateProvider::as_ref(app).get().username_for_display() +} + +fn current_user_uid(app: &AppContext) -> Option { + AuthStateProvider::as_ref(app) + .get() + .user_id() + .map(|uid| uid.to_string()) +} + +fn task_session_id(task: &AmbientAgentTask) -> Option { + task.session_id.as_deref().and_then(parse_session_id) +} + +fn task_session_status(task: &AmbientAgentTask) -> SessionStatus { + if FeatureFlag::CloudConversations.is_enabled() { + return if task.active_run_execution().session_link.is_some() { + SessionStatus::Available + } else { + SessionStatus::Unavailable + }; + } + + if task.active_run_execution().session_id.is_some() { + SessionStatus::Available + } else if (Utc::now() - task.created_at) > SESSION_EXPIRATION_TIME { + SessionStatus::Expired + } else { + SessionStatus::Unavailable + } +} + +fn task_run_time(task: &AmbientAgentTask) -> Option { + let Some(duration) = task.run_time() else { + return Some("Not started".to_string()); + }; + if duration.num_minutes() < 1 { + Some(format!("{} seconds", duration.num_seconds())) + } else { + Some(format!("{} minutes", duration.num_minutes())) + } +} + +fn task_harness(task: &AmbientAgentTask) -> Option { + task.agent_config_snapshot.as_ref().and_then(|config| { + config + .harness + .as_ref() + .map(|harness| harness.harness_type) + .or(Some(Harness::Oz)) + }) +} + +fn conversation_title( + metadata: &ConversationMetadata, + history_model: &BlocklistAIHistoryModel, +) -> String { + history_model + .conversation(&metadata.nav_data.id) + .and_then(|conversation| conversation.title().clone()) + .unwrap_or(metadata.nav_data.title.clone()) +} + +fn conversation_display_status( + metadata: &ConversationMetadata, + history_model: &BlocklistAIHistoryModel, +) -> AgentRunDisplayStatus { + history_model + .conversation(&metadata.nav_data.id) + .map(|conversation| AgentRunDisplayStatus::from_conversation_status(conversation.status())) + .unwrap_or(AgentRunDisplayStatus::ConversationSucceeded) +} + +fn conversation_request_usage( + metadata: &ConversationMetadata, + history_model: &BlocklistAIHistoryModel, +) -> Option { + history_model + .conversation(&metadata.nav_data.id) + .map(|conversation| conversation.credits_spent()) + .or_else(|| { + history_model + .get_conversation_metadata(&metadata.nav_data.id) + .and_then(|metadata| metadata.credits_spent) + }) +} + +fn conversation_artifacts( + metadata: &ConversationMetadata, + history_model: &BlocklistAIHistoryModel, +) -> Vec { + history_model + .conversation(&metadata.nav_data.id) + .map(|conversation| conversation.artifacts().to_vec()) + .or_else(|| { + history_model + .get_conversation_metadata(&metadata.nav_data.id) + .map(|metadata| metadata.artifacts.clone()) + }) + .unwrap_or_default() +} + pub(super) fn entry_for_task( task: &AmbientAgentTask, history_model: &BlocklistAIHistoryModel, app: &AppContext, ) -> AgentConversationEntry { - let item = ConversationOrTask::Task(task); let local_conversation_id = conversation_id_shadowed_by_task(task, history_model); let conversation_metadata = local_conversation_id.and_then(|id| history_model.get_conversation_metadata(&id)); @@ -270,7 +387,7 @@ pub(super) fn entry_for_task( server_conversation_token_for_conversation(conversation_id, None, history_model) }) }); - let status = item.display_status(app); + let status = AgentRunDisplayStatus::from_task(task, app); let has_active_session_id = task .active_execution_session_id() .and_then(parse_session_id) @@ -292,28 +409,31 @@ pub(super) fn entry_for_task( local_conversation_id, ambient_agent_task_id: Some(task.task_id), server_conversation_token, - session_id: item.session_id(), + session_id: task_session_id(task), }, provenance: AgentConversationProvenance::AmbientRun, display: AgentConversationDisplayData { - title: item.title(app), + title: task.title.clone(), initial_query: Some(task.prompt.clone()), - created_at: item.created_at(), - last_updated: item.last_updated(), + created_at: task.created_at, + last_updated: task.updated_at, status: status.clone(), creator: AgentConversationCreator { - name: item.creator_name(app), - uid: item.creator_uid(app), + name: task_creator_name(task, app), + uid: task_creator_uid(task), }, - request_usage: item.request_usage(app), - run_time: item.run_time(), - session_status: item.get_session_status(), - source: item.source().cloned(), + request_usage: task.credits_used(), + run_time: task_run_time(task), + session_status: Some(task_session_status(task)), + source: task.source.clone(), working_directory: conversation_metadata .and_then(|metadata| metadata.initial_working_directory.clone()), - environment_id: item.environment_id().map(ToString::to_string), - harness: item.harness(app), - artifacts: item.artifacts(app), + environment_id: task + .agent_config_snapshot + .as_ref() + .and_then(|snapshot| snapshot.environment_id.clone()), + harness: task_harness(task), + artifacts: task.artifacts.clone(), }, backing: AgentConversationBackingData { has_loaded_conversation: local_conversation_id @@ -367,9 +487,8 @@ fn entry_for_conversation_parts( app: &AppContext, ) -> AgentConversationEntry { let metadata = ConversationMetadata { nav_data }; - let item = ConversationOrTask::Conversation(&metadata); let conversation_id = metadata.nav_data.id; - let status = item.display_status(app); + let status = conversation_display_status(&metadata, history_model); let has_loaded_conversation = history_model.conversation(&conversation_id).is_some(); let has_local_persisted_data = conversation_metadata .is_some_and(|metadata| metadata.has_local_data) @@ -403,19 +522,19 @@ fn entry_for_conversation_parts( }, provenance, display: AgentConversationDisplayData { - title: item.title(app), + title: conversation_title(&metadata, history_model), initial_query: metadata.nav_data.initial_query.clone(), - created_at: item.created_at(), - last_updated: item.last_updated(), + created_at: metadata.nav_data.last_updated.into(), + last_updated: metadata.nav_data.last_updated.into(), status: status.clone(), creator: AgentConversationCreator { - name: item.creator_name(app), - uid: item.creator_uid(app), + name: current_user_name(app), + uid: current_user_uid(app), }, - request_usage: item.request_usage(app), - run_time: item.run_time(), - session_status: item.get_session_status(), - source: item.source().cloned(), + request_usage: conversation_request_usage(&metadata, history_model), + run_time: None, + session_status: None, + source: Some(AgentSource::Interactive), working_directory: metadata .nav_data .latest_working_directory @@ -425,8 +544,8 @@ fn entry_for_conversation_parts( harness: conversation_metadata .and_then(|metadata| metadata.server_conversation_metadata.as_ref()) .map(|metadata| Harness::from(metadata.harness)) - .or_else(|| item.harness(app)), - artifacts: item.artifacts(app), + .or(Some(Harness::Oz)), + artifacts: conversation_artifacts(&metadata, history_model), }, backing: AgentConversationBackingData { has_loaded_conversation, @@ -436,9 +555,7 @@ fn entry_for_conversation_parts( .is_some_and(AIConversationMetadata::is_ambient_agent_conversation), }, capabilities: AgentConversationCapabilities { - can_open: has_local_persisted_data - || has_cloud_data - || item.get_open_action(None, app).is_some(), + can_open: has_local_persisted_data || has_cloud_data, can_copy_link: server_conversation_token_for_conversation( conversation_id, Some(&metadata.nav_data), diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 5b1caf162c..d42ca6110d 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -2,7 +2,13 @@ use chrono::{DateTime, Duration, Utc}; use instant::Instant; use parking_lot::Mutex; use persistence::model::{AgentConversationData, ConversationUsageMetadata}; -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; use warp_core::features::FeatureFlag; use warpui::{App, EntityId, ModelHandle, SingletonEntity}; @@ -31,9 +37,9 @@ use super::entry::{ }; use super::{ AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, - AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationOrTask, - ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, StatusFilter, - TaskFetchState, MAX_PERSONAL_TASKS, MAX_TEAM_TASKS, + AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationUpdateKind, + EnvironmentFilter, HarnessFilter, OwnerFilter, StatusFilter, TaskFetchState, + MAX_PERSONAL_TASKS, MAX_TEAM_TASKS, }; use crate::ai::ambient_agents::task::HarnessConfig; use crate::workspace::WorkspaceAction; @@ -475,8 +481,8 @@ fn test_display_status_terminal_task_state_overrides_matching_conversation() { fn test_status_filter_uses_display_status_for_task_backed_conversations() { App::test((), |mut app| async move { let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + add_entry_projection_test_models(&mut app); + let history_model = BlocklistAIHistoryModel::handle(&app); let now = Utc::now(); let conversation_id = AIConversationId::new(); @@ -522,32 +528,28 @@ fn test_status_filter_uses_display_status_for_task_backed_conversations() { ); app.update(|ctx| { - let done_items: Vec<_> = model - .get_tasks_and_conversations( - &AgentManagementFilters { - owners: OwnerFilter::All, - status: StatusFilter::Done, - ..Default::default() - }, - ctx, - ) - .collect(); + let done_items = model.get_entries( + &AgentManagementFilters { + owners: OwnerFilter::All, + status: StatusFilter::Done, + ..Default::default() + }, + ctx, + ); assert_eq!(done_items.len(), 1); - assert!(matches!( - done_items.first(), - Some(ConversationOrTask::Task(_)) - )); + assert_eq!( + done_items.first().map(|entry| entry.id), + Some(AgentConversationEntryId::AmbientRun(task.task_id)) + ); - let working_items: Vec<_> = model - .get_tasks_and_conversations( - &AgentManagementFilters { - owners: OwnerFilter::All, - status: StatusFilter::Working, - ..Default::default() - }, - ctx, - ) - .collect(); + let working_items = model.get_entries( + &AgentManagementFilters { + owners: OwnerFilter::All, + status: StatusFilter::Working, + ..Default::default() + }, + ctx, + ); assert!(working_items.is_empty()); }); }); @@ -1080,6 +1082,94 @@ fn test_resolve_open_action_handles_server_token_subject_without_entry() { }); } +#[test] +fn test_resolve_open_action_opens_completed_cloud_task_by_server_token() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let token = "completed-cloud-task-token"; + let mut task = create_test_task(&make_uuid(8204), "user-a", Utc::now()); + task.state = AmbientAgentTaskState::Succeeded; + task.conversation_id = Some(token.to_string()); + task.session_id = None; + task.session_link = None; + let task_id = task.task_id; + + app.add_singleton_model(|_| { + let mut model = create_test_model(); + model.tasks.insert(task_id, task); + model + }); + + app.update(|ctx| { + let action = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task_id, + )), + None, + ctx, + ); + + assert!(matches!( + action, + Some(WorkspaceAction::OpenConversationTranscriptViewer { + conversation_id, + ambient_agent_task_id: Some(resolved_task_id), + }) if conversation_id.as_str() == token && resolved_task_id == task_id + )); + }); + }); +} + +#[test] +fn test_resolve_open_action_opens_metadata_only_cloud_conversation_by_server_token() { + App::test((), |mut app| async move { + let token = "metadata-only-token"; + add_entry_projection_test_models(&mut app); + BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, _| { + model.merge_cloud_conversation_metadata(vec![create_server_conversation_metadata( + "Cloud conversation", + token, + None, + )]); + }); + app.add_singleton_model(|_| create_test_model()); + + app.update(|ctx| { + let entries = + AgentConversationsModel::as_ref(ctx).get_entries(&all_owner_filters(), ctx); + let entry = entries + .iter() + .find(|entry| { + entry + .identity + .server_conversation_token + .as_ref() + .is_some_and(|server_token| server_token.as_str() == token) + }) + .expect("metadata-only cloud entry should exist"); + + assert!(entry.backing.has_cloud_data); + assert!(!entry.backing.has_loaded_conversation); + assert!(!entry.backing.has_local_persisted_data); + + let action = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(entry.id), + None, + ctx, + ); + + assert!(matches!( + action, + Some(WorkspaceAction::OpenConversationTranscriptViewer { + conversation_id, + ambient_agent_task_id: None, + }) if conversation_id.as_str() == token + )); + }); + }); +} + #[test] fn test_resolve_copy_link_prefers_active_session_link() { App::test((), |mut app| async move { @@ -1187,6 +1277,100 @@ fn test_resolve_copy_link_returns_none_for_local_only_unsynced_conversation() { }); } +#[test] +fn test_server_token_assignment_updates_copy_link_resolution() { + App::test((), |mut app| async move { + let _interactive_management_guard = + FeatureFlag::InteractiveConversationManagementView.override_enabled(true); + add_entry_projection_test_models(&mut app); + + let conversation_id = AIConversationId::new(); + let terminal_view_id = EntityId::new(); + let conversation = create_restored_conversation( + conversation_id, + "root-task", + AgentConversationData { + server_conversation_token: None, + conversation_usage_metadata: None, + reverted_action_ids: None, + forked_from_server_conversation_token: None, + artifacts_json: None, + parent_agent_id: None, + agent_name: None, + parent_conversation_id: None, + is_remote_child: false, + run_id: None, + autoexecute_override: None, + last_event_sequence: None, + }, + ); + + BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![conversation], ctx); + }); + + let agent_model = app.add_singleton_model(|_| { + let mut model = create_test_model(); + model.conversations.insert( + conversation_id, + create_test_conversation_metadata(conversation_id, "Conversation"), + ); + model + }); + let saw_conversation_updated = Arc::new(AtomicBool::new(false)); + + app.update(|ctx| { + let saw_conversation_updated = saw_conversation_updated.clone(); + ctx.subscribe_to_model(&agent_model, move |_, event, _| { + if matches!( + event, + AgentConversationsModelEvent::ConversationUpdated { .. } + ) { + saw_conversation_updated.store(true, Ordering::SeqCst); + } + }); + + let link = AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation( + conversation_id, + )), + ctx, + ); + assert_eq!(link, None); + }); + + let token = "assigned-token-after-entry-build"; + BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, _| { + model + .set_server_conversation_token_for_conversation(conversation_id, token.to_string()); + }); + agent_model.update(&mut app, |model, ctx| { + model.handle_history_event( + &BlocklistAIHistoryEvent::ConversationServerTokenAssigned { + conversation_id, + terminal_view_id, + }, + ctx, + ); + }); + + app.update(|ctx| { + assert!(saw_conversation_updated.load(Ordering::SeqCst)); + + let link = AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation( + conversation_id, + )), + ctx, + ); + assert_eq!( + link, + Some(ServerConversationToken::new(token.to_string()).conversation_link()) + ); + }); + }); +} + #[test] fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_token() { App::test((), |mut app| async move { @@ -1408,18 +1592,14 @@ fn test_eviction_noop_when_under_cap() { #[test] fn test_environment_none_filter_includes_conversations() { App::test((), |mut app| async move { - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + add_entry_projection_test_models(&mut app); let now = Utc::now(); - let mut model = create_test_model(); - // Task with no environment. let task_no_env = create_test_task(&make_uuid(1), "user-a", now); model.tasks.insert(task_no_env.task_id, task_no_env.clone()); - // Task with an environment (should be excluded when filtering for None). let mut task_with_env = create_test_task(&make_uuid(2), "user-b", now); task_with_env.agent_config_snapshot = Some(AgentConfigSnapshot { environment_id: Some("env_123".to_string()), @@ -1429,27 +1609,10 @@ fn test_environment_none_filter_includes_conversations() { .tasks .insert(task_with_env.task_id, task_with_env.clone()); - // Local conversation (environment_id is always None) should be included. let conversation_id = AIConversationId::new(); model.conversations.insert( conversation_id, - ConversationMetadata { - nav_data: ConversationNavigationData { - id: conversation_id, - title: "Test conversation".to_string(), - initial_query: None, - last_updated: chrono::Local::now(), - terminal_view_id: None, - window_id: None, - pane_view_locator: None, - initial_working_directory: None, - latest_working_directory: None, - is_selected: false, - is_in_active_pane: false, - is_closed: false, - server_conversation_token: None, - }, - }, + create_test_conversation_metadata(conversation_id, "Test conversation"), ); let filters = AgentManagementFilters { @@ -1459,35 +1622,20 @@ fn test_environment_none_filter_includes_conversations() { }; app.update(|ctx| { - let mut saw_conversation = false; - let mut saw_task_no_env = false; - let mut saw_task_with_env = false; - - for item in model.get_tasks_and_conversations(&filters, ctx) { - match item { - ConversationOrTask::Conversation(_) => saw_conversation = true, - ConversationOrTask::Task(task) if task.task_id == task_no_env.task_id => { - saw_task_no_env = true - } - ConversationOrTask::Task(task) if task.task_id == task_with_env.task_id => { - saw_task_with_env = true - } - ConversationOrTask::Task(_) => {} - } - } + let entries = model.get_entries(&filters, ctx); + assert!(entries + .iter() + .any(|entry| entry.id == AgentConversationEntryId::Conversation(conversation_id))); assert!( - saw_conversation, - "expected Environment=None filter to include conversations" - ); - assert!( - saw_task_no_env, - "expected Environment=None filter to include tasks without an environment" - ); - assert!( - !saw_task_with_env, - "expected Environment=None filter to exclude tasks with an environment" + entries + .iter() + .any(|entry| entry.id + == AgentConversationEntryId::AmbientRun(task_no_env.task_id)) ); + assert!(!entries.iter().any( + |entry| entry.id == AgentConversationEntryId::AmbientRun(task_with_env.task_id) + )); }); }) } @@ -1534,7 +1682,7 @@ fn test_task_status_maps_blocked_state_to_blocked() { }); app.update(|ctx| { - let status = ConversationOrTask::Task(&task).status(ctx); + let status = AgentRunDisplayStatus::from_task(&task, ctx).to_conversation_status(); match status { ConversationStatus::Blocked { blocked_action } => { assert_eq!(blocked_action, "Needs clarification"); @@ -1546,11 +1694,11 @@ fn test_task_status_maps_blocked_state_to_blocked() { } #[test] -fn test_get_tasks_and_conversations_prefers_task_when_task_id_matches_conversation_run_id() { +fn test_get_entries_prefers_task_when_task_id_matches_conversation_run_id() { App::test((), |mut app| async move { let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + add_entry_projection_test_models(&mut app); + let history_model = BlocklistAIHistoryModel::handle(&app); let now = Utc::now(); let conversation_id = AIConversationId::new(); @@ -1589,26 +1737,26 @@ fn test_get_tasks_and_conversations_prefers_task_when_task_id_matches_conversati ); app.update(|ctx| { - let items: Vec = model - .get_tasks_and_conversations(&all_owner_filters(), ctx) - .map(|item| match item { - ConversationOrTask::Task(task) => format!("task:{}", task.task_id), - ConversationOrTask::Conversation(conversation) => { - format!("conversation:{}", conversation.nav_data.id) - } - }) - .collect(); + let entries = model.get_entries(&all_owner_filters(), ctx); - assert_eq!(items, vec![format!("task:{}", task.task_id)]); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].id, + AgentConversationEntryId::AmbientRun(task.task_id) + ); + assert_eq!( + entries[0].identity.local_conversation_id, + Some(conversation_id) + ); }); }); } #[test] -fn test_get_tasks_and_conversations_prefers_task_when_server_token_matches() { +fn test_get_entries_prefers_task_when_server_token_matches() { App::test((), |mut app| async move { - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + add_entry_projection_test_models(&mut app); + let history_model = BlocklistAIHistoryModel::handle(&app); let now = Utc::now(); let conversation_id = AIConversationId::new(); @@ -1647,53 +1795,28 @@ fn test_get_tasks_and_conversations_prefers_task_when_server_token_matches() { ); app.update(|ctx| { - let items: Vec = model - .get_tasks_and_conversations(&all_owner_filters(), ctx) - .map(|item| match item { - ConversationOrTask::Task(task) => format!("task:{}", task.task_id), - ConversationOrTask::Conversation(conversation) => { - format!("conversation:{}", conversation.nav_data.id) - } - }) - .collect(); + let entries = model.get_entries(&all_owner_filters(), ctx); - assert_eq!(items, vec![format!("task:{}", task.task_id)]); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].id, + AgentConversationEntryId::AmbientRun(task.task_id) + ); + assert_eq!( + entries[0].identity.local_conversation_id, + Some(conversation_id) + ); }); }); } #[test] -fn test_get_tasks_and_conversations_keeps_unrelated_tasks_and_conversations() { +fn test_get_entries_keeps_unrelated_tasks_and_conversations() { App::test((), |mut app| async move { - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + add_entry_projection_test_models(&mut app); let now = Utc::now(); let conversation_id = AIConversationId::new(); - - let conversation = create_restored_conversation( - conversation_id, - "root-task", - AgentConversationData { - server_conversation_token: Some("server-token-123".to_string()), - conversation_usage_metadata: None, - reverted_action_ids: None, - forked_from_server_conversation_token: None, - artifacts_json: None, - parent_agent_id: None, - agent_name: None, - parent_conversation_id: None, - is_remote_child: false, - run_id: None, - autoexecute_override: None, - last_event_sequence: None, - }, - ); - - history_model.update(&mut app, |model, ctx| { - model.restore_conversations(EntityId::new(), vec![conversation], ctx); - }); - let mut model = create_test_model(); let mut task = create_test_task(&make_uuid(3002), "user-a", now); task.conversation_id = Some("different-token".to_string()); @@ -1704,78 +1827,53 @@ fn test_get_tasks_and_conversations_keeps_unrelated_tasks_and_conversations() { ); app.update(|ctx| { - let items: Vec = model - .get_tasks_and_conversations(&all_owner_filters(), ctx) - .map(|item| match item { - ConversationOrTask::Task(task) => format!("task:{}", task.task_id), - ConversationOrTask::Conversation(conversation) => { - format!("conversation:{}", conversation.nav_data.id) - } - }) - .collect(); + let entries = model.get_entries(&all_owner_filters(), ctx); - assert_eq!(items.len(), 2); - assert!(items.contains(&format!("task:{}", task.task_id))); - assert!(items.contains(&format!("conversation:{conversation_id}"))); + assert_eq!(entries.len(), 2); + assert!(entries + .iter() + .any(|entry| entry.id == AgentConversationEntryId::AmbientRun(task.task_id))); + assert!(entries.iter().any(|entry| { + entry.id == AgentConversationEntryId::Conversation(conversation_id) + })); }); }); } -/// Helper: build a task with the given harness on its config snapshot. -/// -/// `harness` semantics: -/// - `None` → leaves `agent_config_snapshot = None` (stub task). -/// - `Some(None)` → `agent_config_snapshot = Some { harness: None }`. -/// - `Some(Some(h))` → `agent_config_snapshot = Some { harness: Some(h) }`. fn task_with_harness( - task_id_index: usize, + index: usize, creator_uid: &str, harness: Option>, ) -> AmbientAgentTask { - let mut task = create_test_task(&make_uuid(task_id_index), creator_uid, Utc::now()); - match harness { - None => task.agent_config_snapshot = None, - Some(None) => { - task.agent_config_snapshot = Some(AgentConfigSnapshot { - harness: None, - ..Default::default() - }); - } - Some(Some(h)) => { - task.agent_config_snapshot = Some(AgentConfigSnapshot { - harness: Some(HarnessConfig::from_harness_type(h)), - ..Default::default() - }); - } - } + let mut task = create_test_task(&make_uuid(index), creator_uid, Utc::now()); + task.agent_config_snapshot = harness.map(|harness| AgentConfigSnapshot { + harness: harness.map(HarnessConfig::from_harness_type), + ..Default::default() + }); task } #[test] fn test_harness_filter_matches_only_selected_harness() { App::test((), |mut app| async move { - app.add_singleton_model(|_| AuthStateProvider::new_for_test()); - app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + add_entry_projection_test_models(&mut app); let mut model = create_test_model(); let task_claude = task_with_harness(5100, "user-a", Some(Some(Harness::Claude))); let task_gemini = task_with_harness(5101, "user-a", Some(Some(Harness::Gemini))); - // Snapshot present but no harness set → Some(Oz), matches Warp Agent. let task_oz_default = task_with_harness(5102, "user-a", Some(None)); - // No snapshot at all → None, matches only `All`. let task_no_snapshot = task_with_harness(5103, "user-a", None); - for t in [ + for task in [ &task_claude, &task_gemini, &task_oz_default, &task_no_snapshot, ] { - model.tasks.insert(t.task_id, t.clone()); + model.tasks.insert(task.task_id, task.clone()); } - // Local conversation: effectively Warp Agent. let conv_id = AIConversationId::new(); model.conversations.insert( conv_id, @@ -1785,7 +1883,7 @@ fn test_harness_filter_matches_only_selected_harness() { app.update(|ctx| { let items_for = |filter: HarnessFilter| -> Vec { model - .get_tasks_and_conversations( + .get_entries( &AgentManagementFilters { owners: OwnerFilter::All, harness: filter, @@ -1793,29 +1891,24 @@ fn test_harness_filter_matches_only_selected_harness() { }, ctx, ) - .map(|item| match item { - ConversationOrTask::Task(t) => format!("task:{}", t.task_id), - ConversationOrTask::Conversation(c) => { - format!("conversation:{}", c.nav_data.id) + .into_iter() + .map(|entry| match entry.id { + AgentConversationEntryId::AmbientRun(task_id) => format!("task:{task_id}"), + AgentConversationEntryId::Conversation(conversation_id) => { + format!("conversation:{conversation_id}") } }) .collect() }; - // All → everything (incl. the unknown-harness stub task). assert_eq!(items_for(HarnessFilter::All).len(), 5); - // Claude → only the claude task. let claude_items = items_for(HarnessFilter::Specific(Harness::Claude)); assert_eq!(claude_items, vec![format!("task:{}", task_claude.task_id)]); - // Gemini → only the gemini task. let gemini_items = items_for(HarnessFilter::Specific(Harness::Gemini)); assert_eq!(gemini_items, vec![format!("task:{}", task_gemini.task_id)]); - // Warp Agent / Oz → default-snapshot task and local conversation. - // The stub task with no snapshot resolves to `harness() == None` and - // is deliberately excluded from any specific-harness filter. let oz_items = items_for(HarnessFilter::Specific(Harness::Oz)); assert_eq!( oz_items.len(), diff --git a/app/src/ai/agent_management/view.rs b/app/src/ai/agent_management/view.rs index b8ae1cd27b..53ad28498e 100644 --- a/app/src/ai/agent_management/view.rs +++ b/app/src/ai/agent_management/view.rs @@ -1285,6 +1285,7 @@ impl AgentManagementView { ) { match kind { ConversationUpdateKind::Restored => {} + ConversationUpdateKind::MetadataChanged => self.get_tasks_from_model(ctx), ConversationUpdateKind::StatusSet { prev_filter, new_filter, diff --git a/app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs b/app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs index b830f3081f..d86ab4f11e 100644 --- a/app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs +++ b/app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs @@ -19,7 +19,10 @@ use crate::{ api::ServerConversationToken, conversation::{AIConversation, AIConversationId}, }, - agent_conversations_model::AgentConversationsModel, + agent_conversations_model::{ + entry::AgentConversationEntryId, AgentConversationNavigationSubject, + AgentConversationsModel, + }, blocklist::BlocklistAIHistoryModel, }, ui_components::{blended_colors, icons::Icon}, @@ -54,19 +57,14 @@ pub(crate) fn parent_conversation_id( pub(crate) fn conversation_navigation_action( conversation_id: AIConversationId, app: &AppContext, -) -> WorkspaceAction { - AgentConversationsModel::as_ref(app) - .get_conversation(&conversation_id) - .and_then(|conversation| { - conversation.get_open_action(Some(RestoreConversationLayout::SplitPane), app) - }) - .unwrap_or(WorkspaceAction::RestoreOrNavigateToConversation { - pane_view_locator: None, - window_id: None, +) -> Option { + AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation( conversation_id, - terminal_view_id: None, - restore_layout: Some(RestoreConversationLayout::SplitPane), - }) + )), + Some(RestoreConversationLayout::SplitPane), + app, + ) } pub(crate) fn parent_conversation_navigation_card( @@ -79,7 +77,7 @@ pub(crate) fn parent_conversation_navigation_card( .conversation(&parent_conversation_id) .and_then(|conversation| conversation.title()) .unwrap_or_else(|| "Parent conversation".to_string()); - let action = conversation_navigation_action(parent_conversation_id, app); + let action = conversation_navigation_action(parent_conversation_id, app)?; Some(conversation_navigation_card( parent_title, Some("Back to parent conversation".to_string()), diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index bd6a10eb7a..753188d654 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -2,7 +2,8 @@ use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{AIAgentHarness, AIConversation, AIConversationId}; use crate::ai::agent_conversations_model::{ - AgentConversationsModel, AgentConversationsModelEvent, ConversationOrTask, + AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, + AgentConversationsModelEvent, }; use crate::ai::ai_document_view::AIDocumentView; use crate::ai::ambient_agents::AmbientAgentTaskId; @@ -1818,9 +1819,14 @@ impl PaneGroup { }); let restore_kind = match &task_data { - Some((_, Some(task))) => { - let item = ConversationOrTask::Task(task); - match item.get_open_action(None, ctx) { + Some((task_id, Some(_))) => { + match AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry( + AgentConversationEntryId::AmbientRun(*task_id), + ), + None, + ctx, + ) { Some(WorkspaceAction::OpenAmbientAgentSession { session_id, .. }) => AmbientRestoreKind::SharedSession { session_id }, @@ -3340,8 +3346,13 @@ impl PaneGroup { continue; }; - let item = ConversationOrTask::Task(&task); - match item.get_open_action(None, ctx) { + match AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task.task_id, + )), + None, + ctx, + ) { Some(WorkspaceAction::OpenAmbientAgentSession { session_id, task_id: _, diff --git a/app/src/ui_components/agent_icon.rs b/app/src/ui_components/agent_icon.rs index d86a5f3ecb..b9ad547e16 100644 --- a/app/src/ui_components/agent_icon.rs +++ b/app/src/ui_components/agent_icon.rs @@ -15,9 +15,8 @@ use warpui::SingletonEntity; use crate::ai::agent::conversation::ConversationStatus; use crate::ai::agent_conversations_model::{ AgentConversationEntry, AgentConversationProvenance, AgentConversationsModel, - ConversationOrTask, + AgentRunDisplayStatus, }; -use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::view::TerminalView; @@ -30,8 +29,8 @@ use crate::ui_components::icon_with_status::IconWithStatusVariant; /// Resolution order: /// 1. A [`CLIAgentSessionsModel`] session with a known agent wins. Plugin-backed sessions /// surface rich status; command-detected sessions don't. -/// 2. A task-backed run defers to [`conversation_or_task_agent_icon_variant`] so the -/// terminal chrome and the matching conversation list card stay in lockstep. +/// 2. A task-backed run uses task status and harness so the terminal chrome and the +/// matching conversation list card stay in lockstep. /// 3. Live ambient pre-dispatch or a selected local conversation falls through to the /// no-task waterfall. /// 4. Everything else returns `None` so the caller renders a plain-terminal indicator. @@ -56,7 +55,14 @@ pub(crate) fn terminal_view_agent_icon_variant( // Defer to the card helper when we have task data and no CLI session takes precedence. if cli_agent_session.is_none() { if let Some(task) = task_data.as_ref() { - return conversation_or_task_agent_icon_variant(&ConversationOrTask::Task(task), app); + let status = AgentRunDisplayStatus::from_task(task, app).to_conversation_status(); + let harness = task + .agent_config_snapshot + .as_ref() + .and_then(|config| config.harness.as_ref()) + .map(|harness| harness.harness_type) + .unwrap_or(Harness::Oz); + return Some(agent_icon_variant_for_run(harness, status, true)); } } @@ -80,24 +86,6 @@ pub(crate) fn terminal_view_agent_icon_variant( agent_icon_variant_from_terminal_inputs(&inputs) } -/// Returns the agent-icon variant for a [`ConversationOrTask`] card row. -/// -/// Both tasks and conversations resolve their harness through [`ConversationOrTask::harness`]. -pub(crate) fn conversation_or_task_agent_icon_variant( - src: &ConversationOrTask<'_>, - app: &AppContext, -) -> Option { - let status = src.status(app); - let harness = src.harness(app).unwrap_or(Harness::Oz); - let is_ambient = match src { - ConversationOrTask::Task(_) => true, - ConversationOrTask::Conversation(metadata) => BlocklistAIHistoryModel::as_ref(app) - .get_server_conversation_metadata(&metadata.nav_data.id) - .is_some_and(|m| m.ambient_agent_task_id.is_some()), - }; - Some(agent_icon_variant_for_run(harness, status, is_ambient)) -} - pub(crate) fn agent_conversation_entry_icon_variant( entry: &AgentConversationEntry, ) -> Option { diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index eb1561c2c7..e477d71dda 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -32,8 +32,9 @@ pub(crate) use onboarding::OnboardingTutorial; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::ai::agent::conversation::AIConversation; -use crate::ai::agent_conversations_model::AgentConversationsModel; -use crate::ai::agent_conversations_model::ConversationOrTask; +use crate::ai::agent_conversations_model::{ + AgentConversationNavigationSubject, AgentConversationsModel, +}; use crate::ai::agent_management::notifications::toast_stack::AgentNotificationToastStack; use crate::ai::agent_management::notifications::view::{ NotificationMailboxView, NotificationMailboxViewEvent, @@ -4019,36 +4020,14 @@ impl Workspace { return; } - // If the conversation is open in a pane this session, grab its nav data so we can - // navigate directly to it. Otherwise we'll restore from scratch into a new tab. - let nav_data = AgentConversationsModel::as_ref(ctx) - .get_conversation(&conversation_id) - .and_then(|entry| match entry { - ConversationOrTask::Conversation(metadata) => Some(&metadata.nav_data), - ConversationOrTask::Task(_) => None, - }); - - if let Some(nav_data) = nav_data { - let is_active = - ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(nav_data.id, ctx); - let pane_view_locator = is_active.then_some(nav_data.pane_view_locator).flatten(); - self.restore_or_navigate_to_conversation( - nav_data.id, - nav_data.window_id, - pane_view_locator, - nav_data.terminal_view_id, - Some(RestoreConversationLayout::NewTab), - ctx, - ); + if let Some(action) = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::ServerToken(server_token.clone()), + Some(RestoreConversationLayout::NewTab), + ctx, + ) { + ctx.dispatch_typed_action_deferred(action); } else { - self.restore_or_navigate_to_conversation( - conversation_id, - None, - None, - None, - Some(RestoreConversationLayout::NewTab), - ctx, - ); + self.load_cloud_conversation_into_new_transcript_viewer(server_token, ctx); } }