first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
use crate::settings::AISettings;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
|
||||
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsEvent, ActiveAgentViewsModel};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
@@ -12,16 +11,16 @@ use crate::ai::agent_management::notifications::{
|
||||
NotificationSourceAgent,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryEvent;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, ConversationStatusUpdate, QueuedQueryModel};
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::cli_agent_sessions::{
|
||||
CLIAgentSessionStatus, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
|
||||
};
|
||||
use crate::terminal::CLIAgent;
|
||||
use crate::terminal::{CLIAgent, TerminalView};
|
||||
use crate::workspace::util::is_terminal_view_in_same_tab;
|
||||
use crate::workspace::{Workspace, WorkspaceRegistry};
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
use galaxy_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.
|
||||
@@ -43,17 +42,17 @@ 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| {
|
||||
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| {
|
||||
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| {
|
||||
ctx.subscribe_to_model(&active_views_model, |me, _, event, ctx| {
|
||||
me.handle_active_agent_views_changed(event, ctx);
|
||||
});
|
||||
|
||||
@@ -165,14 +164,19 @@ impl AgentNotificationsModel {
|
||||
CLIAgent::Codex => "Notification from Codex",
|
||||
_ => "Task completed.",
|
||||
};
|
||||
let metadata = TerminalViewMetadata::lookup(*terminal_view_id, ctx);
|
||||
self.add_notification(
|
||||
title,
|
||||
message.to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::CLI(*agent),
|
||||
NotificationSourceAgent::CLI {
|
||||
agent: *agent,
|
||||
is_ambient: metadata.is_ambient,
|
||||
},
|
||||
NotificationOrigin::CLISession(*terminal_view_id),
|
||||
*terminal_view_id,
|
||||
vec![],
|
||||
metadata.branch,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -180,16 +184,21 @@ impl AgentNotificationsModel {
|
||||
let title = session_context
|
||||
.display_title()
|
||||
.unwrap_or_else(|| format!("{} needs attention", agent.display_name()));
|
||||
let metadata = TerminalViewMetadata::lookup(*terminal_view_id, ctx);
|
||||
self.add_notification(
|
||||
title,
|
||||
message
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Waiting for input.".to_owned()),
|
||||
NotificationCategory::Request,
|
||||
NotificationSourceAgent::CLI(*agent),
|
||||
NotificationSourceAgent::CLI {
|
||||
agent: *agent,
|
||||
is_ambient: metadata.is_ambient,
|
||||
},
|
||||
NotificationOrigin::CLISession(*terminal_view_id),
|
||||
*terminal_view_id,
|
||||
vec![],
|
||||
metadata.branch,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -237,10 +246,11 @@ impl AgentNotificationsModel {
|
||||
}
|
||||
|
||||
let BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
terminal_view_id,
|
||||
terminal_surface_id,
|
||||
conversation_id,
|
||||
// We shouldn't trigger toasts when restoring conversations on startup.
|
||||
is_restored: false,
|
||||
update: ConversationStatusUpdate::Changed { .. },
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
@@ -262,7 +272,7 @@ impl AgentNotificationsModel {
|
||||
&status,
|
||||
*conversation_id,
|
||||
latest_query,
|
||||
*terminal_view_id,
|
||||
*terminal_surface_id,
|
||||
ctx,
|
||||
);
|
||||
// The new mailbox path handled the event — skip the legacy toast path below.
|
||||
@@ -273,7 +283,7 @@ impl AgentNotificationsModel {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_terminal_view_visible(*terminal_view_id, ctx) {
|
||||
if is_terminal_view_visible(*terminal_surface_id, ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,7 +296,7 @@ impl AgentNotificationsModel {
|
||||
ctx.emit(AgentManagementEvent::ConversationNeedsAttention {
|
||||
window_id,
|
||||
tab_index,
|
||||
terminal_view_id: *terminal_view_id,
|
||||
terminal_view_id: *terminal_surface_id,
|
||||
conversation_id: *conversation_id,
|
||||
});
|
||||
}
|
||||
@@ -310,22 +320,36 @@ impl AgentNotificationsModel {
|
||||
}
|
||||
|
||||
let title = latest_query.unwrap_or_else(|| "Agent task".to_owned());
|
||||
let metadata = TerminalViewMetadata::lookup(terminal_view_id, ctx);
|
||||
let oz_agent = NotificationSourceAgent::Oz {
|
||||
is_ambient: metadata.is_ambient,
|
||||
};
|
||||
|
||||
match status {
|
||||
// When the agent resumes its work, clear stale notifications.
|
||||
ConversationStatus::InProgress => {
|
||||
// When the agent resumes its work (or is automatically recovering from a
|
||||
// transient failure), clear stale notifications.
|
||||
ConversationStatus::InProgress | ConversationStatus::TransientError => {
|
||||
self.remove_notification_by_source(origin, ctx);
|
||||
}
|
||||
ConversationStatus::Success => {
|
||||
// Suppress the completion notification when a queued follow-up prompt will
|
||||
// auto-send as soon as this conversation finishes. The conversation isn't
|
||||
// really in a stopped state, so the notification would be noisy. Pending
|
||||
// artifacts are left intact so they roll into the notification fired when the
|
||||
// conversation eventually finishes with an empty queue.
|
||||
if QueuedQueryModel::as_ref(ctx).has_autofireable_prompt(conversation_id) {
|
||||
return;
|
||||
}
|
||||
let artifacts = self.flush_pending_artifacts(conversation_id);
|
||||
self.add_notification(
|
||||
title,
|
||||
"Task completed.".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::Oz,
|
||||
oz_agent,
|
||||
origin,
|
||||
terminal_view_id,
|
||||
artifacts,
|
||||
metadata.branch,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -335,10 +359,11 @@ impl AgentNotificationsModel {
|
||||
title,
|
||||
"Task was cancelled.".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::Oz,
|
||||
oz_agent,
|
||||
origin,
|
||||
terminal_view_id,
|
||||
artifacts,
|
||||
metadata.branch,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -347,10 +372,11 @@ impl AgentNotificationsModel {
|
||||
title,
|
||||
blocked_action.clone(),
|
||||
NotificationCategory::Request,
|
||||
NotificationSourceAgent::Oz,
|
||||
oz_agent,
|
||||
origin,
|
||||
terminal_view_id,
|
||||
vec![],
|
||||
metadata.branch,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
@@ -360,13 +386,20 @@ impl AgentNotificationsModel {
|
||||
title,
|
||||
"Something went wrong.".to_owned(),
|
||||
NotificationCategory::Error,
|
||||
NotificationSourceAgent::Oz,
|
||||
oz_agent,
|
||||
origin,
|
||||
terminal_view_id,
|
||||
artifacts,
|
||||
metadata.branch,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
// Yielded conversations are still active; mirror the
|
||||
// InProgress arm and clear any stale notification for this
|
||||
// origin.
|
||||
ConversationStatus::WaitingForEvents => {
|
||||
self.remove_notification_by_source(origin, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,14 +434,12 @@ impl AgentNotificationsModel {
|
||||
origin: NotificationOrigin,
|
||||
terminal_view_id: EntityId,
|
||||
artifacts: Vec<Artifact>,
|
||||
branch: Option<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if !*AISettings::as_ref(ctx).show_agent_notifications {
|
||||
return;
|
||||
}
|
||||
let show_agent_notifications = *AISettings::as_ref(ctx).show_agent_notifications;
|
||||
|
||||
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,
|
||||
@@ -420,12 +451,14 @@ impl AgentNotificationsModel {
|
||||
artifacts,
|
||||
branch,
|
||||
);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::AgentNotificationShown {
|
||||
agent_variant: agent.into(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
if show_agent_notifications {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::AgentNotificationShown {
|
||||
agent_variant: agent.into(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
let id = item.id;
|
||||
self.notifications.push(item);
|
||||
@@ -453,13 +486,22 @@ pub enum AgentManagementEvent {
|
||||
impl ConversationStatus {
|
||||
/// Returns true if the updating the conversation with this status should trigger some
|
||||
/// notification to the user.
|
||||
///
|
||||
/// Exhaustive match so a new `ConversationStatus` variant forces a
|
||||
/// deliberate decision about whether it should fire a notification.
|
||||
pub fn should_trigger_notification(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
match self {
|
||||
ConversationStatus::Success
|
||||
| ConversationStatus::Blocked { .. }
|
||||
| ConversationStatus::Error
|
||||
)
|
||||
| ConversationStatus::Blocked { .. }
|
||||
| ConversationStatus::Error => true,
|
||||
// Streaming hasn't reached a notable state; a recovering or
|
||||
// yielded conversation is still active; user-cancellations are
|
||||
// self-evident.
|
||||
ConversationStatus::InProgress
|
||||
| ConversationStatus::TransientError
|
||||
| ConversationStatus::WaitingForEvents
|
||||
| ConversationStatus::Cancelled => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,17 +544,41 @@ fn window_and_tab_idx_id_for_conversation(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_git_branch_for_terminal_view(
|
||||
/// Per-notification metadata derived from a single [`TerminalView`] lookup. Both fields
|
||||
/// are read on the same emit path, so we resolve the view once and pass the projection
|
||||
/// down rather than walking the workspace tree for each.
|
||||
struct TerminalViewMetadata {
|
||||
is_ambient: bool,
|
||||
branch: Option<String>,
|
||||
}
|
||||
|
||||
impl TerminalViewMetadata {
|
||||
fn lookup(terminal_view_id: EntityId, app: &AppContext) -> Self {
|
||||
let Some(terminal_view) = find_terminal_view_by_id(terminal_view_id, app) else {
|
||||
return Self {
|
||||
is_ambient: false,
|
||||
branch: None,
|
||||
};
|
||||
};
|
||||
let view = terminal_view.as_ref(app);
|
||||
Self {
|
||||
is_ambient: view.is_ambient_agent_session(app),
|
||||
branch: view.current_git_branch(app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_terminal_view_by_id(
|
||||
terminal_view_id: EntityId,
|
||||
app: &AppContext,
|
||||
) -> Option<String> {
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
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);
|
||||
return Some(terminal_view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{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 galaxyui::{App, EntityId, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::AgentNotificationsModel;
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent_management::notifications::{
|
||||
NotificationCategory, NotificationFilter, NotificationOrigin, NotificationSourceAgent,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryEvent;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::{report_if_error, BlocklistAIHistoryModel};
|
||||
|
||||
fn setup_app(
|
||||
app: &mut App,
|
||||
@@ -16,7 +21,11 @@ fn setup_app(
|
||||
ModelHandle<BlocklistAIHistoryModel>,
|
||||
ModelHandle<AgentNotificationsModel>,
|
||||
) {
|
||||
initialize_settings_for_tests(app);
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
// Registered after the history model since it subscribes to history events; the
|
||||
// notifications model reads it to suppress completion notifications when a prompt is queued.
|
||||
app.add_singleton_model(crate::ai::blocklist::QueuedQueryModel::new);
|
||||
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
|
||||
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
||||
let notifications = app.add_singleton_model(AgentNotificationsModel::new);
|
||||
@@ -51,7 +60,7 @@ fn artifact_event_accumulates_into_pending() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/42", "feature-branch"),
|
||||
});
|
||||
@@ -76,14 +85,14 @@ fn multiple_artifacts_accumulated_across_turns() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: 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,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "main"),
|
||||
});
|
||||
@@ -98,6 +107,46 @@ fn multiple_artifacts_accumulated_across_turns() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_notification_tracks_unread_activity_when_in_app_notifications_are_hidden() {
|
||||
App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
||||
let (_history, notifications) = setup_app(&mut app);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
report_if_error!(settings.show_agent_notifications.set_value(false, ctx));
|
||||
});
|
||||
|
||||
let conversation_id = AIConversationId::new();
|
||||
let terminal_view_id = EntityId::new();
|
||||
notifications.update(&mut app, |model, ctx| {
|
||||
model.add_notification(
|
||||
"Agent task".to_owned(),
|
||||
"Task completed.".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::Oz { is_ambient: false },
|
||||
NotificationOrigin::Conversation(conversation_id),
|
||||
terminal_view_id,
|
||||
vec![],
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
1
|
||||
);
|
||||
assert!(model
|
||||
.notifications()
|
||||
.has_unread_for_terminal_view(terminal_view_id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_drains_pending_artifacts() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -109,7 +158,7 @@ fn flush_drains_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
|
||||
});
|
||||
@@ -153,7 +202,7 @@ fn deletion_cleans_up_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
|
||||
});
|
||||
@@ -161,9 +210,10 @@ fn deletion_cleans_up_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::DeletedConversation {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
conversation_title: None,
|
||||
run_id: None,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,14 +235,14 @@ fn separate_conversations_have_independent_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: 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,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id: conv_b,
|
||||
artifact: make_plan_artifact("doc-b", "Plan B"),
|
||||
});
|
||||
@@ -209,3 +259,178 @@ fn separate_conversations_have_independent_pending_artifacts() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// should_trigger_notification: pure-function tests pinning which statuses
|
||||
// fire user-facing notifications. Terminal-error and blocked surface;
|
||||
// in-progress, waiting-for-events, and user-cancelled do not.
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_true_for_success() {
|
||||
assert!(ConversationStatus::Success.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_true_for_blocked() {
|
||||
assert!(ConversationStatus::Blocked {
|
||||
blocked_action: "approve diff".to_owned(),
|
||||
}
|
||||
.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_true_for_error() {
|
||||
assert!(ConversationStatus::Error.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_false_for_in_progress() {
|
||||
assert!(!ConversationStatus::InProgress.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_false_for_waiting_for_events() {
|
||||
assert!(!ConversationStatus::WaitingForEvents.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_false_for_cancelled() {
|
||||
assert!(!ConversationStatus::Cancelled.should_trigger_notification());
|
||||
}
|
||||
|
||||
// Mailbox suppression for non-terminal status updates. In App::test the
|
||||
// `is_conversation_open` gate always returns false, so the
|
||||
// WaitingForEvents and InProgress arms both clear stale notifications
|
||||
// regardless of status; this still pins the user-visible contract that
|
||||
// no stale "Task completed" toast survives a non-terminal transition.
|
||||
|
||||
/// Disables `show_agent_notifications` so subsequent `add_notification`
|
||||
/// calls skip the `send_telemetry_from_ctx!` branch — the test app does
|
||||
/// not register a `TelemetryContextProvider` singleton and the macro
|
||||
/// would otherwise panic.
|
||||
fn disable_telemetry_path(app: &mut App) {
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings.show_agent_notifications.set_value(false, ctx));
|
||||
});
|
||||
}
|
||||
|
||||
/// Pre-populates a `Complete` notification for `conversation_id` so that a
|
||||
/// subsequent non-terminal status update has something to clear.
|
||||
fn seed_stale_notification(
|
||||
notifications: &ModelHandle<AgentNotificationsModel>,
|
||||
app: &mut App,
|
||||
conversation_id: AIConversationId,
|
||||
terminal_view_id: EntityId,
|
||||
) {
|
||||
notifications.update(app, |model, ctx| {
|
||||
model.add_notification(
|
||||
"Agent task".to_owned(),
|
||||
"Task completed.".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::Oz { is_ambient: false },
|
||||
NotificationOrigin::Conversation(conversation_id),
|
||||
terminal_view_id,
|
||||
vec![],
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_for_events_clears_stale_notification_and_adds_none() {
|
||||
App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
||||
let (history, notifications) = setup_app(&mut app);
|
||||
disable_telemetry_path(&mut app);
|
||||
|
||||
let conversation = AIConversation::new(false, false);
|
||||
let conversation_id = conversation.id();
|
||||
let terminal_view_id = EntityId::new();
|
||||
history.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
});
|
||||
|
||||
seed_stale_notification(¬ifications, &mut app, conversation_id, terminal_view_id);
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
1,
|
||||
"precondition: one stale notification queued"
|
||||
);
|
||||
});
|
||||
|
||||
history.update(&mut app, |model, ctx| {
|
||||
let conv = model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation was just restored");
|
||||
conv.update_status(ConversationStatus::WaitingForEvents, terminal_view_id, ctx);
|
||||
});
|
||||
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
0,
|
||||
"WaitingForEvents must clear stale notifications and add no new toast"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_progress_resume_clears_stale_notification_and_adds_none() {
|
||||
App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
||||
let (history, notifications) = setup_app(&mut app);
|
||||
disable_telemetry_path(&mut app);
|
||||
|
||||
let conversation = AIConversation::new(false, false);
|
||||
let conversation_id = conversation.id();
|
||||
let terminal_view_id = EntityId::new();
|
||||
history.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
});
|
||||
|
||||
// First move the conversation into WaitingForEvents, then back into
|
||||
// InProgress. The second transition is the resume signal that
|
||||
// PRODUCT.md (18) requires not to fire a notification.
|
||||
history.update(&mut app, |model, ctx| {
|
||||
let conv = model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation was just restored");
|
||||
conv.update_status(ConversationStatus::WaitingForEvents, terminal_view_id, ctx);
|
||||
});
|
||||
|
||||
seed_stale_notification(¬ifications, &mut app, conversation_id, terminal_view_id);
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
1,
|
||||
"precondition: one stale notification queued before the resume transition"
|
||||
);
|
||||
});
|
||||
|
||||
history.update(&mut app, |model, ctx| {
|
||||
let conv = model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation still exists");
|
||||
conv.update_status(ConversationStatus::InProgress, terminal_view_id, ctx);
|
||||
});
|
||||
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
0,
|
||||
"WaitingForEvents → InProgress resume must not fire a notification \
|
||||
(covers PRODUCT.md (18))"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! 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 galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::elements::{
|
||||
@@ -17,8 +17,9 @@ use galaxyui::keymap::{FixedBinding, Keystroke};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
// Modal dimensions based on Figma design.
|
||||
const MODAL_WIDTH: f32 = 440.;
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use string_offset::CharCounter;
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_completer::util::parse_current_commands_and_tokens;
|
||||
use warp_completer::ParsedTokensSnapshot;
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_core::ui::theme::{AnsiColorIdentifier, AnsiColors};
|
||||
use warpui::clipboard::ClipboardContent;
|
||||
use warpui::elements::new_scrollable::{ClippedAxisConfiguration, DualAxisConfig, NewScrollable};
|
||||
use warpui::elements::{
|
||||
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::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, SetupGuideStep};
|
||||
use crate::ai::blocklist::code_block::{
|
||||
render_code_block_plain, CodeBlockOptions, CodeSnippetButtonHandles,
|
||||
@@ -8,26 +30,6 @@ 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 galaxy_completer::signatures::CommandRegistry;
|
||||
use galaxy_completer::{util::parse_current_commands_and_tokens, ParsedTokensSnapshot};
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_core::ui::theme::{AnsiColorIdentifier, AnsiColors};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::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 galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::prelude::ChildView;
|
||||
use galaxyui::text_layout::TextStyle;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::ViewHandle;
|
||||
use galaxyui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use string_offset::CharCounter;
|
||||
|
||||
const DOCS_URL: &str = "https://docs.warp.dev/agent-platform/cloud-agents/overview";
|
||||
const ENV_DOCS_URL: &str =
|
||||
@@ -412,6 +414,7 @@ impl CloudSetupGuideView {
|
||||
mouse_handles: Some(handles),
|
||||
file_path: None,
|
||||
},
|
||||
true,
|
||||
app,
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -4,14 +4,12 @@ use galaxy_core::ui::theme::AnsiColorIdentifier;
|
||||
use galaxyui::elements::{ChildView, CrossAxisAlignment, Empty, Flex, ParentElement};
|
||||
use galaxyui::{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::agent_conversations_model::{AgentConversationEntryId, AgentRunDisplayStatus};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
|
||||
use crate::view_components::copyable_text_field::COPY_FEEDBACK_DURATION;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
const BUTTON_SPACING: f32 = 4.;
|
||||
@@ -25,7 +23,7 @@ pub struct ActionButtonsConfig {
|
||||
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>,
|
||||
pub view_details_item_id: Option<AgentConversationEntryId>,
|
||||
/// Conversation link URL (either to the transcript or live session) for copy link button.
|
||||
pub copy_link_url: Option<String>,
|
||||
}
|
||||
@@ -87,7 +85,7 @@ pub enum AgentDetailsButtonEvent {
|
||||
Open,
|
||||
CancelTask { task_id: AmbientAgentTaskId },
|
||||
ForkConversation { conversation_id: AIConversationId },
|
||||
ViewDetails { item_id: ManagementCardItemId },
|
||||
ViewDetails { item_id: AgentConversationEntryId },
|
||||
CopyLink { link: String },
|
||||
}
|
||||
|
||||
@@ -260,9 +258,7 @@ impl TypedActionView for ConversationActionButtonsRow {
|
||||
}
|
||||
AgentDetailsAction::ViewDetails => {
|
||||
if let Some(item_id) = &self.config.view_details_item_id {
|
||||
ctx.emit(AgentDetailsButtonEvent::ViewDetails {
|
||||
item_id: item_id.clone(),
|
||||
});
|
||||
ctx.emit(AgentDetailsButtonEvent::ViewDetails { item_id: *item_id });
|
||||
}
|
||||
}
|
||||
AgentDetailsAction::CopyLink => {
|
||||
|
||||
@@ -42,12 +42,23 @@ impl NotificationFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the agent that produced a notification.
|
||||
/// Identifies the agent that produced a notification, including whether the run was
|
||||
/// ambient (cloud) or local. The `is_ambient` flag drives the cloud-lobe rendering in
|
||||
/// [`render_agent_avatar`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum NotificationSourceAgent {
|
||||
Oz,
|
||||
CLI(CLIAgent),
|
||||
Oz { is_ambient: bool },
|
||||
CLI { agent: CLIAgent, is_ambient: bool },
|
||||
}
|
||||
|
||||
impl NotificationSourceAgent {
|
||||
pub fn is_ambient(&self) -> bool {
|
||||
match self {
|
||||
NotificationSourceAgent::Oz { is_ambient }
|
||||
| NotificationSourceAgent::CLI { is_ambient, .. } => *is_ambient,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the conversation or session a notification belongs to.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::appearance::Appearance as CoreAppearance;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::{Fill, WarpTheme};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::{
|
||||
ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
|
||||
@@ -11,10 +14,6 @@ use galaxyui::elements::{
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{View, ViewContext, ViewHandle};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance as CoreAppearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
use crate::ai::agent_management::notifications::item::NotificationSourceAgent;
|
||||
@@ -25,9 +24,7 @@ use crate::ai::artifacts::{
|
||||
};
|
||||
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::ui_components::icon_with_status::{render_icon_with_status, IconWithStatusVariant};
|
||||
use crate::util::time_format::format_elapsed_since;
|
||||
use crate::view_components::action_button::ActionButtonTheme;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
@@ -399,14 +396,8 @@ fn render_message_text(message: &str, expanded: bool, appearance: &Appearance) -
|
||||
.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.),
|
||||
};
|
||||
/// Total size of the agent avatar component rendered alongside each notification.
|
||||
const NOTIFICATION_AVATAR_SIZE: f32 = 32.;
|
||||
|
||||
fn render_agent_avatar(
|
||||
agent: NotificationSourceAgent,
|
||||
@@ -415,18 +406,20 @@ fn render_agent_avatar(
|
||||
) -> Box<dyn Element> {
|
||||
let status = notification_category_to_conversation_status(category);
|
||||
let variant = match agent {
|
||||
NotificationSourceAgent::Oz => IconWithStatusVariant::OzAgent {
|
||||
NotificationSourceAgent::Oz { is_ambient } => IconWithStatusVariant::OzAgent {
|
||||
status: Some(status),
|
||||
is_ambient: false,
|
||||
is_ambient,
|
||||
},
|
||||
NotificationSourceAgent::CLI(cli) => IconWithStatusVariant::CLIAgent {
|
||||
agent: cli,
|
||||
NotificationSourceAgent::CLI { agent, is_ambient } => IconWithStatusVariant::CLIAgent {
|
||||
agent,
|
||||
status: Some(status),
|
||||
is_ambient,
|
||||
},
|
||||
};
|
||||
render_icon_with_status(
|
||||
variant,
|
||||
&NOTIFICATION_AVATAR_SIZING,
|
||||
NOTIFICATION_AVATAR_SIZE,
|
||||
0.,
|
||||
theme,
|
||||
theme.surface_2(),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ fn make_conversation_notification(
|
||||
"test".to_owned(),
|
||||
"msg".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::Oz,
|
||||
NotificationSourceAgent::Oz { is_ambient: false },
|
||||
NotificationOrigin::Conversation(conversation_id),
|
||||
false,
|
||||
terminal_view_id,
|
||||
@@ -26,7 +26,10 @@ fn make_cli_session_notification(terminal_view_id: EntityId) -> NotificationItem
|
||||
"cli test".to_owned(),
|
||||
"cli msg".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::CLI(CLIAgent::Claude),
|
||||
NotificationSourceAgent::CLI {
|
||||
agent: CLIAgent::Claude,
|
||||
is_ambient: false,
|
||||
},
|
||||
NotificationOrigin::CLISession(terminal_view_id),
|
||||
false,
|
||||
terminal_view_id,
|
||||
|
||||
@@ -108,9 +108,14 @@ pub enum AgentManagementTelemetryEvent {
|
||||
/// User clicked "Continue locally" in the tombstone
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
TombstoneContinueLocally,
|
||||
/// User clicked "Continue" in the tombstone to start a cloud follow-up.
|
||||
TombstoneContinueInCloud { task_id: String },
|
||||
/// User clicked "Continue locally" in the details panel
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
DetailsPanelContinueLocally,
|
||||
/// User invoked the /continue-locally slash command
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
SlashCommandContinueLocally,
|
||||
/// User clicked "Open in Warp" in the tombstone (wasm)
|
||||
#[cfg(target_family = "wasm")]
|
||||
TombstoneOpenInWarp,
|
||||
@@ -190,8 +195,13 @@ impl TelemetryEvent for AgentManagementTelemetryEvent {
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
AgentManagementTelemetryEvent::TombstoneContinueLocally => None,
|
||||
AgentManagementTelemetryEvent::TombstoneContinueInCloud { task_id } => Some(json!({
|
||||
"task_id": task_id,
|
||||
})),
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
AgentManagementTelemetryEvent::DetailsPanelContinueLocally => None,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
AgentManagementTelemetryEvent::SlashCommandContinueLocally => None,
|
||||
#[cfg(target_family = "wasm")]
|
||||
AgentManagementTelemetryEvent::TombstoneOpenInWarp => None,
|
||||
AgentManagementTelemetryEvent::CloudRunCancelled { task_id } => {
|
||||
@@ -242,8 +252,11 @@ impl TelemetryEventDesc for AgentManagementTelemetryEventDiscriminants {
|
||||
Self::TombstoneArtifactClicked => "AgentManagement.TombstoneArtifactClicked",
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::TombstoneContinueLocally => "AgentManagement.TombstoneContinueLocally",
|
||||
Self::TombstoneContinueInCloud => "AgentManagement.TombstoneContinueInCloud",
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::DetailsPanelContinueLocally => "AgentManagement.DetailsPanelContinueLocally",
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::SlashCommandContinueLocally => "AgentManagement.SlashCommandContinueLocally",
|
||||
#[cfg(target_family = "wasm")]
|
||||
Self::TombstoneOpenInWarp => "AgentManagement.TombstoneOpenInWarp",
|
||||
Self::CloudRunCancelled => "AgentManagement.CloudRunCancelled",
|
||||
@@ -274,10 +287,17 @@ impl TelemetryEventDesc for AgentManagementTelemetryEventDiscriminants {
|
||||
Self::TombstoneArtifactClicked => "User clicked an artifact in the tombstone view",
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::TombstoneContinueLocally => "User clicked Continue locally in the tombstone",
|
||||
Self::TombstoneContinueInCloud => {
|
||||
"User clicked Continue in the tombstone to start a cloud follow-up"
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::DetailsPanelContinueLocally => {
|
||||
"User clicked Continue locally in the details panel"
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Self::SlashCommandContinueLocally => {
|
||||
"User invoked /continue-locally to fork a cloud conversation locally"
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
Self::TombstoneOpenInWarp => "User clicked Open in Warp in the tombstone",
|
||||
Self::CloudRunCancelled => "User cancelled a cloud run",
|
||||
|
||||
+305
-308
@@ -8,62 +8,9 @@ use galaxyui::scene::DropShadow;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use lazy_static::lazy_static;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
use siphasher::sip::SipHasher;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
|
||||
ConversationOrTask, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
|
||||
OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
|
||||
};
|
||||
use crate::ai::agent_management::agent_type_selector::{
|
||||
AgentType, AgentTypeSelector, AgentTypeSelectorEvent,
|
||||
};
|
||||
use crate::ai::agent_management::cloud_setup_guide_view::{
|
||||
CloudSetupGuideEvent, CloudSetupGuideView,
|
||||
};
|
||||
use crate::ai::agent_management::details_action_buttons::{
|
||||
ActionButtonsConfig, AgentDetailsButtonEvent, ConversationActionButtonsRow,
|
||||
};
|
||||
use crate::ai::agent_management::telemetry::{
|
||||
AgentManagementTelemetryEvent, ArtifactType, FilterType, OpenedFrom,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource};
|
||||
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::ai::conversation_details_panel::{
|
||||
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
|
||||
};
|
||||
use crate::ai::conversation_status_ui::render_status_element;
|
||||
use crate::ai::harness_display;
|
||||
use crate::app_state::PersistedAgentManagementFilters;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use crate::menu::{MenuItem, MenuItemFields};
|
||||
use crate::notebooks::NotebookId;
|
||||
use crate::settings::ai::AISettings;
|
||||
use crate::ui_components::avatar::{Avatar, AvatarContent};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
|
||||
};
|
||||
use crate::view_components::compactible_action_button::{
|
||||
CompactibleActionButton, MEDIUM_SIZE_SWITCH_THRESHOLD,
|
||||
};
|
||||
use crate::view_components::dropdown::{Dropdown, DropdownAction, DropdownStyle};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::view_components::FilterableDropdown;
|
||||
use crate::workflows::WorkflowType;
|
||||
use crate::workspace::{ForkedConversationDestination, ToastStack};
|
||||
use crate::workspace::{RestoreConversationLayout, WorkspaceAction};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{send_telemetry_from_ctx, AgentModeEntrypoint};
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
@@ -79,15 +26,71 @@ use galaxyui::elements::{
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text, Wrap,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::ui_components::components::UiComponentStyles;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
keymap::FixedBinding, Action, AppContext, Entity, FocusContext, ModelHandle, SingletonEntity,
|
||||
TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject,
|
||||
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
|
||||
ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter,
|
||||
OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
|
||||
};
|
||||
use crate::ai::agent_management::agent_type_selector::{
|
||||
AgentType, AgentTypeSelector, AgentTypeSelectorEvent,
|
||||
};
|
||||
use crate::ai::agent_management::cloud_setup_guide_view::{
|
||||
CloudSetupGuideEvent, CloudSetupGuideView,
|
||||
};
|
||||
use crate::ai::agent_management::details_action_buttons::{
|
||||
ActionButtonsConfig, AgentDetailsButtonEvent, ConversationActionButtonsRow,
|
||||
};
|
||||
use crate::ai::agent_management::telemetry::{
|
||||
AgentManagementTelemetryEvent, ArtifactType, FilterType, OpenedFrom,
|
||||
};
|
||||
use crate::ai::ambient_agents::{cancel_task_with_toast, AgentSource};
|
||||
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
|
||||
use crate::ai::blocklist::format_credits;
|
||||
use crate::ai::conversation_details_panel::{
|
||||
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
|
||||
};
|
||||
use crate::ai::harness_availability::HarnessAvailabilityModel;
|
||||
use crate::ai::harness_display;
|
||||
use crate::app_state::PersistedAgentManagementFilters;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use crate::menu::{MenuItem, MenuItemFields};
|
||||
use crate::notebooks::NotebookId;
|
||||
use crate::settings::ai::AISettings;
|
||||
use crate::ui_components::agent_icon::agent_conversation_entry_icon_variant;
|
||||
use crate::ui_components::avatar::{Avatar, AvatarContent};
|
||||
use crate::ui_components::icon_with_status::render_icon_with_status;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
|
||||
};
|
||||
use crate::view_components::compactible_action_button::{
|
||||
CompactibleActionButton, MEDIUM_SIZE_SWITCH_THRESHOLD,
|
||||
};
|
||||
use crate::view_components::dropdown::{
|
||||
Dropdown, DropdownAction, DropdownItemAction, DropdownStyle,
|
||||
};
|
||||
use crate::view_components::{DismissibleToast, FilterableDropdown};
|
||||
use crate::workflows::WorkflowType;
|
||||
use crate::workspace::{
|
||||
ForkedConversationDestination, RestoreConversationLayout, ToastStack, WorkspaceAction,
|
||||
};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{send_telemetry_from_ctx, AgentModeEntrypoint};
|
||||
|
||||
lazy_static! {
|
||||
static ref HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
|
||||
@@ -105,8 +108,9 @@ const CARD_CONTENT_PADDING: f32 = 12.;
|
||||
const CARD_BORDER_RADIUS: f32 = 4.;
|
||||
const CARD_MARGIN_BOTTOM: f32 = 8.;
|
||||
|
||||
const STATUS_ICON_SIZE: f32 = 12.;
|
||||
const BUTTON_SIZE: f32 = 20.;
|
||||
/// Total size of the agent icon-with-status component rendered in each card's header row.
|
||||
const CARD_AGENT_ICON_SIZE: f32 = 24.;
|
||||
const CREATOR_AVATAR_FONT_SIZE: f32 = 10.;
|
||||
|
||||
const SESSION_EXPIRED_TEXT: &str = "Sessions expire after one week and cannot be opened.";
|
||||
@@ -125,21 +129,7 @@ fn should_show_artifacts(artifacts: &[Artifact]) -> bool {
|
||||
!artifacts.is_empty() && FeatureFlag::ConversationArtifacts.is_enabled()
|
||||
}
|
||||
|
||||
/// Identifies a card item - either a task ID or a conversation ID
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ManagementCardItemId {
|
||||
Task(AmbientAgentTaskId),
|
||||
Conversation(AIConversationId),
|
||||
}
|
||||
|
||||
impl ManagementCardItemId {
|
||||
fn as_key(&self) -> String {
|
||||
match self {
|
||||
ManagementCardItemId::Task(id) => format!("task_{id}"),
|
||||
ManagementCardItemId::Conversation(id) => format!("conv_{id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub type ManagementCardItemId = AgentConversationEntryId;
|
||||
|
||||
/// Store state for a given task row
|
||||
struct CardState {
|
||||
@@ -222,6 +212,13 @@ impl AgentManagementView {
|
||||
Self::handle_agent_management_model_event,
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&HarnessAvailabilityModel::handle(ctx),
|
||||
|me, _, _event, ctx| {
|
||||
me.update_harness_dropdown(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
let list_state = Self::construct_fresh_list_state(ctx.handle());
|
||||
|
||||
let all_filter_button = ctx.add_typed_action_view(|_ctx| {
|
||||
@@ -496,7 +493,7 @@ impl AgentManagementView {
|
||||
let make_status_option =
|
||||
|label: &str, action: AgentManagementViewAction, icon_data: Option<(Icon, Fill)>| {
|
||||
let mut fields = MenuItemFields::new(label)
|
||||
.with_on_select_action(DropdownAction::SelectActionAndClose(action));
|
||||
.with_on_select_action(DropdownAction::select_action_and_close(action));
|
||||
if let Some((icon, color)) = icon_data {
|
||||
fields = fields.with_icon(icon).with_override_icon_color(color);
|
||||
}
|
||||
@@ -546,7 +543,7 @@ impl AgentManagementView {
|
||||
}
|
||||
|
||||
/// Build the list of source filter items.
|
||||
fn build_source_dropdown_items() -> Vec<MenuItem<DropdownAction<AgentManagementViewAction>>> {
|
||||
fn build_source_dropdown_items() -> Vec<MenuItem<DropdownAction>> {
|
||||
// Build up the sources list
|
||||
let mut sources = vec![
|
||||
AgentSource::WebApp,
|
||||
@@ -564,14 +561,16 @@ impl AgentManagementView {
|
||||
}
|
||||
|
||||
let mut items = vec![MenuItem::Item(
|
||||
MenuItemFields::new("All").with_on_select_action(DropdownAction::SelectActionAndClose(
|
||||
AgentManagementViewAction::SetSourceFilter(SourceFilter::All),
|
||||
)),
|
||||
MenuItemFields::new("All").with_on_select_action(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetSourceFilter(SourceFilter::All),
|
||||
),
|
||||
),
|
||||
)];
|
||||
for source in sources {
|
||||
items.push(MenuItem::Item(
|
||||
MenuItemFields::new(source.display_name()).with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetSourceFilter(SourceFilter::Specific(source)),
|
||||
),
|
||||
),
|
||||
@@ -601,22 +600,22 @@ impl AgentManagementView {
|
||||
|
||||
let items = vec![
|
||||
MenuItem::Item(MenuItemFields::new("All").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::All),
|
||||
),
|
||||
)),
|
||||
MenuItem::Item(MenuItemFields::new("Last 24 hours").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::Last24Hours),
|
||||
),
|
||||
)),
|
||||
MenuItem::Item(MenuItemFields::new("Past 3 days").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::Past3Days),
|
||||
),
|
||||
)),
|
||||
MenuItem::Item(MenuItemFields::new("Last week").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetCreatedOnFilter(CreatedOnFilter::LastWeek),
|
||||
),
|
||||
)),
|
||||
@@ -635,29 +634,29 @@ impl AgentManagementView {
|
||||
|
||||
let items = vec![
|
||||
MenuItem::Item(MenuItemFields::new("All").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
|
||||
ArtifactFilter::All,
|
||||
)),
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::All),
|
||||
),
|
||||
)),
|
||||
MenuItem::Item(MenuItemFields::new("Pull Request").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
|
||||
ArtifactFilter::PullRequest,
|
||||
)),
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::PullRequest),
|
||||
),
|
||||
)),
|
||||
MenuItem::Item(MenuItemFields::new("Plan").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
|
||||
ArtifactFilter::Plan,
|
||||
)),
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::Plan),
|
||||
),
|
||||
)),
|
||||
MenuItem::Item(MenuItemFields::new("Screenshot").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
|
||||
ArtifactFilter::Screenshot,
|
||||
)),
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::Screenshot),
|
||||
),
|
||||
)),
|
||||
MenuItem::Item(MenuItemFields::new("File").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(AgentManagementViewAction::SetArtifactFilter(
|
||||
ArtifactFilter::File,
|
||||
)),
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetArtifactFilter(ArtifactFilter::File),
|
||||
),
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -672,17 +671,27 @@ impl AgentManagementView {
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
Self::setup_filter_menu(&mut dropdown, "Harness", ctx);
|
||||
|
||||
// "All" has no leading icon, matching the Status dropdown's "All" row.
|
||||
let items = Self::build_harness_dropdown_items(ctx);
|
||||
dropdown.set_rich_items(items, ctx);
|
||||
dropdown.set_selected_by_index(0, ctx);
|
||||
dropdown
|
||||
}
|
||||
|
||||
fn build_harness_dropdown_items(app: &AppContext) -> Vec<MenuItem<DropdownAction>> {
|
||||
let mut items = vec![MenuItem::Item(
|
||||
MenuItemFields::new("All").with_on_select_action(DropdownAction::SelectActionAndClose(
|
||||
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::All),
|
||||
)),
|
||||
MenuItemFields::new("All").with_on_select_action(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::All),
|
||||
),
|
||||
),
|
||||
)];
|
||||
|
||||
for harness in [Harness::Oz, Harness::Claude, Harness::Gemini] {
|
||||
let mut fields = MenuItemFields::new(harness_display::display_name(harness))
|
||||
let availability = HarnessAvailabilityModel::as_ref(app);
|
||||
for entry in availability.available_harnesses() {
|
||||
let harness = entry.harness;
|
||||
let mut fields = MenuItemFields::new(entry.display_name.clone())
|
||||
.with_icon(harness_display::icon_for(harness))
|
||||
.with_on_select_action(DropdownAction::SelectActionAndClose(
|
||||
.with_on_select_action(DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::Specific(harness)),
|
||||
));
|
||||
if let Some(color) = harness_display::brand_color(harness) {
|
||||
@@ -691,9 +700,7 @@ impl AgentManagementView {
|
||||
items.push(MenuItem::Item(fields));
|
||||
}
|
||||
|
||||
dropdown.set_rich_items(items, ctx);
|
||||
dropdown.set_selected_by_index(0, ctx);
|
||||
dropdown
|
||||
items
|
||||
}
|
||||
|
||||
fn create_environment_dropdown(
|
||||
@@ -732,7 +739,7 @@ impl AgentManagementView {
|
||||
}
|
||||
|
||||
// Initialize the dropdown menu for the filter dropdowns (status, source)
|
||||
fn setup_filter_menu<A: Action + Clone>(
|
||||
fn setup_filter_menu<A: DropdownItemAction>(
|
||||
dropdown: &mut Dropdown<A>,
|
||||
label_prefix: &'static str,
|
||||
ctx: &mut ViewContext<Dropdown<A>>,
|
||||
@@ -744,7 +751,7 @@ impl AgentManagementView {
|
||||
}
|
||||
|
||||
// Initialize the dropdown menu for the searchable filter dropdowns (creator)
|
||||
fn setup_searchable_filter_menu<A: Action + Clone>(
|
||||
fn setup_searchable_filter_menu<A: DropdownItemAction>(
|
||||
dropdown: &mut FilterableDropdown<A>,
|
||||
label_prefix: &'static str,
|
||||
ctx: &mut ViewContext<FilterableDropdown<A>>,
|
||||
@@ -755,6 +762,13 @@ impl AgentManagementView {
|
||||
dropdown.set_button_variant(ButtonVariant::Secondary);
|
||||
}
|
||||
|
||||
fn update_harness_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let items = Self::build_harness_dropdown_items(ctx);
|
||||
self.harness_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_rich_items(items, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Since the valid set of environments depends on what tasks we have loaded in,
|
||||
/// we use this function to update the available options depending on the most recent
|
||||
/// set of tasks.
|
||||
@@ -771,7 +785,7 @@ impl AgentManagementView {
|
||||
self.environment_dropdown.update(ctx, |dropdown, ctx| {
|
||||
let mut items = vec![MenuItem::Item(
|
||||
MenuItemFields::new("All").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetEnvironmentFilter(EnvironmentFilter::All),
|
||||
),
|
||||
),
|
||||
@@ -779,7 +793,7 @@ impl AgentManagementView {
|
||||
|
||||
items.push(MenuItem::Item(
|
||||
MenuItemFields::new("None").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetEnvironmentFilter(
|
||||
EnvironmentFilter::NoEnvironment,
|
||||
),
|
||||
@@ -793,7 +807,7 @@ impl AgentManagementView {
|
||||
for (environment_id, environment_name) in sorted_envs {
|
||||
items.push(MenuItem::Item(
|
||||
MenuItemFields::new(environment_name).with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetEnvironmentFilter(
|
||||
EnvironmentFilter::Specific(environment_id),
|
||||
),
|
||||
@@ -818,7 +832,7 @@ impl AgentManagementView {
|
||||
self.creator_dropdown.update(ctx, |dropdown, ctx| {
|
||||
let mut items = vec![MenuItem::Item(
|
||||
MenuItemFields::new("All").with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetCreatorFilter(CreatorFilter::All),
|
||||
),
|
||||
),
|
||||
@@ -826,7 +840,7 @@ impl AgentManagementView {
|
||||
for (name, uid) in creators {
|
||||
items.push(MenuItem::Item(
|
||||
MenuItemFields::new(&name).with_on_select_action(
|
||||
DropdownAction::SelectActionAndClose(
|
||||
DropdownAction::select_action_and_close(
|
||||
AgentManagementViewAction::SetCreatorFilter(CreatorFilter::Specific {
|
||||
name,
|
||||
uid,
|
||||
@@ -938,46 +952,34 @@ impl AgentManagementView {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let search_query = self.search_query.trim().to_lowercase();
|
||||
let cards: Vec<CardData> = model
|
||||
.get_tasks_and_conversations(&self.filters, ctx)
|
||||
.filter(|t| {
|
||||
.get_entries(&self.filters, ctx)
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
if search_query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match_indices_case_insensitive(&t.title(ctx), &search_query).is_some()
|
||||
match_indices_case_insensitive(&entry.display.title, &search_query).is_some()
|
||||
})
|
||||
.map(|t| {
|
||||
let item_id = match t {
|
||||
ConversationOrTask::Task(task) => ManagementCardItemId::Task(task.task_id),
|
||||
ConversationOrTask::Conversation(conversation) => {
|
||||
ManagementCardItemId::Conversation(conversation.nav_data.id)
|
||||
}
|
||||
};
|
||||
let artifacts = t.artifacts(ctx);
|
||||
|
||||
let copy_link_url = t.session_or_conversation_link(ctx);
|
||||
let mut config = match t {
|
||||
ConversationOrTask::Task(task) => ActionButtonsConfig::for_task(
|
||||
task.task_id,
|
||||
&t.display_status(ctx),
|
||||
None, // Don't show open button in card hover
|
||||
copy_link_url,
|
||||
),
|
||||
ConversationOrTask::Conversation(conversation) => {
|
||||
ActionButtonsConfig::for_conversation(
|
||||
conversation.nav_data.id,
|
||||
None, // Don't show open button in card hover
|
||||
copy_link_url,
|
||||
.map(|entry| {
|
||||
let item_id = entry.id;
|
||||
let copy_link_url = entry
|
||||
.capabilities
|
||||
.can_copy_link
|
||||
.then(|| {
|
||||
AgentConversationsModel::resolve_copy_link(
|
||||
AgentConversationNavigationSubject::Entry(entry.id),
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
};
|
||||
// Show info button in card hover for ViewDetails if feature flag enabled
|
||||
})
|
||||
.flatten();
|
||||
let mut config = Self::action_buttons_config_for_entry(&entry, None, copy_link_url);
|
||||
if FeatureFlag::AgentManagementDetailsView.is_enabled() {
|
||||
config.view_details_item_id = Some(item_id.clone());
|
||||
config.view_details_item_id = Some(item_id);
|
||||
}
|
||||
|
||||
CardData {
|
||||
item_id,
|
||||
artifacts,
|
||||
artifacts: entry.display.artifacts,
|
||||
action_buttons_config: config,
|
||||
}
|
||||
})
|
||||
@@ -1026,11 +1028,8 @@ impl AgentManagementView {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let action_buttons_view = self.create_action_buttons_view(
|
||||
card.item_id.clone(),
|
||||
card.action_buttons_config,
|
||||
ctx,
|
||||
);
|
||||
let action_buttons_view =
|
||||
self.create_action_buttons_view(card.item_id, card.action_buttons_config, ctx);
|
||||
|
||||
new_items.push(CardState {
|
||||
hover_state: MouseStateHandle::default(),
|
||||
@@ -1074,6 +1073,29 @@ impl AgentManagementView {
|
||||
view
|
||||
}
|
||||
|
||||
fn action_buttons_config_for_entry(
|
||||
entry: &AgentConversationEntry,
|
||||
open_action: Option<WorkspaceAction>,
|
||||
copy_link_url: Option<String>,
|
||||
) -> ActionButtonsConfig {
|
||||
if let Some(task_id) = entry.identity.ambient_agent_task_id {
|
||||
ActionButtonsConfig::for_task(
|
||||
task_id,
|
||||
&entry.display.status,
|
||||
open_action,
|
||||
copy_link_url,
|
||||
)
|
||||
} else if let Some(conversation_id) = entry.identity.local_conversation_id {
|
||||
ActionButtonsConfig::for_conversation(conversation_id, open_action, copy_link_url)
|
||||
} else {
|
||||
ActionButtonsConfig {
|
||||
open_action,
|
||||
copy_link_url,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_action_buttons_event(
|
||||
&mut self,
|
||||
item_id: &ManagementCardItemId,
|
||||
@@ -1109,6 +1131,7 @@ impl AgentManagementView {
|
||||
summarize_after_fork: false,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: None,
|
||||
initial_attachments: vec![],
|
||||
destination: ForkedConversationDestination::NewTab,
|
||||
});
|
||||
}
|
||||
@@ -1122,7 +1145,7 @@ impl AgentManagementView {
|
||||
);
|
||||
|
||||
self.update_details_panel_for_item(item_id, ctx);
|
||||
self.selected_item_id = Some(item_id.clone());
|
||||
self.selected_item_id = Some(*item_id);
|
||||
ctx.notify();
|
||||
}
|
||||
AgentDetailsButtonEvent::CopyLink { link } => {
|
||||
@@ -1136,7 +1159,7 @@ impl AgentManagementView {
|
||||
ctx
|
||||
);
|
||||
}
|
||||
ManagementCardItemId::Task(task_id) => {
|
||||
ManagementCardItemId::AmbientRun(task_id) => {
|
||||
send_telemetry_from_ctx!(
|
||||
AgentManagementTelemetryEvent::SessionLinkCopied {
|
||||
task_id: task_id.to_string(),
|
||||
@@ -1237,13 +1260,9 @@ impl AgentManagementView {
|
||||
self.refresh_details_panel_if_needed(ctx);
|
||||
self.get_tasks_from_model(ctx);
|
||||
}
|
||||
AgentConversationsModelEvent::ConversationUpdated => {
|
||||
self.get_tasks_from_model(ctx);
|
||||
self.refresh_details_panel_if_needed(ctx);
|
||||
ctx.notify();
|
||||
AgentConversationsModelEvent::ConversationUpdated { kind } => {
|
||||
self.handle_conversation_updated(*kind, ctx);
|
||||
}
|
||||
// TaskManuallyOpened is handled by the conversation list view, not here.
|
||||
AgentConversationsModelEvent::TaskManuallyOpened => {}
|
||||
AgentConversationsModelEvent::ConversationArtifactsUpdated { conversation_id } => {
|
||||
self.update_artifacts_for_conversation(*conversation_id, ctx);
|
||||
self.refresh_details_panel_if_needed(ctx);
|
||||
@@ -1253,11 +1272,48 @@ impl AgentManagementView {
|
||||
|
||||
/// Refresh the details panel if it's currently showing an item
|
||||
fn refresh_details_panel_if_needed(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(item_id) = self.selected_item_id.clone() {
|
||||
if let Some(item_id) = self.selected_item_id {
|
||||
self.update_details_panel_for_item(&item_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide how much work a `ConversationUpdated` event requires, based on its kind and the
|
||||
/// active status filter:
|
||||
/// * `Restored`: the underlying status didn't change, so the visible cards don't change
|
||||
/// either. Just refresh the details panel.
|
||||
/// * `MetadataChanged`: rebuild the cards so metadata-derived actions update.
|
||||
/// * `TitleChanged`: rebuild the cards so filtering and titles update.
|
||||
/// * `StatusSet` that crosses the active status filter: rebuild the
|
||||
/// card list via `get_tasks_from_model`.
|
||||
/// * `StatusSet` that doesn't cross the active filter (or `All` is active):
|
||||
/// just refresh the details panel re-render so the status icon picks up the new value.
|
||||
fn handle_conversation_updated(
|
||||
&mut self,
|
||||
kind: ConversationUpdateKind,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match kind {
|
||||
ConversationUpdateKind::Restored => {}
|
||||
ConversationUpdateKind::MetadataChanged => self.get_tasks_from_model(ctx),
|
||||
ConversationUpdateKind::TitleChanged => self.get_tasks_from_model(ctx),
|
||||
ConversationUpdateKind::StatusSet {
|
||||
prev_filter,
|
||||
new_filter,
|
||||
} => {
|
||||
if self
|
||||
.filters
|
||||
.status
|
||||
.is_membership_crossed(prev_filter, new_filter)
|
||||
{
|
||||
self.get_tasks_from_model(ctx);
|
||||
} else {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_details_panel_if_needed(ctx);
|
||||
}
|
||||
|
||||
/// Update the details panel with fresh data for the given item.
|
||||
fn update_details_panel_for_item(
|
||||
&mut self,
|
||||
@@ -1265,75 +1321,28 @@ impl AgentManagementView {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
|
||||
let data = match item_id {
|
||||
ManagementCardItemId::Task(task_id) => {
|
||||
let Some(task_wrapper) = model.get_task(task_id) else {
|
||||
return;
|
||||
};
|
||||
// Agent management view should always open in a new tab
|
||||
let open_action =
|
||||
task_wrapper.get_open_action(Some(RestoreConversationLayout::NewTab), ctx);
|
||||
let copy_link_url = task_wrapper.session_or_conversation_link(ctx);
|
||||
let Some(task) = model.get_task_data(task_id) else {
|
||||
return;
|
||||
};
|
||||
ConversationDetailsData::from_task(&task, open_action, copy_link_url, ctx)
|
||||
}
|
||||
ManagementCardItemId::Conversation(conversation_id) => {
|
||||
let Some(conversation) = model.get_conversation(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
// Agent management view should always open in a new tab
|
||||
let open_action =
|
||||
conversation.get_open_action(Some(RestoreConversationLayout::NewTab), ctx);
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let ai_conversation = conversation
|
||||
.navigation_data()
|
||||
.and_then(|nav| history_model.conversation(&nav.id));
|
||||
|
||||
let server_conv_id = ai_conversation
|
||||
.and_then(|c| c.server_conversation_token())
|
||||
.map(|t| t.as_str().to_string())
|
||||
.or_else(|| {
|
||||
conversation
|
||||
.navigation_data()
|
||||
.and_then(|nav| history_model.get_conversation_metadata(&nav.id))
|
||||
.and_then(|m| m.server_conversation_token.as_ref())
|
||||
.map(|t| t.as_str().to_string())
|
||||
});
|
||||
let artifacts = ai_conversation
|
||||
.map(|c| c.artifacts().to_vec())
|
||||
.unwrap_or_default();
|
||||
let status = Some(conversation.status(ctx));
|
||||
let navigation_data = conversation.navigation_data();
|
||||
let copy_link_url = conversation.session_or_conversation_link(ctx);
|
||||
|
||||
// Prefer server-reported harness when available; otherwise treat as a pure
|
||||
// local conversation (always Warp Agent).
|
||||
let harness = navigation_data
|
||||
.and_then(|nav| history_model.get_server_conversation_metadata(&nav.id))
|
||||
.map(|m| Harness::from(m.harness))
|
||||
.or(Some(Harness::Oz));
|
||||
|
||||
ConversationDetailsData::from_conversation_metadata(
|
||||
*conversation_id,
|
||||
conversation.title(ctx),
|
||||
conversation.creator_name(ctx),
|
||||
conversation.created_at().with_timezone(&chrono::Local),
|
||||
navigation_data.and_then(|n| n.initial_working_directory.clone()),
|
||||
conversation.request_usage(ctx),
|
||||
server_conv_id,
|
||||
artifacts,
|
||||
open_action,
|
||||
status,
|
||||
navigation_data.and_then(|n| n.initial_query.clone()),
|
||||
copy_link_url,
|
||||
harness,
|
||||
)
|
||||
}
|
||||
let Some(entry) = model.get_entry_by_id(item_id, ctx) else {
|
||||
return;
|
||||
};
|
||||
let open_action = AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(*item_id),
|
||||
Some(RestoreConversationLayout::NewTab),
|
||||
ctx,
|
||||
);
|
||||
let copy_link_url = AgentConversationsModel::resolve_copy_link(
|
||||
AgentConversationNavigationSubject::Entry(*item_id),
|
||||
ctx,
|
||||
);
|
||||
let task = entry
|
||||
.identity
|
||||
.ambient_agent_task_id
|
||||
.and_then(|task_id| model.get_task_data(&task_id));
|
||||
let data = ConversationDetailsData::from_agent_conversation_entry(
|
||||
&entry,
|
||||
task.as_ref(),
|
||||
open_action,
|
||||
copy_link_url,
|
||||
);
|
||||
|
||||
self.details_panel.update(ctx, |p, ctx| {
|
||||
p.set_conversation_details(data, ctx);
|
||||
@@ -1347,19 +1356,14 @@ impl AgentManagementView {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let Some(card_data) = model.get_conversation(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let artifacts = card_data.artifacts(ctx);
|
||||
|
||||
// Find the index of the card for this conversation
|
||||
let Some(index) = self
|
||||
.items
|
||||
.iter()
|
||||
.position(|card| card.item_id == ManagementCardItemId::Conversation(conversation_id))
|
||||
else {
|
||||
let Some((index, entry)) = self.items.iter().enumerate().find_map(|(index, card)| {
|
||||
let entry = model.get_entry_by_id(&card.item_id, ctx)?;
|
||||
(entry.identity.local_conversation_id == Some(conversation_id))
|
||||
.then_some((index, entry))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let artifacts = entry.display.artifacts;
|
||||
|
||||
// Update the artifact buttons for this card
|
||||
if should_show_artifacts(&artifacts) {
|
||||
@@ -1497,7 +1501,7 @@ impl AgentManagementView {
|
||||
fn render_session_status_label(
|
||||
appearance: &Appearance,
|
||||
mouse_state: MouseStateHandle,
|
||||
session_status: SessionStatus,
|
||||
session_status: &SessionStatus,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
@@ -1604,21 +1608,17 @@ impl AgentManagementView {
|
||||
};
|
||||
|
||||
let model = AgentConversationsModel::as_ref(app);
|
||||
let card_data = match &card_state.item_id {
|
||||
ManagementCardItemId::Task(task_id) => model.get_task(task_id),
|
||||
ManagementCardItemId::Conversation(conv_id) => model.get_conversation(conv_id),
|
||||
};
|
||||
let Some(card_data) = card_data else {
|
||||
let Some(entry) = model.get_entry_by_id(&card_state.item_id, app) else {
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
self.render_card(card_state, &card_data, appearance, app)
|
||||
self.render_card(card_state, &entry, appearance, app)
|
||||
}
|
||||
|
||||
fn render_card(
|
||||
&self,
|
||||
card_state: &CardState,
|
||||
card_data: &ConversationOrTask,
|
||||
entry: &AgentConversationEntry,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
@@ -1642,10 +1642,8 @@ impl AgentManagementView {
|
||||
let card_hoverable = Hoverable::new(card_state.hover_state.clone(), move |mouse_state| {
|
||||
let mut card_content = Flex::column()
|
||||
.with_spacing(CARD_ROW_SPACING)
|
||||
.with_child(Self::render_header_row(
|
||||
card_state, card_data, appearance, app,
|
||||
))
|
||||
.with_child(Self::render_metadata_row(card_data, appearance, app));
|
||||
.with_child(Self::render_header_row(card_state, entry, appearance))
|
||||
.with_child(Self::render_metadata_row(entry, appearance, app));
|
||||
|
||||
// Add artifacts row if there is a buttons view
|
||||
if let Some(buttons_element) = artifact_buttons_element {
|
||||
@@ -1706,18 +1704,12 @@ impl AgentManagementView {
|
||||
})
|
||||
.with_defer_events_to_children();
|
||||
|
||||
// Add click handler to open session if available
|
||||
let item_id = card_state.item_id.clone();
|
||||
let card_hoverable = if card_data
|
||||
.get_open_action(Some(RestoreConversationLayout::NewTab), app)
|
||||
.is_some()
|
||||
{
|
||||
let item_id = card_state.item_id;
|
||||
let card_hoverable = if entry.capabilities.can_open {
|
||||
card_hoverable
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AgentManagementViewAction::OpenSession {
|
||||
item_id: item_id.clone(),
|
||||
});
|
||||
ctx.dispatch_typed_action(AgentManagementViewAction::OpenSession { item_id });
|
||||
})
|
||||
} else {
|
||||
card_hoverable
|
||||
@@ -1728,29 +1720,30 @@ impl AgentManagementView {
|
||||
|
||||
fn render_header_row(
|
||||
card_state: &CardState,
|
||||
card_data: &ConversationOrTask,
|
||||
entry: &AgentConversationEntry,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
|
||||
let title = card_data.title(app);
|
||||
let title_text = Text::new_inline(title, font_family, font_size)
|
||||
let title_text = Text::new_inline(entry.display.title.clone(), font_family, font_size)
|
||||
.with_color(theme.active_ui_text_color().into());
|
||||
|
||||
let status_icon =
|
||||
render_status_element(&card_data.display_status(app), STATUS_ICON_SIZE, appearance);
|
||||
|
||||
// Build the time and avatar elements
|
||||
let last_updated = card_data.last_updated();
|
||||
let time_str = format_approx_duration_from_now_utc(last_updated);
|
||||
let status_icon = render_icon_with_status(
|
||||
agent_conversation_entry_icon_variant(entry),
|
||||
CARD_AGENT_ICON_SIZE,
|
||||
0.,
|
||||
theme,
|
||||
internal_colors::fg_overlay_1(theme),
|
||||
);
|
||||
let time_str = format_approx_duration_from_now_utc(entry.display.last_updated);
|
||||
let time_text = Text::new_inline(time_str, font_family, font_size)
|
||||
.with_color(theme.nonactive_ui_text_color().into());
|
||||
|
||||
let creator_name = card_data
|
||||
.creator_name(app)
|
||||
let creator_name = entry
|
||||
.display
|
||||
.creator
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let avatar = Self::render_avatar_with_tooltip(
|
||||
&creator_name,
|
||||
@@ -1763,12 +1756,11 @@ impl AgentManagementView {
|
||||
.with_spacing(2.)
|
||||
.with_child(Container::new(status_icon).with_margin_right(4.).finish())
|
||||
.with_child(Expanded::new(1., title_text.finish()).finish());
|
||||
|
||||
let mut time_and_avatar = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(4.);
|
||||
|
||||
if let Some(session_status) = card_data.get_session_status() {
|
||||
if let Some(session_status) = &entry.display.session_status {
|
||||
time_and_avatar.add_child(Self::render_session_status_label(
|
||||
appearance,
|
||||
card_state.session_status_hover_state.clone(),
|
||||
@@ -1792,45 +1784,56 @@ impl AgentManagementView {
|
||||
}
|
||||
|
||||
fn render_metadata_row(
|
||||
card_data: &ConversationOrTask,
|
||||
entry: &AgentConversationEntry,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
|
||||
// Build metadata parts conditionally
|
||||
let mut metadata_parts = Vec::new();
|
||||
|
||||
if let Some(source) = card_data.source() {
|
||||
if let Some(source) = &entry.display.source {
|
||||
metadata_parts.push(format!("Source: {}", source.display_name()));
|
||||
}
|
||||
|
||||
if FeatureFlag::AgentHarness.is_enabled() {
|
||||
if let Some(harness) = card_data.harness() {
|
||||
let availability = HarnessAvailabilityModel::as_ref(app);
|
||||
if availability.should_show_harness_selector() {
|
||||
if let Some(harness) = entry.display.harness {
|
||||
metadata_parts.push(format!(
|
||||
"Harness: {}",
|
||||
harness_display::display_name(harness)
|
||||
availability.display_name_for(harness)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(run_time) = card_data.run_time() {
|
||||
if let Some(executor) = &entry.display.executor {
|
||||
let same_as_creator =
|
||||
executor.uid.is_some() && executor.uid == entry.display.creator.uid;
|
||||
if !same_as_creator {
|
||||
if let Some(name) = executor.name.as_deref().or(executor.uid.as_deref()) {
|
||||
let label = if executor
|
||||
.principal_type
|
||||
.is_some_and(|pt| pt.is_service_account())
|
||||
{
|
||||
"Agent"
|
||||
} else {
|
||||
"Executor"
|
||||
};
|
||||
metadata_parts.push(format!("{label}: {name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(run_time) = &entry.display.run_time {
|
||||
metadata_parts.push(format!("Run time: {run_time}"));
|
||||
}
|
||||
|
||||
if let Some(usage) = card_data.display_request_usage(app) {
|
||||
if let Some(usage) = entry.display.request_usage.map(format_credits) {
|
||||
metadata_parts.push(format!("Credits used: {usage}"));
|
||||
}
|
||||
|
||||
if let Some(tokens) = card_data.display_total_tokens(app) {
|
||||
metadata_parts.push(format!("Tokens: {tokens}"));
|
||||
}
|
||||
|
||||
let metadata_text = metadata_parts.join(" • ");
|
||||
|
||||
Text::new(metadata_text, font_family, font_size)
|
||||
Text::new(metadata_parts.join(" • "), font_family, font_size)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.finish()
|
||||
}
|
||||
@@ -1947,7 +1950,7 @@ impl AgentManagementView {
|
||||
.with_child(ChildView::new(&self.created_on_dropdown).finish())
|
||||
.with_child(ChildView::new(&self.artifact_dropdown).finish());
|
||||
|
||||
if FeatureFlag::AgentHarness.is_enabled() {
|
||||
if HarnessAvailabilityModel::as_ref(app).should_show_harness_selector() {
|
||||
filters_wrap.add_child(ChildView::new(&self.harness_dropdown).finish());
|
||||
}
|
||||
|
||||
@@ -2356,17 +2359,11 @@ impl TypedActionView for AgentManagementView {
|
||||
ctx.notify();
|
||||
}
|
||||
AgentManagementViewAction::OpenSession { item_id } => {
|
||||
let model = AgentConversationsModel::as_ref(ctx);
|
||||
let card_data = match item_id {
|
||||
ManagementCardItemId::Task(task_id) => model.get_task(task_id),
|
||||
ManagementCardItemId::Conversation(conv_id) => model.get_conversation(conv_id),
|
||||
};
|
||||
let Some(card_data) = card_data else {
|
||||
return;
|
||||
};
|
||||
let Some(action) =
|
||||
card_data.get_open_action(Some(RestoreConversationLayout::NewTab), ctx)
|
||||
else {
|
||||
let Some(action) = AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(*item_id),
|
||||
Some(RestoreConversationLayout::NewTab),
|
||||
ctx,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2380,7 +2377,7 @@ impl TypedActionView for AgentManagementView {
|
||||
ctx
|
||||
);
|
||||
}
|
||||
ManagementCardItemId::Task(task_id) => {
|
||||
ManagementCardItemId::AmbientRun(task_id) => {
|
||||
send_telemetry_from_ctx!(
|
||||
AgentManagementTelemetryEvent::CloudRunOpened {
|
||||
task_id: task_id.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user