Skip to content
Draft
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 @@ -483,14 +483,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 @@ -266,15 +266,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
55 changes: 54 additions & 1 deletion app/src/ai/harness_availability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub enum AuthSecretFetchState {
#[derive(Debug, Clone)]
pub struct AuthSecretEntry {
pub name: String,
pub owner: SecretOwner,
}

pub enum HarnessAvailabilityEvent {
Expand All @@ -80,6 +81,15 @@ pub enum HarnessAvailabilityEvent {
AuthSecretCreationFailed {
error: String,
},
AuthSecretDeleted {
harness: Harness,
name: String,
},
AuthSecretDeletionFailed {
harness: Harness,
name: String,
error: String,
},
}

pub struct HarnessAvailabilityModel {
Expand Down Expand Up @@ -209,7 +219,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 @@ -261,6 +274,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 @@ -284,6 +298,36 @@ 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, 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)
{
entries.retain(|entry| entry.name != name);
}
ctx.emit(HarnessAvailabilityEvent::AuthSecretDeleted { harness, name });
}
Err(e) => {
let msg = e.to_string();
report_error!(e.context("Failed to delete harness auth secret"));
ctx.emit(HarnessAvailabilityEvent::AuthSecretDeletionFailed {
harness,
name,
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 @@ -333,6 +377,15 @@ 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 harness_to_graphql_harness(harness: Harness) -> Option<warp_graphql::ai::AgentHarness> {
match harness {
Harness::Oz => Some(warp_graphql::ai::AgentHarness::Oz),
Expand Down
121 changes: 106 additions & 15 deletions app/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,20 @@ pub struct MenuItemFields<A: Action + Clone> {
tooltip_position: MenuTooltipPosition,
right_side_label: Option<RightSideLabel>,
right_side_icon: Option<(icons::Icon, 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.
right_side_icon_action: Option<A>,
/// Optional accessibility label for the right-side icon hit target.
right_side_icon_a11y_label: Option<String>,
/// When true, the right-side icon is rendered as disabled (no hover,
/// no click action). Used to lock the affordance while a pending
/// request is in flight.
right_side_icon_disabled: bool,
/// Optional mouse state for the right-side icon so its hover state is
/// tracked independently from the row.
right_side_icon_mouse_state: MouseStateHandle,
/// 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 @@ -467,6 +481,10 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip_position: MenuTooltipPosition::default(),
right_side_label: None,
right_side_icon: None,
right_side_icon_action: None,
right_side_icon_a11y_label: None,
right_side_icon_disabled: false,
right_side_icon_mouse_state: MouseStateHandle::default(),
override_hover_background_color: None,
icon_size_override: None,
clip_config: None,
Expand Down Expand Up @@ -497,6 +515,10 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip_position: MenuTooltipPosition::default(),
right_side_label: None,
right_side_icon: None,
right_side_icon_action: None,
right_side_icon_a11y_label: None,
right_side_icon_disabled: false,
right_side_icon_mouse_state: MouseStateHandle::default(),
override_hover_background_color: None,
icon_size_override: None,
clip_config: None,
Expand Down Expand Up @@ -530,6 +552,10 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip_position: MenuTooltipPosition::default(),
right_side_label: None,
right_side_icon: None,
right_side_icon_action: None,
right_side_icon_a11y_label: None,
right_side_icon_disabled: false,
right_side_icon_mouse_state: MouseStateHandle::default(),
override_hover_background_color: None,
icon_size_override: None,
clip_config: None,
Expand Down Expand Up @@ -566,6 +592,10 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip_position: MenuTooltipPosition::default(),
right_side_label: None,
right_side_icon: None,
right_side_icon_action: None,
right_side_icon_a11y_label: None,
right_side_icon_disabled: false,
right_side_icon_mouse_state: MouseStateHandle::default(),
override_hover_background_color: None,
icon_size_override: None,
clip_config: None,
Expand Down Expand Up @@ -600,6 +630,10 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip_position: MenuTooltipPosition::default(),
right_side_label: None,
right_side_icon: None,
right_side_icon_action: None,
right_side_icon_a11y_label: None,
right_side_icon_disabled: false,
right_side_icon_mouse_state: MouseStateHandle::default(),
override_hover_background_color: None,
icon_size_override: None,
clip_config: None,
Expand Down Expand Up @@ -633,6 +667,10 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip_position: MenuTooltipPosition::default(),
right_side_label: None,
right_side_icon: None,
right_side_icon_action: None,
right_side_icon_a11y_label: None,
right_side_icon_disabled: false,
right_side_icon_mouse_state: MouseStateHandle::default(),
override_hover_background_color: None,
icon_size_override: None,
clip_config: None,
Expand Down Expand Up @@ -663,6 +701,10 @@ impl<A: Action + Clone> MenuItemFields<A> {
tooltip_position: MenuTooltipPosition::default(),
right_side_label: None,
right_side_icon: None,
right_side_icon_action: None,
right_side_icon_a11y_label: None,
right_side_icon_disabled: false,
right_side_icon_mouse_state: MouseStateHandle::default(),
override_hover_background_color: None,
icon_size_override: None,
clip_config: None,
Expand Down Expand Up @@ -709,6 +751,14 @@ impl<A: Action + Clone> MenuItemFields<A> {
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_action: None,
right_side_icon_a11y_label: self.right_side_icon_a11y_label,
right_side_icon_disabled: self.right_side_icon_disabled,
right_side_icon_mouse_state: self.right_side_icon_mouse_state,
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 @@ -848,6 +898,25 @@ impl<A: Action + Clone> MenuItemFields<A> {
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 {
self.right_side_icon_action = Some(action);
self
}

pub fn with_right_side_icon_a11y_label(mut self, label: impl Into<String>) -> Self {
self.right_side_icon_a11y_label = Some(label.into());
self
}

pub fn with_right_side_icon_disabled(mut self, disabled: bool) -> Self {
self.right_side_icon_disabled = disabled;
self
}

pub fn into_item(self) -> MenuItem<A> {
MenuItem::Item(self)
}
Expand Down Expand Up @@ -984,26 +1053,46 @@ 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 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(),
)
.with_margin_left(icon_size / 2.)
.finish(),
)
.finish(),
)
let icon_element = ConstrainedBox::new(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();

// If a separate right-side icon action is set, wrap the icon in
// its own `Hoverable` so it can dispatch independently of the row.
// The child `Hoverable` consumes the click event, preventing the
// row's `on_select_action` from firing alongside it.
let element: Box<dyn Element> = if let Some(action) = &self.right_side_icon_action {
let mut hoverable =
Hoverable::new(self.right_side_icon_mouse_state.clone(), |_| container);
if !self.right_side_icon_disabled {
let action = action.clone();
hoverable = hoverable.with_cursor(Cursor::PointingHand);
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(|_, _, _| {});
}
hoverable.finish()
} else {
container
};

Some(Shrinkable::new(1., element).finish())
}

fn render_right_aligned_chevron(
Expand Down Expand Up @@ -1197,7 +1286,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
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,14 @@ impl AuthSecretFtuxDropdown {
|me, _, event, ctx| match event {
HarnessAvailabilityEvent::AuthSecretsLoaded
| HarnessAvailabilityEvent::AuthSecretCreated { .. }
| HarnessAvailabilityEvent::AuthSecretDeleted { .. }
| HarnessAvailabilityEvent::AuthSecretsFetchFailed => {
me.refresh_menu(ctx);
ctx.notify();
}
HarnessAvailabilityEvent::Changed
| HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } => {}
| HarnessAvailabilityEvent::AuthSecretCreationFailed { .. }
| HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {}
},
);

Expand Down
4 changes: 3 additions & 1 deletion app/src/terminal/view/ambient_agent/auth_secret_ftux_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ impl AuthSecretFtuxView {
}
HarnessAvailabilityEvent::Changed
| HarnessAvailabilityEvent::AuthSecretsLoaded
| HarnessAvailabilityEvent::AuthSecretsFetchFailed => {}
| HarnessAvailabilityEvent::AuthSecretsFetchFailed
| HarnessAvailabilityEvent::AuthSecretDeleted { .. }
| HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {}
},
);

Expand Down
Loading