Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user