Files
galaxy/app/src/ai/agent_management/view.rs
T

2398 lines
93 KiB
Rust

use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use fuzzy_match::match_indices_case_insensitive;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::new_scrollable::{
NewScrollableElement, ScrollableAppearance, SingleAxisConfig,
};
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Element, Empty, Expanded, Flex, Hoverable, List, ListState, MainAxisSize,
MouseStateHandle, NewScrollable, OffsetPositioning, Padding, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Rect, ScrollStateHandle, ScrollbarWidth, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text, Wrap,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor;
use galaxyui::scene::DropShadow;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use lazy_static::lazy_static;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use siphasher::sip::SipHasher;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject,
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
};
use crate::ai::agent_management::agent_type_selector::{
AgentType, AgentTypeSelector, AgentTypeSelectorEvent,
};
use crate::ai::agent_management::cloud_setup_guide_view::{
CloudSetupGuideEvent, CloudSetupGuideView,
};
use crate::ai::agent_management::details_action_buttons::{
ActionButtonsConfig, AgentDetailsButtonEvent, ConversationActionButtonsRow,
};
use crate::ai::agent_management::telemetry::{
AgentManagementTelemetryEvent, ArtifactType, FilterType, OpenedFrom,
};
use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource};
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
use crate::ai::blocklist::format_credits;
use crate::ai::conversation_details_panel::{
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
};
use crate::ai::harness_availability::HarnessAvailabilityModel;
use crate::ai::harness_display;
use crate::app_state::PersistedAgentManagementFilters;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{MenuItem, MenuItemFields};
use crate::notebooks::NotebookId;
use crate::settings::ai::AISettings;
use crate::ui_components::agent_icon::agent_conversation_entry_icon_variant;
use crate::ui_components::avatar::{Avatar, AvatarContent};
use crate::ui_components::icon_with_status::render_icon_with_status;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
use crate::view_components::compactible_action_button::{
CompactibleActionButton, MEDIUM_SIZE_SWITCH_THRESHOLD,
};
use crate::view_components::dropdown::{
Dropdown, DropdownAction, DropdownItemAction, DropdownStyle,
};
use crate::view_components::{DismissibleToast, FilterableDropdown};
use crate::workflows::WorkflowType;
use crate::workspace::{
ForkedConversationDestination, RestoreConversationLayout, ToastStack, WorkspaceAction,
};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{send_telemetry_from_ctx, AgentModeEntrypoint};
lazy_static! {
static ref HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
}
const MANAGEMENT_PANEL_WIDTH: f32 = 400.;
// Vertical margin for filter row elements to align with dropdown buttons
const FILTER_ROW_VERTICAL_MARGIN: f32 = 6.;
// Environment IDs are a fixed-length ServerId (22 chars), so keep this dropdown compact.
const ENV_DROPDOWN_WIDTH: f32 = 190.;
const CARD_ROW_SPACING: f32 = 8.;
const CARD_CONTENT_PADDING: f32 = 12.;
const CARD_BORDER_RADIUS: f32 = 4.;
const CARD_MARGIN_BOTTOM: f32 = 8.;
const BUTTON_SIZE: f32 = 20.;
/// Total size of the agent icon-with-status component rendered in each card's header row.
const CARD_AGENT_ICON_SIZE: f32 = 24.;
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) {
use crate::util::bindings::cmd_or_ctrl_shift;
app.register_fixed_bindings([FixedBinding::new(
cmd_or_ctrl_shift("f"),
AgentManagementViewAction::FocusSearch,
galaxyui::keymap::macros::id!(AgentManagementView::ui_name()),
)]);
}
fn should_show_artifacts(artifacts: &[Artifact]) -> bool {
!artifacts.is_empty() && FeatureFlag::ConversationArtifacts.is_enabled()
}
pub type ManagementCardItemId = AgentConversationEntryId;
/// Store state for a given task row
struct CardState {
hover_state: MouseStateHandle,
avatar_hover_state: MouseStateHandle,
session_status_hover_state: MouseStateHandle,
artifact_buttons_view: Option<ViewHandle<ArtifactButtonsRow>>,
action_buttons_hover_state: MouseStateHandle,
action_buttons_view: ViewHandle<ConversationActionButtonsRow>,
/// Use this ID to look up the full data from the model
item_id: ManagementCardItemId,
}
pub struct AgentManagementView {
list_state: ListState<()>,
loading_icon_mouse_state: MouseStateHandle,
scroll_state: ScrollStateHandle,
/// Store the most recent requested set of ConversationOrTasks on the view
items: Vec<CardState>,
/// Store filters on the data
filters: AgentManagementFilters,
/// Search query for filtering by title
search_query: String,
search_editor: ViewHandle<EditorView>,
/// Whether the user has dismissed the setup guide
has_dismissed_setup_guide: bool,
/// Whether the user is viewing the setup guide (toggled via button)
is_viewing_setup_guide: bool,
setup_guide_button: CompactibleActionButton,
new_agent_button: CompactibleActionButton,
view_agents_button: ViewHandle<ActionButton>,
/// Agent type selector modal
agent_type_selector: ViewHandle<AgentTypeSelector>,
is_agent_type_selector_open: bool,
cloud_setup_guide_view: ViewHandle<CloudSetupGuideView>,
all_filter_button: ViewHandle<ActionButton>,
personal_filter_button: ViewHandle<ActionButton>,
status_dropdown: ViewHandle<Dropdown<AgentManagementViewAction>>,
source_dropdown: ViewHandle<Dropdown<AgentManagementViewAction>>,
created_on_dropdown: ViewHandle<Dropdown<AgentManagementViewAction>>,
artifact_dropdown: ViewHandle<Dropdown<AgentManagementViewAction>>,
harness_dropdown: ViewHandle<Dropdown<AgentManagementViewAction>>,
environment_dropdown: ViewHandle<FilterableDropdown<AgentManagementViewAction>>,
creator_dropdown: ViewHandle<FilterableDropdown<AgentManagementViewAction>>,
clear_all_filters_button: ViewHandle<ActionButton>,
no_filter_results_button: ViewHandle<ActionButton>,
/// Details panel for showing task/conversation metadata
details_panel: ViewHandle<ConversationDetailsPanel>,
/// Currently selected item ID (for rendering details)
selected_item_id: Option<ManagementCardItemId>,
}
/// Enum to track the state of the view, based on what tasks we have visible
enum ViewState {
/// The model is still loading in data
Loading,
/// Showing the setup guide (this is the zero state when has_tasks is false)
SetupGuide { has_items: bool },
/// We have tasks, but currently have a filter applied that matches none of them
NoFilterMatches,
/// We have tasks that should be shown to the user
HasTasks,
}
impl AgentManagementView {
pub fn new(
persisted_filters: Option<PersistedAgentManagementFilters>,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(
&AgentConversationsModel::handle(ctx),
Self::handle_agent_management_model_event,
);
ctx.subscribe_to_model(
&HarnessAvailabilityModel::handle(ctx),
|me, _, _event, ctx| {
me.update_harness_dropdown(ctx);
},
);
let list_state = Self::construct_fresh_list_state(ctx.handle());
let all_filter_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("All", NakedTheme)
.with_size(ButtonSize::Small)
.with_tooltip("View your agent tasks plus all shared team tasks")
.on_click(|ctx| {
ctx.dispatch_typed_action(AgentManagementViewAction::SetOwnerFilter(
OwnerFilter::All,
))
})
});
let personal_filter_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Personal", NakedTheme)
.with_size(ButtonSize::Small)
.with_tooltip("View agent tasks you created")
.on_click(|ctx| {
ctx.dispatch_typed_action(AgentManagementViewAction::SetOwnerFilter(
OwnerFilter::PersonalOnly,
))
})
});
let setup_guide_button = CompactibleActionButton::new(
"Get started".to_string(),
None,
ButtonSize::Small,
AgentManagementViewAction::ToggleSetupGuide,
Icon::HelpCircle,
Arc::new(SecondaryTheme),
ctx,
);
let view_agents_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("View Agents", NakedTheme)
.with_size(ButtonSize::Small)
.with_icon(Icon::ArrowLeft)
.on_click(|ctx| {
ctx.dispatch_typed_action(AgentManagementViewAction::ToggleSetupGuide)
})
});
// Set up dropdowns
let status_dropdown = ctx.add_typed_action_view(Self::create_status_dropdown);
let source_dropdown = ctx.add_typed_action_view(Self::create_source_dropdown);
let created_on_dropdown = ctx.add_typed_action_view(Self::create_created_on_dropdown);
let artifact_dropdown = ctx.add_typed_action_view(Self::create_artifact_dropdown);
let harness_dropdown = ctx.add_typed_action_view(Self::create_harness_dropdown);
let environment_dropdown = ctx.add_typed_action_view(Self::create_environment_dropdown);
let creator_dropdown = ctx.add_typed_action_view(Self::create_creator_dropdown);
let no_filter_results_button = ctx.add_typed_action_view(move |_ctx| {
ActionButton::new("Clear filters", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(move |ctx| {
ctx.dispatch_typed_action(AgentManagementViewAction::ClearFilters)
})
});
let clear_all_filters_button = ctx.add_typed_action_view(move |_ctx| {
ActionButton::new("Clear all", NakedTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::Small)
.on_click(move |ctx| {
ctx.dispatch_typed_action(AgentManagementViewAction::ClearFilters)
})
});
let cloud_setup_guide_view = ctx.add_typed_action_view(CloudSetupGuideView::new);
ctx.subscribe_to_view(&cloud_setup_guide_view, |_, _, event, ctx| match event {
CloudSetupGuideEvent::OpenNewTabAndInsertWorkflow(workflow) => {
ctx.emit(AgentManagementViewEvent::OpenNewTabAndRunWorkflow(
Box::new(workflow.clone()),
));
}
});
let search_editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::handle(ctx).as_ref(ctx);
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(appearance.ui_font_size()), appearance),
select_all_on_focus: true,
clear_selections_on_blur: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::Always,
..Default::default()
},
ctx,
);
editor.set_placeholder_text("Search", ctx);
editor
});
ctx.subscribe_to_view(&search_editor, |me, _handle, event, ctx| {
me.handle_search_editor_event(event, ctx);
});
let new_agent_button = CompactibleActionButton::new(
"New agent".to_string(),
None,
ButtonSize::Small,
AgentManagementViewAction::ShowAgentTypeSelector,
Icon::Plus,
Arc::new(PrimaryTheme),
ctx,
);
let agent_type_selector = ctx.add_typed_action_view(AgentTypeSelector::new);
ctx.subscribe_to_view(&agent_type_selector, Self::handle_agent_type_selector_event);
let has_dismissed_setup_guide = *AISettings::as_ref(ctx).did_dismiss_cloud_setup_guide;
let filters = persisted_filters.map(|p| p.filters).unwrap_or_default();
let details_panel: ViewHandle<ConversationDetailsPanel> =
ctx.add_typed_action_view(|ctx| {
ConversationDetailsPanel::new(true, MANAGEMENT_PANEL_WIDTH, ctx)
});
ctx.subscribe_to_view(&details_panel, Self::handle_details_panel_event);
let mut view = Self {
list_state,
scroll_state: ScrollStateHandle::default(),
items: Vec::new(),
filters,
search_query: String::new(),
search_editor,
has_dismissed_setup_guide,
is_viewing_setup_guide: false,
setup_guide_button,
view_agents_button,
cloud_setup_guide_view,
loading_icon_mouse_state: MouseStateHandle::default(),
all_filter_button,
personal_filter_button,
status_dropdown,
source_dropdown,
created_on_dropdown,
artifact_dropdown,
harness_dropdown,
environment_dropdown,
creator_dropdown,
clear_all_filters_button,
no_filter_results_button,
new_agent_button,
agent_type_selector,
is_agent_type_selector_open: false,
details_panel,
selected_item_id: None,
};
view.update_filter_buttons(ctx);
view.sync_with_loaded_filters(ctx);
view.update_creator_dropdown(ctx);
view.update_environment_dropdown(ctx);
// Trigger server fetch if persisted filters differ from defaults
// (team tasks are not loaded at startup, so we need to fetch them)
if view.filters != AgentManagementFilters::default() {
view.trigger_filter_fetch(ctx);
}
view.get_tasks_from_model(ctx);
view
}
fn get_view_state(&self, app: &AppContext) -> ViewState {
let model = AgentConversationsModel::as_ref(app);
let has_items = model.has_items();
// If loading with zero items, show skeleton cards
// If loading with items, show list of interactive conversations (with loading indicator in header)
if model.is_loading() && !has_items {
return ViewState::Loading;
}
// Show setup guide if: no items (zero state) or user clicked button to toggle on the guide
if !has_items || self.is_viewing_setup_guide {
return ViewState::SetupGuide { has_items };
}
if self.items.is_empty() {
return ViewState::NoFilterMatches;
}
ViewState::HasTasks
}
fn update_filter_buttons(&mut self, ctx: &mut ViewContext<Self>) {
let owners = self.filters.owners;
self.all_filter_button.update(ctx, |button, ctx| {
button.set_active(owners == OwnerFilter::All, ctx);
});
self.personal_filter_button.update(ctx, |button, ctx| {
button.set_active(owners == OwnerFilter::PersonalOnly, ctx);
});
}
/// Sync the UI to match the loaded filter states.
fn sync_with_loaded_filters(&mut self, ctx: &mut ViewContext<Self>) {
self.status_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(
AgentManagementViewAction::SetStatusFilter(self.filters.status),
ctx,
);
});
self.source_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(
AgentManagementViewAction::SetSourceFilter(self.filters.source.clone()),
ctx,
);
});
self.created_on_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(
AgentManagementViewAction::SetCreatedOnFilter(self.filters.created_on),
ctx,
);
});
self.artifact_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(
AgentManagementViewAction::SetArtifactFilter(self.filters.artifact),
ctx,
);
});
self.harness_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(
AgentManagementViewAction::SetHarnessFilter(self.filters.harness),
ctx,
);
});
}
/// Get current filters for persistence.
pub fn get_filters(&self) -> PersistedAgentManagementFilters {
PersistedAgentManagementFilters {
filters: self.filters.clone(),
}
}
fn construct_fresh_list_state(handle: WeakViewHandle<Self>) -> ListState<()> {
ListState::new(move |index, _scroll_offset, app| {
let Some(view_handle) = handle.upgrade(app) else {
return Empty::new().finish();
};
view_handle.as_ref(app).render_card_at_index(index, app)
})
}
fn create_status_dropdown(
ctx: &mut ViewContext<Dropdown<AgentManagementViewAction>>,
) -> Dropdown<AgentManagementViewAction> {
let (magenta, green, red) = {
let theme = Appearance::as_ref(ctx).theme();
(
theme.ansi_fg_magenta(),
theme.ansi_fg_green(),
theme.ansi_fg_red(),
)
};
let mut dropdown = Dropdown::new(ctx);
Self::setup_filter_menu(&mut dropdown, "Status", ctx);
// Use this helper to make dropdown items with status icons
let make_status_option =
|label: &str, action: AgentManagementViewAction, icon_data: Option<(Icon, Fill)>| {
let mut fields = MenuItemFields::new(label)
.with_on_select_action(DropdownAction::select_action_and_close(action));
if let Some((icon, color)) = icon_data {
fields = fields.with_icon(icon).with_override_icon_color(color);
}
MenuItem::Item(fields)
};
let items = vec![
make_status_option(
"All",
AgentManagementViewAction::SetStatusFilter(StatusFilter::All),
None,
),
make_status_option(
"Working",
AgentManagementViewAction::SetStatusFilter(StatusFilter::Working),
Some((Icon::ClockLoader, Fill::from(magenta))),
),
make_status_option(
"Done",
AgentManagementViewAction::SetStatusFilter(StatusFilter::Done),
Some((Icon::Check, Fill::from(green))),
),
make_status_option(
"Failed",
AgentManagementViewAction::SetStatusFilter(StatusFilter::Failed),
Some((Icon::X, Fill::from(red))),
),
];
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
dropdown
}
fn create_source_dropdown(
ctx: &mut ViewContext<Dropdown<AgentManagementViewAction>>,
) -> Dropdown<AgentManagementViewAction> {
let mut dropdown = Dropdown::new(ctx);
Self::setup_filter_menu(&mut dropdown, "Source", ctx);
// Set a max height so we can fit all of the source options without scrolling
dropdown.set_menu_max_height(200., ctx);
let items = Self::build_source_dropdown_items();
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
dropdown
}
/// Build the list of source filter items.
fn build_source_dropdown_items() -> Vec<MenuItem<DropdownAction>> {
// Build up the sources list
let mut sources = vec![
AgentSource::WebApp,
AgentSource::CloudMode,
AgentSource::AgentWebhook,
AgentSource::Cli,
];
if FeatureFlag::InteractiveConversationManagementView.is_enabled() {
sources.push(AgentSource::Interactive)
}
sources.push(AgentSource::Linear);
sources.push(AgentSource::Slack);
if FeatureFlag::ScheduledAmbientAgents.is_enabled() {
sources.push(AgentSource::ScheduledAgent);
}
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetSourceFilter(SourceFilter::All),
),
),
)];
for source in sources {
items.push(MenuItem::Item(
MenuItemFields::new(source.display_name()).with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetSourceFilter(SourceFilter::Specific(source)),
),
),
));
}
items
}
/// Update the source dropdown items when tasks change.
fn update_source_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
let items = Self::build_source_dropdown_items();
self.source_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_action(
AgentManagementViewAction::SetSourceFilter(self.filters.source.clone()),
ctx,
);
});
}
fn create_created_on_dropdown(
ctx: &mut ViewContext<Dropdown<AgentManagementViewAction>>,
) -> Dropdown<AgentManagementViewAction> {
let mut dropdown = Dropdown::new(ctx);
Self::setup_filter_menu(&mut dropdown, "Created on", ctx);
let items = vec![
MenuItem::Item(MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::All),
),
)),
MenuItem::Item(MenuItemFields::new("Last 24 hours").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::Last24Hours),
),
)),
MenuItem::Item(MenuItemFields::new("Past 3 days").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::Past3Days),
),
)),
MenuItem::Item(MenuItemFields::new("Last week").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::LastWeek),
),
)),
];
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
dropdown
}
fn create_artifact_dropdown(
ctx: &mut ViewContext<Dropdown<AgentManagementViewAction>>,
) -> Dropdown<AgentManagementViewAction> {
let mut dropdown = Dropdown::new(ctx);
Self::setup_filter_menu(&mut dropdown, "Has artifact", ctx);
let items = vec![
MenuItem::Item(MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::All),
),
)),
MenuItem::Item(MenuItemFields::new("Pull Request").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::PullRequest),
),
)),
MenuItem::Item(MenuItemFields::new("Plan").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::Plan),
),
)),
MenuItem::Item(MenuItemFields::new("Screenshot").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::Screenshot),
),
)),
MenuItem::Item(MenuItemFields::new("File").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::File),
),
)),
];
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
dropdown
}
fn create_harness_dropdown(
ctx: &mut ViewContext<Dropdown<AgentManagementViewAction>>,
) -> Dropdown<AgentManagementViewAction> {
let mut dropdown = Dropdown::new(ctx);
Self::setup_filter_menu(&mut dropdown, "Harness", ctx);
let items = Self::build_harness_dropdown_items(ctx);
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
dropdown
}
fn build_harness_dropdown_items(app: &AppContext) -> Vec<MenuItem<DropdownAction>> {
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::All),
),
),
)];
let availability = HarnessAvailabilityModel::as_ref(app);
for entry in availability.available_harnesses() {
let harness = entry.harness;
let mut fields = MenuItemFields::new(entry.display_name.clone())
.with_icon(harness_display::icon_for(harness))
.with_on_select_action(DropdownAction::select_action_and_close(
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::Specific(harness)),
));
if let Some(color) = harness_display::brand_color(harness) {
fields = fields.with_override_icon_color(Fill::from(color));
}
items.push(MenuItem::Item(fields));
}
items
}
fn create_environment_dropdown(
ctx: &mut ViewContext<FilterableDropdown<AgentManagementViewAction>>,
) -> FilterableDropdown<AgentManagementViewAction> {
let mut dropdown = FilterableDropdown::new(ctx);
Self::setup_searchable_filter_menu(&mut dropdown, "Environment", ctx);
// Keep the button compact when a specific environment ID is selected by abbreviating the
// displayed ID. (The dropdown menu still shows the full ID.)
dropdown.set_menu_header_text_override(|text| {
if matches!(text, "All" | "None") {
return format!("Environment: {text}");
}
let abbreviated = text.chars().take(6).collect::<String>();
if abbreviated == text {
format!("Environment: {text}")
} else {
format!("Environment: {abbreviated}…")
}
});
dropdown.set_top_bar_max_width(ENV_DROPDOWN_WIDTH);
dropdown.set_menu_width(ENV_DROPDOWN_WIDTH, ctx);
dropdown
}
fn create_creator_dropdown(
ctx: &mut ViewContext<FilterableDropdown<AgentManagementViewAction>>,
) -> FilterableDropdown<AgentManagementViewAction> {
let mut dropdown = FilterableDropdown::new(ctx);
Self::setup_searchable_filter_menu(&mut dropdown, "Created by", ctx);
dropdown
}
// Initialize the dropdown menu for the filter dropdowns (status, source)
fn setup_filter_menu<A: DropdownItemAction>(
dropdown: &mut Dropdown<A>,
label_prefix: &'static str,
ctx: &mut ViewContext<Dropdown<A>>,
) {
dropdown.set_menu_width(160., ctx);
dropdown.set_main_axis_size(MainAxisSize::Min, ctx);
dropdown.set_menu_header_text_override(move |text| format!("{}: {}", label_prefix, text));
dropdown.set_style(DropdownStyle::ActionButtonSecondary, ctx);
}
// Initialize the dropdown menu for the searchable filter dropdowns (creator)
fn setup_searchable_filter_menu<A: DropdownItemAction>(
dropdown: &mut FilterableDropdown<A>,
label_prefix: &'static str,
ctx: &mut ViewContext<FilterableDropdown<A>>,
) {
dropdown.set_menu_width(320., ctx);
dropdown.set_main_axis_size(MainAxisSize::Min, ctx);
dropdown.set_menu_header_text_override(move |text| format!("{}: {}", label_prefix, text));
dropdown.set_button_variant(ButtonVariant::Secondary);
}
fn update_harness_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
let items = Self::build_harness_dropdown_items(ctx);
self.harness_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_rich_items(items, ctx);
});
}
/// Since the valid set of environments depends on what tasks we have loaded in,
/// we use this function to update the available options depending on the most recent
/// set of tasks.
fn update_environment_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
let model = AgentConversationsModel::as_ref(ctx);
let envs = model.get_all_environment_ids_and_names(ctx);
let selected_name = match &self.filters.environment {
EnvironmentFilter::All => Some("All".to_string()),
EnvironmentFilter::NoEnvironment => Some("None".to_string()),
EnvironmentFilter::Specific(id) => envs.get(id).cloned(),
};
self.environment_dropdown.update(ctx, |dropdown, ctx| {
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetEnvironmentFilter(EnvironmentFilter::All),
),
),
)];
items.push(MenuItem::Item(
MenuItemFields::new("None").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetEnvironmentFilter(
EnvironmentFilter::NoEnvironment,
),
),
),
));
let mut sorted_envs: Vec<_> = envs.into_iter().collect();
sorted_envs.sort_by(|(_, name_a), (_, name_b)| name_a.cmp(name_b));
for (environment_id, environment_name) in sorted_envs {
items.push(MenuItem::Item(
MenuItemFields::new(environment_name).with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetEnvironmentFilter(
EnvironmentFilter::Specific(environment_id),
),
),
),
));
}
dropdown.set_rich_items(items, ctx);
if let Some(selected_name) = selected_name {
dropdown.set_selected_by_name(&selected_name, ctx);
}
});
}
fn update_creator_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
let creators = AgentConversationsModel::as_ref(ctx).get_all_creators(ctx);
let creator_filter_name = match &self.filters.creator {
CreatorFilter::All => "All",
CreatorFilter::Specific { name, .. } => name,
};
self.creator_dropdown.update(ctx, |dropdown, ctx| {
let mut items = vec![MenuItem::Item(
MenuItemFields::new("All").with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatorFilter(CreatorFilter::All),
),
),
)];
for (name, uid) in creators {
items.push(MenuItem::Item(
MenuItemFields::new(&name).with_on_select_action(
DropdownAction::select_action_and_close(
AgentManagementViewAction::SetCreatorFilter(CreatorFilter::Specific {
name,
uid,
}),
),
),
));
}
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_name(creator_filter_name, ctx);
});
}
fn handle_search_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
if let EditorEvent::Edited(_) = event {
let new_query = self.search_editor.as_ref(ctx).buffer_text(ctx);
if new_query != self.search_query {
self.search_query = new_query;
self.get_tasks_from_model(ctx);
}
}
}
/// Common handler for filter changes: fetch, refresh tasks, and save.
fn on_filter_changed(&mut self, ctx: &mut ViewContext<Self>) {
self.trigger_filter_fetch(ctx);
self.get_tasks_from_model(ctx);
ctx.dispatch_global_action("workspace:save_app", ());
}
/// Trigger a server fetch for tasks matching current filters.
fn trigger_filter_fetch(&self, ctx: &mut ViewContext<Self>) {
let current_user_uid = AuthStateProvider::handle(ctx)
.as_ref(ctx)
.get()
.user_id()
.map(|uid| uid.as_string());
if let Some(uid) = current_user_uid {
let filters = self.filters.clone();
AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
model.fetch_tasks_for_filters(&filters, &uid, ctx);
});
}
}
/// Shows the setup guide from a deep-link/action without toggling it off on repeated calls.
pub(crate) fn show_setup_guide_from_link(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_viewing_setup_guide {
send_telemetry_from_ctx!(AgentManagementTelemetryEvent::OpenSetupGuide, ctx);
}
self.is_viewing_setup_guide = true;
ctx.notify();
}
#[cfg(test)]
pub(crate) fn is_showing_setup_guide(&self) -> bool {
self.is_viewing_setup_guide
}
pub(crate) fn apply_environment_filter_from_link(
&mut self,
environment_id: String,
ctx: &mut ViewContext<Self>,
) {
// This navigation should show the team/global task runs list.
self.filters.owners = OwnerFilter::All;
self.filters.reset_all_but_owner();
self.filters.environment = EnvironmentFilter::Specific(environment_id);
self.update_filter_buttons(ctx);
// Clear search query.
self.search_query.clear();
self.search_editor.update(ctx, |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
// Reset the selected states for the dropdowns.
self.status_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.source_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.created_on_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.artifact_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.harness_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.update_environment_dropdown(ctx);
self.update_creator_dropdown(ctx);
self.on_filter_changed(ctx);
}
/// Sync all tasks from the management model, and update the ListState
fn get_tasks_from_model(&mut self, ctx: &mut ViewContext<Self>) {
// Collect all card data we need
struct CardData {
item_id: ManagementCardItemId,
artifacts: Vec<Artifact>,
action_buttons_config: ActionButtonsConfig,
}
// Get sorted tasks and conversations from model
let model = AgentConversationsModel::as_ref(ctx);
let search_query = self.search_query.trim().to_lowercase();
let cards: Vec<CardData> = model
.get_entries(&self.filters, ctx)
.into_iter()
.filter(|entry| {
if search_query.is_empty() {
return true;
}
match_indices_case_insensitive(&entry.display.title, &search_query).is_some()
})
.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,
)
})
.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);
}
CardData {
item_id,
artifacts: entry.display.artifacts,
action_buttons_config: config,
}
})
.collect();
// Drain old state cards for reuse
let mut old_items: HashMap<String, CardState> = self
.items
.drain(..)
.map(|i| (i.item_id.as_key(), i))
.collect();
// Reset the list state, but save scroll position
// We rebuild from scratch because items may have moved around in the view (if updated_at changes, for example)
let current_scroll_index = self.list_state.get_scroll_index();
let current_scroll_offset = self.list_state.get_scroll_offset();
self.list_state = Self::construct_fresh_list_state(ctx.handle());
// Rebuild the list state
let mut new_items = Vec::with_capacity(cards.len());
for card in cards {
self.list_state.add_item();
let card_key = card.item_id.as_key();
if let Some(mut existing) = old_items.remove(&card_key) {
// Update artifacts view if it exists, or create if needed
if should_show_artifacts(&card.artifacts) {
if let Some(view) = &existing.artifact_buttons_view {
view.update(ctx, |v, ctx| v.update_artifacts(&card.artifacts, ctx));
} else {
existing.artifact_buttons_view =
Some(self.create_artifact_buttons_view(&card.artifacts, ctx));
}
} else {
existing.artifact_buttons_view = None;
}
existing.action_buttons_view.update(ctx, |row, ctx| {
row.set_config(card.action_buttons_config, ctx)
});
new_items.push(existing);
} else {
let artifact_buttons_view = if should_show_artifacts(&card.artifacts) {
Some(self.create_artifact_buttons_view(&card.artifacts, ctx))
} else {
None
};
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(),
avatar_hover_state: MouseStateHandle::default(),
session_status_hover_state: MouseStateHandle::default(),
action_buttons_hover_state: MouseStateHandle::default(),
artifact_buttons_view,
action_buttons_view,
item_id: card.item_id,
});
}
}
// Restore approximate scroll position for the new list state
let num_items = new_items.len();
if num_items != 0 {
if current_scroll_index > num_items {
// If we have fewer items than we used to, scroll to the bottom of the list
self.list_state.scroll_to(num_items - 1);
} else {
self.list_state
.scroll_to_with_offset(current_scroll_index, current_scroll_offset);
}
}
self.items = new_items;
ctx.notify();
}
fn create_action_buttons_view(
&self,
item_id: ManagementCardItemId,
config: ActionButtonsConfig,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<ConversationActionButtonsRow> {
let view = ctx.add_typed_action_view(ConversationActionButtonsRow::new);
ctx.subscribe_to_view(&view, move |me, _, event, ctx| {
me.handle_action_buttons_event(&item_id, event, ctx);
});
view.update(ctx, |row, ctx| row.set_config(config, ctx));
view
}
fn action_buttons_config_for_entry(
entry: &AgentConversationEntry,
open_action: Option<WorkspaceAction>,
copy_link_url: Option<String>,
) -> 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,
event: &AgentDetailsButtonEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AgentDetailsButtonEvent::Open => {
// Open button only shown in details panel, not in management view cards.
// We open the cards directly via clicking on them.
}
AgentDetailsButtonEvent::CancelTask { task_id } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::CloudRunCancelled {
task_id: task_id.to_string(),
},
ctx
);
cancel_task_with_toast(*task_id, ctx);
}
AgentDetailsButtonEvent::ForkConversation { conversation_id } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ConversationForked {
conversation_id: conversation_id.to_string(),
},
ctx
);
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
conversation_id: *conversation_id,
fork_from_exchange: None,
summarize_after_fork: false,
summarization_prompt: None,
initial_prompt: None,
initial_attachments: vec![],
destination: ForkedConversationDestination::NewTab,
});
}
AgentDetailsButtonEvent::ViewDetails { item_id } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::DetailsViewed {
item_id: item_id.as_key(),
viewed_from: OpenedFrom::ManagementView,
},
ctx
);
self.update_details_panel_for_item(item_id, ctx);
self.selected_item_id = Some(*item_id);
ctx.notify();
}
AgentDetailsButtonEvent::CopyLink { link } => {
match item_id {
ManagementCardItemId::Conversation(conversation_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ConversationLinkCopied {
conversation_id: conversation_id.to_string(),
copied_from: OpenedFrom::ManagementView,
},
ctx
);
}
ManagementCardItemId::AmbientRun(task_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SessionLinkCopied {
task_id: task_id.to_string(),
copied_from: OpenedFrom::ManagementView,
},
ctx
);
}
}
ctx.clipboard()
.write(ClipboardContent::plain_text(link.clone()));
}
}
}
fn create_artifact_buttons_view(
&self,
artifacts: &[Artifact],
ctx: &mut ViewContext<Self>,
) -> ViewHandle<ArtifactButtonsRow> {
let view = ctx.add_typed_action_view(|ctx| ArtifactButtonsRow::new(artifacts, ctx));
ctx.subscribe_to_view(&view, Self::handle_artifact_buttons_event);
view
}
fn handle_artifact_buttons_event(
&mut self,
_view: ViewHandle<ArtifactButtonsRow>,
event: &ArtifactButtonsRowEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ArtifactButtonsRowEvent::OpenPlan { notebook_uid } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ArtifactClicked {
artifact_type: ArtifactType::Plan
},
ctx
);
ctx.emit(AgentManagementViewEvent::OpenPlanNotebook {
notebook_uid: *notebook_uid,
});
}
ArtifactButtonsRowEvent::CopyBranch { branch } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ArtifactClicked {
artifact_type: ArtifactType::Branch
},
ctx
);
ctx.clipboard()
.write(ClipboardContent::plain_text(branch.clone()));
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default("Copied branch name".to_string());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
ArtifactButtonsRowEvent::OpenPullRequest { url } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ArtifactClicked {
artifact_type: ArtifactType::PullRequest
},
ctx
);
ctx.open_url(url);
}
ArtifactButtonsRowEvent::ViewScreenshots { artifact_uids } => {
crate::ai::artifacts::open_screenshot_lightbox(artifact_uids, ctx);
}
ArtifactButtonsRowEvent::DownloadFile { artifact_uid } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ArtifactClicked {
artifact_type: ArtifactType::File
},
ctx
);
crate::ai::artifacts::download_file_artifact(artifact_uid, ctx);
}
}
}
fn handle_agent_management_model_event(
&mut self,
_model: ModelHandle<AgentConversationsModel>,
event: &AgentConversationsModelEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AgentConversationsModelEvent::ConversationsLoaded
| AgentConversationsModelEvent::NewTasksReceived
| AgentConversationsModelEvent::TasksUpdated => {
self.update_creator_dropdown(ctx);
self.update_environment_dropdown(ctx);
self.update_source_dropdown(ctx);
self.refresh_details_panel_if_needed(ctx);
self.get_tasks_from_model(ctx);
}
AgentConversationsModelEvent::ConversationUpdated { kind } => {
self.handle_conversation_updated(*kind, ctx);
}
AgentConversationsModelEvent::ConversationArtifactsUpdated { conversation_id } => {
self.update_artifacts_for_conversation(*conversation_id, ctx);
self.refresh_details_panel_if_needed(ctx);
}
}
}
/// Refresh the details panel if it's currently showing an item
fn refresh_details_panel_if_needed(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(item_id) = self.selected_item_id {
self.update_details_panel_for_item(&item_id, ctx);
}
}
/// Decide how much work a `ConversationUpdated` event requires, based on its kind and the
/// active status filter:
/// * `Restored`: the underlying status didn't change, so the visible cards don't change
/// either. Just refresh the details panel.
/// * `MetadataChanged`: rebuild the cards so metadata-derived actions update.
/// * `TitleChanged`: rebuild the cards so filtering and titles update.
/// * `StatusSet` that crosses the active status filter: rebuild the
/// card list via `get_tasks_from_model`.
/// * `StatusSet` that doesn't cross the active filter (or `All` is active):
/// just refresh the details panel re-render so the status icon picks up the new value.
fn handle_conversation_updated(
&mut self,
kind: ConversationUpdateKind,
ctx: &mut ViewContext<Self>,
) {
match kind {
ConversationUpdateKind::Restored => {}
ConversationUpdateKind::MetadataChanged => self.get_tasks_from_model(ctx),
ConversationUpdateKind::TitleChanged => self.get_tasks_from_model(ctx),
ConversationUpdateKind::StatusSet {
prev_filter,
new_filter,
} => {
if self
.filters
.status
.is_membership_crossed(prev_filter, new_filter)
{
self.get_tasks_from_model(ctx);
} else {
ctx.notify();
}
}
}
self.refresh_details_panel_if_needed(ctx);
}
/// Update the details panel with fresh data for the given item.
fn update_details_panel_for_item(
&mut self,
item_id: &ManagementCardItemId,
ctx: &mut ViewContext<Self>,
) {
let model = AgentConversationsModel::as_ref(ctx);
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);
});
}
/// Update just the artifact buttons for a specific conversation
fn update_artifacts_for_conversation(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let model = AgentConversationsModel::as_ref(ctx);
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 = 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));
} else {
let new_view = self.create_artifact_buttons_view(&artifacts, ctx);
self.items[index].artifact_buttons_view = Some(new_view);
}
} else {
self.items[index].artifact_buttons_view = None;
}
ctx.notify();
}
fn handle_details_panel_event(
&mut self,
_view: ViewHandle<ConversationDetailsPanel>,
event: &ConversationDetailsPanelEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ConversationDetailsPanelEvent::Close => {
self.selected_item_id = None;
ctx.notify();
}
ConversationDetailsPanelEvent::OpenPlanNotebook { notebook_uid } => {
ctx.emit(AgentManagementViewEvent::OpenPlanNotebook {
notebook_uid: *notebook_uid,
});
}
}
}
fn handle_agent_type_selector_event(
&mut self,
_view: ViewHandle<AgentTypeSelector>,
event: &AgentTypeSelectorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AgentTypeSelectorEvent::Selected(agent_type) => {
self.is_agent_type_selector_open = false;
match agent_type {
AgentType::Cloud => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SpawnNewCloudAgent,
ctx
);
ctx.dispatch_typed_action(&WorkspaceAction::AddAmbientAgentTab);
}
AgentType::Local => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SpawnNewLocalAgent,
ctx
);
ctx.dispatch_typed_action(&WorkspaceAction::NewTabInAgentMode {
entrypoint: AgentModeEntrypoint::AgentManagementView,
zero_state_prompt_suggestion_type: None,
});
}
}
ctx.notify();
}
AgentTypeSelectorEvent::Dismissed => {
self.is_agent_type_selector_open = false;
ctx.notify();
}
}
}
fn build_avatar(name: &str, appearance: &Appearance) -> Container {
let theme = appearance.theme();
// Use a hash function for a range of colors in the IDs
let mut hasher = *HASHER;
name.hash(&mut hasher);
let hash = hasher.finish();
let color = match hash % 6 {
0 => theme.ansi_fg_red(),
1 => theme.ansi_fg_blue(),
2 => theme.ansi_fg_green(),
3 => theme.ansi_fg_yellow(),
4 => theme.ansi_fg_magenta(),
_ => theme.ansi_fg_cyan(),
};
Avatar::new(
AvatarContent::DisplayName(name.to_string()),
UiComponentStyles {
width: Some(BUTTON_SIZE),
height: Some(BUTTON_SIZE),
font_size: Some(CREATOR_AVATAR_FONT_SIZE),
font_family_id: Some(appearance.monospace_font_family()),
font_weight: Some(Weight::Bold),
font_color: Some(theme.surface_1().into()),
background: Some(color.into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(BUTTON_SIZE / 2.0))),
..Default::default()
},
)
.build()
}
fn render_avatar_with_tooltip(
creator_name: &str,
appearance: &Appearance,
mouse_state: MouseStateHandle,
) -> Box<dyn Element> {
let avatar = Self::build_avatar(creator_name, appearance);
let tooltip_text = creator_name.to_string();
let ui_builder = appearance.ui_builder().clone();
// Add a tooltip displaying the creator's full name
Hoverable::new(mouse_state, move |state| {
let mut stack = Stack::new().with_child(avatar.finish());
if state.is_hovered() {
let tooltip = ui_builder.tool_tip(tooltip_text.clone()).build().finish();
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
stack.finish()
})
.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<dyn Element> {
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<dyn Element> {
let theme = appearance.theme();
// Gradient values taken from the placeholders in the code review pane
let base_gradient_color_start = internal_colors::neutral_3(theme);
let mut base_gradient_color_end = base_gradient_color_start;
base_gradient_color_end.a = 26;
// Helper to create a gradient placeholder rect
let placeholder = |max_width: f32, height: f32| {
ConstrainedBox::new(
Rect::new()
.with_horizontal_background_gradient(
base_gradient_color_start,
base_gradient_color_end,
)
.finish(),
)
.with_max_width(max_width)
.with_height(height)
.finish()
};
let header_row = placeholder(180., 14.);
let metadata_row = placeholder(300., 12.);
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(8.)
.with_child(header_row)
.with_child(metadata_row)
.finish();
Container::new(content)
.with_background(theme.surface_1())
.with_border(Border::all(1.).with_border_fill(theme.surface_2()))
.with_uniform_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
}
fn render_loading_state(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let skeleton_cards = (0..5).map(|_| self.render_skeleton_card(appearance));
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_children(skeleton_cards)
.with_spacing(8.)
.finish()
}
fn render_card_at_index(&self, index: usize, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let Some(card_state) = self.items.get(index) else {
return Empty::new().finish();
};
let model = AgentConversationsModel::as_ref(app);
let Some(entry) = model.get_entry_by_id(&card_state.item_id, app) else {
return Empty::new().finish();
};
self.render_card(card_state, &entry, appearance, app)
}
fn render_card(
&self,
card_state: &CardState,
entry: &AgentConversationEntry,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let artifact_buttons_element = card_state.artifact_buttons_view.as_ref().and_then(|view| {
if view.as_ref(app).is_empty() || !FeatureFlag::ConversationArtifacts.is_enabled() {
None
} else {
Some(ChildView::new(view).finish())
}
});
let action_buttons_mouse_over = card_state
.action_buttons_hover_state
.lock()
.expect("action buttons hover state lock poisoned")
.is_mouse_over_element();
let action_buttons_is_empty = card_state.action_buttons_view.as_ref(app).is_empty();
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, 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 {
internal_colors::fg_overlay_2(theme)
} else {
internal_colors::fg_overlay_1(theme)
};
let card = Container::new(card_content.finish())
.with_background(card_background)
.with_border(Border::all(1.).with_border_fill(internal_colors::fg_overlay_2(theme)))
.with_uniform_padding(CARD_CONTENT_PADDING)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CARD_BORDER_RADIUS)))
.with_margin_top(CARD_MARGIN_BOTTOM)
.finish();
let mut stack = Stack::new().with_child(card);
if should_show_action_buttons && !action_buttons_is_empty {
let action_buttons =
Hoverable::new(card_state.action_buttons_hover_state.clone(), |_| {
Container::new(ChildView::new(&card_state.action_buttons_view).finish())
.with_background(theme.surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_uniform_padding(4.)
.with_drop_shadow(DropShadow {
color: ColorU::new(0, 0, 0, 77),
offset: vec2f(0., 4.),
blur_radius: 7.,
spread_radius: 0.,
})
.finish()
})
.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(
vec2f(-CARD_CONTENT_PADDING, 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::MiddleRight,
),
);
}
stack.finish()
})
.with_defer_events_to_children();
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 });
})
} else {
card_hoverable
};
card_hoverable.finish()
}
fn render_header_row(
card_state: &CardState,
entry: &AgentConversationEntry,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_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_icon_with_status(
agent_conversation_entry_icon_variant(entry),
CARD_AGENT_ICON_SIZE,
0.,
theme,
internal_colors::fg_overlay_1(theme),
);
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 = entry
.display
.creator
.name
.clone()
.unwrap_or_else(|| "Unknown".to_string());
let avatar = Self::render_avatar_with_tooltip(
&creator_name,
appearance,
card_state.avatar_hover_state.clone(),
);
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(2.)
.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()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.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.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()
}
fn render_metadata_row(
entry: &AgentConversationEntry,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
let mut metadata_parts = Vec::new();
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) = entry.display.harness {
metadata_parts.push(format!(
"Harness: {}",
availability.display_name_for(harness)
));
}
}
if let Some(executor) = &entry.display.executor {
let same_as_creator =
executor.uid.is_some() && executor.uid == entry.display.creator.uid;
if !same_as_creator {
if let Some(name) = executor.name.as_deref().or(executor.uid.as_deref()) {
let label = if executor
.principal_type
.is_some_and(|pt| pt.is_service_account())
{
"Agent"
} else {
"Executor"
};
metadata_parts.push(format!("{label}: {name}"));
}
}
}
if let Some(run_time) = &entry.display.run_time {
metadata_parts.push(format!("Run time: {run_time}"));
}
if let Some(usage) = entry.display.request_usage.map(format_credits) {
metadata_parts.push(format!("Credits used: {usage}"));
}
Text::new(metadata_parts.join(" • "), font_family, font_size)
.with_color(theme.nonactive_ui_text_color().into())
.finish()
}
// Render the main page header based on the current view state
fn render_header(&self, app: &AppContext) -> Box<dyn Element> {
match self.get_view_state(app) {
ViewState::Loading => self.render_loading_header(app),
ViewState::SetupGuide {
has_items: has_tasks,
} => self.render_setup_guide_header(has_tasks, app),
ViewState::NoFilterMatches | ViewState::HasTasks => self.render_task_list_header(app),
}
}
fn render_setup_guide_header(&self, has_tasks: bool, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let size_switch_threshold = MEDIUM_SIZE_SWITCH_THRESHOLD * appearance.monospace_ui_scalar();
let build_header = |use_expanded: bool| {
let mut header_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
if has_tasks {
header_row.add_child(ChildView::new(&self.view_agents_button).finish());
}
header_row.add_child(Expanded::new(1., Empty::new().finish()).finish());
if !has_tasks && !cfg!(target_family = "wasm") {
let button = if use_expanded {
self.new_agent_button.expanded_button()
} else {
self.new_agent_button.compact_button()
};
header_row.add_child(ChildView::new(button).finish());
}
header_row.finish()
};
SizeConstraintSwitch::new(
build_header(true),
vec![(
SizeConstraintCondition::WidthLessThan(size_switch_threshold),
build_header(false),
)],
)
.finish()
}
fn render_task_list_header(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let is_loading = AgentConversationsModel::handle(app)
.as_ref(app)
.is_loading();
let is_on_team = UserWorkspaces::as_ref(app).current_team().is_some();
let size_switch_threshold = MEDIUM_SIZE_SWITCH_THRESHOLD * appearance.monospace_ui_scalar();
let build_header = |use_expanded: bool| {
let title = Text::new_inline(
"Runs",
appearance.ui_font_family(),
appearance.ui_font_size() + 4.,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into())
.finish();
let setup_guide_button = if use_expanded {
self.setup_guide_button.expanded_button()
} else {
self.setup_guide_button.compact_button()
};
let new_agent_button = if use_expanded {
self.new_agent_button.expanded_button()
} else {
self.new_agent_button.compact_button()
};
let mut header_top = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_spacing(4.)
.with_child(Container::new(title).with_margin_right(12.).finish());
if is_on_team {
header_top.add_child(ChildView::new(&self.personal_filter_button).finish());
header_top.add_child(ChildView::new(&self.all_filter_button).finish());
}
if is_loading {
header_top.add_child(self.render_cloud_loading_icon(appearance));
}
header_top.add_child(Expanded::new(1., Empty::new().finish()).finish());
header_top.add_child(ChildView::new(setup_guide_button).finish());
if !cfg!(target_family = "wasm") {
header_top.add_child(ChildView::new(new_agent_button).finish());
}
let mut filters_wrap = Wrap::row()
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&self.status_dropdown).finish())
.with_child(ChildView::new(&self.source_dropdown).finish())
.with_child(ChildView::new(&self.created_on_dropdown).finish())
.with_child(ChildView::new(&self.artifact_dropdown).finish());
if HarnessAvailabilityModel::as_ref(app).should_show_harness_selector() {
filters_wrap.add_child(ChildView::new(&self.harness_dropdown).finish());
}
filters_wrap.add_child(ChildView::new(&self.environment_dropdown).finish());
if self.filters.owners != OwnerFilter::PersonalOnly {
filters_wrap.add_child(ChildView::new(&self.creator_dropdown).finish());
}
if self.filters.is_filtering() {
filters_wrap.add_child(ChildView::new(&self.clear_all_filters_button).finish());
}
let search_input = self.render_search_input(app);
let header_bottom = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Max)
.with_spacing(8.)
.with_child(Expanded::new(3., filters_wrap.finish()).finish())
.with_child(Expanded::new(1., search_input).finish())
.finish();
let header_bottom = Container::new(header_bottom).with_margin_top(4.).finish();
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(header_top.finish())
.with_child(header_bottom)
.finish()
};
SizeConstraintSwitch::new(
build_header(true),
vec![(
SizeConstraintCondition::WidthLessThan(size_switch_threshold),
build_header(false),
)],
)
.finish()
}
fn render_cloud_loading_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_builder = appearance.ui_builder().clone();
let icon_size = appearance.ui_font_size();
let loading_icon = ConstrainedBox::new(
Icon::Refresh
.to_galaxyui_icon(theme.sub_text_color(theme.surface_1()))
.finish(),
)
.with_height(icon_size)
.with_width(icon_size)
.finish();
Hoverable::new(self.loading_icon_mouse_state.clone(), move |mouse_state| {
let mut stack = Stack::new().with_child(loading_icon);
if mouse_state.is_hovered() {
let tooltip = ui_builder
.tool_tip(String::from("Loading cloud agent runs"))
.build()
.finish();
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., -8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
stack.finish()
})
.finish()
}
fn render_loading_header(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let title = Text::new_inline(
"Runs",
appearance.ui_font_family(),
appearance.ui_font_size() + 4.,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into())
.finish();
let loading_icon = ConstrainedBox::new(
Icon::Loading
.to_galaxyui_icon(Fill::Solid(internal_colors::neutral_6(theme)))
.finish(),
)
.with_height(appearance.ui_font_size() + 2.)
.with_width(appearance.ui_font_size() + 2.)
.finish();
let loading_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Container::new(loading_icon).with_margin_right(10.).finish())
.with_child(
Text::new_inline(
"Loading agents...",
appearance.ui_font_family(),
appearance.ui_font_size() + 2.,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into())
.finish(),
)
.finish();
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(12.)
.with_child(title)
.with_child(loading_row)
.finish()
}
fn render_search_input(&self, app: &AppContext) -> Box<dyn Element> {
let theme = Appearance::handle(app).as_ref(app).theme();
let search_row = Clipped::new(ChildView::new(&self.search_editor).finish()).finish();
Container::new(
ConstrainedBox::new(
Container::new(search_row)
.with_padding(
Padding::uniform(0.)
.with_vertical(5.)
.with_left(8.)
.with_right(8.),
)
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.with_height(30.)
.finish(),
)
.with_margin_top(FILTER_ROW_VERTICAL_MARGIN)
.with_margin_bottom(FILTER_ROW_VERTICAL_MARGIN)
.finish()
}
fn render_no_results_view(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let icon = ConstrainedBox::new(
Icon::FilterOff
.to_galaxyui_icon(appearance.theme().nonactive_ui_text_color())
.finish(),
)
.with_width(24.)
.with_height(24.)
.finish();
let text = Text::new_inline(
"No results matched your filters",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish();
Align::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(12.)
.with_child(icon)
.with_child(text)
.with_child(ChildView::new(&self.no_filter_results_button).finish())
.finish(),
)
.finish()
}
fn render_default_scroll_view(&self, app: &AppContext) -> Box<dyn Element> {
let theme = Appearance::as_ref(app).theme();
let axis_config = SingleAxisConfig::Manual {
handle: self.scroll_state.clone(),
child: NewScrollableElement::finish_scrollable(List::new(self.list_state.clone())),
};
NewScrollable::vertical(
axis_config,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::None, false))
.with_always_handle_events_first(false)
.finish()
}
}
impl Entity for AgentManagementView {
type Event = AgentManagementViewEvent;
}
impl View for AgentManagementView {
fn ui_name() -> &'static str {
"AgentManagementView"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.search_editor);
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let main_content = match self.get_view_state(app) {
ViewState::Loading => self.render_loading_state(app),
ViewState::SetupGuide { .. } => ChildView::new(&self.cloud_setup_guide_view).finish(),
ViewState::NoFilterMatches => self.render_no_results_view(app),
ViewState::HasTasks => self.render_default_scroll_view(app),
};
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(self.render_header(app))
.with_child(
Shrinkable::new(
1.,
Container::new(main_content).with_margin_top(12.).finish(),
)
.finish(),
)
.finish();
let centered = Align::new(content).top_center().finish();
let main_view = Container::new(centered).with_uniform_margin(16.).finish();
// Wrap main view with details panel if we have selected an item
let base_view = if self.selected_item_id.is_some() {
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Expanded::new(1., main_view).finish())
.with_child(ChildView::new(&self.details_panel).finish())
.finish()
} else {
main_view
};
if self.is_agent_type_selector_open {
Stack::new()
.with_child(base_view)
.with_child(ChildView::new(&self.agent_type_selector).finish())
.finish()
} else {
base_view
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AgentManagementViewAction {
SetOwnerFilter(OwnerFilter),
SetStatusFilter(StatusFilter),
SetSourceFilter(SourceFilter),
SetCreatedOnFilter(CreatedOnFilter),
SetArtifactFilter(ArtifactFilter),
SetEnvironmentFilter(EnvironmentFilter),
SetCreatorFilter(CreatorFilter),
SetHarnessFilter(HarnessFilter),
ClearFilters,
ToggleSetupGuide,
ShowAgentTypeSelector,
OpenSession { item_id: ManagementCardItemId },
FocusSearch,
}
pub enum AgentManagementViewEvent {
OpenNewTabAndRunWorkflow(Box<WorkflowType>),
OpenPlanNotebook { notebook_uid: NotebookId },
}
impl TypedActionView for AgentManagementView {
type Action = AgentManagementViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentManagementViewAction::SetOwnerFilter(filter) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::FilterChanged {
filter_type: FilterType::Owner
},
ctx
);
self.filters.owners = *filter;
self.update_filter_buttons(ctx);
self.on_filter_changed(ctx);
}
AgentManagementViewAction::SetStatusFilter(filter) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::FilterChanged {
filter_type: FilterType::Status
},
ctx
);
self.filters.status = *filter;
self.on_filter_changed(ctx);
}
AgentManagementViewAction::SetSourceFilter(filter) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::FilterChanged {
filter_type: FilterType::Source
},
ctx
);
self.filters.source = filter.clone();
self.on_filter_changed(ctx);
}
AgentManagementViewAction::SetCreatedOnFilter(filter) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::FilterChanged {
filter_type: FilterType::CreatedOn
},
ctx
);
self.filters.created_on = *filter;
self.on_filter_changed(ctx);
}
AgentManagementViewAction::SetArtifactFilter(filter) => {
self.filters.artifact = *filter;
self.get_tasks_from_model(ctx);
ctx.dispatch_global_action("workspace:save_app", ());
}
AgentManagementViewAction::SetEnvironmentFilter(filter) => {
self.filters.environment = filter.clone();
self.on_filter_changed(ctx);
}
AgentManagementViewAction::SetCreatorFilter(filter) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::FilterChanged {
filter_type: FilterType::Creator
},
ctx
);
self.filters.creator = filter.clone();
self.on_filter_changed(ctx);
}
AgentManagementViewAction::SetHarnessFilter(filter) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::FilterChanged {
filter_type: FilterType::Harness
},
ctx
);
self.filters.harness = *filter;
self.on_filter_changed(ctx);
}
AgentManagementViewAction::ClearFilters => {
self.filters.reset_all_but_owner();
// Reset the selected states for the dropdowns
self.status_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.source_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.created_on_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.artifact_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.harness_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_index(0, ctx);
});
self.update_environment_dropdown(ctx);
self.update_creator_dropdown(ctx);
self.on_filter_changed(ctx);
}
AgentManagementViewAction::ToggleSetupGuide => {
if self.is_viewing_setup_guide {
// User is leaving the guide - persist dismissal
send_telemetry_from_ctx!(AgentManagementTelemetryEvent::DismissSetupGuide, ctx);
if !self.has_dismissed_setup_guide {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.did_dismiss_cloud_setup_guide.set_value(true, ctx);
});
self.has_dismissed_setup_guide = true;
}
self.is_viewing_setup_guide = false;
} else {
self.show_setup_guide_from_link(ctx);
}
ctx.notify();
}
AgentManagementViewAction::ShowAgentTypeSelector => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::AgentTypeSelectorOpened,
ctx
);
self.is_agent_type_selector_open = true;
ctx.focus(&self.agent_type_selector);
ctx.notify();
}
AgentManagementViewAction::OpenSession { item_id } => {
let Some(action) = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(*item_id),
Some(RestoreConversationLayout::NewTab),
ctx,
) else {
return;
};
match item_id {
ManagementCardItemId::Conversation(conversation_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ConversationOpened {
conversation_id: conversation_id.to_string(),
opened_from: OpenedFrom::ManagementView,
},
ctx
);
}
ManagementCardItemId::AmbientRun(task_id) => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::CloudRunOpened {
task_id: task_id.to_string(),
opened_from: OpenedFrom::ManagementView,
},
ctx
);
}
}
ctx.dispatch_typed_action(&action);
}
AgentManagementViewAction::FocusSearch => {
ctx.focus(&self.search_editor);
}
}
}
}