Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
use std::collections::HashMap;
|
||||
use warpui::{Entity, EntityId, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
use crate::{
|
||||
ai::execution_profiles::profiles::ClientProfileId,
|
||||
pane_group::{ExecutionProfileEditorPane, PaneContent},
|
||||
PaneViewLocator,
|
||||
};
|
||||
|
||||
/// Manages execution profile editor panes across different windows and profiles.
|
||||
///
|
||||
/// This manager tracks which execution profile editor panes are active in each window,
|
||||
/// allowing the application to locate and interact with these panes when needed.
|
||||
/// It maintains a mapping from each window ID to a map from profile IDs to pane data,
|
||||
/// including the locator information needed to find and reference specific panes within their pane groups.
|
||||
#[derive(Default)]
|
||||
pub struct ExecutionProfileEditorManager {
|
||||
panes: HashMap<WindowId, HashMap<ClientProfileId, ExecutionProfileEditorPaneData>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ExecutionProfileEditorPaneData {
|
||||
locator: PaneViewLocator,
|
||||
}
|
||||
|
||||
impl ExecutionProfileEditorManager {
|
||||
pub fn find_pane(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
profile_id: ClientProfileId,
|
||||
) -> Option<PaneViewLocator> {
|
||||
self.panes
|
||||
.get(&window_id)
|
||||
.and_then(|m| m.get(&profile_id))
|
||||
.map(|d| d.locator)
|
||||
}
|
||||
|
||||
pub fn register_pane(
|
||||
&mut self,
|
||||
pane: &ExecutionProfileEditorPane,
|
||||
pane_group_id: EntityId,
|
||||
window_id: WindowId,
|
||||
profile_id: ClientProfileId,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let locator = PaneViewLocator {
|
||||
pane_group_id,
|
||||
pane_id: pane.id(),
|
||||
};
|
||||
self.panes
|
||||
.entry(window_id)
|
||||
.or_default()
|
||||
.insert(profile_id, ExecutionProfileEditorPaneData { locator });
|
||||
}
|
||||
|
||||
pub fn deregister_pane(&mut self, window_id: &WindowId, profile_id: &ClientProfileId) {
|
||||
if let Some(map) = self.panes.get_mut(window_id) {
|
||||
map.remove(profile_id);
|
||||
if map.is_empty() {
|
||||
self.panes.remove(window_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ExecutionProfileEditorManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for ExecutionProfileEditorManager {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
use crate::ai::execution_profiles::{AIExecutionProfile, ActionPermission};
|
||||
use crate::editor::EditorView;
|
||||
use crate::settings::AISettings;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::FilterableDropdown;
|
||||
use crate::view_components::{Dropdown, SubmittableTextInput};
|
||||
use crate::Appearance;
|
||||
use crate::TemplatableMCPServerManager;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use uuid::Uuid;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::elements::Hoverable;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment,
|
||||
MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Shrinkable,
|
||||
Stack, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::AppContext;
|
||||
use warpui::{Element, SingletonEntity, ViewHandle};
|
||||
|
||||
use super::ExecutionProfileEditorView;
|
||||
use super::ExecutionProfileEditorViewAction;
|
||||
|
||||
use crate::settings_view::{render_input_list, render_separator, InputListItem};
|
||||
|
||||
pub const WORKSPACE_OVERRIDE_TOOLTIP_MESSAGE: &str =
|
||||
"This option is enforced by your organization's settings and cannot be customized.";
|
||||
pub fn render_header_section(
|
||||
appearance: &Appearance,
|
||||
profile_name_editor: &ViewHandle<EditorView>,
|
||||
is_default_profile: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let mut column = Flex::column()
|
||||
.with_child(render_header_title(appearance))
|
||||
.with_child(render_header_name_label(appearance))
|
||||
.with_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.text_input(profile_name_editor.clone())
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(8.)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if is_default_profile {
|
||||
column.add_child(render_info_section(
|
||||
"Default profile name cannot be changed.",
|
||||
None,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_margin_bottom(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_header_title(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new_inline("Edit Profile", appearance.ui_font_family(), 16.)
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_header_name_label(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Text::new("Name", appearance.ui_font_family(), 13.)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_section_label(label: &str, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Text::new(label.to_string(), appearance.ui_font_family(), 12.)
|
||||
.with_color(appearance.theme().disabled_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(12.)
|
||||
.with_margin_bottom(20.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_filterable_dropdown_row<T: Clone + 'static + std::fmt::Debug + Send + Sync>(
|
||||
appearance: &Appearance,
|
||||
label: &str,
|
||||
desc: &str,
|
||||
dropdown: &ViewHandle<FilterableDropdown<T>>,
|
||||
) -> Box<dyn Element> {
|
||||
let label_elem = Text::new(label.to_string(), appearance.ui_font_family(), 13.)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish();
|
||||
let desc_elem = Text::new(desc.to_string(), appearance.ui_font_family(), 11.)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let label_desc_column = Flex::column()
|
||||
.with_child(label_elem)
|
||||
.with_child(desc_elem)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(label_desc_column)
|
||||
.with_margin_bottom(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Container::new(ChildView::new(dropdown).finish()).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_info_section(
|
||||
text: &str,
|
||||
_subtext: Option<&str>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let description_color = appearance.theme().disabled_ui_text_color();
|
||||
let alert_icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::AlertCircle
|
||||
.to_warpui_icon(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2()),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(4.)
|
||||
.finish();
|
||||
let text = Text::new(
|
||||
text.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(description_color.into())
|
||||
.finish();
|
||||
let description = Flex::row()
|
||||
.with_children([alert_icon, Shrinkable::new(1.0, text).finish()])
|
||||
.finish();
|
||||
Container::new(description).with_margin_bottom(12.).finish()
|
||||
}
|
||||
|
||||
fn render_permission_row<T: Clone + 'static + std::fmt::Debug + Send + Sync>(
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
dropdown: &ViewHandle<Dropdown<T>>,
|
||||
info_text: &str,
|
||||
show_workspace_override_tooltip: bool,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_elem = Container::new(
|
||||
ConstrainedBox::new(
|
||||
icon.to_warpui_icon(appearance.theme().active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
let label_elem = Text::new(label.to_string(), appearance.ui_font_family(), 13.)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish();
|
||||
let icon_label_row = Flex::row()
|
||||
.with_child(icon_elem)
|
||||
.with_child(label_elem)
|
||||
.finish();
|
||||
let dropdown_element = ChildView::new(dropdown).finish();
|
||||
let dropdown_row = if show_workspace_override_tooltip {
|
||||
wrap_disabled_with_workspace_override_tooltip(
|
||||
dropdown_element,
|
||||
tooltip_mouse_state,
|
||||
appearance,
|
||||
)
|
||||
} else {
|
||||
dropdown_element
|
||||
};
|
||||
let info_section = Container::new(render_info_section(info_text, None, appearance))
|
||||
.with_margin_bottom(12.)
|
||||
.finish();
|
||||
Flex::column()
|
||||
.with_child(icon_label_row)
|
||||
.with_child(dropdown_row)
|
||||
.with_child(info_section)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_models_section(
|
||||
appearance: &Appearance,
|
||||
view: &ExecutionProfileEditorView,
|
||||
) -> Box<dyn Element> {
|
||||
let mut column = Flex::column()
|
||||
.with_child(render_separator(appearance))
|
||||
.with_child(render_section_label("MODELS", appearance))
|
||||
.with_child(render_filterable_dropdown_row(
|
||||
appearance,
|
||||
"Base model",
|
||||
"This model serves as the primary engine behind the agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Warp may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization.",
|
||||
&view.base_model_dropdown,
|
||||
))
|
||||
.with_child(render_filterable_dropdown_row(
|
||||
appearance,
|
||||
"Full terminal use model",
|
||||
"The model used when the agent operates inside interactive terminal applications like database shells, debuggers, REPLs, or dev servers—reading live output and writing commands to the PTY.",
|
||||
&view.full_terminal_use_model_dropdown,
|
||||
));
|
||||
|
||||
if FeatureFlag::LocalComputerUse.is_enabled() {
|
||||
column.add_child(render_filterable_dropdown_row(
|
||||
appearance,
|
||||
"Computer use model",
|
||||
"The model used when the agent takes control of your computer to interact with graphical applications through mouse movements, clicks, and keyboard input.",
|
||||
&view.computer_use_model_dropdown,
|
||||
));
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_margin_bottom(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_permissions_section(
|
||||
appearance: &Appearance,
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
app: &warpui::AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let mut column = Flex::column().with_children([
|
||||
render_separator(appearance),
|
||||
render_section_label("PERMISSIONS", appearance),
|
||||
render_permission_row(
|
||||
appearance,
|
||||
Icon::Code2,
|
||||
"Apply code diffs",
|
||||
&view.apply_code_diffs_dropdown,
|
||||
profile_data.apply_code_diffs.description(),
|
||||
!ai_settings.is_code_diffs_permissions_editable(app),
|
||||
view.tooltip_mouse_state_handles
|
||||
.apply_code_diffs_tooltip_mouse_state
|
||||
.clone(),
|
||||
),
|
||||
render_permission_row(
|
||||
appearance,
|
||||
Icon::Notebook,
|
||||
"Read files",
|
||||
&view.read_files_dropdown,
|
||||
profile_data.read_files.description(),
|
||||
!ai_settings.is_read_files_permissions_editable(app),
|
||||
view.tooltip_mouse_state_handles
|
||||
.read_files_tooltip_mouse_state
|
||||
.clone(),
|
||||
),
|
||||
]);
|
||||
|
||||
if profile_data.read_files == ActionPermission::AlwaysAsk
|
||||
|| profile_data.read_files == ActionPermission::AgentDecides
|
||||
{
|
||||
column.add_child(render_directory_allowlist_section(
|
||||
view,
|
||||
profile_data,
|
||||
appearance,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
column.add_child(render_permission_row(
|
||||
appearance,
|
||||
Icon::Terminal,
|
||||
"Execute commands",
|
||||
&view.execute_commands_dropdown,
|
||||
profile_data.execute_commands.description(),
|
||||
!ai_settings.is_execute_commands_permissions_editable(app),
|
||||
view.tooltip_mouse_state_handles
|
||||
.execute_commands_tooltip_mouse_state
|
||||
.clone(),
|
||||
));
|
||||
|
||||
match profile_data.execute_commands {
|
||||
ActionPermission::AlwaysAllow => {
|
||||
column.add_child(render_command_denylist_section(
|
||||
view,
|
||||
profile_data,
|
||||
appearance,
|
||||
app,
|
||||
));
|
||||
}
|
||||
ActionPermission::AlwaysAsk => {
|
||||
column.add_child(render_command_allowlist_section(
|
||||
view,
|
||||
profile_data,
|
||||
appearance,
|
||||
app,
|
||||
));
|
||||
}
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => {
|
||||
column.add_children([
|
||||
render_command_allowlist_section(view, profile_data, appearance, app),
|
||||
render_command_denylist_section(view, profile_data, appearance, app),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_child(render_permission_row(
|
||||
appearance,
|
||||
Icon::Workflow,
|
||||
"Interact with running commands",
|
||||
&view.write_to_pty_dropdown,
|
||||
profile_data.write_to_pty.description(),
|
||||
!ai_settings.is_write_to_pty_permissions_editable(app),
|
||||
view.tooltip_mouse_state_handles
|
||||
.write_to_pty_tooltip_mouse_state
|
||||
.clone(),
|
||||
));
|
||||
|
||||
if FeatureFlag::LocalComputerUse.is_enabled() {
|
||||
column.add_child(render_permission_row(
|
||||
appearance,
|
||||
Icon::Laptop,
|
||||
"Computer use",
|
||||
&view.computer_use_dropdown,
|
||||
profile_data.computer_use.description(),
|
||||
!ai_settings.is_computer_use_permissions_editable(app),
|
||||
view.tooltip_mouse_state_handles
|
||||
.computer_use_tooltip_mouse_state
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
column.add_child(render_permission_row(
|
||||
appearance,
|
||||
Icon::MessageText,
|
||||
"Ask questions",
|
||||
&view.ask_user_question_dropdown,
|
||||
profile_data.ask_user_question.description(),
|
||||
!ai_settings.is_ask_user_question_permissions_editable(app),
|
||||
view.tooltip_mouse_state_handles
|
||||
.ask_user_question_tooltip_mouse_state
|
||||
.clone(),
|
||||
));
|
||||
|
||||
column.add_child(render_permission_row(
|
||||
appearance,
|
||||
Icon::Dataflow,
|
||||
"Call MCP servers",
|
||||
&view.call_mcp_servers_dropdown,
|
||||
profile_data.mcp_permissions.description(),
|
||||
!ai_settings.is_mcp_permission_editable(app), // Use MCP override for this permission
|
||||
view.tooltip_mouse_state_handles
|
||||
.call_mcp_servers_tooltip_mouse_state
|
||||
.clone(),
|
||||
));
|
||||
|
||||
match profile_data.mcp_permissions {
|
||||
ActionPermission::AlwaysAllow => {
|
||||
column.add_child(render_mcp_denylist_section(
|
||||
view,
|
||||
profile_data,
|
||||
app,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
ActionPermission::AlwaysAsk => {
|
||||
column.add_child(render_mcp_allowlist_section(
|
||||
view,
|
||||
profile_data,
|
||||
app,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => {
|
||||
column.add_children([
|
||||
render_mcp_allowlist_section(view, profile_data, app, appearance),
|
||||
render_mcp_denylist_section(view, profile_data, app, appearance),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if FeatureFlag::WebSearchUI.is_enabled() {
|
||||
column.add_child(
|
||||
Container::new(render_web_search_toggle(appearance, view, profile_data))
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
column.add_child(
|
||||
Container::new(render_plan_auto_sync_toggle(appearance, view, profile_data))
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_margin_bottom(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn create_section_header(
|
||||
label: &str,
|
||||
description: &str,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let label_elem = Text::new(label.to_string(), appearance.ui_font_family(), 13.)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let desc_elem = Text::new(description.to_string(), appearance.ui_font_family(), 11.)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(label_elem)
|
||||
.with_child(desc_elem)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_list_section<T, F, D>(
|
||||
label: &str,
|
||||
description: &str,
|
||||
items: &[T],
|
||||
mouse_handles: &[MouseStateHandle],
|
||||
editor: Option<&ViewHandle<SubmittableTextInput>>,
|
||||
dropdown: Option<&ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>>,
|
||||
on_remove_action: F,
|
||||
display_fn: D,
|
||||
appearance: &Appearance,
|
||||
is_editable: bool,
|
||||
tooltip_mouse_state: MouseStateHandle,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
T: Clone,
|
||||
F: Fn(T) -> ExecutionProfileEditorViewAction,
|
||||
D: Fn(&T) -> String,
|
||||
{
|
||||
let input_items: Vec<InputListItem<ExecutionProfileEditorViewAction>> = items
|
||||
.iter()
|
||||
.cloned()
|
||||
.zip(mouse_handles.iter().cloned())
|
||||
.rev()
|
||||
.map(|(item, mouse_state_handle)| InputListItem {
|
||||
item: display_fn(&item),
|
||||
mouse_state_handle,
|
||||
on_remove_action: on_remove_action(item),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = render_input_list(None, input_items, editor, !is_editable, appearance);
|
||||
let list_element = if !is_editable {
|
||||
wrap_disabled_with_workspace_override_tooltip(list, tooltip_mouse_state, appearance)
|
||||
} else {
|
||||
list
|
||||
};
|
||||
|
||||
let mut column =
|
||||
Flex::column().with_child(create_section_header(label, description, appearance));
|
||||
|
||||
// Add dropdown if provided (for MCP lists)
|
||||
if let Some(dropdown) = dropdown {
|
||||
let dropdown_row = Container::new(ChildView::new(dropdown).finish()).finish();
|
||||
column = column.with_child(dropdown_row);
|
||||
}
|
||||
|
||||
column = column.with_child(list_element);
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_margin_bottom(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_directory_allowlist_section(
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
appearance: &Appearance,
|
||||
app: &warpui::AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_editable = ai_settings.is_directory_allowlist_editable(app);
|
||||
|
||||
render_list_section(
|
||||
"Directory allowlist",
|
||||
"Give the agent file access to certain directories.",
|
||||
&profile_data.directory_allowlist,
|
||||
&view.directory_allowlist_mouse_state_handles,
|
||||
Some(&view.directory_allowlist_editor),
|
||||
None,
|
||||
|path| ExecutionProfileEditorViewAction::RemoveFromDirectoryAllowlist { path },
|
||||
|path| path.display().to_string(),
|
||||
appearance,
|
||||
is_editable,
|
||||
view.tooltip_mouse_state_handles
|
||||
.directory_allowlist_editor_tooltip_mouse_state
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
fn render_command_allowlist_section(
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
appearance: &Appearance,
|
||||
app: &warpui::AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_editable = ai_settings.is_command_allowlist_editable(app);
|
||||
|
||||
render_list_section(
|
||||
"Command allowlist",
|
||||
"Regular expressions to match commands that can be automatically executed by Oz.",
|
||||
&profile_data.command_allowlist,
|
||||
&view.command_allowlist_mouse_state_handles,
|
||||
Some(&view.command_allowlist_editor),
|
||||
None,
|
||||
|predicate| ExecutionProfileEditorViewAction::RemoveFromCommandAllowlist { predicate },
|
||||
|item| item.to_string(),
|
||||
appearance,
|
||||
is_editable,
|
||||
view.tooltip_mouse_state_handles
|
||||
.command_allowlist_editor_tooltip_mouse_state
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_command_denylist_section(
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
appearance: &Appearance,
|
||||
app: &warpui::AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_editable = ai_settings.is_command_denylist_editable(app);
|
||||
|
||||
render_list_section(
|
||||
"Command denylist",
|
||||
"Regular expressions to match commands that Oz should always ask permission to execute.",
|
||||
&profile_data.command_denylist,
|
||||
&view.command_denylist_mouse_state_handles,
|
||||
Some(&view.command_denylist_editor),
|
||||
None,
|
||||
|predicate| ExecutionProfileEditorViewAction::RemoveFromCommandDenylist { predicate },
|
||||
|item| item.to_string(),
|
||||
appearance,
|
||||
is_editable,
|
||||
view.tooltip_mouse_state_handles
|
||||
.command_denylist_editor_tooltip_mouse_state
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn display_mcp_name(uuid: &Uuid, app: &AppContext) -> String {
|
||||
TemplatableMCPServerManager::get_mcp_name(uuid, app).unwrap_or({
|
||||
log::warn!("Expected a name for MCP server {uuid} but could not find one.");
|
||||
format!("MCP Server {uuid}")
|
||||
})
|
||||
}
|
||||
|
||||
fn render_mcp_allowlist_section(
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
app: &warpui::AppContext,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_editable = ai_settings.is_mcp_permission_editable(app);
|
||||
|
||||
render_list_section(
|
||||
"MCP allowlist",
|
||||
"MCP servers that are allowed to be called by Oz.",
|
||||
&profile_data.mcp_allowlist,
|
||||
&view.mcp_allowlist_mouse_state_handles,
|
||||
None,
|
||||
Some(&view.mcp_allowlist_dropdown),
|
||||
|id| ExecutionProfileEditorViewAction::RemoveFromMCPAllowlist { id },
|
||||
|uuid| display_mcp_name(uuid, app),
|
||||
appearance,
|
||||
is_editable,
|
||||
view.tooltip_mouse_state_handles
|
||||
.mcp_allowlist_editor_tooltip_mouse_state
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_mcp_denylist_section(
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
app: &warpui::AppContext,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_editable = ai_settings.is_mcp_permission_editable(app);
|
||||
|
||||
render_list_section(
|
||||
"MCP denylist",
|
||||
"MCP servers that are not allowed to be called by Oz.",
|
||||
&profile_data.mcp_denylist,
|
||||
&view.mcp_denylist_mouse_state_handles,
|
||||
None,
|
||||
Some(&view.mcp_denylist_dropdown),
|
||||
|id| ExecutionProfileEditorViewAction::RemoveFromMCPDenylist { id },
|
||||
|uuid| display_mcp_name(uuid, app),
|
||||
appearance,
|
||||
is_editable,
|
||||
view.tooltip_mouse_state_handles
|
||||
.mcp_denylist_editor_tooltip_mouse_state
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
pub fn render_plan_auto_sync_toggle(
|
||||
appearance: &Appearance,
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = 16.0;
|
||||
let icon_elem = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::Compass
|
||||
.to_warpui_icon(appearance.theme().active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
|
||||
let label_elem = Text::new(
|
||||
"Plan auto-sync".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
13.,
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let desc_elem = Text::new(
|
||||
"The plans this agent creates will be automatically added and synced to Warp Drive."
|
||||
.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
11.,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let current_value = profile_data.autosync_plans_to_warp_drive;
|
||||
let switch = appearance
|
||||
.ui_builder()
|
||||
.switch(view.plan_auto_sync_switch.clone())
|
||||
.check(current_value)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ExecutionProfileEditorViewAction::SetPlanAutoSync {
|
||||
enabled: !current_value,
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
let left_content = Flex::column()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(icon_elem)
|
||||
.with_child(label_elem)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(desc_elem)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(Shrinkable::new(1., left_content).finish())
|
||||
.with_child(switch)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_web_search_toggle(
|
||||
appearance: &Appearance,
|
||||
view: &ExecutionProfileEditorView,
|
||||
profile_data: &AIExecutionProfile,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = 16.0;
|
||||
let icon_elem = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::Globe
|
||||
.to_warpui_icon(appearance.theme().active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
|
||||
let label_elem = Text::new(
|
||||
"Call web tools".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
13.,
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let desc_elem = Text::new(
|
||||
"The agent may use web search when helpful for completing tasks.".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
11.,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_1())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let current_value = profile_data.web_search_enabled;
|
||||
let switch = appearance
|
||||
.ui_builder()
|
||||
.switch(view.web_search_switch.clone())
|
||||
.check(current_value)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ExecutionProfileEditorViewAction::SetWebSearchEnabled {
|
||||
enabled: !current_value,
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
let left_content = Flex::column()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(icon_elem)
|
||||
.with_child(label_elem)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(desc_elem)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(Shrinkable::new(1., left_content).finish())
|
||||
.with_child(switch)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn wrap_disabled_with_workspace_override_tooltip(
|
||||
child: Box<dyn Element>,
|
||||
mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Wrap the disabled element in a hoverable container that can show tooltips
|
||||
Hoverable::new(mouse_state, |state| {
|
||||
let mut stack = Stack::new().with_child(child);
|
||||
if state.is_hovered() {
|
||||
let tooltip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(WORKSPACE_OVERRIDE_TOOLTIP_MESSAGE.to_string())
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_child(
|
||||
tooltip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -4.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::cloud_object::UniquePer;
|
||||
use crate::server::sync_queue::QueueItem;
|
||||
use crate::settings::AISettings;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
|
||||
json_model::{JsonModel, JsonSerializer},
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Revision, ServerCloudObject,
|
||||
},
|
||||
settings::{
|
||||
AgentModeCommandExecutionPredicate, DEFAULT_COMMAND_EXECUTION_ALLOWLIST,
|
||||
DEFAULT_COMMAND_EXECUTION_DENYLIST,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use super::llms::LLMId;
|
||||
|
||||
pub const PROFILE_NAME_MAX_LENGTH: usize = 50;
|
||||
|
||||
pub mod editor;
|
||||
pub mod model_menu_items;
|
||||
pub mod profiles;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ActionPermission {
|
||||
AgentDecides,
|
||||
AlwaysAllow,
|
||||
AlwaysAsk,
|
||||
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum. Say we
|
||||
// want to add a "Never" variant. Without this catch-all, old clients wouldn't be able to deserialize
|
||||
// a "Never" into one of the existing options.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ActionPermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => "The Agent chooses the safest path: acting on its own when confident, and asking for approval when uncertain.",
|
||||
ActionPermission::AlwaysAllow => "Give the Agent full autonomy — no manual approval ever required.",
|
||||
ActionPermission::AlwaysAsk => "Require explicit approval before the Agent takes any action.",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_always_ask(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAsk)
|
||||
}
|
||||
|
||||
pub fn is_always_allow(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WriteToPtyPermission {
|
||||
// This is for backwards compatibility with the old "Never" value.
|
||||
#[serde(alias = "Never")]
|
||||
AlwaysAllow,
|
||||
#[default]
|
||||
AlwaysAsk,
|
||||
AskOnFirstWrite,
|
||||
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl WriteToPtyPermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
WriteToPtyPermission::AlwaysAllow => ActionPermission::AlwaysAllow.description(),
|
||||
WriteToPtyPermission::AskOnFirstWrite => {
|
||||
"The agent will ask for permission the first time it needs to interact with a running command. After that, it will continue automatically for the rest of that command."
|
||||
}
|
||||
WriteToPtyPermission::AlwaysAsk => "The agent will always ask for permission to interact with a running command.",
|
||||
WriteToPtyPermission::Unknown => ActionPermission::Unknown.description(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_always_allow(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ComputerUsePermission {
|
||||
#[default]
|
||||
Never,
|
||||
AlwaysAsk,
|
||||
AlwaysAllow,
|
||||
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Result of resolving the cloud agent computer use setting.
|
||||
/// Contains both the effective value and whether it's forced by organization policy.
|
||||
pub struct CloudAgentComputerUseState {
|
||||
/// Whether computer use is enabled for cloud agents.
|
||||
pub enabled: bool,
|
||||
/// Whether this value is forced by organization settings (true = user cannot change it).
|
||||
pub is_forced_by_org: bool,
|
||||
}
|
||||
|
||||
impl ComputerUsePermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
ComputerUsePermission::Never => {
|
||||
"Computer use tools are disabled and will not be available to the Agent."
|
||||
}
|
||||
ComputerUsePermission::AlwaysAsk => {
|
||||
"Require explicit approval before the Agent uses computer use tools."
|
||||
}
|
||||
ComputerUsePermission::AlwaysAllow => {
|
||||
"Give the Agent full autonomy to use computer use tools without approval."
|
||||
}
|
||||
ComputerUsePermission::Unknown => "Unknown setting.",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!matches!(self, Self::Never | Self::Unknown)
|
||||
}
|
||||
|
||||
pub fn is_always_allow(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow)
|
||||
}
|
||||
|
||||
/// Resolves the effective cloud agent computer use state by reading the workspace
|
||||
/// autonomy setting and user's local preference from their respective singletons.
|
||||
pub fn resolve_cloud_agent_state(ctx: &AppContext) -> CloudAgentComputerUseState {
|
||||
if !FeatureFlag::AgentModeComputerUse.is_enabled() {
|
||||
return CloudAgentComputerUseState {
|
||||
enabled: false,
|
||||
is_forced_by_org: false,
|
||||
};
|
||||
}
|
||||
|
||||
let autonomy_setting = UserWorkspaces::as_ref(ctx)
|
||||
.ai_autonomy_settings()
|
||||
.computer_use_setting;
|
||||
let user_preference = *AISettings::as_ref(ctx).cloud_agent_computer_use_enabled;
|
||||
|
||||
match autonomy_setting {
|
||||
Some(ComputerUsePermission::Never) => CloudAgentComputerUseState {
|
||||
enabled: false,
|
||||
is_forced_by_org: true,
|
||||
},
|
||||
Some(ComputerUsePermission::AlwaysAllow) => CloudAgentComputerUseState {
|
||||
enabled: true,
|
||||
is_forced_by_org: true,
|
||||
},
|
||||
// TODO(QUALITY-297): Currently this case should never be hit because the
|
||||
// AlwaysAsk variant isn't accessible in the admin console. We need to figure
|
||||
// out how to handle it when it eventually becomes available. For now, I'm
|
||||
// treating this conservatively and marking computer use as disabled.
|
||||
Some(ComputerUsePermission::AlwaysAsk) => CloudAgentComputerUseState {
|
||||
enabled: false,
|
||||
is_forced_by_org: true,
|
||||
},
|
||||
Some(ComputerUsePermission::Unknown) | None => CloudAgentComputerUseState {
|
||||
enabled: user_preference,
|
||||
is_forced_by_org: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AskUserQuestionPermission {
|
||||
/// Never pause; skip questions and continue with best judgment.
|
||||
Never,
|
||||
/// Pause and wait for the user, unless auto-approve mode is enabled.
|
||||
#[default]
|
||||
AskExceptInAutoApprove,
|
||||
/// Always pause and wait for the user to answer before continuing, even in auto-approve mode.
|
||||
AlwaysAsk,
|
||||
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl AskUserQuestionPermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
AskUserQuestionPermission::AskExceptInAutoApprove
|
||||
| AskUserQuestionPermission::Unknown => {
|
||||
"The Agent may ask a question and pause for your response, but will continue automatically when auto-approve is on."
|
||||
}
|
||||
AskUserQuestionPermission::Never => {
|
||||
"The Agent will not ask questions and will continue with its best judgment."
|
||||
}
|
||||
AskUserQuestionPermission::AlwaysAsk => {
|
||||
"The Agent may ask a question and will pause for your response even when auto-approve is on."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core data structure representing an AI execution profile, which includes model configuration,
|
||||
/// behavior settings, and permissions.
|
||||
///
|
||||
/// NOTE: `planning_model` was removed after planning via subagent was deprecated; serialized legacy
|
||||
/// profiles may include a `planning_model` field and this field name should remain reserved
|
||||
/// indefinitely.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AIExecutionProfile {
|
||||
pub name: String,
|
||||
pub is_default_profile: bool,
|
||||
pub apply_code_diffs: ActionPermission,
|
||||
pub read_files: ActionPermission,
|
||||
|
||||
pub execute_commands: ActionPermission,
|
||||
pub write_to_pty: WriteToPtyPermission,
|
||||
pub mcp_permissions: ActionPermission,
|
||||
pub ask_user_question: AskUserQuestionPermission,
|
||||
|
||||
/// Always ask for permission for these commands
|
||||
pub command_denylist: Vec<AgentModeCommandExecutionPredicate>,
|
||||
|
||||
/// When the execute_commands is set to AlwaysAsk, autoexecute these commands
|
||||
pub command_allowlist: Vec<AgentModeCommandExecutionPredicate>,
|
||||
|
||||
/// When the read_files is set to AlwaysAsk, autoread from these directories
|
||||
pub directory_allowlist: Vec<PathBuf>,
|
||||
|
||||
pub mcp_allowlist: Vec<uuid::Uuid>,
|
||||
pub mcp_denylist: Vec<uuid::Uuid>,
|
||||
|
||||
pub computer_use: ComputerUsePermission,
|
||||
|
||||
pub base_model: Option<LLMId>,
|
||||
pub coding_model: Option<LLMId>,
|
||||
pub cli_agent_model: Option<LLMId>,
|
||||
pub computer_use_model: Option<LLMId>,
|
||||
|
||||
/// Whether plans created by the agent should be automatically synced to Warp Drive
|
||||
pub autosync_plans_to_warp_drive: bool,
|
||||
|
||||
/// Whether the agent may use web search when helpful for completing tasks
|
||||
pub web_search_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for AIExecutionProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: Default::default(),
|
||||
is_default_profile: false,
|
||||
apply_code_diffs: ActionPermission::AgentDecides,
|
||||
read_files: ActionPermission::AgentDecides,
|
||||
execute_commands: ActionPermission::AlwaysAsk,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAsk,
|
||||
mcp_permissions: ActionPermission::AgentDecides,
|
||||
ask_user_question: AskUserQuestionPermission::AskExceptInAutoApprove,
|
||||
command_denylist: DEFAULT_COMMAND_EXECUTION_DENYLIST.clone(),
|
||||
command_allowlist: Vec::new(),
|
||||
directory_allowlist: Vec::new(),
|
||||
mcp_allowlist: Vec::new(),
|
||||
mcp_denylist: Vec::new(),
|
||||
computer_use: ComputerUsePermission::Never,
|
||||
base_model: None,
|
||||
coding_model: None,
|
||||
cli_agent_model: None,
|
||||
computer_use_model: None,
|
||||
autosync_plans_to_warp_drive: true,
|
||||
web_search_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AIExecutionProfile {
|
||||
pub fn create_default_from_legacy_settings(app: &AppContext) -> Self {
|
||||
// Note that the legacy "Autonomy" and "Code Access" settings are not imported here.
|
||||
// The "Code Access" setting defaulted to "Always Ask", which is the most restrictive, so
|
||||
// it's impossible for us to infer some hesitancy about autonomy from the setting and we should
|
||||
// ignore it. The same applies to "Autonomy".
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
Self {
|
||||
name: "Default".to_string(),
|
||||
is_default_profile: true,
|
||||
command_denylist: ai_settings.agent_mode_command_execution_denylist.clone(),
|
||||
// We initialize the command allowlist to be anything the user added, excluding all
|
||||
// the pre-populated defaults.
|
||||
command_allowlist: ai_settings
|
||||
.agent_mode_command_execution_allowlist
|
||||
.iter()
|
||||
.filter(|cmd| !DEFAULT_COMMAND_EXECUTION_ALLOWLIST.contains(cmd))
|
||||
.cloned()
|
||||
.collect(),
|
||||
directory_allowlist: ai_settings.agent_mode_coding_file_read_allowlist.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
pub fn create_agent_mode_eval_profile() -> Self {
|
||||
Self {
|
||||
name: "Agent Mode Eval".to_string(),
|
||||
is_default_profile: false,
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAllow,
|
||||
mcp_permissions: ActionPermission::AlwaysAllow,
|
||||
ask_user_question: AskUserQuestionPermission::Never,
|
||||
command_denylist: Vec::new(),
|
||||
command_allowlist: Vec::new(),
|
||||
directory_allowlist: Vec::new(),
|
||||
mcp_allowlist: Vec::new(),
|
||||
mcp_denylist: Vec::new(),
|
||||
computer_use: ComputerUsePermission::Never,
|
||||
base_model: None,
|
||||
coding_model: None,
|
||||
cli_agent_model: None,
|
||||
computer_use_model: None,
|
||||
autosync_plans_to_warp_drive: false,
|
||||
web_search_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// This creates a CLI-specific profile that will never ask the user for permission,
|
||||
/// since we cannot do so in a non-interactive setting.
|
||||
pub fn create_default_cli_profile(
|
||||
is_sandboxed: bool,
|
||||
computer_use_override: Option<bool>,
|
||||
) -> Self {
|
||||
let command_denylist = if is_sandboxed {
|
||||
Vec::new()
|
||||
} else {
|
||||
DEFAULT_COMMAND_EXECUTION_DENYLIST.to_vec()
|
||||
};
|
||||
|
||||
let computer_use_permission = match computer_use_override {
|
||||
Some(true) => {
|
||||
if is_sandboxed || FeatureFlag::LocalComputerUse.is_enabled() {
|
||||
ComputerUsePermission::AlwaysAllow
|
||||
} else {
|
||||
ComputerUsePermission::Never
|
||||
}
|
||||
}
|
||||
Some(false) => ComputerUsePermission::Never,
|
||||
None => {
|
||||
if is_sandboxed && ChannelState::channel().is_dogfood() {
|
||||
ComputerUsePermission::AlwaysAllow
|
||||
} else {
|
||||
ComputerUsePermission::Never
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
name: "Default (CLI)".to_owned(),
|
||||
is_default_profile: true,
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
mcp_permissions: ActionPermission::AlwaysAllow,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAllow,
|
||||
ask_user_question: AskUserQuestionPermission::Never,
|
||||
command_denylist,
|
||||
command_allowlist: DEFAULT_COMMAND_EXECUTION_ALLOWLIST.to_vec(),
|
||||
directory_allowlist: Vec::new(),
|
||||
mcp_allowlist: Vec::new(),
|
||||
mcp_denylist: Vec::new(),
|
||||
computer_use: computer_use_permission,
|
||||
base_model: None,
|
||||
coding_model: None,
|
||||
cli_agent_model: None,
|
||||
computer_use_model: None,
|
||||
autosync_plans_to_warp_drive: FeatureFlag::SyncAmbientPlans.is_enabled(),
|
||||
web_search_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudAIExecutionProfile =
|
||||
GenericCloudObject<GenericStringObjectId, CloudAIExecutionProfileModel>;
|
||||
pub type CloudAIExecutionProfileModel = GenericStringModel<AIExecutionProfile, JsonSerializer>;
|
||||
|
||||
impl StringModel for AIExecutionProfile {
|
||||
type CloudObjectType = CloudAIExecutionProfile;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
"AIExecutionProfile"
|
||||
}
|
||||
|
||||
fn should_enforce_revisions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile)
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn warn_if_unsaved_at_quit() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
// Handles case where default profile was previously created and named "Untitled"
|
||||
if self.is_default_profile {
|
||||
"Default".to_string()
|
||||
} else if self.name.trim().is_empty() {
|
||||
"Untitled".to_string()
|
||||
} else {
|
||||
self.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &Self::CloudObjectType,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateAIExecutionProfile {
|
||||
model: object.model().clone().into(),
|
||||
id: object.id,
|
||||
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::AIExecutionProfile(server_ai_execution_profile) =
|
||||
server_cloud_object
|
||||
{
|
||||
return Some(server_ai_execution_profile.model.clone().string_model);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn should_clear_on_unique_key_conflict(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
|
||||
// We want to prevent the creation of several default profiles per user. If it's not the default
|
||||
// profile, then there can be many.
|
||||
self.is_default_profile
|
||||
.then_some(GenericStringObjectUniqueKey {
|
||||
key: "default".to_string(),
|
||||
unique_per: UniquePer::User,
|
||||
})
|
||||
}
|
||||
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for AIExecutionProfile {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::AIExecutionProfile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use crate::ai::llms::{is_using_api_key_for_provider, DisableReason, LLMId, LLMInfo};
|
||||
use crate::menu::{MenuItem, MenuItemFields, MenuTooltipPosition};
|
||||
use itertools::Itertools;
|
||||
use std::sync::Arc;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, ParentElement, SavePosition,
|
||||
Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Style},
|
||||
Action, AppContext, Element,
|
||||
};
|
||||
|
||||
pub fn is_auto(llm: &LLMInfo) -> bool {
|
||||
llm.display_name.to_lowercase().contains("auto")
|
||||
|| llm.id.to_string().to_lowercase().contains("auto")
|
||||
}
|
||||
|
||||
/// Returns true if the given model has other variants with different reasoning levels.
|
||||
pub fn has_reasoning_variants(llm: &LLMInfo, all_models: &[&LLMInfo]) -> bool {
|
||||
if !llm.has_reasoning_level() {
|
||||
return false;
|
||||
}
|
||||
all_models
|
||||
.iter()
|
||||
.filter(|other| other.base_model_name() == llm.base_model_name() && other.id != llm.id)
|
||||
.any(|other| other.has_reasoning_level())
|
||||
}
|
||||
|
||||
fn with_cost_and_profile_info<A: Action + Clone>(
|
||||
item: MenuItemFields<A>,
|
||||
llm: &LLMInfo,
|
||||
profile_default_model: Option<&LLMId>,
|
||||
) -> MenuItemFields<A> {
|
||||
let mut label = String::new();
|
||||
|
||||
if Some(&llm.id) == profile_default_model {
|
||||
label.push_str("Profile default");
|
||||
}
|
||||
|
||||
match llm.usage_metadata.credit_multiplier {
|
||||
Some(mult) if mult != 1. => {
|
||||
let mut formatted_cost = format!("~{mult:.1}")
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string();
|
||||
formatted_cost.push('x');
|
||||
if label.is_empty() {
|
||||
label.push_str(&formatted_cost);
|
||||
} else {
|
||||
label.push_str(&format!(" ({formatted_cost})"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if label.is_empty() {
|
||||
item
|
||||
} else {
|
||||
// Using the key shortcut label to display extra info is a hack.
|
||||
item.with_key_shortcut_label(Some(label))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_item_fields<A: Action + Clone>(
|
||||
llm: &LLMInfo,
|
||||
action: impl Fn(&LLMInfo) -> A,
|
||||
position_id_fn: Option<&dyn Fn(&LLMId) -> String>,
|
||||
model_id_to_add_profile_default_label_to: Option<&LLMId>,
|
||||
collapse_auto: bool,
|
||||
collapse_reasoning_variants: bool,
|
||||
app: &AppContext,
|
||||
) -> MenuItem<A> {
|
||||
let label = if collapse_auto && is_auto(llm) {
|
||||
"auto".to_string()
|
||||
} else if collapse_reasoning_variants && llm.has_reasoning_level() {
|
||||
llm.base_model_name().to_string()
|
||||
} else {
|
||||
llm.menu_display_name()
|
||||
};
|
||||
let is_using_api_key = is_using_api_key_for_provider(&llm.provider, app);
|
||||
|
||||
let mut item = if let Some(position_id_fn) = position_id_fn {
|
||||
let position_id = position_id_fn(&llm.id);
|
||||
MenuItemFields::new_with_custom_label(
|
||||
Arc::new(move |_, _, appearance, _| {
|
||||
let mut item_row =
|
||||
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let icon_container = Container::new(
|
||||
ConstrainedBox::new(if is_using_api_key {
|
||||
Icon::Key
|
||||
.to_warpui_icon(appearance.theme().foreground())
|
||||
.finish()
|
||||
} else {
|
||||
Empty::new().finish()
|
||||
})
|
||||
.with_height(appearance.ui_font_size())
|
||||
.with_width(appearance.ui_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(appearance.ui_font_size() / 2.)
|
||||
.finish();
|
||||
item_row.add_child(icon_container);
|
||||
|
||||
let text = Text::new(
|
||||
label.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
)
|
||||
.finish();
|
||||
item_row.add_child(Shrinkable::new(4., text).finish());
|
||||
SavePosition::new(item_row.finish(), &position_id).finish()
|
||||
}),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let provider_icon = llm.provider.icon().unwrap_or(Icon::Oz);
|
||||
MenuItemFields::new(label).with_icon(provider_icon)
|
||||
};
|
||||
|
||||
item = item
|
||||
.with_on_select_action(action(llm))
|
||||
.with_disabled(llm.disable_reason.is_some());
|
||||
|
||||
if let Some(reason) = &llm.disable_reason {
|
||||
item = item
|
||||
.with_tooltip(reason.tooltip_text())
|
||||
.with_tooltip_position(MenuTooltipPosition::Above);
|
||||
|
||||
if matches!(reason, DisableReason::RequiresUpgrade) {
|
||||
item =
|
||||
item.with_right_side_label("disabled", Properties::default().style(Style::Italic));
|
||||
}
|
||||
}
|
||||
|
||||
with_cost_and_profile_info(item, llm, model_id_to_add_profile_default_label_to).into_item()
|
||||
}
|
||||
|
||||
pub fn available_model_menu_items<A: Action + Clone>(
|
||||
choices: Vec<&LLMInfo>,
|
||||
action: impl Fn(&LLMInfo) -> A,
|
||||
model_id_to_add_profile_default_label_to: Option<&LLMId>,
|
||||
position_id_fn: Option<&dyn Fn(&LLMId) -> String>,
|
||||
collapse_auto: bool,
|
||||
collapse_reasoning_variants: bool,
|
||||
app: &AppContext,
|
||||
) -> Vec<MenuItem<A>> {
|
||||
choices
|
||||
.into_iter()
|
||||
.map(|llm| {
|
||||
make_item_fields(
|
||||
llm,
|
||||
&action,
|
||||
position_id_fn,
|
||||
model_id_to_add_profile_default_label_to,
|
||||
collapse_auto,
|
||||
collapse_reasoning_variants,
|
||||
app,
|
||||
)
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::{
|
||||
AIExecutionProfile, ActionPermission, CloudAIExecutionProfileModel,
|
||||
};
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::{Revision, ServerAIExecutionProfile, ServerMetadata, ServerPermissions};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::PrivacySettings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::LaunchMode;
|
||||
|
||||
fn mock_server_metadata(uid: ServerId) -> ServerMetadata {
|
||||
ServerMetadata {
|
||||
uid,
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: DateTime::<Utc>::default().into(),
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the minimal singleton graph needed to construct an
|
||||
/// `AIExecutionProfilesModel` and exercise its CloudModel interactions.
|
||||
fn install_singletons(app: &mut App, auth_state: AuthStateProvider) {
|
||||
initialize_settings_for_tests(app);
|
||||
app.add_singleton_model(|_| auth_state);
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
}
|
||||
|
||||
/// Regression test for the onboarding autonomy bug where
|
||||
/// `edit_profile_internal` would silently drop edits made to an `Unsynced`
|
||||
/// default profile whenever `personal_drive` returned `None` (logged-out
|
||||
/// users). `apply_agent_settings` calls `set_*` on the default profile the
|
||||
/// moment onboarding completes, which can happen before the user logs in
|
||||
/// (e.g. `LoginSlideEvent::LoginLaterConfirmed`), so those edits must
|
||||
/// persist on the local `Unsynced` state rather than being dropped.
|
||||
#[test]
|
||||
fn edits_persist_on_unsynced_default_profile_when_logged_out() {
|
||||
App::test((), |mut app| async move {
|
||||
install_singletons(&mut app, AuthStateProvider::new_logged_out_for_test());
|
||||
let profile_model = app.add_singleton_model(|ctx| {
|
||||
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
|
||||
});
|
||||
|
||||
let default_profile_id = profile_model.read(&app, |model, _ctx| model.default_profile_id());
|
||||
|
||||
// Sanity-check the precondition: the baseline `apply_code_diffs`
|
||||
// on a fresh default profile is the enum default (`AgentDecides`).
|
||||
profile_model.read(&app, |model, ctx| {
|
||||
assert!(
|
||||
matches!(
|
||||
model.default_profile(ctx).data().apply_code_diffs,
|
||||
ActionPermission::AgentDecides
|
||||
),
|
||||
"unexpected baseline apply_code_diffs"
|
||||
);
|
||||
});
|
||||
|
||||
// Apply the edit that onboarding would make for the Full autonomy
|
||||
// preset. Before the fix, this call no-ops because
|
||||
// `personal_drive` is `None` while the profile is `Unsynced` — the
|
||||
// `set_apply_code_diffs` value was cloned, mutated, then dropped
|
||||
// without being written back to `default_profile_state`.
|
||||
profile_model.update(&mut app, |model, ctx| {
|
||||
model.set_apply_code_diffs(default_profile_id, &ActionPermission::AlwaysAllow, ctx);
|
||||
});
|
||||
|
||||
profile_model.read(&app, |model, ctx| {
|
||||
assert_eq!(
|
||||
model.default_profile(ctx).data().apply_code_diffs,
|
||||
ActionPermission::AlwaysAllow,
|
||||
"edit was dropped: default profile still has the baseline \
|
||||
apply_code_diffs value after an edit made while logged out",
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// Regression test for the "log in to an existing user after onboarding"
|
||||
/// bug. Cloud objects arriving via the initial bulk load are inserted into
|
||||
/// `CloudModel` *without* firing per-object `ObjectCreated` events —
|
||||
/// `update_objects_from_initial_load` passes `emit_events: false` and emits
|
||||
/// a single `CloudModelEvent::InitialLoadCompleted` afterward instead.
|
||||
/// Without the reconciliation handler for `InitialLoadCompleted`, the
|
||||
/// existing user's default profile sits in `CloudModel` but
|
||||
/// `AIExecutionProfilesModel` stays in `Unsynced`, so a subsequent
|
||||
/// onboarding edit creates a duplicate cloud default profile instead of
|
||||
/// editing the existing one. This test drives that sequence and asserts
|
||||
/// the model adopts the cloud profile's sync id.
|
||||
#[test]
|
||||
fn reconciles_unsynced_default_profile_with_cloud_after_initial_load() {
|
||||
App::test((), |mut app| async move {
|
||||
install_singletons(&mut app, AuthStateProvider::new_for_test());
|
||||
let profile_model = app.add_singleton_model(|ctx| {
|
||||
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
|
||||
});
|
||||
|
||||
// Baseline: CloudModel is empty, so the model starts Unsynced and
|
||||
// `sync_id` is `None`.
|
||||
profile_model.read(&app, |model, ctx| {
|
||||
assert!(
|
||||
model.default_profile(ctx).sync_id().is_none(),
|
||||
"default profile should be Unsynced at startup"
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate the user's existing cloud default profile arriving via
|
||||
// initial bulk load. We construct the existing profile with
|
||||
// `apply_code_diffs = AlwaysAllow` so we can verify the model is
|
||||
// reading that cloud object after reconciliation.
|
||||
let cloud_uid = ServerId::from(42);
|
||||
let cloud_sync_id = SyncId::ServerId(cloud_uid);
|
||||
let cloud_profile = AIExecutionProfile {
|
||||
name: "Default".to_string(),
|
||||
is_default_profile: true,
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
..Default::default()
|
||||
};
|
||||
let server_object = ServerAIExecutionProfile {
|
||||
id: cloud_sync_id,
|
||||
model: CloudAIExecutionProfileModel::new(cloud_profile),
|
||||
metadata: mock_server_metadata(cloud_uid),
|
||||
permissions: ServerPermissions::mock_personal(),
|
||||
};
|
||||
|
||||
// Insert the object into CloudModel via the initial-load path
|
||||
// (`emit_events=false`) and then emit `InitialLoadCompleted` so the
|
||||
// reconciliation handler fires.
|
||||
CloudModel::handle(&app).update(&mut app, move |cloud_model, ctx| {
|
||||
let server_objects: Vec<ServerAIExecutionProfile> = vec![server_object];
|
||||
cloud_model.update_objects_from_initial_load(server_objects, false, false, ctx);
|
||||
ctx.emit(CloudModelEvent::InitialLoadCompleted);
|
||||
});
|
||||
|
||||
// The model should now be Synced with the cloud profile's sync_id,
|
||||
// and `default_profile` should read values from the existing cloud
|
||||
// object (proving we're not backed by a fresh client-side default).
|
||||
profile_model.read(&app, |model, ctx| {
|
||||
let info = model.default_profile(ctx);
|
||||
assert_eq!(
|
||||
info.sync_id(),
|
||||
Some(cloud_sync_id),
|
||||
"model did not adopt the existing cloud default profile's sync_id"
|
||||
);
|
||||
assert_eq!(
|
||||
info.data().apply_code_diffs,
|
||||
ActionPermission::AlwaysAllow,
|
||||
"default profile should now surface the existing cloud value"
|
||||
);
|
||||
});
|
||||
|
||||
// Further edits should now target the existing cloud profile in
|
||||
// place, rather than falling through the `Unsynced` branch and
|
||||
// creating a duplicate.
|
||||
let default_profile_id = profile_model.read(&app, |model, _ctx| model.default_profile_id());
|
||||
profile_model.update(&mut app, |model, ctx| {
|
||||
model.set_apply_code_diffs(default_profile_id, &ActionPermission::AlwaysAsk, ctx);
|
||||
});
|
||||
profile_model.read(&app, |model, ctx| {
|
||||
let info = model.default_profile(ctx);
|
||||
assert_eq!(
|
||||
info.sync_id(),
|
||||
Some(cloud_sync_id),
|
||||
"edit should target the same cloud sync_id, not create a duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
info.data().apply_code_diffs,
|
||||
ActionPermission::AlwaysAsk,
|
||||
"edit should be reflected on the existing cloud profile"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user