diff --git a/app/src/ai/active_agent_views_model.rs b/app/src/ai/active_agent_views_model.rs index a6270db24..3e8858ef5 100644 --- a/app/src/ai/active_agent_views_model.rs +++ b/app/src/ai/active_agent_views_model.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent_conversations_model::AgentConversationEntryId; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::orchestration_event_streamer::{ @@ -47,6 +48,17 @@ pub enum ConversationOrTaskId { TaskId(AmbientAgentTaskId), } +impl From for ConversationOrTaskId { + fn from(id: AgentConversationEntryId) -> Self { + match id { + AgentConversationEntryId::Conversation(conversation_id) => { + ConversationOrTaskId::ConversationId(conversation_id) + } + AgentConversationEntryId::AmbientRun(task_id) => ConversationOrTaskId::TaskId(task_id), + } + } +} + impl ConversationOrTaskId { pub fn conversation_id(&self) -> Option { match self { diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index 36d5b6e37..f2237dcbf 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -1,7 +1,10 @@ #[allow(dead_code)] pub mod entry; -pub use entry::AgentConversationEntry; +pub use entry::{ + AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject, + AgentConversationProvenance, +}; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::api::ServerConversationToken; @@ -250,6 +253,9 @@ enum LinkPreference { #[derive(Clone, Debug, PartialEq, Eq)] pub enum AgentRunDisplayStatus { + /// Raw task-service lifecycle states. `from_task` only returns `TaskInProgress` while the + /// task still has an active execution, or when there is no shadowed local conversation to + /// provide a more granular status. TaskQueued, TaskPending, TaskClaimed, @@ -257,13 +263,19 @@ pub enum AgentRunDisplayStatus { TaskSucceeded, TaskFailed, TaskError, - TaskBlocked { blocked_action: String }, + TaskBlocked { + blocked_action: String, + }, TaskCancelled, TaskUnknown, + /// Conversation-derived lifecycle states, used for interactive conversations and for + /// in-progress ambient tasks after they can be resolved to their shadowed local conversation. ConversationInProgress, ConversationSucceeded, ConversationError, - ConversationBlocked { blocked_action: String }, + ConversationBlocked { + blocked_action: String, + }, ConversationCancelled, } @@ -346,6 +358,32 @@ impl AgentRunDisplayStatus { } } + pub fn to_conversation_status(&self) -> ConversationStatus { + match self { + AgentRunDisplayStatus::TaskQueued + | AgentRunDisplayStatus::TaskPending + | AgentRunDisplayStatus::TaskClaimed + | AgentRunDisplayStatus::TaskInProgress + | AgentRunDisplayStatus::ConversationInProgress => ConversationStatus::InProgress, + AgentRunDisplayStatus::TaskSucceeded | AgentRunDisplayStatus::ConversationSucceeded => { + ConversationStatus::Success + } + AgentRunDisplayStatus::TaskFailed + | AgentRunDisplayStatus::TaskError + | AgentRunDisplayStatus::TaskUnknown + | AgentRunDisplayStatus::ConversationError => ConversationStatus::Error, + AgentRunDisplayStatus::TaskBlocked { blocked_action } + | AgentRunDisplayStatus::ConversationBlocked { blocked_action } => { + ConversationStatus::Blocked { + blocked_action: blocked_action.clone(), + } + } + AgentRunDisplayStatus::TaskCancelled | AgentRunDisplayStatus::ConversationCancelled => { + ConversationStatus::Cancelled + } + } + } + pub fn is_cancellable(&self) -> bool { self.is_working() } @@ -557,13 +595,9 @@ impl ConversationOrTask<'_> { /// 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(|s| { - let session_id = s.parse::(); - if let Err(ref e) = session_id { - log::warn!("Failed to parse shared session ID: {e}"); - } - session_id.ok() - }), + ConversationOrTask::Task(task) => { + task.session_id.as_deref().and_then(entry::parse_session_id) + } ConversationOrTask::Conversation(_) => None, } } @@ -1487,6 +1521,184 @@ impl AgentConversationsModel { .collect() } + pub fn get_entry_by_id( + &self, + id: &AgentConversationEntryId, + app: &AppContext, + ) -> Option { + let history_model = BlocklistAIHistoryModel::as_ref(app); + match id { + AgentConversationEntryId::AmbientRun(task_id) => self + .tasks + .get(task_id) + .map(|task| entry::entry_for_task(task, history_model, app)), + AgentConversationEntryId::Conversation(conversation_id) => self + .conversations + .get(conversation_id) + .map(|metadata| entry::entry_for_conversation(metadata, history_model, app)) + .or_else(|| { + history_model + .get_conversation_metadata(conversation_id) + .map(|metadata| { + let nav_data = + ConversationNavigationData::from_historical_conversation_metadata( + metadata, + ); + entry::entry_for_historical_metadata( + metadata, + nav_data, + history_model, + app, + ) + }) + }), + } + } + + pub fn resolve_open_action( + subject: AgentConversationNavigationSubject, + restore_layout: Option, + app: &AppContext, + ) -> Option { + let model = Self::as_ref(app); + match subject { + AgentConversationNavigationSubject::Entry(id) => model + .get_entry_by_id(&id, app) + .and_then(|entry| model.resolve_entry_open_action(&entry, restore_layout, app)), + AgentConversationNavigationSubject::ServerToken(server_token) => model + .entry_for_server_token(&server_token, app) + .and_then(|entry| model.resolve_entry_open_action(&entry, restore_layout, app)) + .or_else(|| { + Some(WorkspaceAction::OpenConversationTranscriptViewer { + ambient_agent_task_id: model.task_id_for_server_token(&server_token), + conversation_id: server_token, + }) + }), + } + } + + fn resolve_entry_open_action( + &self, + entry: &AgentConversationEntry, + restore_layout: Option, + app: &AppContext, + ) -> Option { + let active_views_model = ActiveAgentViewsModel::as_ref(app); + + if let Some(task_id) = entry.identity.ambient_agent_task_id { + if let Some(terminal_view_id) = + active_views_model.get_terminal_view_id_for_ambient_task(task_id) + { + return Some(WorkspaceAction::FocusTerminalViewInWorkspace { terminal_view_id }); + } + } + + if let Some(conversation_id) = entry.identity.local_conversation_id { + if active_views_model.is_conversation_open(conversation_id, app) { + if let Some(nav_data) = self + .conversations + .get(&conversation_id) + .map(|metadata| &metadata.nav_data) + { + return Some(WorkspaceAction::RestoreOrNavigateToConversation { + conversation_id, + window_id: nav_data.window_id, + pane_view_locator: nav_data.pane_view_locator, + terminal_view_id: nav_data.terminal_view_id, + restore_layout, + }); + } + + if let Some(terminal_view_id) = + active_views_model.get_terminal_view_id_for_conversation(conversation_id, app) + { + return Some(WorkspaceAction::FocusTerminalViewInWorkspace { + terminal_view_id, + }); + } + } + } + + if let Some(task_id) = entry.identity.ambient_agent_task_id { + if let Some(session_id) = self + .tasks + .get(&task_id) + .and_then(AmbientAgentTask::active_execution_session_id) + .and_then(entry::parse_session_id) + { + return Some(WorkspaceAction::OpenAmbientAgentSession { + session_id, + task_id, + }); + } + } + + if let Some(conversation_id) = entry.identity.local_conversation_id { + let nav_data = self + .conversations + .get(&conversation_id) + .map(|metadata| &metadata.nav_data); + if entry.backing.has_loaded_conversation + || entry.backing.has_local_persisted_data + || nav_data.is_some() + { + return Some(WorkspaceAction::RestoreOrNavigateToConversation { + conversation_id, + window_id: nav_data.and_then(|nav_data| nav_data.window_id), + pane_view_locator: None, + terminal_view_id: nav_data.and_then(|nav_data| nav_data.terminal_view_id), + restore_layout, + }); + } + } + + entry + .identity + .server_conversation_token + .as_ref() + .map(|token| WorkspaceAction::OpenConversationTranscriptViewer { + conversation_id: token.clone(), + ambient_agent_task_id: entry.identity.ambient_agent_task_id, + }) + } + + fn entry_for_server_token( + &self, + server_token: &ServerConversationToken, + app: &AppContext, + ) -> Option { + let history_model = BlocklistAIHistoryModel::as_ref(app); + if let Some(task) = self.tasks.values().find(|task| { + task.conversation_id() + .is_some_and(|conversation_id| conversation_id == server_token.as_str()) + }) { + return Some(entry::entry_for_task(task, history_model, app)); + } + + let conversation_id = history_model.find_conversation_id_by_server_token(server_token)?; + if let Some(task) = self.tasks.values().find(|task| { + entry::conversation_id_shadowed_by_task(task, history_model) == Some(conversation_id) + }) { + return Some(entry::entry_for_task(task, history_model, app)); + } + + self.get_entry_by_id( + &AgentConversationEntryId::Conversation(conversation_id), + app, + ) + } + + fn task_id_for_server_token( + &self, + server_token: &ServerConversationToken, + ) -> Option { + self.tasks.values().find_map(|task| { + task.conversation_id() + .is_some_and(|conversation_id| conversation_id == server_token.as_str()) + .then_some(task.task_id) + }) + } + fn handle_history_event( &mut self, event: &BlocklistAIHistoryEvent, diff --git a/app/src/ai/agent_conversations_model/entry.rs b/app/src/ai/agent_conversations_model/entry.rs index e18bb6e1c..7b5a92e59 100644 --- a/app/src/ai/agent_conversations_model/entry.rs +++ b/app/src/ai/agent_conversations_model/entry.rs @@ -1,3 +1,4 @@ +use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId}; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::AIConversationId; use crate::ai::ambient_agents::{AgentSource, AmbientAgentTask, AmbientAgentTaskId}; @@ -20,12 +21,30 @@ use super::{ /// /// Task-backed rows use the ambient run ID even when they are attached to a local /// conversation, so task-specific affordances do not disappear when local data is present. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum AgentConversationEntryId { AmbientRun(AmbientAgentTaskId), Conversation(AIConversationId), } +impl From for AgentConversationEntryId { + fn from(id: ConversationOrTaskId) -> Self { + match id { + ConversationOrTaskId::ConversationId(conversation_id) => { + AgentConversationEntryId::Conversation(conversation_id) + } + ConversationOrTaskId::TaskId(task_id) => AgentConversationEntryId::AmbientRun(task_id), + } + } +} + +/// Navigation request input for resolving an entry or server-token handle at action time. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentConversationNavigationSubject { + Entry(AgentConversationEntryId), + ServerToken(ServerConversationToken), +} + /// Normalized row data for agent conversation list, management, and navigation surfaces. /// /// The entry keeps local conversation identity, ambient run identity, cloud token identity, @@ -241,6 +260,17 @@ pub(super) fn entry_for_task( }) }); let status = item.display_status(app); + let has_active_session_id = task + .active_execution_session_id() + .and_then(parse_session_id) + .is_some(); + let has_open_ambient_session = ActiveAgentViewsModel::as_ref(app) + .get_terminal_view_id_for_ambient_task(task.task_id) + .is_some(); + let can_open = has_open_ambient_session + || has_active_session_id + || local_conversation_id.is_some() + || server_conversation_token.is_some(); AgentConversationEntry { id: AgentConversationEntryId::AmbientRun(task.task_id), @@ -279,7 +309,7 @@ pub(super) fn entry_for_task( has_ambient_run: true, }, capabilities: AgentConversationCapabilities { - can_open: item.get_open_action(None, app).is_some(), + can_open, can_copy_link: item.session_or_conversation_link(app).is_some(), can_share: task.conversation_id().is_some() || local_conversation_id @@ -389,7 +419,9 @@ fn entry_for_conversation_parts( .is_some_and(AIConversationMetadata::is_ambient_agent_conversation), }, capabilities: AgentConversationCapabilities { - can_open: item.get_open_action(None, app).is_some(), + can_open: has_local_persisted_data + || has_cloud_data + || item.get_open_action(None, app).is_some(), can_copy_link: item.session_or_conversation_link(app).is_some(), can_share: history_model.can_conversation_be_shared(&conversation_id), can_delete: has_local_persisted_data, @@ -415,3 +447,13 @@ fn server_conversation_token_for_conversation( }) .or_else(|| nav_data.and_then(|nav_data| nav_data.server_conversation_token.clone())) } + +pub(super) fn parse_session_id(session_id: &str) -> Option { + match session_id.parse::() { + Ok(session_id) => Some(session_id), + Err(e) => { + log::warn!("Failed to parse shared session ID: {e}"); + None + } + } +} diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 45d66e201..2369b5741 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -26,7 +26,9 @@ use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions}; use crate::server::ids::ServerId; use crate::test_util::ai_agent_tasks::{create_api_task, create_message}; -use super::entry::{AgentConversationEntryId, AgentConversationProvenance}; +use super::entry::{ + AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationProvenance, +}; use super::{ AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationOrTask, @@ -34,6 +36,7 @@ use super::{ TaskFetchState, MAX_PERSONAL_TASKS, MAX_TEAM_TASKS, }; use crate::ai::ambient_agents::task::HarnessConfig; +use crate::workspace::WorkspaceAction; use warp_cli::agent::Harness; /// Creates a test task with specified creator UID and updated_at time @@ -906,6 +909,177 @@ fn test_get_entries_keeps_unrelated_task_and_conversation_entries() { }); } +#[test] +fn test_resolve_open_action_prefers_active_ambient_terminal() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let now = Utc::now(); + let task = create_test_task(&make_uuid(8200), "user-a", now); + let task_id = task.task_id; + let terminal_view_id = EntityId::new(); + + app.add_singleton_model(|_| { + let mut model = create_test_model(); + model.tasks.insert(task_id, task); + model + }); + ActiveAgentViewsModel::handle(&app).update(&mut app, |model, ctx| { + model.register_ambient_session(terminal_view_id, task_id, ctx); + }); + + app.update(|ctx| { + let action = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task_id, + )), + None, + ctx, + ); + + assert!(matches!( + action, + Some(WorkspaceAction::FocusTerminalViewInWorkspace { terminal_view_id: id }) + if id == terminal_view_id + )); + }); + }); +} + +#[test] +fn test_resolve_open_action_opens_active_ambient_session() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let now = Utc::now(); + let session_id = make_uuid(8201); + let mut task = create_test_task(&make_uuid(8202), "user-a", now); + task.state = AmbientAgentTaskState::InProgress; + task.session_id = Some(session_id.clone()); + task.session_link = Some("https://example.com/session".to_string()); + task.is_sandbox_running = true; + 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::OpenAmbientAgentSession { + session_id: resolved_session_id, + task_id: resolved_task_id, + }) if resolved_session_id.to_string() == session_id && resolved_task_id == task_id + )); + }); + }); +} + +#[test] +fn test_resolve_open_action_falls_back_to_local_conversation_for_invalid_session() { + App::test((), |mut app| async move { + let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); + add_entry_projection_test_models(&mut app); + + let now = Utc::now(); + let conversation_id = AIConversationId::new(); + let task_id = make_uuid(8203); + 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: Some(task_id.clone()), + autoexecute_override: None, + last_event_sequence: None, + }, + ); + + BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { + model.restore_conversations(EntityId::new(), vec![conversation], ctx); + }); + + let mut task = create_test_task(&task_id, "user-a", now); + task.state = AmbientAgentTaskState::InProgress; + task.session_id = Some("not-a-session-id".to_string()); + task.session_link = Some("https://example.com/session".to_string()); + task.is_sandbox_running = true; + let task_id = task.task_id; + + app.add_singleton_model(|_| { + let mut model = create_test_model(); + model.tasks.insert(task_id, task); + model.conversations.insert( + conversation_id, + create_test_conversation_metadata(conversation_id, "Conversation"), + ); + model + }); + + app.update(|ctx| { + let action = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task_id, + )), + None, + ctx, + ); + + assert!(matches!( + action, + Some(WorkspaceAction::RestoreOrNavigateToConversation { + conversation_id: resolved_conversation_id, + .. + }) if resolved_conversation_id == conversation_id + )); + }); + }); +} + +#[test] +fn test_resolve_open_action_handles_server_token_subject_without_entry() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + app.add_singleton_model(|_| create_test_model()); + + let server_token = ServerConversationToken::new("server-token-subject".to_string()); + app.update(|ctx| { + let action = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::ServerToken(server_token.clone()), + None, + ctx, + ); + + assert!(matches!( + action, + Some(WorkspaceAction::OpenConversationTranscriptViewer { + conversation_id, + ambient_agent_task_id: None, + }) if conversation_id == server_token + )); + }); + }); +} + #[test] fn test_eviction_protects_personal_from_team_overflow() { // Add 50 old personal tasks + 600 new team tasks diff --git a/app/src/ui_components/agent_icon.rs b/app/src/ui_components/agent_icon.rs index 5703ab8f5..d86a5f3ec 100644 --- a/app/src/ui_components/agent_icon.rs +++ b/app/src/ui_components/agent_icon.rs @@ -13,7 +13,10 @@ use warpui::AppContext; use warpui::SingletonEntity; use crate::ai::agent::conversation::ConversationStatus; -use crate::ai::agent_conversations_model::{AgentConversationsModel, ConversationOrTask}; +use crate::ai::agent_conversations_model::{ + AgentConversationEntry, AgentConversationProvenance, AgentConversationsModel, + ConversationOrTask, +}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; @@ -95,6 +98,20 @@ pub(crate) fn conversation_or_task_agent_icon_variant( Some(agent_icon_variant_for_run(harness, status, is_ambient)) } +pub(crate) fn agent_conversation_entry_icon_variant( + entry: &AgentConversationEntry, +) -> Option { + let status = entry.display.status.to_conversation_status(); + let is_ambient = matches!(entry.provenance, AgentConversationProvenance::AmbientRun) + || entry.backing.has_ambient_run + || entry.identity.ambient_agent_task_id.is_some(); + Some(agent_icon_variant_for_run( + entry.display.harness.unwrap_or(Harness::Oz), + status, + is_ambient, + )) +} + /// Primitive inputs to the terminal-view waterfall, gathered once from the live /// [`TerminalView`] / [`AppContext`]. struct TerminalIconInputs { diff --git a/app/src/ui_components/agent_icon_tests.rs b/app/src/ui_components/agent_icon_tests.rs index 263b7e443..6a8483f32 100644 --- a/app/src/ui_components/agent_icon_tests.rs +++ b/app/src/ui_components/agent_icon_tests.rs @@ -10,13 +10,22 @@ //! //! Adding a new canonical state is a one-enum-variant + one `expected` arm + one `*_inputs` //! arm change; the table test below enforces every surface agrees. +use chrono::Utc; use warp_cli::agent::Harness; use super::{ - agent_icon_variant_for_run, agent_icon_variant_from_terminal_inputs, CLISessionInputs, - TerminalIconInputs, + agent_conversation_entry_icon_variant, agent_icon_variant_for_run, + agent_icon_variant_from_terminal_inputs, CLISessionInputs, TerminalIconInputs, +}; +use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; +use crate::ai::agent_conversations_model::entry::{ + AgentConversationBackingData, AgentConversationCapabilities, AgentConversationCreator, + AgentConversationDisplayData, AgentConversationIdentity, +}; +use crate::ai::agent_conversations_model::{ + AgentConversationEntry, AgentConversationEntryId, AgentConversationProvenance, + AgentRunDisplayStatus, }; -use crate::ai::agent::conversation::ConversationStatus; use crate::terminal::CLIAgent; use crate::ui_components::icon_with_status::IconWithStatusVariant; @@ -372,3 +381,58 @@ fn local_claude_vs_cloud_claude_differ_only_by_is_ambient() { assert!(!local.is_ambient); assert!(cloud.is_ambient); } + +#[test] +fn non_ambient_entry_uses_display_harness() { + let conversation_id = AIConversationId::new(); + let entry = AgentConversationEntry { + id: AgentConversationEntryId::Conversation(conversation_id), + identity: AgentConversationIdentity { + local_conversation_id: Some(conversation_id), + ambient_agent_task_id: None, + server_conversation_token: None, + session_id: None, + }, + provenance: AgentConversationProvenance::CloudSyncedConversation, + display: AgentConversationDisplayData { + title: "Codex conversation".to_string(), + initial_query: None, + created_at: Utc::now(), + last_updated: Utc::now(), + status: AgentRunDisplayStatus::ConversationSucceeded, + creator: AgentConversationCreator::default(), + request_usage: None, + run_time: None, + source: None, + working_directory: None, + environment_id: None, + harness: Some(Harness::Codex), + artifacts: Vec::new(), + }, + backing: AgentConversationBackingData { + has_loaded_conversation: true, + has_local_persisted_data: true, + has_cloud_data: true, + has_ambient_run: false, + }, + capabilities: AgentConversationCapabilities { + can_open: true, + can_copy_link: false, + can_share: false, + can_delete: false, + can_fork_locally: false, + can_cancel: false, + }, + }; + + let variant = agent_conversation_entry_icon_variant(&entry).unwrap(); + assert_eq!( + AgentIconFields::from_variant(&variant).unwrap(), + AgentIconFields { + is_cli: true, + cli_agent: Some(CLIAgent::Codex), + status: Some(ConversationStatus::Success), + is_ambient: false, + } + ); +} diff --git a/app/src/workspace/view/conversation_list/item.rs b/app/src/workspace/view/conversation_list/item.rs index 1cd8241f0..30e4a08ef 100644 --- a/app/src/workspace/view/conversation_list/item.rs +++ b/app/src/workspace/view/conversation_list/item.rs @@ -1,11 +1,12 @@ use crate::ai::active_agent_views_model::ActiveAgentViewsModel; -use crate::ai::active_agent_views_model::ConversationOrTaskId; -use crate::ai::agent_conversations_model::ConversationOrTask; +use crate::ai::agent_conversations_model::{ + AgentConversationEntry, AgentConversationEntryId, AgentConversationProvenance, +}; use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING}; use crate::appearance::Appearance; use crate::drive::sharing::dialog::SharingDialog; use crate::menu::Menu; -use crate::ui_components::agent_icon::conversation_or_task_agent_icon_variant; +use crate::ui_components::agent_icon::agent_conversation_entry_icon_variant; use crate::ui_components::icon_with_status::render_icon_with_status; use crate::ui_components::icons::Icon; use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection}; @@ -48,12 +49,14 @@ const LIST_ITEM_AGENT_SIZE: f32 = 22.; const LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05; /// Generate a position ID for a conversation list item -fn conversation_item_position_id(id: &ConversationOrTaskId) -> String { +fn conversation_item_position_id(id: &AgentConversationEntryId) -> String { match id { - ConversationOrTaskId::ConversationId(conv_id) => { + AgentConversationEntryId::Conversation(conv_id) => { format!("conversation_list_item_{conv_id}") } - ConversationOrTaskId::TaskId(task_id) => format!("conversation_list_task_{task_id}"), + AgentConversationEntryId::AmbientRun(task_id) => { + format!("conversation_list_task_{task_id}") + } } } @@ -77,7 +80,7 @@ pub enum OverflowMenuDisplay { } pub struct ItemProps<'a> { - pub conversation: &'a ConversationOrTask<'a>, + pub conversation: &'a AgentConversationEntry, pub highlight_indices: Option<&'a Vec>, pub is_selected: bool, pub is_focused_conversation: bool, @@ -85,7 +88,7 @@ pub struct ItemProps<'a> { pub state: &'a ItemState, pub overflow_menu: &'a ViewHandle>, pub overflow_menu_display: OverflowMenuDisplay, - pub conversation_id: ConversationOrTaskId, + pub conversation_id: AgentConversationEntryId, pub sharing_dialog: &'a ViewHandle, pub is_share_dialog_open: bool, pub list_position_id: &'a str, @@ -187,8 +190,12 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { let font_size = appearance.ui_font_size(); let title_font_size = font_size + 2.; - let mut title_text = Text::new_inline(conversation.title(app), font_family, title_font_size) - .with_color(theme.main_text_color(theme.background()).into()); + let mut title_text = Text::new_inline( + conversation.display.title.clone(), + font_family, + title_font_size, + ) + .with_color(theme.main_text_color(theme.background()).into()); if let Some(indices) = highlight_indices { if !indices.is_empty() { @@ -210,17 +217,20 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { // ambient runs) so the row matches the vertical tab / pane header. Fall back to the // plain status-only icon when the helper can't produce an agent variant (never today, // but keeps the surface future-proof). - let icon_element: Box = - match conversation_or_task_agent_icon_variant(conversation, app) { - Some(variant) => render_icon_with_status( - variant, - LIST_ITEM_AGENT_SIZE, - LIST_ITEM_OVERLAY_EXTRA_OVERHANG, - theme, - theme.background(), - ), - None => render_status_element(&conversation.status(app), font_size, appearance), - }; + let icon_element: Box = match agent_conversation_entry_icon_variant(conversation) { + Some(variant) => render_icon_with_status( + variant, + LIST_ITEM_AGENT_SIZE, + LIST_ITEM_OVERLAY_EXTRA_OVERHANG, + theme, + theme.background(), + ), + None => render_status_element( + &conversation.display.status.to_conversation_status(), + font_size, + appearance, + ), + }; let icon_and_title_row = Shrinkable::new( 1.0, @@ -234,7 +244,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { .finish(); let timestamp = Text::new_inline( - format_approx_duration_from_now_utc(conversation.last_updated()), + format_approx_duration_from_now_utc(conversation.display.last_updated), font_family, font_size - 2., ) @@ -280,10 +290,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { .with_child(bottom_row) .finish(); - // Use shared logic from ConversationOrTask to determine open action - let open_action = conversation.get_open_action(None, app); - let title = conversation.title(app); - let tooltip_text = truncate_from_end(&title, MAX_TOOLTIP_LENGTH); + let can_open = conversation.capabilities.can_open; + let tooltip_text = truncate_from_end(&conversation.display.title, MAX_TOOLTIP_LENGTH); let overflow_button_state = state.overflow_button_state.clone(); let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| { let container = Container::new(row) @@ -374,7 +382,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { }) .with_defer_events_to_children(); - let hoverable_element = if open_action.is_some() { + let hoverable_element = if can_open { hoverable .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { @@ -432,23 +440,30 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { /// Returns the secondary label for a conversation list item: /// - For local conversations: the working directory. /// - For tasks: the source (Linear, Slack, CLI, etc.) -fn format_item_subtext(conversation: &ConversationOrTask, app: &AppContext) -> Option { - match conversation { - ConversationOrTask::Task(task) => { - task.source.as_ref().map(|s| s.display_name().to_string()) - } - ConversationOrTask::Conversation(metadata) => { - // If this conversation is active (with an expanded agent view), - // we use the terminal session's live working directory. - let live_pwd = ActiveAgentViewsModel::as_ref(app) - .get_active_session_for_conversation(metadata.nav_data.id, app) - .and_then(|session| session.as_ref(app).current_working_directory().cloned()); - - let pwd = live_pwd.or_else(|| metadata.nav_data.initial_working_directory.clone()); - pwd.map(|pwd| { - let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from)); - user_friendly_path(&pwd, home_dir.as_deref()).into_owned() - }) - } +fn format_item_subtext(conversation: &AgentConversationEntry, app: &AppContext) -> Option { + if matches!( + conversation.provenance, + AgentConversationProvenance::AmbientRun + ) { + return conversation + .display + .source + .as_ref() + .map(|source| source.display_name().to_string()); } + + let live_pwd = conversation + .identity + .local_conversation_id + .and_then(|conversation_id| { + ActiveAgentViewsModel::as_ref(app) + .get_active_session_for_conversation(conversation_id, app) + .and_then(|session| session.as_ref(app).current_working_directory().cloned()) + }); + + let pwd = live_pwd.or_else(|| conversation.display.working_directory.clone()); + pwd.map(|pwd| { + let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from)); + user_friendly_path(&pwd, home_dir.as_deref()).into_owned() + }) } diff --git a/app/src/workspace/view/conversation_list/view.rs b/app/src/workspace/view/conversation_list/view.rs index df0177da4..2db50033f 100644 --- a/app/src/workspace/view/conversation_list/view.rs +++ b/app/src/workspace/view/conversation_list/view.rs @@ -4,9 +4,10 @@ use std::ops::Range; use std::sync::{Arc, Mutex}; use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId}; -use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::AIConversationId; -use crate::ai::agent_conversations_model::{AgentConversationsModel, ConversationOrTask}; +use crate::ai::agent_conversations_model::{ + AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, +}; use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom}; use crate::ai::blocklist::history_model::BlocklistAIHistoryModel; use crate::appearance::Appearance; @@ -60,7 +61,7 @@ const INITIAL_MAX_PAST_ITEMS: usize = 10; struct StateHandles { list_state: UniformListState, scroll_state: ScrollStateHandle, - item_states: HashMap, + item_states: HashMap, start_new_conversation_item: ItemState, list_hover: MouseStateHandle, zero_state_button: MouseStateHandle, @@ -101,7 +102,7 @@ enum ListItem { #[derive(Clone, Copy)] struct OverflowMenuState { - conversation_id: ConversationOrTaskId, + conversation_id: AgentConversationEntryId, /// When `Some`, the menu was opened via right-click and should be /// positioned at the cursor location rather than the kebab button. position: Option, @@ -114,19 +115,19 @@ pub enum ConversationListViewAction { terminal_view_id: Option, }, ToggleOverflowMenu { - conversation_id: ConversationOrTaskId, + conversation_id: AgentConversationEntryId, /// When `Some`, the menu was opened via right-click and should be /// positioned where the right click took place. position: Option, }, OpenShareDialog { - conversation_id: ConversationOrTaskId, + conversation_id: AgentConversationEntryId, }, DeleteFromOverflowMenu { - conversation_id: ConversationOrTaskId, + conversation_id: AgentConversationEntryId, }, OpenItem { - id: ConversationOrTaskId, + id: AgentConversationEntryId, }, ArrowUp, ArrowDown, @@ -137,7 +138,7 @@ pub enum ConversationListViewAction { ToggleSection(ConversationSection), ToggleViewAll, ForkConversation { - conversation_id: ConversationOrTaskId, + conversation_id: AgentConversationEntryId, destination: ForkedConversationDestination, }, } @@ -163,7 +164,7 @@ pub struct ConversationListView { /// Sharing dialog for conversations. sharing_dialog: ViewHandle, /// Track which conversation the share dialog is open for. - share_dialog_open_for: Option, + share_dialog_open_for: Option, selected_index: Option, collapsed_sections: HashSet, /// Cached flat list of items (headers + conversations) for rendering and navigation. @@ -291,11 +292,15 @@ impl ConversationListView { /// Rebuilds the flat list of items based on sections and collapse state. fn rebuild_list_items(&mut self, ctx: &mut ViewContext) { let active_views_model = ActiveAgentViewsModel::as_ref(ctx); - let active_ids = if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() { - active_views_model.get_all_active_conversation_ids(ctx) - } else { - active_views_model.get_all_open_conversation_ids(ctx) - }; + let active_ids: HashSet<_> = + if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() { + active_views_model.get_all_active_conversation_ids(ctx) + } else { + active_views_model.get_all_open_conversation_ids(ctx) + } + .into_iter() + .map(AgentConversationEntryId::from) + .collect(); let focused_new_conversation = active_views_model.maybe_get_focused_new_conversation(ctx.window_id(), ctx); @@ -306,7 +311,13 @@ impl ConversationListView { let mut past_items = Vec::new(); for entry in model.filtered_items() { let list_item = ListItem::Conversation(entry.clone()); - if active_ids.contains(&entry.id) { + let local_conversation_entry_id = model + .get_item_by_id(&entry.id, ctx) + .and_then(|entry| entry.identity.local_conversation_id) + .map(AgentConversationEntryId::Conversation); + let is_active = active_ids.contains(&entry.id) + || local_conversation_entry_id.is_some_and(|id| active_ids.contains(&id)); + if is_active { active_items.push(list_item); } else { past_items.push(list_item); @@ -316,7 +327,7 @@ impl ConversationListView { // If the focused conversation is a new/empty conversation that's not already in the list, // add it as a regular conversation entry so it participates in the sort. if let Some(new_conv_id) = focused_new_conversation { - let conv_id = ConversationOrTaskId::ConversationId(new_conv_id); + let conv_id = AgentConversationEntryId::Conversation(new_conv_id); let already_in_list = active_items .iter() .any(|item| matches!(item, ListItem::Conversation(entry) if entry.id == conv_id)); @@ -331,7 +342,18 @@ impl ConversationListView { // Sort active items by last opened time (most recently opened first). active_items.sort_by(|a, b| { let get_time = |item: &ListItem| match item { - ListItem::Conversation(entry) => active_views_model.get_last_opened_time(&entry.id), + ListItem::Conversation(entry) => { + let entry_time = active_views_model + .get_last_opened_time(&ConversationOrTaskId::from(entry.id)); + let local_time = model + .get_item_by_id(&entry.id, ctx) + .and_then(|item| item.identity.local_conversation_id) + .and_then(|id| { + active_views_model + .get_last_opened_time(&ConversationOrTaskId::ConversationId(id)) + }); + entry_time.max(local_time) + } _ => None, }; get_time(b).cmp(&get_time(a)) @@ -389,8 +411,10 @@ impl ConversationListView { self.list_items.get(index) } - /// Finds the flat index of a conversation or task by ID, or None if not found. - fn get_index_of_conversation_id(&self, conversation_id: ConversationOrTaskId) -> Option { + fn get_index_of_conversation_id( + &self, + conversation_id: AgentConversationEntryId, + ) -> Option { self.list_items.iter().position(|item| match item { ListItem::Conversation(entry) => entry.id == conversation_id, ListItem::SectionHeader(_) @@ -406,8 +430,9 @@ impl ConversationListView { // Select the focused conversation if there is one. let focused_conversation = ActiveAgentViewsModel::as_ref(ctx).get_focused_conversation(ctx.window_id()); - self.selected_index = - focused_conversation.and_then(|id| self.get_index_of_conversation_id(id)); + self.selected_index = focused_conversation + .map(AgentConversationEntryId::from) + .and_then(|id| self.get_index_of_conversation_id(id)); if let Some(index) = self.selected_index { self.state_handles.list_state.scroll_to(index); @@ -531,10 +556,9 @@ impl ConversationListView { self.focus_query_editor(ctx); } - /// Send telemetry for opening a conversation or task - fn send_open_telemetry(id: &ConversationOrTaskId, ctx: &mut ViewContext) { + fn send_open_telemetry(id: &AgentConversationEntryId, ctx: &mut ViewContext) { match id { - ConversationOrTaskId::ConversationId(conversation_id) => { + AgentConversationEntryId::Conversation(conversation_id) => { send_telemetry_from_ctx!( AgentManagementTelemetryEvent::ConversationOpened { conversation_id: conversation_id.to_string(), @@ -543,7 +567,7 @@ impl ConversationListView { ctx ); } - ConversationOrTaskId::TaskId(task_id) => { + AgentConversationEntryId::AmbientRun(task_id) => { send_telemetry_from_ctx!( AgentManagementTelemetryEvent::CloudRunOpened { task_id: task_id.to_string(), @@ -570,13 +594,11 @@ impl ConversationListView { ctx.emit(Event::NewConversationInNewTab); } ListItem::Conversation(entry) => { - let model = self.view_model.as_ref(ctx); - let Some(item) = model.get_item_by_id(&entry.id, ctx) else { - return; - }; - - // Use shared logic from ConversationOrTask to determine click action - if let Some(action) = item.get_open_action(None, ctx) { + if let Some(action) = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(entry.id), + None, + ctx, + ) { Self::send_open_telemetry(&entry.id, ctx); ctx.dispatch_typed_action(&action); } @@ -867,12 +889,12 @@ impl TypedActionView for ConversationListView { return; } - let id = ConversationOrTaskId::ConversationId(*conversation_id); + let id = AgentConversationEntryId::Conversation(*conversation_id); let conversation_title = self .view_model .as_ref(ctx) .get_item_by_id(&id, ctx) - .map(|c| c.title(ctx).to_string()) + .map(|entry| entry.display.title) .unwrap_or_else(|| "Conversation".to_string()); ctx.emit(Event::ShowDeleteConfirmationDialog { conversation_id: *conversation_id, @@ -896,41 +918,27 @@ impl TypedActionView for ConversationListView { }); let conversation_id = *conversation_id; - let is_ambient_agent_conversation = - matches!(conversation_id, ConversationOrTaskId::TaskId(_)); + let Some(entry) = self + .view_model + .as_ref(ctx) + .get_item_by_id(&conversation_id, ctx) + else { + return; + }; let mut delete_item = MenuItemFields::new("Delete") .with_override_text_color(Appearance::as_ref(ctx).theme().ansi_fg_red()) .with_on_select_action(ConversationListViewAction::DeleteFromOverflowMenu { conversation_id, }) - .with_disabled(is_ambient_agent_conversation); - if is_ambient_agent_conversation { - delete_item = delete_item - .with_tooltip("Ambient agent conversations cannot be deleted"); + .with_disabled(!entry.capabilities.can_delete); + if !entry.capabilities.can_delete { + delete_item = + delete_item.with_tooltip("This conversation cannot be deleted"); } - // Check if conversation is shareable: - // - For tasks: check if there's an associated conversation_id - // - For conversations: check if synced to cloud - let is_shareable = match conversation_id { - ConversationOrTaskId::TaskId(task_id) => { - if let Some(ConversationOrTask::Task(task)) = - AgentConversationsModel::as_ref(ctx).get_task(&task_id) - { - task.conversation_id().is_some() - } else { - false - } - } - ConversationOrTaskId::ConversationId(conv_id) => { - BlocklistAIHistoryModel::as_ref(ctx) - .can_conversation_be_shared(&conv_id) - } - }; - // Only show share item if the conversation is shareable - let share_item = if is_shareable { + let share_item = if entry.capabilities.can_share { Some( MenuItemFields::new("Share conversation") .with_on_select_action( @@ -944,7 +952,7 @@ impl TypedActionView for ConversationListView { let fork_items: Option<[MenuItem; 2]> = // Forking from a closed ambient agent conversation is not supported at this point. - if !is_ambient_agent_conversation { + if entry.capabilities.can_fork_locally { Some([ MenuItemFields::new("Fork in new pane") .with_on_select_action( @@ -988,28 +996,13 @@ impl TypedActionView for ConversationListView { ConversationListViewAction::OpenShareDialog { conversation_id } => { // Clear selection state when opening share dialog self.selected_index = None; - - // Resolve the AIConversationId for the shareable object - let ai_conversation_id: Option = match conversation_id { - ConversationOrTaskId::TaskId(task_id) => { - // For tasks, look up the associated conversation_id by server token - if let Some(ConversationOrTask::Task(task)) = - AgentConversationsModel::as_ref(ctx).get_task(task_id) - { - task.conversation_id().and_then(|token_str| { - let server_token = - ServerConversationToken::new(token_str.to_string()); - BlocklistAIHistoryModel::as_ref(ctx) - .find_conversation_id_by_server_token(&server_token) - }) - } else { - None - } - } - ConversationOrTaskId::ConversationId(conv_id) => Some(*conv_id), - }; - - let Some(ai_conversation_id) = ai_conversation_id else { + let Some(ai_conversation_id) = self + .view_model + .as_ref(ctx) + .get_item_by_id(conversation_id, ctx) + .filter(|entry| entry.capabilities.can_share) + .and_then(|entry| entry.identity.local_conversation_id) + else { return; }; @@ -1026,14 +1019,22 @@ impl TypedActionView for ConversationListView { ctx.notify(); } ConversationListViewAction::DeleteFromOverflowMenu { conversation_id } => { - let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id + let Some(entry) = self + .view_model + .as_ref(ctx) + .get_item_by_id(conversation_id, ctx) else { - // For now, delete is only implemented for non-ambient conversations. + return; + }; + let Some(ai_conversation_id) = entry.identity.local_conversation_id else { + return; + }; + if !entry.capabilities.can_delete { return; }; let conversation = - BlocklistAIHistoryModel::as_ref(ctx).conversation(ai_conversation_id); + BlocklistAIHistoryModel::as_ref(ctx).conversation(&ai_conversation_id); if let Some(conversation) = conversation { if !conversation.status().is_done() && !conversation.is_empty() { @@ -1054,29 +1055,21 @@ impl TypedActionView for ConversationListView { self.selected_index = None; - let item = self - .view_model - .as_ref(ctx) - .get_item_by_id(conversation_id, ctx); - let terminal_view_id = item - .as_ref() - .and_then(|item| item.navigation_data().and_then(|nav| nav.terminal_view_id)); - let conversation_title = item - .as_ref() - .map(|c| c.title(ctx).to_string()) - .unwrap_or_else(|| "Conversation".to_string()); + let terminal_view_id = ActiveAgentViewsModel::as_ref(ctx) + .get_terminal_view_id_for_conversation(ai_conversation_id, ctx); + let conversation_title = entry.display.title; ctx.emit(Event::ShowDeleteConfirmationDialog { - conversation_id: *ai_conversation_id, + conversation_id: ai_conversation_id, conversation_title, terminal_view_id, }); } ConversationListViewAction::OpenItem { id } => { - let model = self.view_model.as_ref(ctx); - let Some(item) = model.get_item_by_id(id, ctx) else { - return; - }; - let Some(action) = item.get_open_action(None, ctx) else { + let Some(action) = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(*id), + None, + ctx, + ) else { return; }; @@ -1140,13 +1133,18 @@ impl TypedActionView for ConversationListView { conversation_id, destination, } => { - let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id + let Some(ai_conversation_id) = self + .view_model + .as_ref(ctx) + .get_item_by_id(conversation_id, ctx) + .filter(|entry| entry.capabilities.can_fork_locally) + .and_then(|entry| entry.identity.local_conversation_id) else { return; }; ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation { - conversation_id: *ai_conversation_id, + conversation_id: ai_conversation_id, fork_from_exchange: None, summarize_after_fork: false, summarization_prompt: None, @@ -1204,8 +1202,9 @@ impl View for ConversationListView { let list_items = self.list_items.clone(); let overflow_menu = self.item_overflow_menu.clone(); let overflow_menu_state = self.overflow_menu_state; - let focused_conversation = - ActiveAgentViewsModel::as_ref(app).get_focused_conversation(self.window_id); + let focused_conversation = ActiveAgentViewsModel::as_ref(app) + .get_focused_conversation(self.window_id) + .map(AgentConversationEntryId::from); let sharing_dialog = self.sharing_dialog.clone(); let share_dialog_open_for = self.share_dialog_open_for; let list_position_id = self.get_position_id(); @@ -1248,8 +1247,15 @@ impl View for ConversationListView { } ListItem::Conversation(entry) => { let conversation = model.get_item_by_id(&entry.id, app)?; - let is_focused_conversation = focused_conversation - .is_some_and(|focused| entry.id == focused); + let local_conversation_entry_id = conversation + .identity + .local_conversation_id + .map(AgentConversationEntryId::Conversation); + let is_focused_conversation = + focused_conversation.is_some_and(|focused| { + entry.id == focused + || local_conversation_entry_id == Some(focused) + }); let state = item_states.get(&entry.id)?; let highlight_ref = if entry.highlight_indices.is_empty() { None diff --git a/app/src/workspace/view/conversation_list/view_model.rs b/app/src/workspace/view/conversation_list/view_model.rs index 106f3aa33..8156f0025 100644 --- a/app/src/workspace/view/conversation_list/view_model.rs +++ b/app/src/workspace/view/conversation_list/view_model.rs @@ -1,8 +1,7 @@ -use crate::ai::active_agent_views_model::ConversationOrTaskId; use crate::ai::agent_conversations_model::{ - AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, - ConversationOrTask, CreatedOnFilter, CreatorFilter, OwnerFilter, SessionStatus, SourceFilter, - StatusFilter, + AgentConversationEntry, AgentConversationEntryId, AgentConversationsModel, + AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, CreatedOnFilter, + CreatorFilter, OwnerFilter, SourceFilter, StatusFilter, }; use fuzzy_match::match_indices_case_insensitive; use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity}; @@ -11,13 +10,13 @@ pub struct ConversationListViewModelEvent; #[derive(Clone, Debug)] pub struct ConversationEntry { - pub id: ConversationOrTaskId, + pub id: AgentConversationEntryId, pub highlight_indices: Vec, } pub struct ConversationListViewModel { conversations_model: ModelHandle, - cached_conversation_or_task_ids: Vec, + cached_entry_ids: Vec, filtered_items: Vec, search_query: String, } @@ -51,7 +50,7 @@ impl ConversationListViewModel { let mut model = Self { conversations_model, - cached_conversation_or_task_ids: Vec::new(), + cached_entry_ids: Vec::new(), filtered_items: Vec::new(), search_query: String::new(), }; @@ -61,15 +60,15 @@ impl ConversationListViewModel { /// Rebuilds the cached list of IDs from the current task/conversation set. /// - /// The cache stores only `ConversationOrTaskId`s; per-item fields like + /// The cache stores only `AgentConversationEntryId`s; per-item fields like /// status, title, and last-updated are read fresh at render time via /// `get_item_by_id`. Callers should therefore avoid invoking this on /// events that only mutate per-item state (e.g. `ConversationUpdated`); /// emitting `ConversationListViewModelEvent` is sufficient there. fn refresh_cached_items(&mut self, ctx: &mut ModelContext) { let model = self.conversations_model.as_ref(ctx); - self.cached_conversation_or_task_ids = model - .get_tasks_and_conversations( + self.cached_entry_ids = model + .get_entries( &AgentManagementFilters { owners: OwnerFilter::PersonalOnly, status: StatusFilter::All, @@ -82,18 +81,9 @@ impl ConversationListViewModel { }, ctx, ) - // Expired and Unavailable ambient agent sessions can't be opened, so we filter them out. - // Regular conversations have None session_status - .filter(|item| { - item.get_session_status() - .is_none_or(|status| status == SessionStatus::Available) - }) - .map(|item| match item { - ConversationOrTask::Task(task) => ConversationOrTaskId::TaskId(task.task_id), - ConversationOrTask::Conversation(conv) => { - ConversationOrTaskId::ConversationId(conv.nav_data.id) - } - }) + .into_iter() + .filter(|entry| entry.capabilities.can_open) + .map(|entry| entry.id) .collect(); self.apply_search_filter(ctx); @@ -116,7 +106,7 @@ impl ConversationListViewModel { if search_query.is_empty() { self.filtered_items = self - .cached_conversation_or_task_ids + .cached_entry_ids .iter() .map(|id| ConversationEntry { id: *id, @@ -125,27 +115,22 @@ impl ConversationListViewModel { .collect(); } else { let mut matched_items: Vec<(i64, ConversationEntry)> = self - .cached_conversation_or_task_ids + .cached_entry_ids .iter() .filter_map(|id| { - let item = match id { - ConversationOrTaskId::TaskId(task_id) => { - conversations_model.get_task(task_id) - } - ConversationOrTaskId::ConversationId(conv_id) => { - conversations_model.get_conversation(conv_id) - } - }?; - - match_indices_case_insensitive(&item.title(ctx), &search_query).map(|result| { - ( - result.score, - ConversationEntry { - id: *id, - highlight_indices: result.matched_indices, - }, - ) - }) + let item = conversations_model.get_entry_by_id(id, ctx)?; + + match_indices_case_insensitive(&item.display.title, &search_query).map( + |result| { + ( + result.score, + ConversationEntry { + id: *id, + highlight_indices: result.matched_indices, + }, + ) + }, + ) }) .collect(); @@ -156,7 +141,7 @@ impl ConversationListViewModel { /// Returns the total number of conversations in the model before any filtering is applied. pub fn unfiltered_item_count(&self) -> usize { - self.cached_conversation_or_task_ids.len() + self.cached_entry_ids.len() } /// Returns the filtered items with their highlight indices. @@ -164,20 +149,17 @@ impl ConversationListViewModel { &self.filtered_items } - /// Look up a conversation or task by ID. - pub fn get_item_by_id<'a>( + /// Look up a normalized conversation entry by ID. + pub fn get_item_by_id( &self, - id: &ConversationOrTaskId, - ctx: &'a AppContext, - ) -> Option> { + id: &AgentConversationEntryId, + ctx: &AppContext, + ) -> Option { let model = self.conversations_model.as_ref(ctx); - match id { - ConversationOrTaskId::TaskId(task_id) => model.get_task(task_id), - ConversationOrTaskId::ConversationId(conv_id) => model.get_conversation(conv_id), - } + model.get_entry_by_id(id, ctx) } - pub fn current_ids(&self) -> impl Iterator { + pub fn current_ids(&self) -> impl Iterator { self.filtered_items.iter().map(|item| &item.id) } }