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

630 lines
24 KiB
Rust

use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::new_scrollable::{ScrollableAppearance, SingleAxisConfig};
use galaxyui::elements::{
Border, ChildView, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Dismiss, DispatchEventResult, Element, Empty, EventHandler,
Fill as ElementFill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
NewScrollable, Padding, ParentElement, Radius, SavePosition, ScrollTarget,
ScrollToPositionMode, ScrollbarWidth, Shrinkable,
};
use galaxyui::fonts::Weight;
use galaxyui::keymap::macros::id;
use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::ai::agent_management::notifications::item::NotificationFilter;
use crate::ai::agent_management::notifications::item_rendering::{
create_notification_artifact_buttons_view, handle_notification_artifact_buttons_event,
render_notification_item_content, NotificationRenderContext,
};
use crate::ai::agent_management::notifications::{
NotificationId, NotificationItem, NotificationItems,
};
use crate::ai::agent_management::{AgentManagementEvent, AgentNotificationsModel};
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
const ITEM_PADDING: f32 = 12.;
/// Position ID prefix used with `SavePosition` so the clipped scrollable can
/// scroll keyboard-selected items into view.
const ITEM_POSITION_PREFIX: &str = "notification_mailbox_item_";
pub struct NotificationMailboxView {
active_filter: NotificationFilter,
scroll_state: ClippedScrollStateHandle,
filter_button_mouse_states: Vec<MouseStateHandle>,
close_button: ViewHandle<ActionButton>,
mark_all_read_button: ViewHandle<ActionButton>,
notification_mouse_states: Vec<MouseStateHandle>,
// Cached IDs of notifications matching the active filter, in display order.
// (Avoids re-filtering the full list on every individual item render.)
filtered_ids: Vec<NotificationId>,
/// Artifact button views for each filtered notification (parallel to `filtered_ids`).
artifact_buttons_views: Vec<Option<ViewHandle<ArtifactButtonsRow>>>,
/// Index of the currently keyboard-selected notification item, if any.
selected_index: Option<usize>,
}
impl Entity for NotificationMailboxView {
type Event = NotificationMailboxViewEvent;
}
#[derive(Debug, Clone)]
pub enum NotificationMailboxViewEvent {
NavigateToTerminal {
terminal_view_id: galaxyui::EntityId,
},
Dismissed,
}
#[derive(Debug)]
pub enum NotificationMailboxViewAction {
SetFilter(NotificationFilter),
MarkAllRead,
ClickItem(NotificationId),
Dismiss,
SelectPrevious,
SelectNext,
CycleFilter,
ActivateSelected,
}
impl NotificationMailboxView {
pub fn init(app: &mut AppContext) {
app.register_fixed_bindings([
FixedBinding::new(
"up",
NotificationMailboxViewAction::SelectPrevious,
id!(NotificationMailboxView::ui_name()),
),
FixedBinding::new(
"down",
NotificationMailboxViewAction::SelectNext,
id!(NotificationMailboxView::ui_name()),
),
FixedBinding::new(
"shift-tab",
NotificationMailboxViewAction::CycleFilter,
id!(NotificationMailboxView::ui_name()),
),
FixedBinding::new(
"enter",
NotificationMailboxViewAction::ActivateSelected,
id!(NotificationMailboxView::ui_name()),
),
FixedBinding::new(
"escape",
NotificationMailboxViewAction::Dismiss,
id!(NotificationMailboxView::ui_name()),
),
]);
}
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let model_handle = AgentNotificationsModel::handle(ctx);
ctx.subscribe_to_model(&model_handle, |me, _handle, event, ctx| match event {
AgentManagementEvent::NotificationAdded { .. }
| AgentManagementEvent::NotificationUpdated
| AgentManagementEvent::AllNotificationsMarkedRead => {
me.rebuild_filtered_ids(ctx);
ctx.notify();
}
// Legacy toast path.
AgentManagementEvent::ConversationNeedsAttention { .. } => {}
});
let close_button = ctx.add_typed_action_view(|_| {
ActionButton::new("", NakedTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::XSmall)
.with_tooltip("Close")
.with_tooltip_sublabel("Esc")
.on_click(|ctx| {
ctx.dispatch_typed_action(NotificationMailboxViewAction::Dismiss);
})
});
let mark_all_read_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Mark all as read", NakedTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(NotificationMailboxViewAction::MarkAllRead);
})
});
Self {
active_filter: NotificationFilter::All,
scroll_state: Default::default(),
filter_button_mouse_states: (0..enum_iterator::cardinality::<NotificationFilter>())
.map(|_| MouseStateHandle::default())
.collect(),
close_button,
mark_all_read_button,
notification_mouse_states: Vec::new(),
filtered_ids: Vec::new(),
artifact_buttons_views: Vec::new(),
selected_index: None,
}
}
/// Resets the mailbox state when opening. Called from the workspace toggle handler.
pub fn reset_for_open(&mut self, select_first: bool, ctx: &mut ViewContext<Self>) {
self.rebuild_filtered_ids(ctx);
self.selected_index = if select_first && !self.filtered_ids.is_empty() {
Some(0)
} else {
None
};
}
fn set_active_filter(&mut self, filter: NotificationFilter, ctx: &mut ViewContext<Self>) {
self.active_filter = filter;
self.selected_index = None;
self.rebuild_filtered_ids(ctx);
ctx.notify();
}
fn activate_notification(&mut self, id: NotificationId, ctx: &mut ViewContext<Self>) {
let terminal_view_id = AgentNotificationsModel::as_ref(ctx)
.notifications()
.get_by_id(id)
.map(|item| item.terminal_view_id);
AgentNotificationsModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_item_read(id, ctx);
});
if let Some(terminal_view_id) = terminal_view_id {
ctx.emit(NotificationMailboxViewEvent::NavigateToTerminal { terminal_view_id });
}
}
/// Refreshes the cached filtered notification IDs and mouse states.
fn rebuild_filtered_ids(&mut self, ctx: &mut ViewContext<Self>) {
let notifications = AgentNotificationsModel::as_ref(ctx).notifications();
// If the active filter's tab would be hidden (0 items), fall back to "All".
if self.active_filter != NotificationFilter::All
&& notifications.filtered_count(self.active_filter) == 0
{
self.active_filter = NotificationFilter::All;
}
self.filtered_ids = notifications
.items_filtered(self.active_filter)
.map(|item| item.id)
.collect();
self.notification_mouse_states
.resize_with(self.filtered_ids.len(), MouseStateHandle::default);
let artifact_data: Vec<_> = notifications
.items_filtered(self.active_filter)
.map(|item| item.artifacts.clone())
.collect();
let _ = notifications;
self.artifact_buttons_views = artifact_data
.iter()
.map(|artifacts| Self::create_artifact_buttons_view_from_artifacts(artifacts, ctx))
.collect();
// Clamp selection to valid range after list contents change.
if self.filtered_ids.is_empty() {
self.selected_index = None;
} else if let Some(idx) = self.selected_index {
if idx >= self.filtered_ids.len() {
self.selected_index = Some(self.filtered_ids.len() - 1);
}
}
}
fn render_item_at_index(&self, index: usize, app: &AppContext) -> Box<dyn Element> {
let notifications = AgentNotificationsModel::as_ref(app).notifications();
let Some(item) = self
.filtered_ids
.get(index)
.and_then(|id| notifications.get_by_id(*id))
else {
return Empty::new().finish();
};
let Some(mouse_state) = self.notification_mouse_states.get(index).cloned() else {
log::error!("missing mouse state for notification item at index {index}");
return Empty::new().finish();
};
let artifact_buttons = self
.artifact_buttons_views
.get(index)
.and_then(|v| v.as_ref());
let is_selected = self.selected_index == Some(index);
self.render_notification_item(
item,
mouse_state,
artifact_buttons,
is_selected,
Appearance::as_ref(app),
)
}
fn create_artifact_buttons_view_from_artifacts(
artifacts: &[Artifact],
ctx: &mut ViewContext<Self>,
) -> Option<ViewHandle<ArtifactButtonsRow>> {
let view = create_notification_artifact_buttons_view(artifacts, ctx)?;
ctx.subscribe_to_view(&view, Self::handle_artifact_buttons_event);
Some(view)
}
fn handle_artifact_buttons_event(
&mut self,
_view: ViewHandle<ArtifactButtonsRow>,
event: &ArtifactButtonsRowEvent,
ctx: &mut ViewContext<Self>,
) {
handle_notification_artifact_buttons_event(event, ctx);
}
}
impl TypedActionView for NotificationMailboxView {
type Action = NotificationMailboxViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
NotificationMailboxViewAction::SetFilter(filter) => {
self.set_active_filter(*filter, ctx);
}
NotificationMailboxViewAction::MarkAllRead => {
AgentNotificationsModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_all_items_read(ctx);
});
}
NotificationMailboxViewAction::ClickItem(id) => {
self.activate_notification(*id, ctx);
}
NotificationMailboxViewAction::Dismiss => {
ctx.emit(NotificationMailboxViewEvent::Dismissed);
}
NotificationMailboxViewAction::SelectPrevious => {
match self.selected_index {
Some(idx) if idx > 0 => self.selected_index = Some(idx - 1),
None if !self.filtered_ids.is_empty() => self.selected_index = Some(0),
_ => {}
}
self.scroll_selected_into_view();
ctx.notify();
}
NotificationMailboxViewAction::SelectNext => {
let max = self.filtered_ids.len().saturating_sub(1);
match self.selected_index {
Some(idx) if idx < max => self.selected_index = Some(idx + 1),
None if !self.filtered_ids.is_empty() => self.selected_index = Some(0),
_ => {}
}
self.scroll_selected_into_view();
ctx.notify();
}
NotificationMailboxViewAction::CycleFilter => {
let notifications = AgentNotificationsModel::as_ref(ctx).notifications();
let visible = notifications.visible_filters();
let current_pos = visible
.iter()
.position(|f| *f == self.active_filter)
.unwrap_or(0);
let next_filter = visible[(current_pos + 1) % visible.len()];
self.set_active_filter(next_filter, ctx);
}
NotificationMailboxViewAction::ActivateSelected => {
if let Some(idx) = self.selected_index {
if let Some(id) = self.filtered_ids.get(idx).copied() {
self.activate_notification(id, ctx);
}
}
}
}
}
}
impl View for NotificationMailboxView {
fn ui_name() -> &'static str {
"NotificationMailboxView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let notifications = AgentNotificationsModel::as_ref(app).notifications();
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Min)
.with_child(self.render_header(appearance))
.with_child(self.render_filter_bar(notifications, app));
if notifications.filtered_count(self.active_filter) == 0 {
column.add_child(self.render_empty_state(appearance));
} else {
let theme = appearance.theme();
// Render all items directly in a column so the scrollable can
// measure their natural height instead of always filling its
// max constraint (which was the behaviour with the viewport-based
// List element).
let mut items_column =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for index in 0..self.filtered_ids.len() {
items_column.add_child(
SavePosition::new(
self.render_item_at_index(index, app),
&format!("{ITEM_POSITION_PREFIX}{index}"),
)
.finish(),
);
}
let item_list = NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: self.scroll_state.clone(),
child: items_column.finish(),
},
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
ElementFill::None,
)
.with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, true))
.finish();
column.add_child(Shrinkable::new(1.0, item_list).finish());
}
let popup = Container::new(column.finish())
.with_padding(Padding::default().with_top(4.))
.with_background(appearance.theme().surface_2())
.with_border(Border::all(1.).with_border_color(appearance.theme().outline().into()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)));
// Wrap the popup in an EventHandler that consumes clicks on empty space
// so they don't fall through to the Dismiss layer.
let popup = EventHandler::new(popup.finish())
.on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation)
.finish();
Dismiss::new(
ConstrainedBox::new(popup)
.with_width(420.)
.with_max_height(500.)
.finish(),
)
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(NotificationMailboxViewAction::Dismiss);
})
.prevent_interaction_with_other_elements()
.finish()
}
}
impl NotificationMailboxView {
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let label = appearance
.ui_builder()
.wrappable_text("Notifications".to_string(), false)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_color: Some(theme.main_text_color(theme.surface_2()).into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish();
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(label)
.with_child(ChildView::new(&self.close_button).finish())
.finish(),
)
.with_padding(
Padding::default()
.with_top(8.)
.with_bottom(4.)
.with_horizontal(12.),
)
.finish()
}
fn render_filter_bar(
&self,
notifications: &NotificationItems,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let has_unread = notifications.filtered_count(NotificationFilter::Unread) > 0;
let mut filter_buttons = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_spacing(2.);
for (i, filter) in notifications.visible_filters().into_iter().enumerate() {
let Some(mouse_state) = self.filter_button_mouse_states.get(i).cloned() else {
log::warn!("missing mouse state for filter button at index {i}");
continue;
};
let is_active = self.active_filter == filter;
let count = notifications.filtered_count(filter);
let label = if count == 0 {
filter.label().to_string()
} else {
format!("{} ({count})", filter.label())
};
let text_color = if is_active {
theme.main_text_color(theme.surface_2())
} else {
theme.sub_text_color(theme.surface_2())
};
let background = if is_active {
Some(internal_colors::fg_overlay_3(theme).into())
} else {
None
};
let button = EventHandler::new(
Hoverable::new(mouse_state, move |state| {
let bg = if state.is_hovered() && !is_active {
Some(internal_colors::fg_overlay_2(theme).into())
} else {
background
};
let mut container = Container::new(
appearance
.ui_builder()
.wrappable_text(label.clone(), false)
.with_style(UiComponentStyles {
font_size: Some(12.),
font_weight: Some(Weight::Semibold),
font_color: Some(text_color.into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish(),
)
.with_padding(Padding::default().with_vertical(4.).with_horizontal(8.))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if let Some(bg) = bg {
container = container.with_background_color(bg);
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(NotificationMailboxViewAction::SetFilter(filter));
DispatchEventResult::StopPropagation
})
.finish();
filter_buttons.add_child(button);
}
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(filter_buttons.finish());
if has_unread {
row.add_child(ChildView::new(&self.mark_all_read_button).finish());
}
Container::new(row.finish())
.with_padding(
Padding::default()
.with_vertical(12.)
.with_left(12.)
.with_right(6.),
)
.with_border(Border::top(1.).with_border_color(theme.outline().into()))
.with_border(Border::bottom(1.).with_border_color(theme.outline().into()))
.finish()
}
fn render_empty_state(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
Container::new(
appearance
.ui_builder()
.wrappable_text("No notifications".to_string(), false)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_color: Some(theme.sub_text_color(theme.surface_2()).into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish(),
)
.with_uniform_padding(ITEM_PADDING)
.finish()
}
/// Asks the clipped scrollable to bring the currently selected item into
/// view on the next paint pass.
fn scroll_selected_into_view(&self) {
if let Some(idx) = self.selected_index {
self.scroll_state.scroll_to_position(ScrollTarget {
position_id: format!("{ITEM_POSITION_PREFIX}{idx}"),
mode: ScrollToPositionMode::FullyIntoView,
});
}
}
fn render_notification_item(
&self,
item: &NotificationItem,
mouse_state: MouseStateHandle,
artifact_buttons: Option<&ViewHandle<ArtifactButtonsRow>>,
is_selected: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let id = item.id;
let has_branch = item.branch.is_some();
let row = render_notification_item_content(
item,
artifact_buttons,
NotificationRenderContext::Mailbox,
false,
Box::new(|_| {}),
None,
appearance,
);
EventHandler::new(
Hoverable::new(mouse_state, move |state| {
let item_padding = if has_branch {
Padding::uniform(ITEM_PADDING)
} else {
Padding::default()
.with_vertical(ITEM_PADDING)
.with_horizontal(16.)
};
let mut container = Container::new(row).with_padding(item_padding);
if is_selected {
container = container
.with_background_color(internal_colors::fg_overlay_3(theme).into());
} else if state.is_hovered() {
container = container
.with_background_color(internal_colors::fg_overlay_2(theme).into());
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(NotificationMailboxViewAction::ClickItem(id));
DispatchEventResult::StopPropagation
})
.finish()
}
}