From 140ab845b73dd78372c141e9d935cf3d1b7e3d28 Mon Sep 17 00:00:00 2001 From: Zach Bai Date: Mon, 4 May 2026 18:39:51 -0700 Subject: [PATCH 1/2] Migrate agent management view to use new AgentConversationEntry abstraction. --- app/src/ai/agent_conversations_model.rs | 41 ++ app/src/ai/agent_conversations_model/entry.rs | 30 +- app/src/ai/agent_conversations_model_tests.rs | 175 ++++++++ .../details_action_buttons.rs | 11 +- app/src/ai/agent_management/view.rs | 380 ++++++------------ app/src/ai/conversation_details_panel.rs | 94 ++++- 6 files changed, 452 insertions(+), 279 deletions(-) diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index f2237dcbfc..b4ee4bd73d 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -469,6 +469,7 @@ pub enum ConversationOrTask<'a> { Conversation(&'a ConversationMetadata), } +#[allow(dead_code)] impl ConversationOrTask<'_> { pub fn title(&self, app: &AppContext) -> String { match self { @@ -1577,6 +1578,22 @@ impl AgentConversationsModel { } } + pub fn resolve_copy_link( + subject: AgentConversationNavigationSubject, + 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_copy_link(&entry)), + AgentConversationNavigationSubject::ServerToken(server_token) => model + .entry_for_server_token(&server_token, app) + .and_then(|entry| model.resolve_entry_copy_link(&entry)) + .or_else(|| Some(server_token.conversation_link())), + } + } + fn resolve_entry_open_action( &self, entry: &AgentConversationEntry, @@ -1662,6 +1679,28 @@ impl AgentConversationsModel { }) } + fn resolve_entry_copy_link(&self, entry: &AgentConversationEntry) -> Option { + if let Some(task_id) = entry.identity.ambient_agent_task_id { + if let Some(session_link) = self.tasks.get(&task_id).and_then(|task| { + task.has_active_execution() + .then(|| { + task.active_run_execution() + .session_link + .map(ToString::to_string) + }) + .flatten() + }) { + return Some(session_link); + } + } + + entry + .identity + .server_conversation_token + .as_ref() + .map(ServerConversationToken::conversation_link) + } + fn entry_for_server_token( &self, server_token: &ServerConversationToken, @@ -1786,6 +1825,7 @@ impl AgentConversationsModel { /// 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, @@ -1856,6 +1896,7 @@ impl AgentConversationsModel { } /// 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) } diff --git a/app/src/ai/agent_conversations_model/entry.rs b/app/src/ai/agent_conversations_model/entry.rs index 7b5a92e599..e44483b88f 100644 --- a/app/src/ai/agent_conversations_model/entry.rs +++ b/app/src/ai/agent_conversations_model/entry.rs @@ -14,7 +14,7 @@ use warpui::{AppContext, SingletonEntity}; use super::{ artifacts_match_filter, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationOrTask, CreatedOnFilter, CreatorFilter, EnvironmentFilter, - HarnessFilter, OwnerFilter, SourceFilter, StatusFilter, + HarnessFilter, OwnerFilter, SessionStatus, SourceFilter, StatusFilter, }; /// Stable projection identity used by list and navigation surfaces. @@ -27,6 +27,15 @@ pub enum AgentConversationEntryId { Conversation(AIConversationId), } +impl AgentConversationEntryId { + pub fn as_key(&self) -> String { + match self { + AgentConversationEntryId::AmbientRun(id) => format!("task_{id}"), + AgentConversationEntryId::Conversation(id) => format!("conv_{id}"), + } + } +} + impl From for AgentConversationEntryId { fn from(id: ConversationOrTaskId) -> Self { match id { @@ -42,6 +51,7 @@ impl From for AgentConversationEntryId { #[derive(Clone, Debug, PartialEq, Eq)] pub enum AgentConversationNavigationSubject { Entry(AgentConversationEntryId), + #[allow(dead_code)] ServerToken(ServerConversationToken), } @@ -80,6 +90,7 @@ pub struct AgentConversationDisplayData { pub creator: AgentConversationCreator, pub request_usage: Option, pub run_time: Option, + pub session_status: Option, pub source: Option, pub working_directory: Option, pub environment_id: Option, @@ -271,6 +282,9 @@ pub(super) fn entry_for_task( || has_active_session_id || local_conversation_id.is_some() || server_conversation_token.is_some(); + let can_copy_link = task.has_active_execution() + && task.active_run_execution().session_link.is_some() + || server_conversation_token.is_some(); AgentConversationEntry { id: AgentConversationEntryId::AmbientRun(task.task_id), @@ -293,8 +307,10 @@ pub(super) fn entry_for_task( }, request_usage: item.request_usage(app), run_time: item.run_time(), + session_status: item.get_session_status(), source: item.source().cloned(), - working_directory: None, + 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), @@ -310,7 +326,7 @@ pub(super) fn entry_for_task( }, capabilities: AgentConversationCapabilities { can_open, - can_copy_link: item.session_or_conversation_link(app).is_some(), + can_copy_link, can_share: task.conversation_id().is_some() || local_conversation_id .is_some_and(|id| history_model.can_conversation_be_shared(&id)), @@ -398,6 +414,7 @@ fn entry_for_conversation_parts( }, request_usage: item.request_usage(app), run_time: item.run_time(), + session_status: item.get_session_status(), source: item.source().cloned(), working_directory: metadata .nav_data @@ -422,7 +439,12 @@ fn entry_for_conversation_parts( 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_copy_link: server_conversation_token_for_conversation( + conversation_id, + Some(&metadata.nav_data), + history_model, + ) + .is_some(), can_share: history_model.can_conversation_be_shared(&conversation_id), can_delete: has_local_persisted_data, can_fork_locally: has_local_persisted_data, diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 2369b5741e..5b1caf162c 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -1080,6 +1080,181 @@ fn test_resolve_open_action_handles_server_token_subject_without_entry() { }); } +#[test] +fn test_resolve_copy_link_prefers_active_session_link() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let now = Utc::now(); + let session_link = "https://example.com/session/active"; + let mut task = create_test_task(&make_uuid(8300), "user-a", now); + task.state = AmbientAgentTaskState::InProgress; + task.session_id = Some(make_uuid(8301)); + task.session_link = Some(session_link.to_string()); + task.conversation_id = Some("session-backed-token".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 link = AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task_id, + )), + ctx, + ); + + assert_eq!(link.as_deref(), Some(session_link)); + }); + }); +} + +#[test] +fn test_resolve_copy_link_uses_cloud_conversation_link_for_inactive_task() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let token = "inactive-task-token"; + let mut task = create_test_task(&make_uuid(8302), "user-a", Utc::now()); + task.conversation_id = Some(token.to_string()); + 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 link = AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task_id, + )), + ctx, + ); + + assert_eq!( + link, + Some(ServerConversationToken::new(token.to_string()).conversation_link()) + ); + + let entry = AgentConversationsModel::as_ref(ctx) + .get_entry_by_id(&AgentConversationEntryId::AmbientRun(task_id), ctx) + .expect("task entry should exist"); + assert!(entry.capabilities.can_copy_link); + }); + }); +} + +#[test] +fn test_resolve_copy_link_returns_none_for_local_only_unsynced_conversation() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let conversation_id = AIConversationId::new(); + app.add_singleton_model(|_| { + let mut model = create_test_model(); + model.conversations.insert( + conversation_id, + create_test_conversation_metadata(conversation_id, "Local only"), + ); + model + }); + + app.update(|ctx| { + let link = AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation( + conversation_id, + )), + ctx, + ); + + assert_eq!(link, None); + + let entry = AgentConversationsModel::as_ref(ctx) + .get_entry_by_id( + &AgentConversationEntryId::Conversation(conversation_id), + ctx, + ) + .expect("conversation entry should exist"); + assert!(!entry.capabilities.can_copy_link); + }); + }); +} + +#[test] +fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_token() { + App::test((), |mut app| async move { + let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); + add_entry_projection_test_models(&mut app); + + let conversation_id = AIConversationId::new(); + let token = "attached-conversation-token"; + let task_id = make_uuid(8303); + let conversation = create_restored_conversation( + conversation_id, + "root-task", + AgentConversationData { + server_conversation_token: Some(token.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: 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", Utc::now()); + task.conversation_id = None; + 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 link = AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task_id, + )), + ctx, + ); + + assert_eq!( + link, + Some(ServerConversationToken::new(token.to_string()).conversation_link()) + ); + + let entry = AgentConversationsModel::as_ref(ctx) + .get_entry_by_id(&AgentConversationEntryId::AmbientRun(task_id), ctx) + .expect("task entry should exist"); + assert!(entry.capabilities.can_copy_link); + assert_eq!(entry.identity.local_conversation_id, Some(conversation_id)); + }); + }); +} + #[test] fn test_eviction_protects_personal_from_team_overflow() { // Add 50 old personal tasks + 600 new team tasks diff --git a/app/src/ai/agent_management/details_action_buttons.rs b/app/src/ai/agent_management/details_action_buttons.rs index 819e11ba35..e6a823a00c 100644 --- a/app/src/ai/agent_management/details_action_buttons.rs +++ b/app/src/ai/agent_management/details_action_buttons.rs @@ -7,8 +7,7 @@ use warpui::{AppContext, Element, Entity, TypedActionView, View, ViewContext, Vi use crate::view_components::copyable_text_field::COPY_FEEDBACK_DURATION; use crate::ai::agent::conversation::AIConversationId; -use crate::ai::agent_conversations_model::AgentRunDisplayStatus; -use crate::ai::agent_management::view::ManagementCardItemId; +use crate::ai::agent_conversations_model::{AgentConversationEntryId, AgentRunDisplayStatus}; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme}; @@ -25,7 +24,7 @@ pub struct ActionButtonsConfig { pub fork_conversation_id: Option, /// Shows an info button for viewing more details. /// Only used in management view hover toolbelt. - pub view_details_item_id: Option, + pub view_details_item_id: Option, /// Conversation link URL (either to the transcript or live session) for copy link button. pub copy_link_url: Option, } @@ -87,7 +86,7 @@ pub enum AgentDetailsButtonEvent { Open, CancelTask { task_id: AmbientAgentTaskId }, ForkConversation { conversation_id: AIConversationId }, - ViewDetails { item_id: ManagementCardItemId }, + ViewDetails { item_id: AgentConversationEntryId }, CopyLink { link: String }, } @@ -260,9 +259,7 @@ impl TypedActionView for ConversationActionButtonsRow { } AgentDetailsAction::ViewDetails => { if let Some(item_id) = &self.config.view_details_item_id { - ctx.emit(AgentDetailsButtonEvent::ViewDetails { - item_id: item_id.clone(), - }); + ctx.emit(AgentDetailsButtonEvent::ViewDetails { item_id: *item_id }); } } AgentDetailsAction::CopyLink => { diff --git a/app/src/ai/agent_management/view.rs b/app/src/ai/agent_management/view.rs index 864a35eda3..ddea12b813 100644 --- a/app/src/ai/agent_management/view.rs +++ b/app/src/ai/agent_management/view.rs @@ -12,9 +12,10 @@ use warpui::ui_components::button::ButtonVariant; use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent_conversations_model::{ + AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, - ConversationOrTask, ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, - HarnessFilter, OwnerFilter, SessionStatus, SourceFilter, StatusFilter, + ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter, + OwnerFilter, SessionStatus, SourceFilter, StatusFilter, }; use crate::ai::agent_management::agent_type_selector::{ AgentType, AgentTypeSelector, AgentTypeSelectorEvent, @@ -28,10 +29,9 @@ use crate::ai::agent_management::details_action_buttons::{ use crate::ai::agent_management::telemetry::{ AgentManagementTelemetryEvent, ArtifactType, FilterType, OpenedFrom, }; -use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource}; use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent}; -use crate::ai::blocklist::history_model::BlocklistAIHistoryModel; +use crate::ai::blocklist::format_credits; use crate::ai::conversation_details_panel::{ ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent, }; @@ -66,7 +66,6 @@ use crate::workspaces::user_workspaces::UserWorkspaces; use crate::{send_telemetry_from_ctx, AgentModeEntrypoint}; use pathfinder_geometry::vector::vec2f; use settings::Setting; -use warp_cli::agent::Harness; use warp_core::ui::icons::Icon; use warp_core::ui::theme::color::internal_colors; use warp_core::ui::theme::Fill; @@ -109,7 +108,6 @@ const CARD_MARGIN_BOTTOM: f32 = 8.; const STATUS_ICON_SIZE: f32 = 12.; const BUTTON_SIZE: f32 = 20.; const CREATOR_AVATAR_FONT_SIZE: f32 = 10.; - const SESSION_EXPIRED_TEXT: &str = "Sessions expire after one week and cannot be opened."; pub fn init(app: &mut AppContext) { @@ -126,23 +124,8 @@ fn should_show_artifacts(artifacts: &[Artifact]) -> bool { !artifacts.is_empty() && FeatureFlag::ConversationArtifacts.is_enabled() } -/// Identifies a card item - either a task ID or a conversation ID -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ManagementCardItemId { - Task(AmbientAgentTaskId), - Conversation(AIConversationId), -} - -impl ManagementCardItemId { - fn as_key(&self) -> String { - match self { - ManagementCardItemId::Task(id) => format!("task_{id}"), - ManagementCardItemId::Conversation(id) => format!("conv_{id}"), - } - } -} +pub type ManagementCardItemId = AgentConversationEntryId; -/// Store state for a given task row struct CardState { hover_state: MouseStateHandle, avatar_hover_state: MouseStateHandle, @@ -150,7 +133,6 @@ struct CardState { artifact_buttons_view: Option>, action_buttons_hover_state: MouseStateHandle, action_buttons_view: ViewHandle, - /// Use this ID to look up the full data from the model item_id: ManagementCardItemId, } @@ -159,7 +141,6 @@ pub struct AgentManagementView { loading_icon_mouse_state: MouseStateHandle, scroll_state: ScrollStateHandle, - /// Store the most recent requested set of ConversationOrTasks on the view items: Vec, /// Store filters on the data @@ -961,46 +942,34 @@ impl AgentManagementView { let model = AgentConversationsModel::as_ref(ctx); let search_query = self.search_query.trim().to_lowercase(); let cards: Vec = model - .get_tasks_and_conversations(&self.filters, ctx) - .filter(|t| { + .get_entries(&self.filters, ctx) + .into_iter() + .filter(|entry| { if search_query.is_empty() { return true; } - match_indices_case_insensitive(&t.title(ctx), &search_query).is_some() + match_indices_case_insensitive(&entry.display.title, &search_query).is_some() }) - .map(|t| { - let item_id = match t { - ConversationOrTask::Task(task) => ManagementCardItemId::Task(task.task_id), - ConversationOrTask::Conversation(conversation) => { - ManagementCardItemId::Conversation(conversation.nav_data.id) - } - }; - let artifacts = t.artifacts(ctx); - - let copy_link_url = t.session_or_conversation_link(ctx); - let mut config = match t { - ConversationOrTask::Task(task) => ActionButtonsConfig::for_task( - task.task_id, - &t.display_status(ctx), - None, // Don't show open button in card hover - copy_link_url, - ), - ConversationOrTask::Conversation(conversation) => { - ActionButtonsConfig::for_conversation( - conversation.nav_data.id, - None, // Don't show open button in card hover - copy_link_url, + .map(|entry| { + let item_id = entry.id; + let copy_link_url = entry + .capabilities + .can_copy_link + .then(|| { + AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(entry.id), + ctx, ) - } - }; - // Show info button in card hover for ViewDetails if feature flag enabled + }) + .flatten(); + let mut config = Self::action_buttons_config_for_entry(&entry, None, copy_link_url); if FeatureFlag::AgentManagementDetailsView.is_enabled() { - config.view_details_item_id = Some(item_id.clone()); + config.view_details_item_id = Some(item_id); } CardData { item_id, - artifacts, + artifacts: entry.display.artifacts, action_buttons_config: config, } }) @@ -1049,11 +1018,8 @@ impl AgentManagementView { } else { None }; - let action_buttons_view = self.create_action_buttons_view( - card.item_id.clone(), - card.action_buttons_config, - ctx, - ); + let action_buttons_view = + self.create_action_buttons_view(card.item_id, card.action_buttons_config, ctx); new_items.push(CardState { hover_state: MouseStateHandle::default(), @@ -1097,6 +1063,29 @@ impl AgentManagementView { view } + fn action_buttons_config_for_entry( + entry: &AgentConversationEntry, + open_action: Option, + copy_link_url: Option, + ) -> ActionButtonsConfig { + if let Some(task_id) = entry.identity.ambient_agent_task_id { + ActionButtonsConfig::for_task( + task_id, + &entry.display.status, + open_action, + copy_link_url, + ) + } else if let Some(conversation_id) = entry.identity.local_conversation_id { + ActionButtonsConfig::for_conversation(conversation_id, open_action, copy_link_url) + } else { + ActionButtonsConfig { + open_action, + copy_link_url, + ..Default::default() + } + } + } + fn handle_action_buttons_event( &mut self, item_id: &ManagementCardItemId, @@ -1145,7 +1134,7 @@ impl AgentManagementView { ); self.update_details_panel_for_item(item_id, ctx); - self.selected_item_id = Some(item_id.clone()); + self.selected_item_id = Some(*item_id); ctx.notify(); } AgentDetailsButtonEvent::CopyLink { link } => { @@ -1159,7 +1148,7 @@ impl AgentManagementView { ctx ); } - ManagementCardItemId::Task(task_id) => { + ManagementCardItemId::AmbientRun(task_id) => { send_telemetry_from_ctx!( AgentManagementTelemetryEvent::SessionLinkCopied { task_id: task_id.to_string(), @@ -1272,7 +1261,7 @@ impl AgentManagementView { /// Refresh the details panel if it's currently showing an item fn refresh_details_panel_if_needed(&mut self, ctx: &mut ViewContext) { - if let Some(item_id) = self.selected_item_id.clone() { + if let Some(item_id) = self.selected_item_id { self.update_details_panel_for_item(&item_id, ctx); } } @@ -1317,75 +1306,28 @@ impl AgentManagementView { ctx: &mut ViewContext, ) { let model = AgentConversationsModel::as_ref(ctx); - - let data = match item_id { - ManagementCardItemId::Task(task_id) => { - let Some(task_wrapper) = model.get_task(task_id) else { - return; - }; - // Agent management view should always open in a new tab - let open_action = - task_wrapper.get_open_action(Some(RestoreConversationLayout::NewTab), ctx); - let copy_link_url = task_wrapper.session_or_conversation_link(ctx); - let Some(task) = model.get_task_data(task_id) else { - return; - }; - ConversationDetailsData::from_task(&task, open_action, copy_link_url, ctx) - } - ManagementCardItemId::Conversation(conversation_id) => { - let Some(conversation) = model.get_conversation(conversation_id) else { - return; - }; - // Agent management view should always open in a new tab - let open_action = - conversation.get_open_action(Some(RestoreConversationLayout::NewTab), ctx); - - let history_model = BlocklistAIHistoryModel::as_ref(ctx); - let ai_conversation = conversation - .navigation_data() - .and_then(|nav| history_model.conversation(&nav.id)); - - let server_conv_id = ai_conversation - .and_then(|c| c.server_conversation_token()) - .map(|t| t.as_str().to_string()) - .or_else(|| { - conversation - .navigation_data() - .and_then(|nav| history_model.get_conversation_metadata(&nav.id)) - .and_then(|m| m.server_conversation_token.as_ref()) - .map(|t| t.as_str().to_string()) - }); - let artifacts = ai_conversation - .map(|c| c.artifacts().to_vec()) - .unwrap_or_default(); - let status = Some(conversation.status(ctx)); - let navigation_data = conversation.navigation_data(); - let copy_link_url = conversation.session_or_conversation_link(ctx); - - // Prefer server-reported harness when available; otherwise treat as a pure - // local conversation (always Warp Agent). - let harness = navigation_data - .and_then(|nav| history_model.get_server_conversation_metadata(&nav.id)) - .map(|m| Harness::from(m.harness)) - .or(Some(Harness::Oz)); - - ConversationDetailsData::from_conversation_metadata( - *conversation_id, - conversation.title(ctx), - conversation.creator_name(ctx), - conversation.created_at().with_timezone(&chrono::Local), - navigation_data.and_then(|n| n.initial_working_directory.clone()), - conversation.request_usage(ctx), - server_conv_id, - artifacts, - open_action, - status, - navigation_data.and_then(|n| n.initial_query.clone()), - copy_link_url, - harness, - ) - } + let Some(entry) = model.get_entry_by_id(item_id, ctx) else { + return; }; + let open_action = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(*item_id), + Some(RestoreConversationLayout::NewTab), + ctx, + ); + let copy_link_url = AgentConversationsModel::resolve_copy_link( + AgentConversationNavigationSubject::Entry(*item_id), + ctx, + ); + let task = entry + .identity + .ambient_agent_task_id + .and_then(|task_id| model.get_task_data(&task_id)); + let data = ConversationDetailsData::from_agent_conversation_entry( + &entry, + task.as_ref(), + open_action, + copy_link_url, + ); self.details_panel.update(ctx, |p, ctx| { p.set_conversation_details(data, ctx); @@ -1399,21 +1341,15 @@ impl AgentManagementView { ctx: &mut ViewContext, ) { let model = AgentConversationsModel::as_ref(ctx); - let Some(card_data) = model.get_conversation(&conversation_id) else { + let Some((index, entry)) = self.items.iter().enumerate().find_map(|(index, card)| { + let entry = model.get_entry_by_id(&card.item_id, ctx)?; + (entry.identity.local_conversation_id == Some(conversation_id)) + .then_some((index, entry)) + }) else { return; }; - let artifacts = card_data.artifacts(ctx); + let artifacts = entry.display.artifacts; - // Find the index of the card for this conversation - let Some(index) = self - .items - .iter() - .position(|card| card.item_id == ManagementCardItemId::Conversation(conversation_id)) - else { - return; - }; - - // Update the artifact buttons for this card if should_show_artifacts(&artifacts) { if let Some(view) = &self.items[index].artifact_buttons_view { view.update(ctx, |v, ctx| v.update_artifacts(&artifacts, ctx)); @@ -1545,56 +1481,6 @@ impl AgentManagementView { .finish() } - // Renders a session status label based on the provided session status - fn render_session_status_label( - appearance: &Appearance, - mouse_state: MouseStateHandle, - session_status: SessionStatus, - ) -> Box { - let theme = appearance.theme(); - let font_family = appearance.ui_font_family(); - let font_size = appearance.ui_font_size(); - let ui_builder = appearance.ui_builder().clone(); - - // Early return if session is available - no status label rendered - let (label_text, tooltip_text_opt) = match session_status { - SessionStatus::Expired => ("Session expired", Some(SESSION_EXPIRED_TEXT)), - SessionStatus::Unavailable => ("No session available", None), - SessionStatus::Available => return Empty::new().finish(), - }; - - Hoverable::new(mouse_state, move |state| { - let label = Text::new_inline(label_text, font_family, font_size) - .with_color(theme.nonactive_ui_text_color().into()); - - let container = Container::new(label.finish()) - .with_background(internal_colors::fg_overlay_2(theme)) - .with_horizontal_padding(4.) - .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))); - - let mut stack = Stack::new().with_child(container.finish()); - if state.is_hovered() { - if let Some(tooltip_text) = tooltip_text_opt { - let tooltip = ui_builder - .tool_tip(tooltip_text.to_string()) - .build() - .finish(); - stack.add_positioned_overlay_child( - tooltip, - OffsetPositioning::offset_from_parent( - vec2f(0., -4.), - ParentOffsetBounds::WindowByPosition, - ParentAnchor::TopMiddle, - ChildAnchor::BottomMiddle, - ), - ); - } - } - stack.finish() - }) - .finish() - } - // Create a skeleton card for the loading screen fn render_skeleton_card(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); @@ -1656,21 +1542,17 @@ impl AgentManagementView { }; let model = AgentConversationsModel::as_ref(app); - let card_data = match &card_state.item_id { - ManagementCardItemId::Task(task_id) => model.get_task(task_id), - ManagementCardItemId::Conversation(conv_id) => model.get_conversation(conv_id), - }; - let Some(card_data) = card_data else { + let Some(entry) = model.get_entry_by_id(&card_state.item_id, app) else { return Empty::new().finish(); }; - self.render_card(card_state, &card_data, appearance, app) + self.render_card(card_state, &entry, appearance, app) } fn render_card( &self, card_state: &CardState, - card_data: &ConversationOrTask, + entry: &AgentConversationEntry, appearance: &Appearance, app: &AppContext, ) -> Box { @@ -1694,18 +1576,13 @@ impl AgentManagementView { let card_hoverable = Hoverable::new(card_state.hover_state.clone(), move |mouse_state| { let mut card_content = Flex::column() .with_spacing(CARD_ROW_SPACING) - .with_child(Self::render_header_row( - card_state, card_data, appearance, app, - )) - .with_child(Self::render_metadata_row(card_data, appearance, app)); + .with_child(Self::render_header_row(card_state, entry, appearance)) + .with_child(Self::render_metadata_row(entry, appearance, app)); - // Add artifacts row if there is a buttons view if let Some(buttons_element) = artifact_buttons_element { card_content.add_child(buttons_element); } - // Determine whether to show the buttons based on whether we are hovering on the action buttons or the card, - // to prevent lots of flickering. let should_show_action_buttons = mouse_state.is_hovered() || action_buttons_mouse_over; let card_background = if should_show_action_buttons { @@ -1741,8 +1618,6 @@ impl AgentManagementView { .with_cursor(Cursor::PointingHand) .with_defer_events_to_children(); - // Note: we use an overlay layer so that the hover on the top of the list can extend outside - // of the list boundaries, rendered unclipped. stack.add_positioned_overlay_child( action_buttons.finish(), OffsetPositioning::offset_from_parent( @@ -1758,18 +1633,12 @@ impl AgentManagementView { }) .with_defer_events_to_children(); - // Add click handler to open session if available - let item_id = card_state.item_id.clone(); - let card_hoverable = if card_data - .get_open_action(Some(RestoreConversationLayout::NewTab), app) - .is_some() - { + let item_id = card_state.item_id; + let card_hoverable = if entry.capabilities.can_open { card_hoverable .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AgentManagementViewAction::OpenSession { - item_id: item_id.clone(), - }); + ctx.dispatch_typed_action(AgentManagementViewAction::OpenSession { item_id }); }) } else { card_hoverable @@ -1780,29 +1649,25 @@ impl AgentManagementView { fn render_header_row( card_state: &CardState, - card_data: &ConversationOrTask, + entry: &AgentConversationEntry, appearance: &Appearance, - app: &AppContext, ) -> Box { let theme = appearance.theme(); let font_family = appearance.ui_font_family(); let font_size = appearance.ui_font_size(); - let title = card_data.title(app); - let title_text = Text::new_inline(title, font_family, font_size) + let title_text = Text::new_inline(entry.display.title.clone(), font_family, font_size) .with_color(theme.active_ui_text_color().into()); - let status_icon = - render_status_element(&card_data.display_status(app), STATUS_ICON_SIZE, appearance); - - // Build the time and avatar elements - let last_updated = card_data.last_updated(); - let time_str = format_approx_duration_from_now_utc(last_updated); + render_status_element(&entry.display.status, STATUS_ICON_SIZE, appearance); + let time_str = format_approx_duration_from_now_utc(entry.display.last_updated); let time_text = Text::new_inline(time_str, font_family, font_size) .with_color(theme.nonactive_ui_text_color().into()); - - let creator_name = card_data - .creator_name(app) + let creator_name = entry + .display + .creator + .name + .clone() .unwrap_or_else(|| "Unknown".to_string()); let avatar = Self::render_avatar_with_tooltip( &creator_name, @@ -1816,52 +1681,41 @@ impl AgentManagementView { .with_child(Container::new(status_icon).with_margin_right(4.).finish()) .with_child(Expanded::new(1., title_text.finish()).finish()); - let mut time_and_avatar = Flex::row() + let time_and_avatar = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(4.); - - if let Some(session_status) = card_data.get_session_status() { - time_and_avatar.add_child(Self::render_session_status_label( - appearance, - card_state.session_status_hover_state.clone(), - session_status, - )); - } - - time_and_avatar.add_child(time_text.finish()); - time_and_avatar.add_child(avatar); + .with_spacing(4.) + .with_child(time_text.finish()) + .with_child(avatar) + .finish(); row.add_child( - Container::new(time_and_avatar.finish()) + Container::new(time_and_avatar) .with_margin_right(2.) .finish(), ); - // We want to make sure the text in the row is always at least the button height ConstrainedBox::new(row.finish()) .with_min_height(BUTTON_SIZE) .finish() } fn render_metadata_row( - card_data: &ConversationOrTask, + entry: &AgentConversationEntry, appearance: &Appearance, app: &AppContext, ) -> Box { let theme = appearance.theme(); let font_family = appearance.ui_font_family(); let font_size = appearance.ui_font_size(); - - // Build metadata parts conditionally let mut metadata_parts = Vec::new(); - if let Some(source) = card_data.source() { + if let Some(source) = &entry.display.source { metadata_parts.push(format!("Source: {}", source.display_name())); } let availability = HarnessAvailabilityModel::as_ref(app); if availability.should_show_harness_selector() { - if let Some(harness) = card_data.harness(app) { + if let Some(harness) = entry.display.harness { metadata_parts.push(format!( "Harness: {}", availability.display_name_for(harness) @@ -1869,17 +1723,15 @@ impl AgentManagementView { } } - if let Some(run_time) = card_data.run_time() { + if let Some(run_time) = &entry.display.run_time { metadata_parts.push(format!("Run time: {run_time}")); } - if let Some(usage) = card_data.display_request_usage(app) { + if let Some(usage) = entry.display.request_usage.map(format_credits) { metadata_parts.push(format!("Credits used: {usage}")); } - let metadata_text = metadata_parts.join(" • "); - - Text::new(metadata_text, font_family, font_size) + Text::new(metadata_parts.join(" • "), font_family, font_size) .with_color(theme.nonactive_ui_text_color().into()) .finish() } @@ -2405,17 +2257,11 @@ impl TypedActionView for AgentManagementView { ctx.notify(); } AgentManagementViewAction::OpenSession { item_id } => { - let model = AgentConversationsModel::as_ref(ctx); - let card_data = match item_id { - ManagementCardItemId::Task(task_id) => model.get_task(task_id), - ManagementCardItemId::Conversation(conv_id) => model.get_conversation(conv_id), - }; - let Some(card_data) = card_data else { - return; - }; - let Some(action) = - card_data.get_open_action(Some(RestoreConversationLayout::NewTab), ctx) - else { + let Some(action) = AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(*item_id), + Some(RestoreConversationLayout::NewTab), + ctx, + ) else { return; }; @@ -2429,7 +2275,7 @@ impl TypedActionView for AgentManagementView { ctx ); } - ManagementCardItemId::Task(task_id) => { + ManagementCardItemId::AmbientRun(task_id) => { send_telemetry_from_ctx!( AgentManagementTelemetryEvent::CloudRunOpened { task_id: task_id.to_string(), diff --git a/app/src/ai/conversation_details_panel.rs b/app/src/ai/conversation_details_panel.rs index 04e73eb79b..9ad8573669 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -29,7 +29,7 @@ use warpui::{ use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::AIConversation; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; -use crate::ai::agent_conversations_model::AgentRunDisplayStatus; +use crate::ai::agent_conversations_model::{AgentConversationEntry, AgentRunDisplayStatus}; use crate::ai::agent_management::details_action_buttons::{ ActionButtonsConfig, AgentDetailsButtonEvent, ConversationActionButtonsRow, }; @@ -370,6 +370,97 @@ impl ConversationDetailsData { } } + pub fn from_agent_conversation_entry( + entry: &AgentConversationEntry, + task: Option<&AmbientAgentTask>, + open_action: Option, + copy_link_url: Option, + ) -> Self { + let creator = entry + .display + .creator + .name + .clone() + .map(|name| CreatorInfo::new(name, None)); + let created_at = Some(entry.display.created_at.with_timezone(&Local)); + let source_prompt = entry.display.initial_query.clone(); + let harness = entry.display.harness; + + if let Some(task_id) = entry.identity.ambient_agent_task_id { + let error_message = task.and_then(|task| { + task.state + .is_failure_like() + .then(|| task.status_message.as_ref().map(|m| m.message.clone())) + .flatten() + }); + let credits = task.and_then(|task| { + task.active_run_execution().request_usage.and_then(|u| { + Some(CreditsInfo::AmbientConversation { + inference: u.inference_cost? as f32, + compute: u.compute_cost? as f32, + }) + }) + }); + let skill_spec = task + .and_then(|task| task.agent_config_snapshot.as_ref()) + .and_then(|config| config.skill_spec.as_ref()) + .and_then(|spec_str| SkillSpec::from_str(spec_str).ok()); + + return ConversationDetailsData { + mode: PanelMode::Task { + task_id: Some(task_id), + directory: entry.display.working_directory.clone(), + display_status: Some(entry.display.status.clone()), + error_message, + environment_id: entry.display.environment_id.clone(), + conversation_id: entry + .identity + .server_conversation_token + .as_ref() + .map(|token| token.as_str().to_string()), + }, + title: entry.display.title.clone(), + creator, + created_at, + credits, + run_time: task.and_then(AmbientAgentTask::run_time), + artifacts: entry.display.artifacts.clone(), + open_action, + source_prompt, + copy_link_url, + skill_spec, + harness, + }; + } + + ConversationDetailsData { + mode: PanelMode::Conversation { + directory: entry.display.working_directory.clone(), + server_conversation_id: entry + .identity + .server_conversation_token + .as_ref() + .map(|token| token.as_str().to_string()), + ai_conversation_id: entry.identity.local_conversation_id, + status: Some(entry.display.status.to_conversation_status()), + }, + title: entry.display.title.clone(), + creator, + created_at, + credits: entry + .display + .request_usage + .map(CreditsInfo::LocalConversation), + run_time: None, + artifacts: entry.display.artifacts.clone(), + open_action, + source_prompt, + copy_link_url, + skill_spec: None, + harness, + } + } + /// Minimal details data for when we only know the task id (e.g. shared sessions) /// but have not loaded the full `AmbientAgentTask` yet. pub fn from_task_id(task_id: AmbientAgentTaskId) -> Self { @@ -399,6 +490,7 @@ impl ConversationDetailsData { #[allow(clippy::too_many_arguments)] /// Used to populate the details panel from the management view, where we don't always have access /// to the full `AIConversation`. + #[allow(dead_code)] pub fn from_conversation_metadata( ai_conversation_id: AIConversationId, title: String, From 183f89c44ad1298fcdcb7f4f6993b980525cdbc2 Mon Sep 17 00:00:00 2001 From: Zach Bai Date: Tue, 5 May 2026 23:14:49 -0700 Subject: [PATCH 2/2] Self review/cleanup. : --- app/src/ai/agent_conversations_model.rs | 2 +- app/src/ai/agent_management/view.rs | 82 +++++++++++++++++++++-- app/src/ai/conversation_details_panel.rs | 2 +- app/src/ui_components/agent_icon_tests.rs | 1 + 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index b4ee4bd73d..1fbe81ffe5 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -93,7 +93,7 @@ enum TaskFetchState { const MAX_PERSONAL_TASKS: usize = 200; const MAX_TEAM_TASKS: usize = 300; -#[derive(PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum SessionStatus { Available, Expired, diff --git a/app/src/ai/agent_management/view.rs b/app/src/ai/agent_management/view.rs index ddea12b813..b8ae1cd27b 100644 --- a/app/src/ai/agent_management/view.rs +++ b/app/src/ai/agent_management/view.rs @@ -108,6 +108,7 @@ const CARD_MARGIN_BOTTOM: f32 = 8.; const STATUS_ICON_SIZE: f32 = 12.; const BUTTON_SIZE: f32 = 20.; const CREATOR_AVATAR_FONT_SIZE: f32 = 10.; + const SESSION_EXPIRED_TEXT: &str = "Sessions expire after one week and cannot be opened."; pub fn init(app: &mut AppContext) { @@ -126,6 +127,7 @@ fn should_show_artifacts(artifacts: &[Artifact]) -> bool { pub type ManagementCardItemId = AgentConversationEntryId; +/// Store state for a given task row struct CardState { hover_state: MouseStateHandle, avatar_hover_state: MouseStateHandle, @@ -133,6 +135,7 @@ struct CardState { artifact_buttons_view: Option>, action_buttons_hover_state: MouseStateHandle, action_buttons_view: ViewHandle, + /// Use this ID to look up the full data from the model item_id: ManagementCardItemId, } @@ -141,6 +144,7 @@ pub struct AgentManagementView { loading_icon_mouse_state: MouseStateHandle, scroll_state: ScrollStateHandle, + /// Store the most recent requested set of ConversationOrTasks on the view items: Vec, /// Store filters on the data @@ -1350,6 +1354,7 @@ impl AgentManagementView { }; let artifacts = entry.display.artifacts; + // Update the artifact buttons for this card if should_show_artifacts(&artifacts) { if let Some(view) = &self.items[index].artifact_buttons_view { view.update(ctx, |v, ctx| v.update_artifacts(&artifacts, ctx)); @@ -1481,6 +1486,56 @@ impl AgentManagementView { .finish() } + // Renders a session status label based on the provided session status + fn render_session_status_label( + appearance: &Appearance, + mouse_state: MouseStateHandle, + session_status: &SessionStatus, + ) -> Box { + let theme = appearance.theme(); + let font_family = appearance.ui_font_family(); + let font_size = appearance.ui_font_size(); + let ui_builder = appearance.ui_builder().clone(); + + // Early return if session is available - no status label rendered + let (label_text, tooltip_text_opt) = match session_status { + SessionStatus::Expired => ("Session expired", Some(SESSION_EXPIRED_TEXT)), + SessionStatus::Unavailable => ("No session available", None), + SessionStatus::Available => return Empty::new().finish(), + }; + + Hoverable::new(mouse_state, move |state| { + let label = Text::new_inline(label_text, font_family, font_size) + .with_color(theme.nonactive_ui_text_color().into()); + + let container = Container::new(label.finish()) + .with_background(internal_colors::fg_overlay_2(theme)) + .with_horizontal_padding(4.) + .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))); + + let mut stack = Stack::new().with_child(container.finish()); + if state.is_hovered() { + if let Some(tooltip_text) = tooltip_text_opt { + let tooltip = ui_builder + .tool_tip(tooltip_text.to_string()) + .build() + .finish(); + stack.add_positioned_overlay_child( + tooltip, + OffsetPositioning::offset_from_parent( + vec2f(0., -4.), + ParentOffsetBounds::WindowByPosition, + ParentAnchor::TopMiddle, + ChildAnchor::BottomMiddle, + ), + ); + } + } + stack.finish() + }) + .finish() + } + // Create a skeleton card for the loading screen fn render_skeleton_card(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); @@ -1579,10 +1634,13 @@ impl AgentManagementView { .with_child(Self::render_header_row(card_state, entry, appearance)) .with_child(Self::render_metadata_row(entry, appearance, app)); + // Add artifacts row if there is a buttons view if let Some(buttons_element) = artifact_buttons_element { card_content.add_child(buttons_element); } + // Determine whether to show the buttons based on whether we are hovering on the action buttons or the card, + // to prevent lots of flickering. let should_show_action_buttons = mouse_state.is_hovered() || action_buttons_mouse_over; let card_background = if should_show_action_buttons { @@ -1618,6 +1676,8 @@ impl AgentManagementView { .with_cursor(Cursor::PointingHand) .with_defer_events_to_children(); + // Note: we use an overlay layer so that the hover on the top of the list can extend outside + // of the list boundaries, rendered unclipped. stack.add_positioned_overlay_child( action_buttons.finish(), OffsetPositioning::offset_from_parent( @@ -1680,20 +1740,28 @@ impl AgentManagementView { .with_spacing(2.) .with_child(Container::new(status_icon).with_margin_right(4.).finish()) .with_child(Expanded::new(1., title_text.finish()).finish()); - - let time_and_avatar = Flex::row() + let mut time_and_avatar = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(4.) - .with_child(time_text.finish()) - .with_child(avatar) - .finish(); + .with_spacing(4.); + + if let Some(session_status) = &entry.display.session_status { + time_and_avatar.add_child(Self::render_session_status_label( + appearance, + card_state.session_status_hover_state.clone(), + session_status, + )); + } + + time_and_avatar.add_child(time_text.finish()); + time_and_avatar.add_child(avatar); row.add_child( - Container::new(time_and_avatar) + Container::new(time_and_avatar.finish()) .with_margin_right(2.) .finish(), ); + // We want to make sure the text in the row is always at least the button height ConstrainedBox::new(row.finish()) .with_min_height(BUTTON_SIZE) .finish() diff --git a/app/src/ai/conversation_details_panel.rs b/app/src/ai/conversation_details_panel.rs index 9ad8573669..2d48dccc1f 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -488,9 +488,9 @@ impl ConversationDetailsData { } #[allow(clippy::too_many_arguments)] + #[allow(dead_code)] /// Used to populate the details panel from the management view, where we don't always have access /// to the full `AIConversation`. - #[allow(dead_code)] pub fn from_conversation_metadata( ai_conversation_id: AIConversationId, title: String, diff --git a/app/src/ui_components/agent_icon_tests.rs b/app/src/ui_components/agent_icon_tests.rs index 6a8483f32e..0d4f2a091d 100644 --- a/app/src/ui_components/agent_icon_tests.rs +++ b/app/src/ui_components/agent_icon_tests.rs @@ -403,6 +403,7 @@ fn non_ambient_entry_uses_display_harness() { creator: AgentConversationCreator::default(), request_usage: None, run_time: None, + session_status: None, source: None, working_directory: None, environment_id: None,