504 lines
18 KiB
Rust
504 lines
18 KiB
Rust
use std::time::Duration;
|
|
|
|
use pathfinder_color::ColorU;
|
|
use pathfinder_geometry::vector::vec2f;
|
|
use uuid::Uuid;
|
|
use warpui::elements::{DropShadow, Expanded};
|
|
use warpui::r#async::Timer;
|
|
use warpui::WindowId;
|
|
use warpui::{
|
|
elements::{
|
|
ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
|
DispatchEventResult, EventHandler, Flex, Hoverable, Icon, MouseStateHandle,
|
|
OffsetPositioning, Padding, ParentElement, PositionedElementAnchor,
|
|
PositionedElementOffsetBounds, Radius, SavePosition, Stack,
|
|
},
|
|
keymap::Keystroke,
|
|
r#async::SpawnedFutureHandle,
|
|
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
|
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
|
|
};
|
|
|
|
use crate::appearance::Appearance;
|
|
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
|
use crate::terminal::view::TerminalAction;
|
|
use crate::util::bindings::keybinding_name_to_keystroke;
|
|
use crate::workspace::{Workspace, WorkspaceAction};
|
|
|
|
const AGENT_TOAST_WIDTH: f32 = 260.;
|
|
const AGENT_TOAST_PADDING: f32 = 12.;
|
|
const AGENT_TOAST_CORNER_RADIUS: f32 = 4.;
|
|
const CLOSE_BUTTON_SIZE: f32 = 20.;
|
|
|
|
/// Data for an individual agent toast
|
|
struct AgentToastData {
|
|
/// The toast itself
|
|
toast: AgentToast,
|
|
/// Abort handle for timeout-based dismissal
|
|
abort_handle: Option<SpawnedFutureHandle>,
|
|
/// Unique identifier for the toast
|
|
uuid: Uuid,
|
|
}
|
|
|
|
/// A stack of agent-specific toasts for displaying task completion notifications
|
|
pub struct AgentToastStack {
|
|
timeout: Duration,
|
|
toasts: Vec<AgentToastData>,
|
|
/// Cached keystroke for the jump to latest toast action
|
|
jump_to_toast_shortcut: Option<Keystroke>,
|
|
/// Navigation data for the most recent toast. Persists even after toast is dismissed
|
|
latest_toast_navigation_data: Option<(WindowId, usize, EntityId)>,
|
|
}
|
|
|
|
impl AgentToastStack {
|
|
pub fn new(timeout: Duration, ctx: &mut ViewContext<Self>) -> Self {
|
|
// Set up caching for the keyboard shortcut
|
|
let jump_to_toast_shortcut =
|
|
keybinding_name_to_keystroke("workspace:jump_to_latest_toast", ctx);
|
|
|
|
// Subscribe to keybinding changes to update the cached shortcut
|
|
ctx.subscribe_to_model(
|
|
&KeybindingChangedNotifier::handle(ctx),
|
|
move |me, _, event, ctx| {
|
|
let KeybindingChangedEvent::BindingChanged {
|
|
binding_name,
|
|
new_trigger,
|
|
} = event;
|
|
if binding_name == "workspace:jump_to_latest_toast" {
|
|
me.jump_to_toast_shortcut = new_trigger.clone();
|
|
ctx.notify();
|
|
}
|
|
},
|
|
);
|
|
|
|
Self {
|
|
timeout,
|
|
toasts: Vec::new(),
|
|
jump_to_toast_shortcut,
|
|
latest_toast_navigation_data: None,
|
|
}
|
|
}
|
|
|
|
/// Add a new agent toast to the stack
|
|
pub fn add_toast(&mut self, toast: AgentToast, ctx: &mut ViewContext<Self>) {
|
|
let uuid = Uuid::new_v4();
|
|
let abort_handle = ctx.spawn_abortable(
|
|
Timer::after(self.timeout),
|
|
move |view, _, ctx| view.dismiss_toast_by_uuid(&uuid, ctx),
|
|
|_, _| {},
|
|
);
|
|
|
|
self.latest_toast_navigation_data =
|
|
Some((toast.window_id, toast.tab_index, toast.terminal_view_id));
|
|
|
|
self.toasts.push(AgentToastData {
|
|
toast,
|
|
abort_handle: Some(abort_handle),
|
|
uuid,
|
|
});
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Dismiss a toast by its UUID
|
|
pub fn dismiss_toast_by_uuid(&mut self, uuid: &Uuid, ctx: &mut ViewContext<Self>) {
|
|
if let Some(index) = self.toasts.iter().position(|toast| toast.uuid == *uuid) {
|
|
let toast_data = self.toasts.remove(index);
|
|
if let Some(abort_handle) = toast_data.abort_handle {
|
|
abort_handle.abort();
|
|
}
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
/// Cancel the dismissal timeout for a toast
|
|
pub fn cancel_dismissal_timeout(&mut self, uuid: &Uuid) {
|
|
if let Some(toast_data) = self.toasts.iter_mut().find(|toast| toast.uuid == *uuid) {
|
|
if let Some(abort_handle) = toast_data.abort_handle.take() {
|
|
abort_handle.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start a new dismissal timeout for a toast
|
|
pub fn start_dismissal_timeout(&mut self, uuid: Uuid, ctx: &mut ViewContext<Self>) {
|
|
if let Some(toast_data) = self.toasts.iter_mut().find(|toast| toast.uuid == uuid) {
|
|
// Cancel any existing timeout
|
|
if let Some(abort_handle) = toast_data.abort_handle.take() {
|
|
abort_handle.abort();
|
|
}
|
|
|
|
// Start a new timeout
|
|
let abort_handle = ctx.spawn_abortable(
|
|
Timer::after(self.timeout),
|
|
move |view, _, ctx| view.dismiss_toast_by_uuid(&uuid, ctx),
|
|
|_, _| {},
|
|
);
|
|
|
|
toast_data.abort_handle = Some(abort_handle);
|
|
}
|
|
}
|
|
|
|
/// Get the UUID of the most recent (latest) toast
|
|
pub fn latest_toast_uuid(&self) -> Option<Uuid> {
|
|
self.toasts.last().map(|toast_data| toast_data.uuid)
|
|
}
|
|
|
|
pub fn get_latest_toast_navigation_data(&self) -> Option<(WindowId, usize, EntityId)> {
|
|
self.latest_toast_navigation_data
|
|
}
|
|
}
|
|
|
|
impl View for AgentToastStack {
|
|
fn ui_name() -> &'static str {
|
|
"AgentToastStack"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let mut rendered_toasts =
|
|
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
|
|
|
let latest_toast_uuid = self.toasts.last().map(|toast| toast.uuid);
|
|
|
|
// Render toasts in reverse order so most recent appears at top
|
|
for toast_data in self.toasts.iter().rev() {
|
|
let is_latest = latest_toast_uuid == Some(toast_data.uuid);
|
|
rendered_toasts.add_child(
|
|
Container::new(toast_data.toast.render(
|
|
app,
|
|
toast_data.uuid,
|
|
is_latest,
|
|
self.jump_to_toast_shortcut.clone(),
|
|
))
|
|
.with_margin_bottom(5.)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
Container::new(rendered_toasts.finish())
|
|
// Tried handling this with OffsetPositioning as we do with top margin.
|
|
// For whatever reason, it did not work for right margin when using TopRight alignment.
|
|
.with_margin_right(AGENT_TOAST_PADDING)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Entity for AgentToastStack {
|
|
type Event = ();
|
|
}
|
|
|
|
/// Actions that can be dispatched on the agent toast stack
|
|
#[derive(Debug)]
|
|
pub enum AgentToastAction {
|
|
ClickDismissButton(Uuid),
|
|
CancelDismissalTimeout(Uuid),
|
|
StartDismissalTimeout(Uuid),
|
|
ClickToastBody(Uuid),
|
|
}
|
|
|
|
impl TypedActionView for AgentToastStack {
|
|
type Action = AgentToastAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
AgentToastAction::ClickDismissButton(uuid) => {
|
|
self.dismiss_toast_by_uuid(uuid, ctx);
|
|
}
|
|
AgentToastAction::CancelDismissalTimeout(uuid) => {
|
|
self.cancel_dismissal_timeout(uuid);
|
|
}
|
|
AgentToastAction::StartDismissalTimeout(uuid) => {
|
|
self.start_dismissal_timeout(*uuid, ctx);
|
|
}
|
|
AgentToastAction::ClickToastBody(uuid) => {
|
|
if let Some((window_id, tab_id, terminal_view_id)) = self
|
|
.toasts
|
|
.iter()
|
|
.find(|toast| toast.uuid == *uuid)
|
|
.map(|toast| {
|
|
(
|
|
toast.toast.window_id,
|
|
toast.toast.tab_index,
|
|
toast.toast.terminal_view_id,
|
|
)
|
|
})
|
|
{
|
|
ctx.windows().show_window_and_focus_app(window_id);
|
|
|
|
if let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) {
|
|
if let Some(handle) = workspaces.first() {
|
|
ctx.dispatch_typed_action_for_view(
|
|
window_id,
|
|
handle.id(),
|
|
&WorkspaceAction::ActivateTab(tab_id),
|
|
);
|
|
}
|
|
}
|
|
ctx.dispatch_typed_action_for_view(
|
|
window_id,
|
|
terminal_view_id,
|
|
&TerminalAction::Focus,
|
|
);
|
|
}
|
|
self.dismiss_toast_by_uuid(uuid, ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A specialized toast for Agent Mode completion notifications
|
|
#[derive(Clone)]
|
|
pub struct AgentToast {
|
|
task_name: String,
|
|
icon: Icon,
|
|
window_id: WindowId,
|
|
tab_index: usize,
|
|
terminal_view_id: EntityId,
|
|
close_button_mouse_state: MouseStateHandle,
|
|
container_hover_state: MouseStateHandle,
|
|
close_button_hover_state: MouseStateHandle,
|
|
}
|
|
|
|
impl AgentToast {
|
|
pub fn new(
|
|
task_name: String,
|
|
icon: Icon,
|
|
window_id: WindowId,
|
|
tab_index: usize,
|
|
terminal_view_id: EntityId,
|
|
) -> Self {
|
|
Self {
|
|
task_name,
|
|
icon,
|
|
window_id,
|
|
tab_index,
|
|
terminal_view_id,
|
|
close_button_mouse_state: Default::default(),
|
|
container_hover_state: Default::default(),
|
|
close_button_hover_state: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn text_color(&self, appearance: &Appearance) -> ColorU {
|
|
appearance
|
|
.theme()
|
|
.main_text_color(appearance.theme().background())
|
|
.into()
|
|
}
|
|
|
|
fn position_id(&self, uuid: Uuid) -> String {
|
|
format!("agent_toast_{uuid}")
|
|
}
|
|
|
|
pub fn render(
|
|
&self,
|
|
app: &AppContext,
|
|
uuid: Uuid,
|
|
is_latest: bool,
|
|
jump_to_toast_keystroke: Option<Keystroke>,
|
|
) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
let ui_builder = appearance.ui_builder();
|
|
|
|
let mut row = Flex::row();
|
|
|
|
let icon_size = appearance.ui_font_size() * 1.2;
|
|
row.add_child(
|
|
Container::new(
|
|
ConstrainedBox::new(self.icon.finish())
|
|
.with_height(icon_size)
|
|
.with_width(icon_size)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(8.)
|
|
// Accounts for line height
|
|
.with_vertical_margin(1.)
|
|
.finish(),
|
|
);
|
|
|
|
row.add_child(
|
|
Expanded::new(
|
|
1.,
|
|
Flex::column()
|
|
.with_child({
|
|
let font_size = appearance.ui_font_size() * 1.2;
|
|
let line_height = font_size * appearance.line_height_ratio();
|
|
let max_height_for_3_lines = line_height * 3.0;
|
|
|
|
let text_content = ConstrainedBox::new(
|
|
ui_builder
|
|
.wrappable_text(self.task_name.clone(), true)
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(font_size),
|
|
font_color: Some(self.text_color(appearance)),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.with_max_height(max_height_for_3_lines)
|
|
.finish();
|
|
|
|
if is_latest {
|
|
// Add keyboard shortcut to the latest toast
|
|
let mut row =
|
|
Flex::row().with_child(Expanded::new(1., text_content).finish());
|
|
if let Some(keystroke) = jump_to_toast_keystroke {
|
|
row = row.with_child(
|
|
self.render_keyboard_shortcut(app, appearance, keystroke),
|
|
);
|
|
}
|
|
row.finish()
|
|
} else {
|
|
text_content
|
|
}
|
|
})
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
);
|
|
|
|
let row = ConstrainedBox::new(row.finish())
|
|
.with_max_width(AGENT_TOAST_WIDTH)
|
|
.finish();
|
|
|
|
self.render_container(row, appearance, uuid)
|
|
}
|
|
|
|
fn render_container(
|
|
&self,
|
|
content: Box<dyn Element>,
|
|
appearance: &Appearance,
|
|
uuid: Uuid,
|
|
) -> Box<dyn Element> {
|
|
let navigation_action = WorkspaceAction::FocusTerminalViewInWorkspace {
|
|
terminal_view_id: self.terminal_view_id,
|
|
};
|
|
EventHandler::new(
|
|
Hoverable::new(self.container_hover_state.clone(), |mouse_state| {
|
|
let container = Container::new(content)
|
|
.with_padding(Padding::uniform(AGENT_TOAST_PADDING))
|
|
.with_background(appearance.theme().surface_3())
|
|
.with_drop_shadow(DropShadow::default())
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
|
AGENT_TOAST_CORNER_RADIUS,
|
|
)));
|
|
|
|
let mut stack = Stack::new().with_child(
|
|
SavePosition::new(container.finish(), &self.position_id(uuid)).finish(),
|
|
);
|
|
|
|
let is_close_button_hovered = self
|
|
.close_button_hover_state
|
|
.lock()
|
|
.is_ok_and(|state| state.is_hovered());
|
|
|
|
if mouse_state.is_hovered() || is_close_button_hovered {
|
|
stack.add_positioned_overlay_child(
|
|
self.render_close_button(appearance, uuid),
|
|
OffsetPositioning::offset_from_save_position_element(
|
|
self.position_id(uuid),
|
|
vec2f(4., -4.),
|
|
PositionedElementOffsetBounds::WindowByPosition,
|
|
PositionedElementAnchor::TopRight,
|
|
ChildAnchor::TopRight,
|
|
),
|
|
);
|
|
}
|
|
stack.finish()
|
|
})
|
|
.on_hover(move |is_hovered, ctx, _, _| {
|
|
// Cancel dismissal timeout when hovering
|
|
if is_hovered {
|
|
ctx.dispatch_typed_action(AgentToastAction::CancelDismissalTimeout(uuid));
|
|
} else {
|
|
ctx.dispatch_typed_action(AgentToastAction::StartDismissalTimeout(uuid));
|
|
}
|
|
})
|
|
.finish(),
|
|
)
|
|
.on_left_mouse_down(move |ctx, _, _| {
|
|
// Dismiss immediately when clicked on the toast body
|
|
ctx.dispatch_typed_action(AgentToastAction::ClickToastBody(uuid));
|
|
|
|
ctx.dispatch_typed_action(navigation_action.clone());
|
|
DispatchEventResult::PropagateToParent
|
|
})
|
|
.finish()
|
|
}
|
|
|
|
fn render_keyboard_shortcut(
|
|
&self,
|
|
_app: &AppContext,
|
|
appearance: &Appearance,
|
|
keystroke: Keystroke,
|
|
) -> Box<dyn Element> {
|
|
use crate::ui_components::blended_colors;
|
|
use warpui::ui_components::keyboard_shortcut::KeyboardShortcut;
|
|
|
|
let theme = appearance.theme();
|
|
|
|
let keybinding_style = UiComponentStyles {
|
|
font_family_id: Some(appearance.monospace_font_family()),
|
|
font_color: Some(blended_colors::text_main(theme, theme.surface_2())),
|
|
font_size: Some(appearance.ui_font_size()),
|
|
background: Some(theme.surface_2().into()),
|
|
border_radius: Some(CornerRadius::with_all(Radius::Pixels(3.0))),
|
|
padding: Some(Coords {
|
|
top: 1.0,
|
|
bottom: 1.0,
|
|
left: 4.0,
|
|
right: 4.0,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
Container::new(
|
|
KeyboardShortcut::new(&keystroke, keybinding_style)
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.with_margin_left(8.)
|
|
.finish()
|
|
}
|
|
|
|
fn render_close_button(&self, appearance: &Appearance, uuid: Uuid) -> Box<dyn Element> {
|
|
let ui_builder = appearance.ui_builder();
|
|
|
|
EventHandler::new(
|
|
Hoverable::new(self.close_button_hover_state.clone(), |_| {
|
|
Container::new(
|
|
ui_builder
|
|
.close_button(CLOSE_BUTTON_SIZE, self.close_button_mouse_state.clone())
|
|
.with_style(UiComponentStyles {
|
|
font_color: Some(appearance.theme().foreground().into()),
|
|
background: Some(appearance.theme().surface_2().into()),
|
|
border_color: Some(appearance.theme().surface_3().into()),
|
|
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
|
|
border_width: Some(2.),
|
|
padding: Some(Coords {
|
|
top: 2.,
|
|
bottom: 2.,
|
|
left: 2.,
|
|
right: 2.,
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(AgentToastAction::ClickDismissButton(uuid));
|
|
})
|
|
.finish(),
|
|
)
|
|
.finish()
|
|
})
|
|
.finish(),
|
|
)
|
|
.on_left_mouse_down(|_, _, _| {
|
|
// Stop propagation so the parent toast click handler doesn't get called
|
|
DispatchEventResult::StopPropagation
|
|
})
|
|
.finish()
|
|
}
|
|
}
|