Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions app/src/ai/agent_conversations_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,10 +541,6 @@ impl ConversationOrTask<'_> {
}
}

pub fn is_ambient_agent_conversation(&self) -> bool {
matches!(self, ConversationOrTask::Task(_))
}

/// Returns the navigation data for local conversations, used for emitting the Navigate event.
pub fn navigation_data(&self) -> Option<&ConversationNavigationData> {
match self {
Expand Down
66 changes: 54 additions & 12 deletions app/src/ai/agent_management/agent_management_model.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::HashMap;

use warp_core::features::FeatureFlag;
use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, WindowId};
use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};

use crate::settings::AISettings;

Expand All @@ -17,7 +17,7 @@ use crate::server::telemetry::TelemetryEvent;
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;
Expand Down Expand Up @@ -165,31 +165,41 @@ 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,
);
}
CLIAgentSessionStatus::Blocked { message } => {
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,
);
}
Expand Down Expand Up @@ -310,6 +320,10 @@ 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.
Expand All @@ -322,10 +336,11 @@ impl AgentNotificationsModel {
title,
"Task completed.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
}
Expand All @@ -335,10 +350,11 @@ impl AgentNotificationsModel {
title,
"Task was cancelled.".to_owned(),
NotificationCategory::Complete,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
}
Expand All @@ -347,10 +363,11 @@ impl AgentNotificationsModel {
title,
blocked_action.clone(),
NotificationCategory::Request,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
vec![],
metadata.branch,
ctx,
);
}
Expand All @@ -360,10 +377,11 @@ impl AgentNotificationsModel {
title,
"Something went wrong.".to_owned(),
NotificationCategory::Error,
NotificationSourceAgent::Oz,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
}
Expand Down Expand Up @@ -401,14 +419,14 @@ 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 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,
Expand Down Expand Up @@ -502,17 +520,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);
}
}
}
Expand Down
17 changes: 14 additions & 3 deletions app/src/ai/agent_management/notifications/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 10 additions & 16 deletions app/src/ai/agent_management/notifications/item_rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,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;
Expand Down Expand Up @@ -399,14 +397,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,
Expand All @@ -415,18 +407,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(),
)
Expand Down
7 changes: 5 additions & 2 deletions app/src/ai/agent_management/notifications/item_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
26 changes: 6 additions & 20 deletions app/src/ai/ambient_agents/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,31 +83,17 @@ impl HarnessConfig {
}
}

/// Parses a harness type name (e.g. `"claude"`) into a [`Harness`] variant.
/// Unknown values fall back to [`Harness::Unknown`] so we don't
/// misrepresent a future-server harness as Oz; UI surfaces should treat
/// `Unknown` as a non-Oz, non-runnable harness.
pub(crate) fn harness_from_name(name: &str) -> Harness {
match name {
"claude" => Harness::Claude,
"opencode" => Harness::OpenCode,
"gemini" => Harness::Gemini,
"codex" => Harness::Codex,
"oz" => Harness::Oz,
other => {
log::warn!("Unknown harness config name: {other:?}; treating as Unknown");
Harness::Unknown
}
}
}

fn serialize_harness<S: Serializer>(harness: &Harness, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&harness.to_string())
serializer.serialize_str(harness.config_name())
}

fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Harness, D::Error> {
let name = String::deserialize(deserializer)?;
Ok(harness_from_name(&name))
let harness = Harness::from_config_name(&name);
if matches!(harness, Harness::Unknown) && name != Harness::Unknown.config_name() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont understand this name != Harness::Unknown.config_name()?

log::warn!("Unknown harness config name: {name:?}; treating as Unknown");
}
Ok(harness)
}

/// Authentication secrets for third-party harnesses.
Expand Down
4 changes: 2 additions & 2 deletions app/src/server/telemetry/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,8 +524,8 @@ pub enum NotificationAgentVariant {
impl From<NotificationSourceAgent> for NotificationAgentVariant {
fn from(agent: NotificationSourceAgent) -> Self {
match agent {
NotificationSourceAgent::Oz => Self::Oz,
NotificationSourceAgent::CLI(cli_agent) => Self::CLIAgent(cli_agent.into()),
NotificationSourceAgent::Oz { .. } => Self::Oz,
NotificationSourceAgent::CLI { agent, .. } => Self::CLIAgent(agent.into()),
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions app/src/terminal/cli_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use markdown_parser::parse_markdown;
use pathfinder_color::ColorU;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use warp_cli::agent::Harness;
use warp_editor::content::{buffer::Buffer, markdown::MarkdownStyle};

use warpui::{AppContext, SingletonEntity};
Expand Down Expand Up @@ -162,6 +163,19 @@ impl CLIAgent {
serde_json::from_value(name.into()).unwrap_or(CLIAgent::Unknown)
}

/// Returns the [`CLIAgent`] corresponding to a cloud-agent [`Harness`] when it represents a
/// third-party agent. Returns `None` for [`Harness::Oz`] (Warp's built-in harness has no
/// distinct CLI agent identity).
pub fn from_harness(harness: Harness) -> Option<Self> {
match harness {
Harness::Oz => None,
Harness::Claude => Some(CLIAgent::Claude),
Harness::Gemini => Some(CLIAgent::Gemini),
Harness::OpenCode => Some(CLIAgent::OpenCode),
Harness::Unknown => Some(CLIAgent::Unknown),
}
Comment on lines +171 to +176
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] This match omits Harness::Codex, so the new function is non-exhaustive and the crate will not compile.

Suggested change
Harness::Oz => None,
Harness::Claude => Some(CLIAgent::Claude),
Harness::Gemini => Some(CLIAgent::Gemini),
Harness::OpenCode => Some(CLIAgent::OpenCode),
Harness::Unknown => Some(CLIAgent::Unknown),
}
Harness::Oz => None,
Harness::Claude => Some(CLIAgent::Claude),
Harness::Gemini => Some(CLIAgent::Gemini),
Harness::OpenCode => Some(CLIAgent::OpenCode),
Harness::Codex => Some(CLIAgent::Codex),
Harness::Unknown => Some(CLIAgent::Unknown),

}

pub fn display_name(&self) -> &'static str {
match self {
CLIAgent::Claude => "Claude Code",
Expand Down
2 changes: 0 additions & 2 deletions app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2299,8 +2299,6 @@ struct TerminalViewMouseStates {
show_in_file_explorer_tooltip: MouseStateHandle,
jump_to_bottom_of_block_button: MouseStateHandle,

// Mouse state for the pane header ambient agent indicator tooltip.
ambient_agent_indicator_mouse_handle: MouseStateHandle,
parent_conversation_header_link: MouseStateHandle,
}

Expand Down
Loading
Loading