Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,536 @@
use std::collections::HashMap;
use warp_core::features::FeatureFlag;
use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, WindowId};
use crate::settings::AISettings;
use crate::ai::active_agent_views_model::{ActiveAgentViewsEvent, ActiveAgentViewsModel};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent_management::notifications::{
NotificationCategory, NotificationId, NotificationItem, NotificationItems, NotificationOrigin,
NotificationSourceAgent,
};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::server::telemetry::TelemetryEvent;
use crate::terminal::cli_agent_sessions::{
CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
};
use crate::terminal::CLIAgent;
use crate::workspace::util::is_terminal_view_in_same_tab;
use crate::workspace::{Workspace, WorkspaceRegistry};
use crate::BlocklistAIHistoryModel;
use warp_core::send_telemetry_from_ctx;
/// Singleton model responsible for triggering in-app notifications on blocking conversation
/// status updates and tracking/storing these notifications for the notifications mailbox.
/// Tracks and stores notifications for both warp agent conversations and other supported
/// cli agent sessions.
pub struct AgentNotificationsModel {
notifications: NotificationItems,
/// Artifacts accumulated during the current turn for each conversation.
/// Drained into the notification when a terminal state fires, cleared on InProgress.
pub(crate) pending_artifacts: HashMap<AIConversationId, Vec<Artifact>>,
}
impl Entity for AgentNotificationsModel {
type Event = AgentManagementEvent;
}
impl SingletonEntity for AgentNotificationsModel {}
impl AgentNotificationsModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, event, ctx| {
me.handle_history_event(event, ctx);
});
let cli_sessions_model = CLIAgentSessionsModel::handle(ctx);
ctx.subscribe_to_model(&cli_sessions_model, |me, event, ctx| {
me.handle_cli_agent_session_event(event, ctx);
});
let active_views_model = ActiveAgentViewsModel::handle(ctx);
ctx.subscribe_to_model(&active_views_model, |me, event, ctx| {
me.handle_active_agent_views_changed(event, ctx);
});
Self {
notifications: NotificationItems::default(),
pending_artifacts: HashMap::new(),
}
}
pub(crate) fn notifications(&self) -> &NotificationItems {
&self.notifications
}
pub(crate) fn mark_item_read(&mut self, id: NotificationId, ctx: &mut ModelContext<Self>) {
if self.notifications.mark_item_read(id) {
ctx.emit(AgentManagementEvent::NotificationUpdated);
}
}
pub(crate) fn mark_all_items_read(&mut self, ctx: &mut ModelContext<Self>) {
if self.notifications.mark_all_items_read() {
ctx.emit(AgentManagementEvent::AllNotificationsMarkedRead);
}
}
/// Marks all notifications from the given terminal view as read.
pub(crate) fn mark_items_from_terminal_view_read(
&mut self,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) {
if !FeatureFlag::HOANotifications.is_enabled() {
return;
}
if self
.notifications
.mark_all_terminal_view_items_as_read(terminal_view_id)
{
ctx.emit(AgentManagementEvent::NotificationUpdated);
}
}
fn handle_active_agent_views_changed(
&mut self,
event: &ActiveAgentViewsEvent,
ctx: &mut ModelContext<Self>,
) {
if !FeatureFlag::HOANotifications.is_enabled() {
return;
}
match event {
ActiveAgentViewsEvent::ConversationClosed { conversation_id } => {
// When a conversation is closed, clean up its notifications
// (as there's no conversation to navigate to when you click said notifications).
if self
.notifications
.remove_by_origin(NotificationOrigin::Conversation(*conversation_id))
{
ctx.emit(AgentManagementEvent::NotificationUpdated);
}
}
ActiveAgentViewsEvent::TerminalViewFocused
| ActiveAgentViewsEvent::WindowClosed
| ActiveAgentViewsEvent::AmbientSessionOpened { .. }
| ActiveAgentViewsEvent::AmbientSessionClosed { .. } => {}
}
}
fn handle_cli_agent_session_event(
&mut self,
event: &CLIAgentSessionsModelEvent,
ctx: &mut ModelContext<Self>,
) {
if !FeatureFlag::HOANotifications.is_enabled() {
return;
}
match event {
CLIAgentSessionsModelEvent::Ended {
terminal_view_id, ..
} => {
self.remove_notification_by_source(
NotificationOrigin::CLISession(*terminal_view_id),
ctx,
);
}
CLIAgentSessionsModelEvent::Started { .. }
| CLIAgentSessionsModelEvent::InputSessionChanged { .. }
| CLIAgentSessionsModelEvent::SessionUpdated { .. } => {}
CLIAgentSessionsModelEvent::StatusChanged {
terminal_view_id,
agent,
status,
session_context,
} => match status {
// When the agent resumes its work we can assume that the previous notification is stale.
CLIAgentSessionStatus::InProgress => {
self.remove_notification_by_source(
NotificationOrigin::CLISession(*terminal_view_id),
ctx,
);
}
CLIAgentSessionStatus::Success => {
let title = session_context
.display_title()
.unwrap_or_else(|| format!("{} completed", agent.display_name()));
let message = match agent {
CLIAgent::Codex => "Notification from Codex",
_ => "Task completed.",
};
self.add_notification(
title,
message.to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::CLI(*agent),
NotificationOrigin::CLISession(*terminal_view_id),
*terminal_view_id,
vec![],
ctx,
);
}
CLIAgentSessionStatus::Blocked { message } => {
let title = session_context
.display_title()
.unwrap_or_else(|| format!("{} needs attention", agent.display_name()));
self.add_notification(
title,
message
.clone()
.unwrap_or_else(|| "Waiting for input.".to_owned()),
NotificationCategory::Request,
NotificationSourceAgent::CLI(*agent),
NotificationOrigin::CLISession(*terminal_view_id),
*terminal_view_id,
vec![],
ctx,
);
}
},
}
}
fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Self>,
) {
// When a conversation is deleted or removed, clean up its notification and pending artifacts.
if let BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
} = event
{
if FeatureFlag::HOANotifications.is_enabled() {
self.pending_artifacts.remove(conversation_id);
self.remove_notification_by_source(
NotificationOrigin::Conversation(*conversation_id),
ctx,
);
}
return;
}
// Accumulate artifacts as they arrive during the conversation.
if let BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
conversation_id,
artifact,
..
} = event
{
if FeatureFlag::HOANotifications.is_enabled() {
self.pending_artifacts
.entry(*conversation_id)
.or_default()
.push(artifact.clone());
}
return;
}
let BlocklistAIHistoryEvent::UpdatedConversationStatus {
terminal_view_id,
conversation_id,
// We shouldn't trigger toasts when restoring conversations on startup.
is_restored: false,
} = event
else {
return;
};
let ai_history_model = BlocklistAIHistoryModel::as_ref(ctx);
let Some(updated_conversation) = ai_history_model.conversation(conversation_id) else {
return;
};
if updated_conversation.should_exclude_from_navigation() {
return;
}
let status = updated_conversation.status().clone();
let latest_query = updated_conversation.latest_user_query();
if FeatureFlag::HOANotifications.is_enabled() {
self.handle_history_event_for_mailbox(
&status,
*conversation_id,
latest_query,
*terminal_view_id,
ctx,
);
// The new mailbox path handled the event — skip the legacy toast path below.
return;
}
if !status.should_trigger_notification() {
return;
}
if is_terminal_view_visible(*terminal_view_id, ctx) {
return;
}
let Some((window_id, tab_index)) =
window_and_tab_idx_id_for_conversation(*conversation_id, ctx)
else {
return;
};
ctx.emit(AgentManagementEvent::ConversationNeedsAttention {
window_id,
tab_index,
terminal_view_id: *terminal_view_id,
conversation_id: *conversation_id,
});
}
fn handle_history_event_for_mailbox(
&mut self,
status: &ConversationStatus,
conversation_id: AIConversationId,
latest_query: Option<String>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) {
let origin = NotificationOrigin::Conversation(conversation_id);
// If the conversation view is no longer open, don't create notifications for it
// (there's nothing to navigate to when clicking them).
if !ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(conversation_id, ctx) {
self.pending_artifacts.remove(&conversation_id);
self.remove_notification_by_source(origin, ctx);
return;
}
let title = latest_query.unwrap_or_else(|| "Agent task".to_owned());
match status {
// When the agent resumes its work, clear stale notifications.
ConversationStatus::InProgress => {
self.remove_notification_by_source(origin, ctx);
}
ConversationStatus::Success => {
let artifacts = self.flush_pending_artifacts(conversation_id);
self.add_notification(
title,
"Task completed.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
origin,
terminal_view_id,
artifacts,
ctx,
);
}
ConversationStatus::Cancelled => {
let artifacts = self.flush_pending_artifacts(conversation_id);
self.add_notification(
title,
"Task was cancelled.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
origin,
terminal_view_id,
artifacts,
ctx,
);
}
ConversationStatus::Blocked { blocked_action } => {
self.add_notification(
title,
blocked_action.clone(),
NotificationCategory::Request,
NotificationSourceAgent::Oz,
origin,
terminal_view_id,
vec![],
ctx,
);
}
ConversationStatus::Error => {
let artifacts = self.flush_pending_artifacts(conversation_id);
self.add_notification(
title,
"Something went wrong.".to_owned(),
NotificationCategory::Error,
NotificationSourceAgent::Oz,
origin,
terminal_view_id,
artifacts,
ctx,
);
}
}
}
/// Removes the existing notification for the given source (if any) and emits an update event.
fn remove_notification_by_source(
&mut self,
origin: NotificationOrigin,
ctx: &mut ModelContext<Self>,
) {
if self.notifications.remove_by_origin(origin) {
ctx.emit(AgentManagementEvent::NotificationUpdated);
}
}
/// Drains and returns the pending artifacts for a conversation.
pub(crate) fn flush_pending_artifacts(
&mut self,
conversation_id: AIConversationId,
) -> Vec<Artifact> {
self.pending_artifacts
.remove(&conversation_id)
.unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
fn add_notification(
&mut self,
title: String,
message: String,
category: NotificationCategory,
agent: NotificationSourceAgent,
origin: NotificationOrigin,
terminal_view_id: EntityId,
artifacts: Vec<Artifact>,
ctx: &mut ModelContext<Self>,
) {
if !*AISettings::as_ref(ctx).show_agent_notifications {
return;
}
let is_visible = is_terminal_view_visible(terminal_view_id, ctx);
let branch = resolve_git_branch_for_terminal_view(terminal_view_id, ctx);
let item = NotificationItem::new(
title,
message,
category,
agent,
origin,
is_visible,
terminal_view_id,
artifacts,
branch,
);
send_telemetry_from_ctx!(
TelemetryEvent::AgentNotificationShown {
agent_variant: agent.into(),
},
ctx
);
let id = item.id;
self.notifications.push(item);
ctx.emit(AgentManagementEvent::NotificationAdded { id });
}
}
#[derive(Clone, Debug)]
pub enum AgentManagementEvent {
/// A Warp-native conversation needs attention and is not visible in the current window/tab.
ConversationNeedsAttention {
window_id: WindowId,
tab_index: usize,
terminal_view_id: EntityId,
conversation_id: AIConversationId,
},
/// A new notification was added to the persistent notification center.
NotificationAdded { id: NotificationId },
/// A notification's read state changed.
NotificationUpdated,
/// All notifications were marked as read.
AllNotificationsMarkedRead,
}
impl ConversationStatus {
/// Returns true if the updating the conversation with this status should trigger some
/// notification to the user.
pub fn should_trigger_notification(&self) -> bool {
matches!(
self,
ConversationStatus::Success
| ConversationStatus::Blocked { .. }
| ConversationStatus::Error
)
}
}
fn is_terminal_view_visible(terminal_view_id: EntityId, app: &AppContext) -> bool {
let Some(active_id) = active_focused_terminal_id(app) else {
return false;
};
active_id == terminal_view_id
|| is_terminal_view_in_same_tab(&active_id, &terminal_view_id, app)
}
fn window_and_tab_idx_id_for_conversation(
conversation_id: AIConversationId,
app: &AppContext,
) -> Option<(WindowId, usize)> {
WorkspaceRegistry::as_ref(app)
.all_workspaces(app)
.iter()
.find_map(|(window_id, workspace_handle)| {
workspace_handle
.as_ref(app)
.tab_views()
.enumerate()
.find_map(|(tab_idx, pane_group)| {
pane_group
.as_ref(app)
.terminal_pane_ids()
.filter_map(|pane_id| {
pane_group
.as_ref(app)
.terminal_view_from_pane_id(pane_id, app)
})
.find_map(|terminal_view| {
let terminal_view_conversation_id =
terminal_view.as_ref(app).active_conversation_id(app)?;
(terminal_view_conversation_id == conversation_id)
.then_some((*window_id, tab_idx))
})
})
})
}
fn resolve_git_branch_for_terminal_view(
terminal_view_id: EntityId,
app: &AppContext,
) -> Option<String> {
for (_, workspace_handle) in WorkspaceRegistry::as_ref(app).all_workspaces(app) {
for pane_group in workspace_handle.as_ref(app).tab_views() {
let pane_group = pane_group.as_ref(app);
for pane_id in pane_group.terminal_pane_ids() {
if let Some(terminal_view) = pane_group.terminal_view_from_pane_id(pane_id, app) {
if terminal_view.id() == terminal_view_id {
return terminal_view.as_ref(app).current_git_branch(app);
}
}
}
}
}
None
}
fn active_focused_terminal_id(app: &AppContext) -> Option<EntityId> {
let active_window = app.windows().active_window()?;
let workspace = app
.views_of_type::<Workspace>(active_window)
.and_then(|views| views.first().cloned())?;
let workspace = workspace.as_ref(app);
workspace.active_terminal_id(app)
}
#[cfg(test)]
#[path = "agent_management_model_tests.rs"]
mod tests;
@@ -0,0 +1,211 @@
use warp_core::features::FeatureFlag;
use warpui::{App, EntityId, ModelHandle};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::BlocklistAIHistoryModel;
use super::AgentNotificationsModel;
fn setup_app(
app: &mut App,
) -> (
ModelHandle<BlocklistAIHistoryModel>,
ModelHandle<AgentNotificationsModel>,
) {
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
let notifications = app.add_singleton_model(AgentNotificationsModel::new);
(history, notifications)
}
fn make_pr_artifact(url: &str, branch: &str) -> Artifact {
Artifact::PullRequest {
url: url.to_string(),
branch: branch.to_string(),
repo: None,
number: None,
}
}
fn make_plan_artifact(doc_uid: &str, title: &str) -> Artifact {
Artifact::Plan {
document_uid: doc_uid.to_string(),
notebook_uid: None,
title: Some(title.to_string()),
}
}
#[test]
fn artifact_event_accumulates_into_pending() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (history, notifications) = setup_app(&mut app);
let conversation_id = AIConversationId::new();
let terminal_view_id = EntityId::new();
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/42", "feature-branch"),
});
});
notifications.read(&app, |model, _| {
let pending = model.pending_artifacts.get(&conversation_id).unwrap();
assert_eq!(pending.len(), 1);
assert!(matches!(&pending[0], Artifact::PullRequest { branch, .. } if branch == "feature-branch"));
});
});
}
#[test]
fn multiple_artifacts_accumulated_across_turns() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (history, notifications) = setup_app(&mut app);
let conversation_id = AIConversationId::new();
let terminal_view_id = EntityId::new();
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
conversation_id,
artifact: make_plan_artifact("doc-1", "My Plan"),
});
});
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "main"),
});
});
notifications.read(&app, |model, _| {
let pending = model.pending_artifacts.get(&conversation_id).unwrap();
assert_eq!(pending.len(), 2);
assert!(matches!(&pending[0], Artifact::Plan { title: Some(t), .. } if t == "My Plan"));
assert!(matches!(&pending[1], Artifact::PullRequest { .. }));
});
});
}
#[test]
fn flush_drains_pending_artifacts() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (history, notifications) = setup_app(&mut app);
let conversation_id = AIConversationId::new();
let terminal_view_id = EntityId::new();
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
});
});
notifications.update(&mut app, |model, _| {
let artifacts = model.flush_pending_artifacts(conversation_id);
assert_eq!(artifacts.len(), 1);
assert!(matches!(&artifacts[0], Artifact::PullRequest { branch, .. } if branch == "branch-1"));
});
notifications.read(&app, |model, _| {
assert!(!model.pending_artifacts.contains_key(&conversation_id));
});
});
}
#[test]
fn flush_returns_empty_vec_when_no_artifacts() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (_history, notifications) = setup_app(&mut app);
let conversation_id = AIConversationId::new();
notifications.update(&mut app, |model, _| {
let artifacts = model.flush_pending_artifacts(conversation_id);
assert!(artifacts.is_empty());
});
});
}
#[test]
fn deletion_cleans_up_pending_artifacts() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (history, notifications) = setup_app(&mut app);
let conversation_id = AIConversationId::new();
let terminal_view_id = EntityId::new();
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
conversation_id,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
});
});
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::DeletedConversation {
terminal_view_id,
conversation_id,
conversation_title: None,
});
});
notifications.read(&app, |model, _| {
assert!(!model.pending_artifacts.contains_key(&conversation_id));
});
});
}
#[test]
fn separate_conversations_have_independent_pending_artifacts() {
App::test((), |mut app| async move {
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
let (history, notifications) = setup_app(&mut app);
let conv_a = AIConversationId::new();
let conv_b = AIConversationId::new();
let terminal_view_id = EntityId::new();
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
conversation_id: conv_a,
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-a"),
});
});
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
terminal_view_id,
conversation_id: conv_b,
artifact: make_plan_artifact("doc-b", "Plan B"),
});
});
notifications.update(&mut app, |model, _| {
let a = model.flush_pending_artifacts(conv_a);
assert_eq!(a.len(), 1);
assert!(matches!(&a[0], Artifact::PullRequest { branch, .. } if branch == "branch-a"));
let b = model.flush_pending_artifacts(conv_b);
assert_eq!(b.len(), 1);
assert!(matches!(&b[0], Artifact::Plan { title: Some(t), .. } if t == "Plan B"));
});
});
}
@@ -0,0 +1,474 @@
//! Agent type selector modal.
//!
//! This modal is displayed when users click "New agent" to choose between
//! cloud and local agent modes.
use crate::appearance::Appearance;
use crate::ui_components::icons::Icon;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::color::blend::Blend;
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
DropShadow, Element, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Radius, Shrinkable, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::keymap::{FixedBinding, Keystroke};
use warpui::platform::Cursor;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
// Modal dimensions based on Figma design.
const MODAL_WIDTH: f32 = 440.;
const DIALOG_CORNER_RADIUS: f32 = 8.;
const HEADER_PADDING_TOP: f32 = 24.;
const HEADER_PADDING_BOTTOM: f32 = 12.;
const HEADER_PADDING_HORIZONTAL: f32 = 24.;
const BODY_PADDING_VERTICAL: f32 = 16.;
const BODY_PADDING_HORIZONTAL: f32 = 20.;
const OPTION_PADDING_VERTICAL: f32 = 8.;
const OPTION_PADDING_HORIZONTAL: f32 = 12.;
const OPTION_CORNER_RADIUS: f32 = 4.;
const OPTION_GAP: f32 = 12.;
const OPTIONS_VERTICAL_GAP: f32 = 8.;
const AVATAR_SIZE: f32 = 48.;
const AVATAR_ICON_SIZE: f32 = 24.;
const TITLE_FONT_SIZE: f32 = 16.;
const OPTION_TITLE_FONT_SIZE: f32 = 14.;
const OPTION_DESC_FONT_SIZE: f32 = 12.;
/// The type of agent selected by the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentType {
/// Cloud agent - runs autonomously in a cloud environment.
Cloud,
/// Local agent - runs on the user's machine.
Local,
}
#[derive(Debug, Clone)]
pub enum AgentTypeSelectorAction {
SelectCloudAgent,
SelectLocalAgent,
Dismiss,
HoveredIn(usize),
ArrowUp,
ArrowDown,
Enter,
}
#[derive(Debug)]
pub enum AgentTypeSelectorEvent {
Selected(AgentType),
Dismissed,
}
pub struct AgentTypeSelector {
close_button_mouse_state: MouseStateHandle,
cloud_agent_mouse_state: MouseStateHandle,
local_agent_mouse_state: MouseStateHandle,
dialog_mouse_state: MouseStateHandle,
selected_option_index: usize,
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings(vec![
FixedBinding::new(
"escape",
AgentTypeSelectorAction::Dismiss,
id!("AgentTypeSelector"),
),
FixedBinding::new(
"enter",
AgentTypeSelectorAction::Enter,
id!("AgentTypeSelector"),
),
FixedBinding::new(
"numpadenter",
AgentTypeSelectorAction::Enter,
id!("AgentTypeSelector"),
),
FixedBinding::new(
"up",
AgentTypeSelectorAction::ArrowUp,
id!("AgentTypeSelector"),
),
FixedBinding::new(
"down",
AgentTypeSelectorAction::ArrowDown,
id!("AgentTypeSelector"),
),
]);
}
impl AgentTypeSelector {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self {
close_button_mouse_state: MouseStateHandle::default(),
cloud_agent_mouse_state: MouseStateHandle::default(),
local_agent_mouse_state: MouseStateHandle::default(),
dialog_mouse_state: MouseStateHandle::default(),
// Cloud agent is selected by default (index 0).
selected_option_index: 0,
}
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let title = Text::new(
"Choose your agent".to_string(),
appearance.ui_font_family(),
TITLE_FONT_SIZE,
)
.with_style(Properties::default().weight(Weight::Bold))
.with_color(theme.active_ui_text_color().into())
.soft_wrap(true)
.finish();
let close_button = appearance
.ui_builder()
.close_button(16., self.close_button_mouse_state.clone())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(AgentTypeSelectorAction::Dismiss);
})
.finish();
let esc_keystroke = Keystroke::parse("escape").expect("escape keystroke parses");
let esc_pill = appearance
.ui_builder()
.keyboard_shortcut(&esc_keystroke)
.with_style(UiComponentStyles {
font_size: Some(OPTION_DESC_FONT_SIZE),
font_color: Some(theme.nonactive_ui_text_color().into_solid()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: 3.,
right: 3.,
}),
height: Some(16.),
..Default::default()
})
.build()
.finish();
let right_controls = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(close_button)
.with_child(Container::new(esc_pill).with_margin_left(4.).finish())
.finish();
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(12.)
.with_child(Shrinkable::new(1., title).finish())
.with_child(right_controls)
.finish()
}
#[allow(clippy::too_many_arguments)]
fn render_option(
&self,
index: usize,
icon: Icon,
title: &'static str,
description: &'static str,
is_suggested: bool,
mouse_state: MouseStateHandle,
action: AgentTypeSelectorAction,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let active_text = theme.active_ui_text_color();
let nonactive_text = theme.nonactive_ui_text_color();
let base_background = theme.surface_2();
let hover_background = base_background.blend(&internal_colors::accent_overlay_1(theme));
let base_border = internal_colors::neutral_4(theme);
let hover_border = theme.accent().into_solid();
let avatar_background = internal_colors::neutral_2(theme);
let avatar_border = internal_colors::neutral_3(theme);
let badge_background = internal_colors::neutral_2(theme);
let badge_border = internal_colors::neutral_3(theme);
let badge_text_color = internal_colors::neutral_5(theme);
let icon_color = nonactive_text;
let is_selected = self.selected_option_index == index;
let action = action.clone();
Hoverable::new(mouse_state, move |state| {
let is_hovered = state.is_hovered() || state.is_clicked();
let (background, border_color) = if is_hovered || is_selected {
(hover_background, hover_border)
} else {
(base_background, base_border)
};
let avatar_icon = ConstrainedBox::new(icon.to_warpui_icon(icon_color).finish())
.with_width(AVATAR_ICON_SIZE)
.with_height(AVATAR_ICON_SIZE)
.finish();
let avatar_contents = ConstrainedBox::new(
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(avatar_icon)
.finish(),
)
.with_width(AVATAR_SIZE)
.with_height(AVATAR_SIZE)
.finish();
let avatar = Container::new(avatar_contents)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_background(avatar_background)
.with_border(Border::all(1.).with_border_color(avatar_border))
.finish();
let title_text = Text::new(title.to_string(), font_family, OPTION_TITLE_FONT_SIZE)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(active_text.into())
.finish();
let mut title_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.)
.with_child(title_text);
if is_suggested {
let suggested_text =
Text::new("Suggested".to_string(), font_family, OPTION_DESC_FONT_SIZE)
.with_style(Properties::default().weight(Weight::Medium))
.with_color(badge_text_color)
.finish();
let suggested = Container::new(suggested_text)
.with_horizontal_padding(8.)
.with_vertical_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_background(badge_background)
.with_border(Border::all(1.).with_border_color(badge_border))
.finish();
title_row.add_child(Container::new(suggested).with_margin_left(8.).finish());
}
let description_text =
Text::new(description.to_string(), font_family, OPTION_DESC_FONT_SIZE)
.with_style(Properties::default().weight(Weight::Normal))
.with_color(nonactive_text.into())
.soft_wrap(true)
.finish();
let text_content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title_row.finish())
.with_child(
Container::new(description_text)
.with_margin_top(4.)
.finish(),
)
.finish();
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(OPTION_GAP)
.with_child(avatar)
.with_child(Shrinkable::new(1., text_content).finish())
.finish(),
)
.with_padding_left(OPTION_PADDING_HORIZONTAL)
.with_padding_right(OPTION_PADDING_HORIZONTAL)
.with_padding_top(OPTION_PADDING_VERTICAL)
.with_padding_bottom(OPTION_PADDING_VERTICAL)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OPTION_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_color(border_color))
.with_background(background)
.finish()
})
.with_cursor(Cursor::PointingHand)
.additional_on_hover(move |is_hovered, ctx, _app, _pos| {
if is_hovered {
ctx.dispatch_typed_action(AgentTypeSelectorAction::HoveredIn(index));
}
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(action.clone());
})
.finish()
}
fn render_modal(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
let header = Container::new(self.render_header(appearance))
.with_padding_top(HEADER_PADDING_TOP)
.with_padding_bottom(HEADER_PADDING_BOTTOM)
.with_padding_left(HEADER_PADDING_HORIZONTAL)
.with_padding_right(HEADER_PADDING_HORIZONTAL)
.finish();
let cloud_agent_option = self.render_option(
0,
Icon::OzCloud,
"Cloud agent",
"Runs autonomously in a cloud environment you choose. Best for parallel or long-running work.",
true,
self.cloud_agent_mouse_state.clone(),
AgentTypeSelectorAction::SelectCloudAgent,
appearance,
);
let local_agent_option = self.render_option(
1,
Icon::Oz,
"Local agent",
"Runs on your machine and requires supervision. Best for quick, interactive tasks.",
false,
self.local_agent_mouse_state.clone(),
AgentTypeSelectorAction::SelectLocalAgent,
appearance,
);
let options = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(OPTIONS_VERTICAL_GAP)
.with_child(cloud_agent_option)
.with_child(local_agent_option)
.finish();
let body = Container::new(options)
.with_padding_top(BODY_PADDING_VERTICAL)
.with_padding_bottom(BODY_PADDING_VERTICAL)
.with_padding_left(BODY_PADDING_HORIZONTAL)
.with_padding_right(BODY_PADDING_HORIZONTAL)
.finish();
let dialog_contents = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(body)
.finish();
let dialog_background = theme
.surface_1()
.blend(&internal_colors::fg_overlay_1(theme));
let dialog_border = internal_colors::neutral_4(theme);
let dialog = Container::new(dialog_contents)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(DIALOG_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_color(dialog_border))
.with_background(dialog_background)
.with_drop_shadow(DropShadow {
color: ColorU::new(0, 0, 0, 77),
offset: vec2f(0., 7.),
blur_radius: 7.,
spread_radius: 0.,
})
.finish();
// Wrap dialog in a Hoverable to consume click events within the modal,
// preventing the Dismiss from triggering when clicking inside the dialog.
let clickable_dialog =
Hoverable::new(self.dialog_mouse_state.clone(), |_| dialog).on_click(|_, _, _| {});
let constrained_dialog = ConstrainedBox::new(clickable_dialog.finish())
.with_width(MODAL_WIDTH)
.finish();
let dismiss_dialog = Dismiss::new(constrained_dialog)
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(AgentTypeSelectorAction::Dismiss);
})
.finish();
Container::new(Align::new(dismiss_dialog).finish())
.with_background_color(ColorU::new(0, 0, 0, 179))
.with_corner_radius(app.windows().window_corner_radius())
.finish()
}
}
impl Entity for AgentTypeSelector {
type Event = AgentTypeSelectorEvent;
}
impl TypedActionView for AgentTypeSelector {
type Action = AgentTypeSelectorAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentTypeSelectorAction::SelectCloudAgent => {
ctx.emit(AgentTypeSelectorEvent::Selected(AgentType::Cloud));
ctx.notify();
}
AgentTypeSelectorAction::SelectLocalAgent => {
ctx.emit(AgentTypeSelectorEvent::Selected(AgentType::Local));
ctx.notify();
}
AgentTypeSelectorAction::Dismiss => {
ctx.emit(AgentTypeSelectorEvent::Dismissed);
ctx.notify();
}
AgentTypeSelectorAction::HoveredIn(index) => {
self.selected_option_index = *index;
ctx.notify();
}
AgentTypeSelectorAction::ArrowUp | AgentTypeSelectorAction::ArrowDown => {
// Toggle between the two options.
self.selected_option_index = if self.selected_option_index == 0 {
1
} else {
0
};
ctx.notify();
}
AgentTypeSelectorAction::Enter => {
match self.selected_option_index {
0 => {
ctx.emit(AgentTypeSelectorEvent::Selected(AgentType::Cloud));
}
1 => {
ctx.emit(AgentTypeSelectorEvent::Selected(AgentType::Local));
}
_ => {}
}
ctx.notify();
}
}
}
}
impl View for AgentTypeSelector {
fn ui_name() -> &'static str {
"AgentTypeSelector"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
self.render_modal(appearance, app)
}
}
@@ -0,0 +1,735 @@
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, SetupGuideStep};
use crate::ai::blocklist::code_block::{
render_code_block_plain, CodeBlockOptions, CodeSnippetButtonHandles,
};
use crate::appearance::Appearance;
use crate::completer::SessionAgnosticContext;
use crate::send_telemetry_from_ctx;
use crate::view_components::action_button::{ActionButton, SecondaryTheme};
use crate::workflows::workflow::{Argument, ArgumentType, Workflow};
use crate::workflows::WorkflowType;
use serde::Serialize;
use std::collections::HashMap;
use string_offset::CharCounter;
use warp_completer::signatures::CommandRegistry;
use warp_completer::{util::parse_current_commands_and_tokens, ParsedTokensSnapshot};
use warp_core::report_error;
use warp_core::ui::theme::{AnsiColorIdentifier, AnsiColors};
use warpui::clipboard::ClipboardContent;
use warpui::elements::{
new_scrollable::{ClippedAxisConfiguration, DualAxisConfig, NewScrollable},
Align, Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Element, Empty, Expanded, Flex, Highlight, HighlightedRange,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::prelude::ChildView;
use warpui::text_layout::TextStyle;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::ViewHandle;
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
const DOCS_URL: &str = "https://docs.warp.dev/agent-platform/cloud-agents/overview";
const ENV_DOCS_URL: &str =
"https://docs.warp.dev/reference/cli/integration-setup#creating-an-environment";
const OZ_URL: &str = "https://oz.warp.dev";
const CONTENT_MAX_WIDTH: f32 = 720.;
const CREATE_ENV_SLASH_CMD: &str = "/create-environment";
const CREATE_ENV_CLI_CMD: &str =
"oz environment create [OPTIONS] --name <NAME> --docker-image <DOCKER_IMAGE>";
const CREATE_SLACK_INTEGRATION_CMD: &str =
"oz integration create slack --environment {{environment_id}}";
const CREATE_LINEAR_INTEGRATION_CMD: &str =
"oz integration create linear --environment {{environment_id}}";
pub struct CloudSetupGuideView {
create_env_code_handles: CodeSnippetButtonHandles,
create_env_cli_code_handles: CodeSnippetButtonHandles,
create_slack_integration_code_handles: CodeSnippetButtonHandles,
create_linear_integration_code_handles: CodeSnippetButtonHandles,
docs_link_mouse_state: MouseStateHandle,
env_docs_link_mouse_state: MouseStateHandle,
integration_docs_link_mouse_state: MouseStateHandle,
visit_oz_button: ViewHandle<ActionButton>,
parsed_tokens: HashMap<&'static str, ParsedTokensSnapshot>,
vertical_scroll_state: ClippedScrollStateHandle,
horizontal_scroll_state: ClippedScrollStateHandle,
}
#[derive(Debug, Clone)]
pub enum CloudSetupGuideAction {
CopyCode {
code: String,
step: SetupGuideStep,
},
RunWorkflow {
workflow: Box<WorkflowType>,
step: SetupGuideStep,
},
VisitOz,
OpenDocs {
docs: SetupGuideDocs,
},
}
/// Which URL the user clicked in the setup guide (also used in telemetry)
#[derive(Clone, Copy, Debug, Serialize)]
pub enum SetupGuideDocs {
Main,
Environment,
Integration,
}
pub enum CloudSetupGuideEvent {
OpenNewTabAndInsertWorkflow(WorkflowType),
}
impl CloudSetupGuideView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let code_snippets: Vec<&'static str> = vec![
CREATE_ENV_SLASH_CMD,
CREATE_ENV_CLI_CMD,
CREATE_SLACK_INTEGRATION_CMD,
CREATE_LINEAR_INTEGRATION_CMD,
];
// We spawn a background task to parse the commands in the code blocks,
// and re-render the blocks once it's done. Colors are computed at render time
// so they respond to theme changes.
let context = SessionAgnosticContext::new(CommandRegistry::global_instance());
ctx.spawn(
async move {
let mut results = HashMap::new();
for snippet in code_snippets {
let parsed =
parse_current_commands_and_tokens(snippet.to_string(), &context).await;
results.insert(snippet, parsed);
}
results
},
|view, results, ctx| {
view.parsed_tokens = results;
ctx.notify();
},
);
let visit_oz_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Visit Oz", SecondaryTheme)
.on_click(|ctx| ctx.dispatch_typed_action(CloudSetupGuideAction::VisitOz))
});
Self {
create_env_code_handles: CodeSnippetButtonHandles::default(),
create_env_cli_code_handles: CodeSnippetButtonHandles::default(),
create_slack_integration_code_handles: CodeSnippetButtonHandles::default(),
create_linear_integration_code_handles: CodeSnippetButtonHandles::default(),
docs_link_mouse_state: MouseStateHandle::default(),
env_docs_link_mouse_state: MouseStateHandle::default(),
integration_docs_link_mouse_state: MouseStateHandle::default(),
visit_oz_button,
parsed_tokens: HashMap::new(),
vertical_scroll_state: ClippedScrollStateHandle::default(),
horizontal_scroll_state: ClippedScrollStateHandle::default(),
}
}
/// Render the main header for the setup guide.
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let title_font_size = 24.;
let subtitle_font_size = 16.;
let mut header_container = Flex::column().with_spacing(8.);
let title = Text::new(
"Getting started with Oz cloud agents",
appearance.ui_font_family(),
title_font_size,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into_solid())
.finish();
header_container.add_child(title);
let subtitle = Text::new(
"Start Oz cloud agents directly in Warp from an integration (Linear, Slack), with an event (GitHub, built-in schedule), or programmatically with the Oz SDK or CLI.",
appearance.ui_font_family(),
subtitle_font_size,
)
.with_color(theme.nonactive_ui_text_color().into_solid())
.finish();
header_container.add_child(subtitle);
// Documentation link line.
let docs_line = Flex::row()
.with_child(
Text::new_inline(
"Check out the ",
appearance.ui_font_family(),
subtitle_font_size,
)
.with_color(theme.nonactive_ui_text_color().into_solid())
.finish(),
)
.with_child(
appearance
.ui_builder()
.link(
"Oz documentation".to_string(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(CloudSetupGuideAction::OpenDocs {
docs: SetupGuideDocs::Main,
});
})),
self.docs_link_mouse_state.clone(),
)
.with_style(UiComponentStyles {
font_size: Some(subtitle_font_size),
..Default::default()
})
.build()
.finish(),
)
.with_child(
Text::new_inline(
" to learn more.",
appearance.ui_font_family(),
subtitle_font_size,
)
.with_color(theme.nonactive_ui_text_color().into_solid())
.finish(),
);
header_container.add_child(docs_line.finish());
header_container.finish()
}
/// Render the quick start banner with link to oz.warp.dev.
fn render_quick_start_banner(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let font_size = 16.;
let text = Text::new_inline(
"Quick start: Visit oz.warp.dev for a UI-based setup experience.",
appearance.ui_font_family(),
font_size,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into_solid())
.finish();
// Use cyan overlay for the blue border per Figma spec.
let border_color = theme.ansi_overlay_2(theme.terminal_colors().normal.cyan);
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text)
.with_child(ChildView::new(&self.visit_oz_button).finish())
.finish(),
)
.with_background(theme.surface_overlay_1())
.with_border(Border::all(1.).with_border_fill(border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_horizontal_padding(16.)
.with_vertical_padding(12.)
.finish()
}
/// Render the manual setup section header.
fn render_manual_setup_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let font_size = 16.;
Text::new(
"Manual setup: Create a Slack or Linear integration with the Oz CLI",
appearance.ui_font_family(),
font_size,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into_solid())
.finish()
}
/// Render a styled number to be displayed with each step.
fn render_step_number(number: u32, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let number_text = Text::new(
number.to_string(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into_solid())
.finish();
let centered_number = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(number_text)
.finish();
Container::new(
ConstrainedBox::new(centered_number)
.with_width(28.)
.with_height(28.)
.finish(),
)
.with_background(theme.surface_1())
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish()
}
/// Render a description that includes a link at the end
/// (e.g. "Use warp's environment setup command to have an agent help you through it. LINK[Visit docs]")
fn render_description_with_link(
prefix: &'static str,
link_text: &'static str,
link_mouse_state: MouseStateHandle,
telemetry_url: SetupGuideDocs,
appearance: &Appearance,
) -> Box<dyn Element> {
let step_desc_font_size = 14.;
let link = appearance
.ui_builder()
.link(
link_text.to_string(),
None,
Some(Box::new(move |ctx| {
ctx.dispatch_typed_action(CloudSetupGuideAction::OpenDocs {
docs: telemetry_url,
});
})),
link_mouse_state,
)
.with_style(UiComponentStyles {
font_size: Some(step_desc_font_size),
..Default::default()
})
.build()
.finish();
Flex::row()
.with_child(
Text::new_inline(prefix, appearance.ui_font_family(), step_desc_font_size)
.with_color(appearance.theme().nonactive_ui_text_color().into_solid())
.finish(),
)
.with_child(link)
.finish()
}
/// Render a code block with buttons to copy and run the code.
fn render_code_block(
&self,
code: &'static str,
handles: CodeSnippetButtonHandles,
app: &AppContext,
) -> Box<dyn Element> {
let terminal_colors = Appearance::as_ref(app).theme().terminal_colors().normal;
let highlights = self
.parsed_tokens
.get(code)
.map(|parsed| tokens_to_highlight_ranges(parsed, &terminal_colors))
.unwrap_or_default();
// Match command to formatted workflow with correct args.
let Some((workflow, setup_step)) = (match code {
CREATE_ENV_SLASH_CMD => Some((
WorkflowType::Local(
Workflow::new("Create Environment", CREATE_ENV_SLASH_CMD).with_arguments(vec![
Argument::new("github link or local filepath", ArgumentType::Text)
.with_description("GitHub link or local filepath to the repository"),
]),
),
SetupGuideStep::CreateEnvironment,
)),
CREATE_ENV_CLI_CMD => Some((
WorkflowType::Local(
Workflow::new("Create Environment (CLI)", CREATE_ENV_CLI_CMD).with_arguments(
vec![
Argument::new("NAME", ArgumentType::Text)
.with_description("Name for the environment"),
Argument::new("DOCKER_IMAGE", ArgumentType::Text)
.with_description("Docker image to use for the environment"),
],
),
),
SetupGuideStep::CreateEnvironmentCli,
)),
CREATE_SLACK_INTEGRATION_CMD => Some((
WorkflowType::Local(
Workflow::new("Create Slack Integration", CREATE_SLACK_INTEGRATION_CMD)
.with_arguments(vec![Argument::new("environment_id", ArgumentType::Text)
.with_description("ID of the environment to integrate with")]),
),
SetupGuideStep::CreateSlackIntegration,
)),
CREATE_LINEAR_INTEGRATION_CMD => Some((
WorkflowType::Local(
Workflow::new("Create Linear Integration", CREATE_LINEAR_INTEGRATION_CMD)
.with_arguments(vec![Argument::new("environment_id", ArgumentType::Text)
.with_description("ID of the environment to integrate with")]),
),
SetupGuideStep::CreateLinearIntegration,
)),
_ => None,
}) else {
report_error!(anyhow::anyhow!(
"Received unknown code in render_code_block: {}",
code
));
return Empty::new().finish();
};
render_code_block_plain(
code,
highlights.into_iter(),
CodeBlockOptions {
on_open: None,
on_execute: Some(Box::new(move |_code, ctx| {
ctx.dispatch_typed_action(CloudSetupGuideAction::RunWorkflow {
workflow: Box::new(workflow.clone()),
step: setup_step,
});
})),
on_copy: Some(Box::new(move |_code, ctx| {
ctx.dispatch_typed_action(CloudSetupGuideAction::CopyCode {
code: code.to_string().clone(),
step: setup_step,
});
})),
on_insert: None,
footer_element: None,
mouse_handles: Some(handles),
file_path: None,
},
app,
None,
)
}
/// Render step 1: Create an environment.
fn render_step_1(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
let step_title_font_size = 14.;
let step_desc_font_size = 14.;
let title_row = Flex::row()
.with_spacing(16.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Self::render_step_number(1, appearance))
.with_child(
Text::new(
"Create an environment",
appearance.ui_font_family(),
step_title_font_size,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into_solid())
.finish(),
)
.finish();
let description = Container::new(
Text::new(
"First, set up an environment to create an integration.",
appearance.ui_font_family(),
step_desc_font_size,
)
.with_color(theme.nonactive_ui_text_color().into_solid())
.finish(),
)
.with_padding_left(46.)
.finish();
let sub_description = Container::new(Self::render_description_with_link(
"Use Warp's environment setup command to have an agent help you through it. ",
"Visit docs",
self.env_docs_link_mouse_state.clone(),
SetupGuideDocs::Environment,
appearance,
))
.with_padding_left(46.)
.with_padding_bottom(8.)
.finish();
let slash_cmd_code_block = Container::new(self.render_code_block(
CREATE_ENV_SLASH_CMD,
self.create_env_code_handles.clone(),
app,
))
.with_padding_left(46.)
.finish();
let or_text = Container::new(
Text::new(
"Or, supply your own existing docker image.",
appearance.ui_font_family(),
step_desc_font_size,
)
.with_color(theme.nonactive_ui_text_color().into_solid())
.finish(),
)
.with_padding_left(46.)
.with_padding_top(8.)
.with_padding_bottom(8.)
.finish();
let cli_code_block = Container::new(self.render_code_block(
CREATE_ENV_CLI_CMD,
self.create_env_cli_code_handles.clone(),
app,
))
.with_padding_left(46.)
.finish();
Flex::column()
.with_spacing(8.)
.with_child(title_row)
.with_child(description)
.with_child(sub_description)
.with_child(slash_cmd_code_block)
.with_child(or_text)
.with_child(cli_code_block)
.finish()
}
/// Render step 2: Create an integration.
fn render_step_2(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
let step_title_font_size = 14.;
let title_row = Flex::row()
.with_spacing(16.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Self::render_step_number(2, appearance))
.with_child(
Text::new(
"Create an integration",
appearance.ui_font_family(),
step_title_font_size,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.active_ui_text_color().into_solid())
.finish(),
)
.finish();
let sub_description = Container::new(Self::render_description_with_link(
"Integrate Slack or Linear to assign Warp's Agent tasks with @Warp. ",
"Visit docs",
self.integration_docs_link_mouse_state.clone(),
SetupGuideDocs::Integration,
appearance,
))
.with_padding_left(46.)
.with_padding_bottom(8.)
.finish();
let code_block_slack = Container::new(self.render_code_block(
CREATE_SLACK_INTEGRATION_CMD,
self.create_slack_integration_code_handles.clone(),
app,
))
.with_padding_left(46.)
.finish();
let code_block_linear = Container::new(self.render_code_block(
CREATE_LINEAR_INTEGRATION_CMD,
self.create_linear_integration_code_handles.clone(),
app,
))
.with_padding_left(46.)
.finish();
Flex::column()
.with_spacing(8.)
.with_child(title_row)
.with_child(sub_description)
.with_child(code_block_slack)
.with_child(code_block_linear)
.finish()
}
}
impl Entity for CloudSetupGuideView {
type Event = CloudSetupGuideEvent;
}
impl View for CloudSetupGuideView {
fn ui_name() -> &'static str {
"AgentManagementHelpPageView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let steps = Flex::column()
.with_spacing(24.)
.with_child(self.render_step_1(appearance, app))
.with_child(self.render_step_2(appearance, app))
.finish();
let mut content = Flex::column()
.with_spacing(24.)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
content.add_child(self.render_header(appearance));
content.add_child(self.render_quick_start_banner(appearance));
content.add_child(self.render_manual_setup_header(appearance));
content.add_child(steps);
let content = content.finish();
let scrollable = NewScrollable::horizontal_and_vertical(
DualAxisConfig::Clipped {
horizontal: ClippedAxisConfiguration {
handle: self.horizontal_scroll_state.clone(),
max_size: None,
stretch_child: true,
},
vertical: ClippedAxisConfiguration {
handle: self.vertical_scroll_state.clone(),
max_size: None,
stretch_child: false,
},
child: Align::new(
Container::new(
ConstrainedBox::new(content)
.with_max_width(CONTENT_MAX_WIDTH)
.finish(),
)
.with_uniform_padding(24.)
.finish(),
)
.top_center()
.finish(),
},
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
warpui::elements::Fill::None,
)
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Expanded::new(1., scrollable).finish())
.finish()
}
}
impl TypedActionView for CloudSetupGuideView {
type Action = CloudSetupGuideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CloudSetupGuideAction::CopyCode { code, step } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SetupGuideStepCopy { step: *step },
ctx
);
ctx.clipboard()
.write(ClipboardContent::plain_text(code.clone()));
}
CloudSetupGuideAction::RunWorkflow { workflow, step } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SetupGuideStepRun { step: *step },
ctx
);
ctx.emit(CloudSetupGuideEvent::OpenNewTabAndInsertWorkflow(
(**workflow).clone(),
));
}
CloudSetupGuideAction::VisitOz => {
ctx.open_url(OZ_URL);
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SetupGuideStepRun {
step: SetupGuideStep::VisitOz
},
ctx
);
}
CloudSetupGuideAction::OpenDocs { docs } => {
let url = match docs {
SetupGuideDocs::Main => DOCS_URL,
SetupGuideDocs::Environment => ENV_DOCS_URL,
SetupGuideDocs::Integration => DOCS_URL,
};
ctx.open_url(url);
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::SetupGuideDocsLink { docs: *docs },
ctx
);
}
}
}
}
/// Highlight the commands in command blocks correctly (including the command prefix).
/// It's unfortunate that we have to do this manually, but the alternative is inserting a custom code editor into this component
/// and that would be a lot of bloat for not much benefit.
fn tokens_to_highlight_ranges(
parsed_tokens: &ParsedTokensSnapshot,
terminal_colors: &AnsiColors,
) -> Vec<HighlightedRange> {
let code = &parsed_tokens.buffer_text;
let mut highlights = Vec::new();
// Handle slash commands: if code starts with '/', highlight the command prefix in magenta
if code.starts_with('/') {
if let Some(space_idx) = code.find(' ') {
let color = AnsiColorIdentifier::Magenta.to_ansi_color(terminal_colors);
highlights.push(HighlightedRange {
highlight: Highlight::new()
.with_text_style(TextStyle::new().with_foreground_color(color.into())),
highlight_indices: (0..space_idx).collect(),
});
return highlights;
}
}
// Highlight commands in the code block (converting bytes to char indexes as we go).
let mut char_counter = CharCounter::new(code);
for token_data in &parsed_tokens.parsed_tokens {
let Some(description) = &token_data.token_description else {
continue;
};
let byte_start = token_data.token.span.start();
let byte_end = token_data.token.span.end();
let Some(char_start) = char_counter.char_offset(byte_start) else {
continue;
};
let Some(char_end) = char_counter.char_offset(byte_end) else {
continue;
};
let char_indices: Vec<usize> = (char_start.as_usize()..char_end.as_usize()).collect();
if char_indices.is_empty() {
continue;
}
let color_id: AnsiColorIdentifier = description.suggestion_type.to_name().into();
let color = color_id.to_ansi_color(terminal_colors);
highlights.push(HighlightedRange {
highlight: Highlight::new()
.with_text_style(TextStyle::new().with_foreground_color(color.into())),
highlight_indices: char_indices,
});
}
highlights
}
@@ -0,0 +1,289 @@
//! Action buttons row for conversation details panel.
use warp_core::ui::theme::AnsiColorIdentifier;
use warpui::elements::{ChildView, CrossAxisAlignment, Empty, Flex, ParentElement};
use warpui::{AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle};
use crate::view_components::copyable_text_field::COPY_FEEDBACK_DURATION;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::AgentRunDisplayStatus;
use crate::ai::agent_management::view::ManagementCardItemId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::workspace::WorkspaceAction;
const BUTTON_SPACING: f32 = 4.;
/// Per-button config for the action buttons row.
/// Each field controls one button independently.
#[derive(Debug, Clone, Default)]
pub struct ActionButtonsConfig {
pub open_action: Option<WorkspaceAction>,
pub cancel_task_id: Option<AmbientAgentTaskId>,
pub fork_conversation_id: Option<AIConversationId>,
/// Shows an info button for viewing more details.
/// Only used in management view hover toolbelt.
pub view_details_item_id: Option<ManagementCardItemId>,
/// Conversation link URL (either to the transcript or live session) for copy link button.
pub copy_link_url: Option<String>,
}
impl ActionButtonsConfig {
/// Returns true if no buttons will be rendered.
pub fn is_empty(&self) -> bool {
self.open_action.is_none()
&& self.cancel_task_id.is_none()
&& self.fork_conversation_id.is_none()
&& self.view_details_item_id.is_none()
&& self.copy_link_url.is_none()
}
/// Create config for a task.
/// - `display_status`: used to determine if cancel button should show.
/// - `open_action`: pass `Some(action)` to show open button, `None` to hide
/// - `copy_link_url`: conversation link URL, or `None` to hide
pub fn for_task(
task_id: AmbientAgentTaskId,
display_status: &AgentRunDisplayStatus,
open_action: Option<WorkspaceAction>,
copy_link_url: Option<String>,
) -> Self {
Self {
open_action,
cancel_task_id: if display_status.is_cancellable() {
Some(task_id)
} else {
None
},
fork_conversation_id: None,
view_details_item_id: None,
copy_link_url,
}
}
/// Create config for a conversation.
/// - `open_action`: pass `Some(action)` to show open button, `None` to hide
/// - `copy_link_url`: conversation link URL, or `None` to hide
pub fn for_conversation(
conversation_id: AIConversationId,
open_action: Option<WorkspaceAction>,
copy_link_url: Option<String>,
) -> Self {
Self {
open_action,
cancel_task_id: None,
fork_conversation_id: Some(conversation_id),
view_details_item_id: None,
copy_link_url,
}
}
}
/// Events emitted by the action buttons.
#[derive(Debug, Clone)]
pub enum AgentDetailsButtonEvent {
Open,
CancelTask { task_id: AmbientAgentTaskId },
ForkConversation { conversation_id: AIConversationId },
ViewDetails { item_id: ManagementCardItemId },
CopyLink { link: String },
}
/// Actions dispatched by button clicks (internal).
#[derive(Debug, Clone)]
pub enum AgentDetailsAction {
Open,
CancelTask,
ForkConversation,
ViewDetails,
CopyLink,
}
/// Reusable action buttons row for details panel.
pub struct ConversationActionButtonsRow {
config: ActionButtonsConfig,
open_button: ViewHandle<ActionButton>,
cancel_task_button: ViewHandle<ActionButton>,
fork_conversation_button: ViewHandle<ActionButton>,
view_details_button: ViewHandle<ActionButton>,
copy_link_button: ViewHandle<ActionButton>,
}
impl ConversationActionButtonsRow {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let open_button = ctx.add_typed_action_view(|_| {
Self::make_action_button(
Icon::LinkExternal,
"Open conversation",
None,
AgentDetailsAction::Open,
)
});
let cancel_task_button = ctx.add_typed_action_view(|_| {
Self::make_action_button(
Icon::StopFilled,
"Cancel task",
Some(AnsiColorIdentifier::Red),
AgentDetailsAction::CancelTask,
)
});
let fork_conversation_button = ctx.add_typed_action_view(|_| {
Self::make_action_button(
Icon::ArrowSplit,
"Fork conversation",
None,
AgentDetailsAction::ForkConversation,
)
});
let view_details_button = ctx.add_typed_action_view(|_| {
Self::make_action_button(
Icon::Info,
"View details",
None,
AgentDetailsAction::ViewDetails,
)
});
let copy_link_button = ctx.add_typed_action_view(|_| {
Self::make_action_button(
Icon::Link,
"Copy link to run",
None,
AgentDetailsAction::CopyLink,
)
});
Self {
config: ActionButtonsConfig::default(),
open_button,
cancel_task_button,
fork_conversation_button,
view_details_button,
copy_link_button,
}
}
/// Set the config and rerender.
pub fn set_config(&mut self, config: ActionButtonsConfig, ctx: &mut ViewContext<Self>) {
self.config = config;
ctx.notify();
}
/// Returns true if no buttons will be rendered.
pub fn is_empty(&self) -> bool {
self.config.is_empty()
}
fn make_action_button(
icon: Icon,
tooltip: &str,
icon_color: Option<AnsiColorIdentifier>,
action: AgentDetailsAction,
) -> ActionButton {
let mut button = ActionButton::new("", SecondaryTheme)
.with_icon(icon)
.with_size(ButtonSize::Small)
.with_tooltip(tooltip)
.on_click(move |ctx| {
ctx.dispatch_typed_action(action.clone());
});
if let Some(color) = icon_color {
button = button.with_icon_ansi_color(color);
}
button
}
}
impl Entity for ConversationActionButtonsRow {
type Event = AgentDetailsButtonEvent;
}
impl View for ConversationActionButtonsRow {
fn ui_name() -> &'static str {
"ConversationActionButtonsRow"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
if self.config.is_empty() {
return Empty::new().finish();
}
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(BUTTON_SPACING);
if self.config.copy_link_url.is_some() {
row.add_child(ChildView::new(&self.copy_link_button).finish());
}
if self.config.open_action.is_some() {
row.add_child(ChildView::new(&self.open_button).finish());
}
if self.config.cancel_task_id.is_some() {
row.add_child(ChildView::new(&self.cancel_task_button).finish());
}
if self.config.fork_conversation_id.is_some() && !cfg!(target_family = "wasm") {
row.add_child(ChildView::new(&self.fork_conversation_button).finish());
}
if self.config.view_details_item_id.is_some() {
row.add_child(ChildView::new(&self.view_details_button).finish());
}
row.finish()
}
}
impl TypedActionView for ConversationActionButtonsRow {
type Action = AgentDetailsAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentDetailsAction::Open => {
if self.config.open_action.is_some() {
ctx.emit(AgentDetailsButtonEvent::Open);
}
}
AgentDetailsAction::CancelTask => {
if let Some(task_id) = self.config.cancel_task_id {
ctx.emit(AgentDetailsButtonEvent::CancelTask { task_id });
}
}
AgentDetailsAction::ForkConversation => {
if let Some(conversation_id) = self.config.fork_conversation_id {
ctx.emit(AgentDetailsButtonEvent::ForkConversation { conversation_id });
}
}
AgentDetailsAction::ViewDetails => {
if let Some(item_id) = &self.config.view_details_item_id {
ctx.emit(AgentDetailsButtonEvent::ViewDetails {
item_id: item_id.clone(),
});
}
}
AgentDetailsAction::CopyLink => {
if let Some(link) = &self.config.copy_link_url {
ctx.emit(AgentDetailsButtonEvent::CopyLink { link: link.clone() });
self.copy_link_button.update(ctx, |button, ctx| {
button.set_icon(Some(Icon::Check), ctx);
});
let duration = COPY_FEEDBACK_DURATION;
ctx.spawn(
async move {
warpui::r#async::Timer::after(duration).await;
},
|me, _, ctx| {
me.copy_link_button.update(ctx, |button, ctx| {
button.set_icon(Some(Icon::Link), ctx);
});
},
);
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
mod agent_management_model;
pub(crate) mod agent_type_selector;
pub(crate) mod details_action_buttons;
pub(crate) mod notifications;
pub(crate) mod cloud_setup_guide_view;
pub(crate) mod telemetry;
pub(crate) mod view;
pub(crate) use agent_management_model::{AgentManagementEvent, AgentNotificationsModel};
pub fn init(app: &mut warpui::AppContext) {
view::init(app);
agent_type_selector::init(app);
notifications::view::NotificationMailboxView::init(app);
}
@@ -0,0 +1,208 @@
use enum_iterator::Sequence;
use instant::Instant;
use uuid::Uuid;
use warpui::EntityId;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::artifacts::Artifact;
use crate::terminal::CLIAgent;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NotificationId(Uuid);
impl NotificationId {
fn new() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationCategory {
/// The agent has stopped (i.e. successfully completed or was cancelled)
Complete,
/// The agent needs user action (i.e. blocked on some permission request or idle prompt)
Request,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Sequence)]
pub enum NotificationFilter {
All,
Unread,
Errors,
}
impl NotificationFilter {
pub(crate) fn label(&self) -> &'static str {
match self {
NotificationFilter::All => "All tabs",
NotificationFilter::Unread => "Unread",
NotificationFilter::Errors => "Errors",
}
}
}
/// Identifies the agent that produced a notification.
#[derive(Debug, Clone, Copy)]
#[allow(clippy::upper_case_acronyms)]
pub enum NotificationSourceAgent {
Oz,
CLI(CLIAgent),
}
/// Identifies the conversation or session a notification belongs to.
/// Used for de-duplication (replacing stale notifications on update)
/// and cleanup (removing notifications when the source closes).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NotificationOrigin {
Conversation(AIConversationId),
/// CLI sessions are keyed by terminal view because we only track one session per pane.
CLISession(EntityId),
}
#[derive(Debug, Clone)]
pub struct NotificationItem {
pub id: NotificationId,
pub origin: NotificationOrigin,
pub title: String,
pub message: String,
pub category: NotificationCategory,
pub agent: NotificationSourceAgent,
/// Whether the user has already seen this notification
/// (either because they clicked into it or because it was emitted for a conversation
/// that they've since navigated to).
pub is_read: bool,
pub created_at: Instant,
pub terminal_view_id: EntityId,
pub artifacts: Vec<Artifact>,
/// The git branch associated with this notification's conversation/session.
/// When present, the notification renders in "rich" layout with a branch header row.
/// When absent, it falls back to the "simple" layout.
pub branch: Option<String>,
}
impl NotificationItem {
/// Marks this notification as read. Returns true if it was previously unread.
fn mark_as_read(&mut self) -> bool {
if self.is_read {
return false;
}
self.is_read = true;
true
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
title: String,
message: String,
category: NotificationCategory,
agent: NotificationSourceAgent,
origin: NotificationOrigin,
is_read: bool,
terminal_view_id: EntityId,
artifacts: Vec<Artifact>,
branch: Option<String>,
) -> Self {
Self {
id: NotificationId::new(),
origin,
title,
message,
category,
agent,
is_read,
created_at: Instant::now(),
terminal_view_id,
artifacts,
branch,
}
}
}
#[derive(Debug, Default)]
pub struct NotificationItems {
items: Vec<NotificationItem>,
}
impl NotificationItems {
/// Push a notification items into the mailbox list
/// (deleting older notifications if we've exceeded the max list size).
pub(crate) fn push(&mut self, item: NotificationItem) {
self.remove_by_origin(item.origin);
self.items.insert(0, item);
self.items.truncate(100);
}
pub(crate) fn remove_by_origin(&mut self, key: NotificationOrigin) -> bool {
let before = self.items.len();
self.items.retain(|item| item.origin != key);
self.items.len() != before
}
pub(crate) fn items_filtered(
&self,
filter: NotificationFilter,
) -> impl Iterator<Item = &NotificationItem> {
self.items.iter().filter(move |item| match filter {
NotificationFilter::All => true,
NotificationFilter::Unread => !item.is_read,
NotificationFilter::Errors => item.category == NotificationCategory::Error,
})
}
pub(crate) fn filtered_count(&self, filter: NotificationFilter) -> usize {
self.items_filtered(filter).count()
}
/// Returns the filters that should be shown as tabs.
/// "All" is always included; other filters are included only when they have at least one item.
pub(crate) fn visible_filters(&self) -> Vec<NotificationFilter> {
enum_iterator::all::<NotificationFilter>()
.filter(|f| *f == NotificationFilter::All || self.filtered_count(*f) > 0)
.collect()
}
pub(crate) fn get_by_id(&self, id: NotificationId) -> Option<&NotificationItem> {
self.items.iter().find(|item| item.id == id)
}
/// Marks all notifications from the given terminal view as read.
/// Returns true if any were changed.
pub(crate) fn mark_all_terminal_view_items_as_read(
&mut self,
terminal_view_id: EntityId,
) -> bool {
let mut any_changed = false;
for item in &mut self.items {
if item.terminal_view_id == terminal_view_id {
any_changed |= item.mark_as_read();
}
}
any_changed
}
pub(crate) fn mark_item_read(&mut self, id: NotificationId) -> bool {
self.items
.iter_mut()
.find(|item| item.id == id)
.is_some_and(|item| item.mark_as_read())
}
pub(crate) fn mark_all_items_read(&mut self) -> bool {
let mut any_changed = false;
for item in &mut self.items {
any_changed |= item.mark_as_read();
}
any_changed
}
pub(crate) fn has_unread_for_terminal_view(&self, terminal_view_id: EntityId) -> bool {
self.items
.iter()
.any(|item| item.terminal_view_id == terminal_view_id && !item.is_read)
}
}
#[cfg(test)]
#[path = "item_tests.rs"]
mod tests;
@@ -0,0 +1,509 @@
use std::sync::Arc;
use pathfinder_color::ColorU;
use warp_core::ui::icons::Icon;
use warp_core::ui::theme::{Fill, WarpTheme};
use warpui::clipboard::ClipboardContent;
use warpui::elements::{
ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
Element, EventHandler, Flex, MainAxisAlignment, MainAxisSize, ParentElement, Radius, Rect,
Shrinkable,
};
use warpui::fonts::Weight;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{View, ViewContext, ViewHandle};
use warp_core::ui::appearance::Appearance as CoreAppearance;
use warp_core::ui::theme::color::internal_colors;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent_management::notifications::item::NotificationSourceAgent;
use crate::ai::agent_management::notifications::{NotificationCategory, NotificationItem};
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, ArtifactType};
use crate::ai::artifacts::{
open_screenshot_lightbox, Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent,
};
use crate::appearance::Appearance;
use crate::send_telemetry_from_ctx;
use crate::ui_components::icon_with_status::{
render_icon_with_status, IconWithStatusSizing, IconWithStatusVariant,
};
use crate::util::time_format::format_elapsed_since;
use crate::view_components::action_button::ActionButtonTheme;
use crate::workspace::WorkspaceAction;
const COLLAPSED_MAX_CHARS: usize = 100;
const EXPANDED_MAX_CHARS: usize = 500;
fn truncate_text(text: &str, max_chars: usize) -> String {
if text.chars().count() > max_chars - 3 {
let truncated: String = text.chars().take(max_chars).collect();
format!("{truncated}…")
} else {
text.to_owned()
}
}
/// Returns true when either the title or message would be truncated by `truncate_text`.
fn content_is_truncated(title: &str, message: &str) -> bool {
title.chars().count() > COLLAPSED_MAX_CHARS - 3
|| message.chars().count() > COLLAPSED_MAX_CHARS - 3
}
/// Determines toast-vs-mailbox rendering differences.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum NotificationRenderContext {
Toast,
Mailbox,
}
/// Button theme for artifact chips in notifications.
/// Uses `outline` for the border so it's visible against `surface_2`.
pub(crate) struct NotificationArtifactButtonTheme;
impl ActionButtonTheme for NotificationArtifactButtonTheme {
fn background(&self, hovered: bool, appearance: &CoreAppearance) -> Option<Fill> {
if hovered {
Some(internal_colors::fg_overlay_2(appearance.theme()))
} else {
None
}
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &CoreAppearance,
) -> ColorU {
appearance.theme().foreground().into_solid()
}
fn border(&self, appearance: &CoreAppearance) -> Option<ColorU> {
Some(appearance.theme().outline().into_solid())
}
}
/// Callback invoked when the user clicks the expand/collapse affordance on a clamped message.
pub(crate) type OnExpandClick = Box<dyn Fn(&mut warpui::EventContext)>;
/// Renders the inner content of a notification item.
/// Dispatches to the rich layout (with branch row) or simple layout based on `item.branch`.
pub(crate) fn render_notification_item_content(
item: &NotificationItem,
artifact_buttons: Option<&ViewHandle<ArtifactButtonsRow>>,
context: NotificationRenderContext,
message_expanded: bool,
on_expand_click: OnExpandClick,
extra_content: Option<Box<dyn Element>>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let text_column = if item.branch.is_some() {
render_rich_text_column(
item,
artifact_buttons,
context,
message_expanded,
on_expand_click,
extra_content,
appearance,
)
} else {
render_simple_text_column(
item,
artifact_buttons,
context,
message_expanded,
on_expand_click,
extra_content,
appearance,
)
};
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(render_agent_avatar(item.agent, item.category, theme))
.with_margin_right(8.)
.with_margin_top(2.)
.finish(),
)
.with_child(Shrinkable::new(1.0, text_column).finish())
.finish()
}
/// Rich layout: branch row + clamped title + clamped message + artifact buttons.
fn render_rich_text_column(
item: &NotificationItem,
artifact_buttons: Option<&ViewHandle<ArtifactButtonsRow>>,
context: NotificationRenderContext,
message_expanded: bool,
on_expand_click: OnExpandClick,
extra_content: Option<Box<dyn Element>>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let branch = item.branch.as_deref().unwrap_or_default();
let branch_left = render_branch_label(branch, appearance);
let is_truncated = content_is_truncated(&item.title, &item.message);
let branch_right: Box<dyn Element> = match context {
NotificationRenderContext::Toast if is_truncated || message_expanded => {
render_expand_chevron(message_expanded, on_expand_click, theme)
}
NotificationRenderContext::Toast => {
// No chevron when content fits.
Flex::row().finish()
}
NotificationRenderContext::Mailbox => render_timestamp_with_dot(item, appearance),
};
let branch_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(branch_left)
.with_child(branch_right)
.finish();
let title = render_clamped_title(&item.title, message_expanded, appearance);
let message = render_message_text(&item.message, message_expanded, appearance);
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(branch_row)
.with_child(title)
.with_child(Container::new(message).with_margin_top(2.).finish());
append_trailing_content(&mut content, artifact_buttons, extra_content);
content.finish()
}
/// Simple layout: title (+ optional chevron) | timestamp row + message + artifact buttons.
fn render_simple_text_column(
item: &NotificationItem,
artifact_buttons: Option<&ViewHandle<ArtifactButtonsRow>>,
context: NotificationRenderContext,
message_expanded: bool,
on_expand_click: OnExpandClick,
extra_content: Option<Box<dyn Element>>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let is_truncated = content_is_truncated(&item.title, &item.message);
let title_text = render_clamped_title(&item.title, message_expanded, appearance);
let title_row: Box<dyn Element> = if context == NotificationRenderContext::Mailbox {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Shrinkable::new(1.0, title_text).finish())
.with_child(render_timestamp_with_dot(item, appearance))
.finish()
} else if is_truncated || message_expanded {
let chevron = render_expand_chevron(message_expanded, on_expand_click, theme);
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(Shrinkable::new(1.0, title_text).finish())
.with_child(Container::new(chevron).with_margin_top(2.).finish())
.finish()
} else {
title_text
};
let message = render_message_text(&item.message, message_expanded, appearance);
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(title_row)
.with_child(Container::new(message).with_margin_top(2.).finish());
append_trailing_content(&mut content, artifact_buttons, extra_content);
content.finish()
}
/// Appends artifact buttons and extra content to a text column.
fn append_trailing_content(
content: &mut Flex,
artifact_buttons: Option<&ViewHandle<ArtifactButtonsRow>>,
extra_content: Option<Box<dyn Element>>,
) {
if let Some(artifact_buttons) = artifact_buttons {
content.add_child(
Container::new(ChildView::new(artifact_buttons).finish())
.with_margin_top(8.)
.finish(),
);
}
if let Some(extra) = extra_content {
content.add_child(extra);
}
}
/// Renders a git-branch icon + branch name label.
fn render_branch_label(branch: &str, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let color = theme.sub_text_color(theme.surface_1());
Shrinkable::new(
1.0,
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(2.)
.with_child(
ConstrainedBox::new(Icon::GitBranch.to_warpui_icon(color).finish())
.with_width(10.)
.with_height(10.)
.finish(),
)
.with_child(
Shrinkable::new(
1.0,
appearance
.ui_builder()
.wrappable_text(branch.to_owned(), false)
.with_style(UiComponentStyles {
font_size: Some(12.),
font_color: Some(color.into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish(),
)
.finish(),
)
.finish(),
)
.finish()
}
/// Renders the timestamp text + optional unread dot.
fn render_timestamp_with_dot(item: &NotificationItem, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(
appearance
.ui_builder()
.wrappable_text(format_elapsed_since(item.created_at), false)
.with_style(UiComponentStyles {
font_size: Some(12.),
font_color: Some(theme.disabled_text_color(theme.surface_1()).into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish(),
);
if !item.is_read {
row.add_child(
Container::new(
ConstrainedBox::new(
Rect::new()
.with_background(theme.accent())
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_width(10.)
.with_height(10.)
.finish(),
)
.with_margin_left(8.)
.finish(),
);
}
row.finish()
}
/// Renders a clickable expand/collapse chevron icon.
fn render_expand_chevron(
expanded: bool,
on_click: OnExpandClick,
theme: &WarpTheme,
) -> Box<dyn Element> {
let icon = if expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let chevron = ConstrainedBox::new(
icon.to_warpui_icon(theme.disabled_text_color(theme.surface_1()))
.finish(),
)
.with_width(12.)
.with_height(12.)
.finish();
EventHandler::new(chevron)
.on_left_mouse_down(move |ctx, _, _| {
on_click(ctx);
DispatchEventResult::StopPropagation
})
.finish()
}
/// Renders the title text, truncated based on expanded state.
fn render_clamped_title(title: &str, expanded: bool, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let max = if expanded {
EXPANDED_MAX_CHARS
} else {
COLLAPSED_MAX_CHARS
};
appearance
.ui_builder()
.wrappable_text(truncate_text(title, max), true)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Semibold),
font_color: Some(theme.main_text_color(theme.surface_1()).into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish()
}
/// Renders the message text, truncated based on expanded state.
fn render_message_text(message: &str, expanded: bool, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let max = if expanded {
EXPANDED_MAX_CHARS
} else {
COLLAPSED_MAX_CHARS
};
appearance
.ui_builder()
.wrappable_text(truncate_text(message, max), true)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_color: Some(theme.sub_text_color(theme.surface_1()).into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish()
}
const NOTIFICATION_AVATAR_SIZING: IconWithStatusSizing = IconWithStatusSizing {
icon_size: 16.,
padding: 8.,
badge_icon_size: 12.,
badge_padding: 2.,
overall_size_override: None,
badge_offset: (6., 6.),
};
fn render_agent_avatar(
agent: NotificationSourceAgent,
category: NotificationCategory,
theme: &WarpTheme,
) -> Box<dyn Element> {
let status = notification_category_to_conversation_status(category);
let variant = match agent {
NotificationSourceAgent::Oz => IconWithStatusVariant::OzAgent {
status: Some(status),
is_ambient: false,
},
NotificationSourceAgent::CLI(cli) => IconWithStatusVariant::CLIAgent {
agent: cli,
status: Some(status),
},
};
render_icon_with_status(
variant,
&NOTIFICATION_AVATAR_SIZING,
theme,
theme.surface_2(),
)
}
fn notification_category_to_conversation_status(
category: NotificationCategory,
) -> ConversationStatus {
match category {
NotificationCategory::Complete => ConversationStatus::Success,
NotificationCategory::Request => ConversationStatus::Blocked {
blocked_action: String::new(),
},
NotificationCategory::Error => ConversationStatus::Error,
}
}
/// Creates an `ArtifactButtonsRow` view with the notification-specific theme.
/// The caller is responsible for subscribing to events on the returned view.
pub(crate) fn create_notification_artifact_buttons_view(
artifacts: &[Artifact],
ctx: &mut ViewContext<impl View>,
) -> Option<ViewHandle<ArtifactButtonsRow>> {
if artifacts.is_empty() {
return None;
}
let theme = Arc::new(NotificationArtifactButtonTheme);
Some(ctx.add_typed_action_view(|ctx| ArtifactButtonsRow::with_theme(artifacts, theme, ctx)))
}
/// Handles artifact button events from notification views (toasts and mailbox).
pub(crate) fn handle_notification_artifact_buttons_event(
event: &ArtifactButtonsRowEvent,
ctx: &mut ViewContext<impl View>,
) {
match event {
ArtifactButtonsRowEvent::OpenPlan { notebook_uid } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ArtifactClicked {
artifact_type: ArtifactType::Plan
},
ctx
);
ctx.dispatch_typed_action(&WorkspaceAction::OpenNotebook {
id: (*notebook_uid).into(),
});
}
ArtifactButtonsRowEvent::CopyBranch { branch } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ArtifactClicked {
artifact_type: ArtifactType::Branch
},
ctx
);
ctx.clipboard()
.write(ClipboardContent::plain_text(branch.clone()));
}
ArtifactButtonsRowEvent::OpenPullRequest { url } => {
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ArtifactClicked {
artifact_type: ArtifactType::PullRequest
},
ctx
);
ctx.open_url(url);
}
ArtifactButtonsRowEvent::ViewScreenshots { artifact_uids } => {
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);
}
}
}
@@ -0,0 +1,98 @@
use warpui::EntityId;
use super::*;
use crate::ai::agent::conversation::AIConversationId;
use crate::terminal::CLIAgent;
fn make_conversation_notification(
conversation_id: AIConversationId,
terminal_view_id: EntityId,
) -> NotificationItem {
NotificationItem::new(
"test".to_owned(),
"msg".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
NotificationOrigin::Conversation(conversation_id),
false,
terminal_view_id,
vec![],
None,
)
}
fn make_cli_session_notification(terminal_view_id: EntityId) -> NotificationItem {
NotificationItem::new(
"cli test".to_owned(),
"cli msg".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::CLI(CLIAgent::Claude),
NotificationOrigin::CLISession(terminal_view_id),
false,
terminal_view_id,
vec![],
None,
)
}
#[test]
fn remove_by_origin_cleans_up_conversation_notification() {
let mut items = NotificationItems::default();
let conversation_id = AIConversationId::new();
let terminal_view_id = EntityId::new();
items.push(make_conversation_notification(
conversation_id,
terminal_view_id,
));
assert_eq!(items.filtered_count(NotificationFilter::All), 1);
let removed = items.remove_by_origin(NotificationOrigin::Conversation(conversation_id));
assert!(removed);
assert_eq!(items.filtered_count(NotificationFilter::All), 0);
}
#[test]
fn remove_by_origin_cleans_up_cli_session_notification() {
let mut items = NotificationItems::default();
let terminal_view_id = EntityId::new();
items.push(make_cli_session_notification(terminal_view_id));
assert_eq!(items.filtered_count(NotificationFilter::All), 1);
let removed = items.remove_by_origin(NotificationOrigin::CLISession(terminal_view_id));
assert!(removed);
assert_eq!(items.filtered_count(NotificationFilter::All), 0);
}
#[test]
fn remove_by_origin_leaves_unrelated_notifications() {
let mut items = NotificationItems::default();
let conv_id = AIConversationId::new();
let terminal_a = EntityId::new();
let terminal_b = EntityId::new();
items.push(make_conversation_notification(conv_id, terminal_a));
items.push(make_cli_session_notification(terminal_b));
assert_eq!(items.filtered_count(NotificationFilter::All), 2);
// Remove only the conversation notification; the CLI session notification should remain.
let removed = items.remove_by_origin(NotificationOrigin::Conversation(conv_id));
assert!(removed);
assert_eq!(items.filtered_count(NotificationFilter::All), 1);
let remaining = items
.items_filtered(NotificationFilter::All)
.next()
.unwrap();
assert_eq!(remaining.origin, NotificationOrigin::CLISession(terminal_b));
}
#[test]
fn remove_by_origin_returns_false_when_nothing_to_remove() {
let mut items = NotificationItems::default();
let terminal_view_id = EntityId::new();
let removed = items.remove_by_origin(NotificationOrigin::CLISession(terminal_view_id));
assert!(!removed);
}
@@ -0,0 +1,9 @@
pub(crate) mod item;
pub(crate) mod item_rendering;
pub(crate) mod toast_stack;
pub(crate) mod view;
pub(crate) use item::{
NotificationCategory, NotificationFilter, NotificationId, NotificationItem, NotificationItems,
NotificationOrigin, NotificationSourceAgent,
};
@@ -0,0 +1,500 @@
use std::time::Duration;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::color::blend::Blend;
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::{
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, Element, EventHandler, Flex, Hoverable, MouseStateHandle,
OffsetPositioning, Padding, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, Stack,
};
use warpui::keymap::Keystroke;
use warpui::platform::Cursor;
use warpui::r#async::{SpawnedFutureHandle, Timer};
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::keyboard_shortcut::KeyboardShortcut;
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
use crate::ai::agent_management::notifications::item_rendering::{
create_notification_artifact_buttons_view, handle_notification_artifact_buttons_event,
render_notification_item_content, NotificationRenderContext, OnExpandClick,
};
use crate::ai::agent_management::notifications::{NotificationId, NotificationItem};
use crate::ai::agent_management::{AgentManagementEvent, AgentNotificationsModel};
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
use crate::appearance::Appearance;
use crate::terminal::session_settings::SessionSettings;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::workspace::view::JUMP_TO_LATEST_TOAST_BINDING_NAME;
use crate::workspace::WorkspaceAction;
const CLOSE_BUTTON_SIZE: f32 = 20.;
/// Tracks the state of a single visible toast in the stack
/// (its auto-dismiss timer, hover state, and close button).
struct NotificationToastItem {
notification_id: NotificationId,
abort_handle: Option<SpawnedFutureHandle>,
mouse_state: MouseStateHandle,
close_button_mouse_state: MouseStateHandle,
close_button_hover_state: MouseStateHandle,
artifact_buttons_view: Option<ViewHandle<ArtifactButtonsRow>>,
message_expanded: bool,
}
pub struct AgentNotificationToastStack {
toasts: Vec<NotificationToastItem>,
mailbox_is_open: bool,
}
impl Entity for AgentNotificationToastStack {
type Event = ();
}
#[derive(Debug)]
pub enum AgentNotificationToastAction {
CancelDismissalTimeout(NotificationId),
StartDismissalTimeout(NotificationId),
Click(NotificationId),
Dismiss(NotificationId),
ToggleMessageExpanded(NotificationId),
}
impl AgentNotificationToastStack {
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 { id } => {
me.on_notification_added(*id, ctx);
}
AgentManagementEvent::NotificationUpdated
| AgentManagementEvent::AllNotificationsMarkedRead => {
me.remove_dismissed_toasts(ctx);
}
AgentManagementEvent::ConversationNeedsAttention { .. } => {}
});
Self {
toasts: Vec::new(),
mailbox_is_open: false,
}
}
/// Updates the mailbox-open state. When opening, all visible toasts are dismissed
/// (the mailbox already shows the same notifications).
pub fn set_mailbox_open(&mut self, open: bool, ctx: &mut ViewContext<Self>) {
self.mailbox_is_open = open;
if open {
self.dismiss_all(ctx);
}
}
/// Dismiss all visible toasts (called when the mailbox opens).
fn dismiss_all(&mut self, ctx: &mut ViewContext<Self>) {
for entry in self.toasts.drain(..) {
if let Some(handle) = entry.abort_handle {
handle.abort();
}
}
ctx.notify();
}
fn on_notification_added(&mut self, id: NotificationId, ctx: &mut ViewContext<Self>) {
// Don't show in-app toasts when the window is not active.
// Native desktop notifications handle the unfocused case.
if ctx.windows().active_window() != Some(ctx.window_id()) {
return;
}
// Don't show toasts when the notification mailbox is already open.
// (dismiss_all is called on open, so any new arrival would be immediately visible in the mailbox.)
if self.mailbox_is_open {
return;
}
let notifications = AgentNotificationsModel::as_ref(ctx).notifications();
let Some(item) = notifications.get_by_id(id) else {
return;
};
// Don't show a toast for notifications that are already read
// (e.g. the terminal was visible when the notification was created).
if item.is_read {
return;
}
// Clone artifacts before releasing the immutable borrow on ctx.
let artifacts = item.artifacts.clone();
let _ = notifications;
// The notification model de-dupes by origin, so a new notification for the same
// conversation replaces the old one with a new ID.
self.remove_dismissed_toasts(ctx);
let artifact_buttons_view =
Self::create_artifact_buttons_view_from_artifacts(&artifacts, ctx);
self.toasts.push(NotificationToastItem {
notification_id: id,
abort_handle: None,
mouse_state: MouseStateHandle::default(),
close_button_mouse_state: MouseStateHandle::default(),
close_button_hover_state: MouseStateHandle::default(),
artifact_buttons_view,
message_expanded: false,
});
self.start_dismissal_timeout(id, ctx);
// Evict the oldest toasts if we exceed the visible limit.
while self.toasts.len() > 2 {
let evicted = self.toasts.remove(0);
if let Some(handle) = evicted.abort_handle {
handle.abort();
}
}
ctx.notify();
}
/// Removes toasts for notifications that no longer exist in the model or have been read.
fn remove_dismissed_toasts(&mut self, ctx: &mut ViewContext<Self>) {
let notifications = AgentNotificationsModel::as_ref(ctx).notifications();
let before = self.toasts.len();
self.toasts.retain(|entry| {
let should_remove = notifications
.get_by_id(entry.notification_id)
.is_none_or(|item| item.is_read);
if should_remove {
if let Some(handle) = &entry.abort_handle {
handle.abort();
}
false
} else {
true
}
});
if self.toasts.len() != before {
ctx.notify();
}
}
fn dismiss_toast_by_id(&mut self, id: &NotificationId, ctx: &mut ViewContext<Self>) {
if let Some(idx) = self.toasts.iter().position(|e| e.notification_id == *id) {
let entry = self.toasts.remove(idx);
if let Some(handle) = entry.abort_handle {
handle.abort();
}
ctx.notify();
}
}
/// Pauses the auto-dismiss timer for a toast (e.g. while the user is hovering over it).
fn cancel_dismissal_timeout(&mut self, id: &NotificationId) {
if let Some(entry) = self.toasts.iter_mut().find(|e| e.notification_id == *id) {
if let Some(handle) = entry.abort_handle.take() {
handle.abort();
}
}
}
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);
}
fn start_dismissal_timeout(&mut self, id: NotificationId, ctx: &mut ViewContext<Self>) {
if let Some(entry) = self.toasts.iter_mut().find(|e| e.notification_id == id) {
if let Some(handle) = entry.abort_handle.take() {
handle.abort();
}
let duration_secs = *SessionSettings::as_ref(ctx).notification_toast_duration_secs;
let abort_handle = ctx.spawn_abortable(
Timer::after(Duration::from_secs(duration_secs)),
move |me, _, ctx| me.dismiss_toast_by_id(&id, ctx),
|_, _| {},
);
entry.abort_handle = Some(abort_handle);
}
}
}
impl TypedActionView for AgentNotificationToastStack {
type Action = AgentNotificationToastAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentNotificationToastAction::CancelDismissalTimeout(id) => {
self.cancel_dismissal_timeout(id);
}
AgentNotificationToastAction::StartDismissalTimeout(id) => {
self.start_dismissal_timeout(*id, ctx);
}
AgentNotificationToastAction::Click(id) => {
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.dispatch_typed_action(&WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id,
});
}
self.dismiss_toast_by_id(id, ctx);
}
AgentNotificationToastAction::Dismiss(id) => {
self.dismiss_toast_by_id(id, ctx);
}
AgentNotificationToastAction::ToggleMessageExpanded(id) => {
if let Some(entry) = self.toasts.iter_mut().find(|e| e.notification_id == *id) {
entry.message_expanded = !entry.message_expanded;
ctx.notify();
}
}
}
}
}
impl View for AgentNotificationToastStack {
fn ui_name() -> &'static str {
"AgentNotificationToastStack"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let notifications = AgentNotificationsModel::as_ref(app).notifications();
let keystroke = keybinding_name_to_keystroke(JUMP_TO_LATEST_TOAST_BINDING_NAME, app);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::End);
for (i, entry) in self.toasts.iter().rev().enumerate() {
let Some(item) = notifications.get_by_id(entry.notification_id) else {
continue;
};
let is_newest = i == 0;
let toast = render_toast(
item,
entry.notification_id,
entry.mouse_state.clone(),
entry.close_button_mouse_state.clone(),
entry.close_button_hover_state.clone(),
entry.artifact_buttons_view.as_ref(),
entry.message_expanded,
is_newest.then(|| keystroke.clone()).flatten(),
appearance,
);
column.add_child(Container::new(toast).with_margin_bottom(4.).finish());
}
column.finish()
}
}
fn toast_position_id(id: NotificationId) -> String {
format!("notification_toast_{id:?}")
}
fn render_close_button(
id: NotificationId,
close_button_mouse_state: MouseStateHandle,
close_button_hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
EventHandler::new(
Hoverable::new(close_button_hover_state, |_| {
Container::new(
appearance
.ui_builder()
.close_button(CLOSE_BUTTON_SIZE, close_button_mouse_state.clone())
.with_style(UiComponentStyles {
font_color: Some(theme.foreground().into()),
background: Some(theme.surface_3().into()),
border_color: Some(theme.outline().into()),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
border_width: Some(1.),
padding: Some(Coords {
top: 2.,
bottom: 2.,
left: 2.,
right: 2.,
}),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AgentNotificationToastAction::Dismiss(id));
})
.finish(),
)
.finish()
})
.finish(),
)
.on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation)
.finish()
}
#[allow(clippy::too_many_arguments)]
fn render_toast(
item: &NotificationItem,
id: NotificationId,
mouse_state: MouseStateHandle,
close_button_mouse_state: MouseStateHandle,
close_button_hover_state: MouseStateHandle,
artifact_buttons: Option<&ViewHandle<ArtifactButtonsRow>>,
message_expanded: bool,
keystroke: Option<Keystroke>,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let on_expand: OnExpandClick = Box::new(move |ctx: &mut warpui::EventContext| {
ctx.dispatch_typed_action(AgentNotificationToastAction::ToggleMessageExpanded(id));
});
let keybinding_hint = keystroke.map(|ks| render_keybinding_hint(ks, appearance));
let content = render_notification_item_content(
item,
artifact_buttons,
NotificationRenderContext::Toast,
message_expanded,
on_expand,
keybinding_hint,
appearance,
);
let inner_column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(content);
let position_id = toast_position_id(id);
EventHandler::new(
Hoverable::new(mouse_state, move |state| {
let bg = if state.is_hovered() {
theme
.surface_2()
.blend(&internal_colors::fg_overlay_3(theme))
} else {
theme
.surface_2()
.blend(&internal_colors::fg_overlay_2(theme))
};
let container = Container::new(inner_column.finish())
.with_padding(Padding::uniform(12.))
.with_background(bg)
.with_border(Border::all(1.).with_border_color(theme.outline().into()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)));
let sized = ConstrainedBox::new(container.finish())
.with_width(420.)
.finish();
let is_close_hovered = close_button_hover_state
.lock()
.is_ok_and(|s| s.is_hovered());
let mut stack =
Stack::new().with_child(SavePosition::new(sized, &position_id).finish());
if state.is_hovered() || is_close_hovered {
stack.add_positioned_overlay_child(
render_close_button(
id,
close_button_mouse_state.clone(),
close_button_hover_state.clone(),
appearance,
),
OffsetPositioning::offset_from_save_position_element(
&position_id,
vec2f(-4., -4.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
}
stack.finish()
})
.with_cursor(Cursor::PointingHand)
.on_hover(move |is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(AgentNotificationToastAction::CancelDismissalTimeout(id));
} else {
ctx.dispatch_typed_action(AgentNotificationToastAction::StartDismissalTimeout(id));
}
})
.finish(),
)
.on_left_mouse_down(move |ctx, _, _| {
ctx.dispatch_typed_action(AgentNotificationToastAction::Click(id));
DispatchEventResult::StopPropagation
})
.finish()
}
fn render_keybinding_hint(keystroke: Keystroke, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let hint_text = appearance
.ui_builder()
.wrappable_text("Open conversation".to_string(), false)
.with_style(UiComponentStyles {
font_size: Some(12.),
font_color: Some(theme.disabled_text_color(theme.surface_2()).into()),
font_family_id: Some(appearance.ui_font_family()),
..Default::default()
})
.build()
.finish();
let keybinding_style = UiComponentStyles {
font_family_id: Some(appearance.monospace_font_family()),
font_color: Some(theme.sub_text_color(theme.surface_2()).into()),
font_size: Some(12.),
background: Some(internal_colors::fg_overlay_3(theme).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()
};
let shortcut = KeyboardShortcut::new(&keystroke, keybinding_style)
.build()
.finish();
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1.0, hint_text).finish())
.with_child(Container::new(shortcut).with_margin_left(8.).finish())
.finish(),
)
.with_margin_top(8.)
.finish()
}
@@ -0,0 +1,625 @@
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::new_scrollable::{ScrollableAppearance, SingleAxisConfig};
use warpui::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 warpui::fonts::Weight;
use warpui::keymap::macros::id;
use warpui::keymap::FixedBinding;
use warpui::platform::Cursor;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{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: warpui::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()
}
}
+293
View File
@@ -0,0 +1,293 @@
use serde::Serialize;
use serde_json::json;
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use crate::ai::agent_management::cloud_setup_guide_view::SetupGuideDocs;
/// Which setup guide workflow step the user interacted with
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupGuideStep {
/// Quick start banner: Visit Oz
VisitOz,
/// Step 1: Create environment (slash command)
CreateEnvironment,
/// Step 1: Create environment (CLI command)
CreateEnvironmentCli,
/// Step 2: Create Slack integration
CreateSlackIntegration,
/// Step 2: Create Linear integration
CreateLinearIntegration,
}
/// Where the item was opened from
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OpenedFrom {
ManagementView,
ConversationList,
DetailsPanel,
}
/// Type of artifact clicked
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactType {
Plan,
Branch,
PullRequest,
File,
}
/// Type of filter changed
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FilterType {
Status,
Source,
CreatedOn,
Creator,
Owner,
Harness,
}
/// Telemetry events for the agent management view
#[derive(Serialize, Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum AgentManagementTelemetryEvent {
/// User toggled the agent management view open or closed
ViewToggled { is_open: bool },
/// User opened the setup guide
OpenSetupGuide,
/// User dismissed the setup guide
DismissSetupGuide,
/// User spawned a new local agent
SpawnNewLocalAgent,
/// User spawned a new cloud agent
SpawnNewCloudAgent,
/// User opened the agent type selector modal
AgentTypeSelectorOpened,
/// User ran a workflow step from the setup guide
SetupGuideStepRun { step: SetupGuideStep },
/// User copied a workflow step from the setup guide
SetupGuideStepCopy { step: SetupGuideStep },
/// User clicked a URL in the setup guide
SetupGuideDocsLink { docs: SetupGuideDocs },
/// User opened a conversation
ConversationOpened {
conversation_id: String,
opened_from: OpenedFrom,
},
/// User opened a cloud run
CloudRunOpened {
task_id: String,
opened_from: OpenedFrom,
},
/// User clicked an artifact button
ArtifactClicked { artifact_type: ArtifactType },
/// User changed a filter
FilterChanged { filter_type: FilterType },
/// User clicked an item details button
DetailsViewed {
item_id: String,
viewed_from: OpenedFrom,
},
/// User copied a conversation link
ConversationLinkCopied {
conversation_id: String,
copied_from: OpenedFrom,
},
/// User copied a session link
SessionLinkCopied {
task_id: String,
copied_from: OpenedFrom,
},
/// User clicked an artifact in the tombstone view
TombstoneArtifactClicked { artifact_type: ArtifactType },
/// User clicked "Continue locally" in the tombstone
#[cfg(not(target_family = "wasm"))]
TombstoneContinueLocally,
/// User clicked "Continue locally" in the details panel
#[cfg(not(target_family = "wasm"))]
DetailsPanelContinueLocally,
/// User clicked "Open in Warp" in the tombstone (wasm)
#[cfg(target_family = "wasm")]
TombstoneOpenInWarp,
/// User cancelled a cloud run
CloudRunCancelled { task_id: String },
/// User forked a conversation
ConversationForked { conversation_id: String },
}
impl TelemetryEvent for AgentManagementTelemetryEvent {
fn name(&self) -> &'static str {
AgentManagementTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<serde_json::Value> {
match self {
AgentManagementTelemetryEvent::ViewToggled { is_open } => {
Some(json!({ "is_open": is_open }))
}
AgentManagementTelemetryEvent::OpenSetupGuide => None,
AgentManagementTelemetryEvent::DismissSetupGuide => None,
AgentManagementTelemetryEvent::SpawnNewLocalAgent => None,
AgentManagementTelemetryEvent::SpawnNewCloudAgent => None,
AgentManagementTelemetryEvent::AgentTypeSelectorOpened => None,
AgentManagementTelemetryEvent::SetupGuideStepRun { step } => {
Some(json!({ "step": step }))
}
AgentManagementTelemetryEvent::SetupGuideStepCopy { step } => {
Some(json!({ "step": step }))
}
AgentManagementTelemetryEvent::SetupGuideDocsLink { docs } => {
Some(json!({ "docs": docs }))
}
AgentManagementTelemetryEvent::ConversationOpened {
conversation_id,
opened_from,
} => Some(json!({
"conversation_id": conversation_id,
"opened_from": opened_from,
})),
AgentManagementTelemetryEvent::CloudRunOpened {
task_id,
opened_from,
} => Some(json!({
"task_id": task_id,
"opened_from": opened_from,
})),
AgentManagementTelemetryEvent::ArtifactClicked { artifact_type } => {
Some(json!({ "artifact_type": artifact_type }))
}
AgentManagementTelemetryEvent::FilterChanged { filter_type } => {
Some(json!({ "filter_type": filter_type }))
}
AgentManagementTelemetryEvent::DetailsViewed {
item_id,
viewed_from,
} => Some(json!({
"item_id": item_id,
"viewed_from": viewed_from,
})),
AgentManagementTelemetryEvent::ConversationLinkCopied {
conversation_id,
copied_from,
} => Some(json!({
"conversation_id": conversation_id,
"copied_from": copied_from,
})),
AgentManagementTelemetryEvent::SessionLinkCopied {
task_id,
copied_from,
} => Some(json!({
"task_id": task_id,
"copied_from": copied_from,
})),
AgentManagementTelemetryEvent::TombstoneArtifactClicked { artifact_type } => {
Some(json!({ "artifact_type": artifact_type }))
}
#[cfg(not(target_family = "wasm"))]
AgentManagementTelemetryEvent::TombstoneContinueLocally => None,
#[cfg(not(target_family = "wasm"))]
AgentManagementTelemetryEvent::DetailsPanelContinueLocally => None,
#[cfg(target_family = "wasm")]
AgentManagementTelemetryEvent::TombstoneOpenInWarp => None,
AgentManagementTelemetryEvent::CloudRunCancelled { task_id } => {
Some(json!({ "task_id": task_id }))
}
AgentManagementTelemetryEvent::ConversationForked { conversation_id } => {
Some(json!({ "conversation_id": conversation_id }))
}
}
}
fn description(&self) -> &'static str {
AgentManagementTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
AgentManagementTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
false
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for AgentManagementTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::ViewToggled => "AgentManagement.ViewToggled",
Self::OpenSetupGuide => "AgentManagement.OpenSetupGuide",
Self::DismissSetupGuide => "AgentManagement.DismissSetupGuide",
Self::SpawnNewLocalAgent => "AgentManagement.SpawnNewLocalAgent",
Self::SpawnNewCloudAgent => "AgentManagement.SpawnNewCloudAgent",
Self::AgentTypeSelectorOpened => "AgentManagement.AgentTypeSelectorOpened",
Self::SetupGuideStepRun => "AgentManagement.SetupGuideStepRun",
Self::SetupGuideStepCopy => "AgentManagement.SetupGuideStepCopy",
Self::SetupGuideDocsLink => "AgentManagement.SetupGuideDocsLink",
Self::ConversationOpened => "AgentManagement.ConversationOpened",
Self::CloudRunOpened => "AgentManagement.CloudRunOpened",
Self::ArtifactClicked => "AgentManagement.ArtifactClicked",
Self::FilterChanged => "AgentManagement.FilterChanged",
Self::DetailsViewed => "AgentManagement.DetailsViewed",
Self::ConversationLinkCopied => "AgentManagement.ConversationLinkCopied",
Self::SessionLinkCopied => "AgentManagement.SessionLinkCopied",
Self::TombstoneArtifactClicked => "AgentManagement.TombstoneArtifactClicked",
#[cfg(not(target_family = "wasm"))]
Self::TombstoneContinueLocally => "AgentManagement.TombstoneContinueLocally",
#[cfg(not(target_family = "wasm"))]
Self::DetailsPanelContinueLocally => "AgentManagement.DetailsPanelContinueLocally",
#[cfg(target_family = "wasm")]
Self::TombstoneOpenInWarp => "AgentManagement.TombstoneOpenInWarp",
Self::CloudRunCancelled => "AgentManagement.CloudRunCancelled",
Self::ConversationForked => "AgentManagement.ConversationForked",
}
}
fn description(&self) -> &'static str {
match self {
Self::ViewToggled => "User toggled the agent management view open or closed",
Self::OpenSetupGuide => "User opened the ambient agent setup guide",
Self::DismissSetupGuide => "User dismissed the ambient agent setup guide",
Self::SpawnNewLocalAgent => "User spawned a new local agent from agent management",
Self::SpawnNewCloudAgent => "User spawned a new cloud agent from agent management",
Self::AgentTypeSelectorOpened => {
"User opened the agent type selector from agent management"
}
Self::SetupGuideStepRun => "User ran a workflow step from the setup guide",
Self::SetupGuideStepCopy => "User copied a workflow step from the setup guide",
Self::SetupGuideDocsLink => "User clicked a docs URL in the setup guide",
Self::ConversationOpened => "User opened a conversation",
Self::CloudRunOpened => "User opened a cloud run",
Self::ArtifactClicked => "User clicked an artifact button",
Self::FilterChanged => "User changed a filter in the management view",
Self::DetailsViewed => "User clicked View details",
Self::ConversationLinkCopied => "User copied a conversation link",
Self::SessionLinkCopied => "User copied a session link",
Self::TombstoneArtifactClicked => "User clicked an artifact in the tombstone view",
#[cfg(not(target_family = "wasm"))]
Self::TombstoneContinueLocally => "User clicked Continue locally in the tombstone",
#[cfg(not(target_family = "wasm"))]
Self::DetailsPanelContinueLocally => {
"User clicked Continue locally in the details panel"
}
#[cfg(target_family = "wasm")]
Self::TombstoneOpenInWarp => "User clicked Open in Warp in the tombstone",
Self::CloudRunCancelled => "User cancelled a cloud run",
Self::ConversationForked => "User forked a conversation",
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Always
}
}
warp_core::register_telemetry_event!(AgentManagementTelemetryEvent);
File diff suppressed because it is too large Load Diff