Skip to content
Merged
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
8 changes: 6 additions & 2 deletions app/src/ai/blocklist/inline_action/run_agents_card_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,14 +435,18 @@ impl RunAgentsCardView {
}
HarnessAvailabilityEvent::Changed
| HarnessAvailabilityEvent::AuthSecretsLoaded
| HarnessAvailabilityEvent::AuthSecretsFetchFailed => {
| HarnessAvailabilityEvent::AuthSecretsFetchFailed
| HarnessAvailabilityEvent::AuthSecretDeleted { .. } => {
// Repopulate even on fetch failure to replace "Loading…".
// Deleted events also force a repopulate so this card
// stops surfacing the deleted secret as an option.
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
ctx.notify();
}
HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } => {}
HarnessAvailabilityEvent::AuthSecretCreationFailed { .. }
| HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {}
},
);

Expand Down
9 changes: 7 additions & 2 deletions app/src/ai/document/orchestration_config_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,15 +281,20 @@ impl OrchestrationConfigBlockView {
}
HarnessAvailabilityEvent::Changed
| HarnessAvailabilityEvent::AuthSecretsLoaded
| HarnessAvailabilityEvent::AuthSecretsFetchFailed => {
| HarnessAvailabilityEvent::AuthSecretsFetchFailed
| HarnessAvailabilityEvent::AuthSecretDeleted { .. } => {
// Repopulate even on fetch failure to replace "Loading…".
// The Deleted event also triggers a refresh so any
// already-mounted picker drops the deleted entry from
// its menu.
if me.pickers_initialized {
oc::repopulate_all_pickers(&mut me.edit_state, &me.pickers, ctx);
}
me.maybe_auto_open_create_modal(ctx);
ctx.notify();
}
HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } => {}
HarnessAvailabilityEvent::AuthSecretCreationFailed { .. }
| HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {}
},
);

Expand Down
71 changes: 70 additions & 1 deletion app/src/ai/harness_availability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub enum AuthSecretFetchState {
#[derive(Debug, Clone)]
pub struct AuthSecretEntry {
pub name: String,
pub owner: SecretOwner,
}

pub enum HarnessAvailabilityEvent {
Expand All @@ -81,6 +82,17 @@ pub enum HarnessAvailabilityEvent {
AuthSecretCreationFailed {
error: String,
},
AuthSecretDeleted {
harness: Harness,
name: String,
owner: SecretOwner,
},
AuthSecretDeletionFailed {
harness: Harness,
name: String,
owner: SecretOwner,
error: String,
},
}

pub struct HarnessAvailabilityModel {
Expand Down Expand Up @@ -210,7 +222,10 @@ impl HarnessAvailabilityModel {
RequestState::RequestSucceeded(secrets) => {
let entries = secrets
.into_iter()
.map(|s| AuthSecretEntry { name: s.name })
.map(|s| AuthSecretEntry {
owner: secret_owner_from_space(&s.owner),
name: s.name,
})
.collect();
me.auth_secrets
.insert(harness, AuthSecretFetchState::Loaded(entries));
Expand Down Expand Up @@ -262,6 +277,7 @@ impl HarnessAvailabilityModel {
Ok(secret) => {
let entry = AuthSecretEntry {
name: secret.name.clone(),
owner: secret_owner_from_space(&secret.owner),
};
match me.auth_secrets.get_mut(&harness) {
Some(AuthSecretFetchState::Loaded(entries)) => {
Expand All @@ -285,6 +301,43 @@ impl HarnessAvailabilityModel {
});
}

pub fn delete_auth_secret(
&mut self,
harness: Harness,
name: String,
owner: SecretOwner,
ctx: &mut ModelContext<Self>,
) {
let manager = ManagedSecretManager::handle(ctx);
let delete_future = manager
.as_ref(ctx)
.delete_secret(owner.clone(), name.clone());
ctx.spawn(delete_future, move |me, result, ctx| match result {
Ok(()) => {
if let Some(AuthSecretFetchState::Loaded(entries)) =
me.auth_secrets.get_mut(&harness)
{
remove_deleted_auth_secret_entry(entries, &name, &owner);
}
ctx.emit(HarnessAvailabilityEvent::AuthSecretDeleted {
harness,
name,
owner,
});
}
Err(e) => {
let msg = e.to_string();
report_error!(e.context("Failed to delete harness auth secret"));
ctx.emit(HarnessAvailabilityEvent::AuthSecretDeletionFailed {
harness,
name,
owner,
error: msg,
});
}
});
}

pub fn refresh(&self, ctx: &mut ModelContext<Self>) {
// The endpoint queries `user`, which requires auth.
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
Expand Down Expand Up @@ -334,6 +387,22 @@ fn get_cached(ctx: &ModelContext<HarnessAvailabilityModel>) -> Option<Vec<Harnes
serde_json::from_str::<Vec<HarnessAvailability>>(&raw).ok()
}

fn secret_owner_from_space(space: &warp_graphql::object::Space) -> SecretOwner {
match space.type_ {
warp_graphql::object::SpaceType::Team => SecretOwner::Team {
team_uid: space.uid.clone().into_inner(),
},
warp_graphql::object::SpaceType::User => SecretOwner::CurrentUser,
}
}

fn remove_deleted_auth_secret_entry(
entries: &mut Vec<AuthSecretEntry>,
name: &str,
owner: &SecretOwner,
) {
entries.retain(|entry| entry.name.as_str() != name || &entry.owner != owner);
}
fn harness_to_graphql_harness(harness: Harness) -> Option<warp_graphql::ai::AgentHarness> {
match harness {
Harness::Oz => Some(warp_graphql::ai::AgentHarness::Oz),
Expand Down
157 changes: 132 additions & 25 deletions app/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ use std::cell::OnceCell;
use std::sync::Arc;
use std::{fmt, vec};

use crate::safe_triangle::SafeTriangle;
use crate::themes::theme::Fill;
use crate::util::time_format::format_approx_duration_from_now_sentence_case;
use crate::{
appearance::Appearance,
ui_components::{buttons::icon_button_with_color, icons},
};
use chrono::{DateTime, Local};
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
Expand All @@ -27,12 +34,6 @@ use warpui::{
Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, WindowId,
};

use crate::appearance::Appearance;
use crate::safe_triangle::SafeTriangle;
use crate::themes::theme::Fill;
use crate::ui_components::icons;
use crate::util::time_format::format_approx_duration_from_now_sentence_case;

pub const CHEVRON_RIGHT_ALIGN_SVG_PATH: &str = "bundled/svg/chevron-right-align.svg";

const SUBMENU_OVERLAP: f32 = 8.;
Expand Down Expand Up @@ -391,6 +392,47 @@ struct RightSideLabel {
text: String,
font_properties: Properties,
}
#[derive(Clone)]
struct RightSideIconConfig<A> {
icon: icons::Icon,
override_color: Option<Fill>,
/// Optional action dispatched when the right-side icon is clicked. When
/// set, the right-side icon becomes its own hit target: clicking it
/// dispatches this action without firing the row's own `on_select_action`,
/// and prevents the row click from propagating.
action: Option<A>,
/// Optional accessibility label for the right-side icon hit target.
a11y_label: Option<String>,
/// When true, the right-side icon is rendered as disabled with no hover or
/// click action.
disabled: bool,
/// Tracks hover state independently from the row.
mouse_state: MouseStateHandle,
}

impl<A> RightSideIconConfig<A> {
fn new(icon: icons::Icon) -> Self {
Self {
icon,
override_color: None,
action: None,
a11y_label: None,
disabled: false,
mouse_state: MouseStateHandle::default(),
}
}

fn without_action<B>(self) -> RightSideIconConfig<B> {
RightSideIconConfig {
icon: self.icon,
override_color: self.override_color,
action: None,
a11y_label: self.a11y_label,
disabled: self.disabled,
mouse_state: self.mouse_state,
}
}
}

#[derive(Clone, Default)]
pub struct MenuItemFields<A: Action + Clone> {
Expand All @@ -417,7 +459,7 @@ pub struct MenuItemFields<A: Action + Clone> {
tooltip: Option<String>,
tooltip_position: MenuTooltipPosition,
right_side_label: Option<RightSideLabel>,
right_side_icon: Option<(icons::Icon, Option<Fill>)>,
right_side_icon: Option<RightSideIconConfig<A>>,
/// Optional override for the background color
/// hovered or selected. When `None`, the default hover/selected background
/// from the theme is used (accent or dark overlay, depending on
Expand Down Expand Up @@ -710,7 +752,13 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip: self.tooltip,
tooltip_position: self.tooltip_position,
right_side_label: self.right_side_label,
right_side_icon: self.right_side_icon,
// The right-side icon action is `Option<A>`; we can't safely map
// it to `Option<B>` here, so drop it. Callers that need the
// right-side action must set it via `with_right_side_icon_action`
// after conversion.
right_side_icon: self
.right_side_icon
.map(RightSideIconConfig::without_action),
override_hover_background_color: self.override_hover_background_color,
icon_size_override: self.icon_size_override,
clip_config: self.clip_config,
Expand Down Expand Up @@ -846,7 +894,32 @@ impl<A: Action + Clone> MenuItemFields<A> {
}

pub fn with_right_side_icon(mut self, icon: icons::Icon) -> Self {
self.right_side_icon = Some((icon, None));
self.right_side_icon = Some(RightSideIconConfig::new(icon));
self
}

/// Sets a separate action that fires when the right-side icon is
/// clicked. The action is independent from the row's
/// `on_select_action`: clicking the icon dispatches this action and
/// the row click is suppressed.
pub fn with_right_side_icon_action(mut self, action: A) -> Self {
if let Some(config) = &mut self.right_side_icon {
config.action = Some(action);
}
self
}

pub fn with_right_side_icon_a11y_label(mut self, label: impl Into<String>) -> Self {
if let Some(config) = &mut self.right_side_icon {
config.a11y_label = Some(label.into());
}
self
}

pub fn with_right_side_icon_disabled(mut self, disabled: bool) -> Self {
if let Some(config) = &mut self.right_side_icon {
config.disabled = disabled;
}
self
}

Expand Down Expand Up @@ -986,26 +1059,57 @@ impl<A: Action + Clone> MenuItemFields<A> {
&self,
appearance: &Appearance,
color: Fill,
dispatch_item_actions: bool,
) -> Option<Box<dyn Element>> {
let (icon, override_color) = self.right_side_icon.as_ref()?;
let config = self.right_side_icon.as_ref()?;
let icon_size = self
.icon_size_override
.unwrap_or_else(|| appearance.ui_font_size());
let icon_color = override_color.unwrap_or(color);
Some(
Shrinkable::new(
1.,
Container::new(
ConstrainedBox::new(icon.to_warpui_icon(icon_color).finish())
.with_width(icon_size)
.with_height(icon_size)
.finish(),
)
let icon_color = config.override_color.unwrap_or(color);
if let Some(action) = &config.action {
let mut button = icon_button_with_color(
appearance,
config.icon,
false,
config.mouse_state.clone(),
icon_color,
);
if config.disabled {
button = button.disabled();
}
let mut hoverable = button.build();
if !config.disabled {
let action = action.clone();
hoverable = hoverable.on_click(move |ctx, _, _| {
if dispatch_item_actions {
ctx.dispatch_typed_action(action.clone());
}
});
// Swallow mouse-down too so the row's click handler
// doesn't latch onto the press that targets the icon.
hoverable = hoverable.on_mouse_down(|_, _, _| {});
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.

⚠️ [IMPORTANT] This does not stop the parent row Hoverable from also processing the click: parent hoverables only skip child-handled events when with_defer_events_to_children() is set, and the disabled icon path installs no handler at all. Clicking the X can still select/close the row instead of only opening delete confirmation; make the row defer to the icon hit target and consume disabled-icon clicks too.

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.

This also seems legit - if I click the "x" and then click cancel, the key I "x"ed is now selected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ack

}
let button_element = if config.disabled {
EventHandler::new(hoverable.finish())
.on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation)
.on_left_mouse_up(|_, _, _| DispatchEventResult::StopPropagation)
.finish()
} else {
hoverable.finish()
};
let element = Container::new(button_element)
.with_margin_left(icon_size / 2.)
.finish(),
)
.finish(),
)
.finish();
return Some(Shrinkable::new(1., Align::new(element).right().finish()).finish());
}
let icon_element = ConstrainedBox::new(config.icon.to_warpui_icon(icon_color).finish())
.with_width(icon_size)
.with_height(icon_size)
.finish();
let container = Container::new(icon_element)
.with_margin_left(icon_size / 2.)
.finish();
Some(Shrinkable::new(1., Align::new(container).right().finish()).finish())
}

fn render_right_aligned_chevron(
Expand Down Expand Up @@ -1199,7 +1303,9 @@ impl<A: Action + Clone> MenuItemFields<A> {
));
}

if let Some(right_icon) = self.render_right_side_icon(appearance, primary_color) {
if let Some(right_icon) =
self.render_right_side_icon(appearance, primary_color, dispatch_item_actions)
{
label_row.add_child(right_icon);
}
}
Expand Down Expand Up @@ -1298,6 +1404,7 @@ impl<A: Action + Clone> MenuItemFields<A> {
});
}
});
ret = ret.with_defer_events_to_children();

let on_select_action = self.on_select_action.clone();

Expand Down
Loading