474 lines
16 KiB
Rust
474 lines
16 KiB
Rust
use crate::ai::agent::{SuggestedAgentModeWorkflow, SuggestedLoggingId, SuggestedRule};
|
|
use crate::ai::facts::CloudAIFactModel;
|
|
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
|
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
|
use crate::drive::CloudObjectTypeAndId;
|
|
use crate::server::cloud_objects::update_manager::{
|
|
ObjectOperation, OperationSuccessType, UpdateManagerEvent,
|
|
};
|
|
use crate::server::ids::SyncId;
|
|
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, SecondaryTheme};
|
|
use crate::TelemetryEvent;
|
|
use crate::{
|
|
ai::facts::{AIFact, AIMemory},
|
|
server::{cloud_objects::update_manager::UpdateManager, ids::ClientId},
|
|
ui_components::{blended_colors, icons::Icon},
|
|
};
|
|
use galaxy_core::send_telemetry_from_ctx;
|
|
use galaxy_core::ui::appearance::Appearance;
|
|
use galaxy_core::ui::theme::Fill;
|
|
use galaxyui::{
|
|
elements::{Align, ChildView, Container, ParentElement, SavePosition, Stack},
|
|
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
|
};
|
|
use pathfinder_color::ColorU;
|
|
|
|
use super::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId;
|
|
use super::suggested_rule_modal::SuggestedRuleAndId;
|
|
|
|
const MAX_CHIP_WIDTH: f32 = 316.;
|
|
|
|
const MAX_PROMPT_TOOLTIP_LENGTH: usize = 200;
|
|
|
|
/// A chip view component for displaying suggested rules and agent mode workflows.
|
|
///
|
|
/// This component is responsible for:
|
|
/// - Rendering a clickable chip that represents a suggested rule or agent mode workflow
|
|
/// - Tracking the state of the rule/workflow (saved or not)
|
|
/// - Handling clicks to show the appropriate modal dialog via workspace-level events
|
|
/// - Syncing with the cloud model for persistence
|
|
///
|
|
/// When clicked, the chip emits events to display either the `SuggestedRuleModal` or
|
|
/// `SuggestedAgentModeWorkflowModal` at the workspace level, which position themselves
|
|
/// relative to the chip position.
|
|
///
|
|
/// # UI Behavior
|
|
/// - A chip with a saved rule shows a check icon and has a different theme
|
|
/// - The chip position is saved for the modal dialogs to position relative to it
|
|
/// - Chips have limited width and tooltips for overflow content
|
|
///
|
|
/// # Events
|
|
/// The component emits events to:
|
|
/// - Show rule or workflow modals
|
|
/// - Open existing rules or workflows directly
|
|
///
|
|
/// An [`ActionButton`] theme for suggested rules and prompts.
|
|
struct SuggestionButtonTheme;
|
|
|
|
impl ActionButtonTheme for SuggestionButtonTheme {
|
|
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
|
|
if hovered {
|
|
Some(blended_colors::fg_overlay_2(appearance.theme()))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn text_color(
|
|
&self,
|
|
_hovered: bool,
|
|
_background: Option<Fill>,
|
|
appearance: &Appearance,
|
|
) -> ColorU {
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().background())
|
|
.into()
|
|
}
|
|
|
|
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
|
|
Some(blended_colors::neutral_4(appearance.theme()))
|
|
}
|
|
}
|
|
|
|
/// A theme for the dismiss button in the suggestion footer.
|
|
pub struct SuggestionDismissButtonTheme;
|
|
|
|
impl ActionButtonTheme for SuggestionDismissButtonTheme {
|
|
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
|
|
if hovered {
|
|
Some(blended_colors::fg_overlay_2(appearance.theme()))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn text_color(
|
|
&self,
|
|
_hovered: bool,
|
|
_background: Option<Fill>,
|
|
appearance: &Appearance,
|
|
) -> ColorU {
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().background())
|
|
.into()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum SuggestedChipViewEvent {
|
|
ShowSuggestedRuleDialog {
|
|
rule_and_id: SuggestedRuleAndId,
|
|
},
|
|
OpenAIFactCollection {
|
|
sync_id: Option<SyncId>,
|
|
},
|
|
OpenWorkflow {
|
|
sync_id: SyncId,
|
|
},
|
|
ShowSuggestedAgentModeWorkflowModal {
|
|
workflow_and_id: SuggestedAgentModeWorkflowAndId,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum SuggestedViewAction {
|
|
ChipClicked,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Suggestion {
|
|
Rule {
|
|
rule: SuggestedRule,
|
|
},
|
|
AgentModeWorkflow {
|
|
workflow: SuggestedAgentModeWorkflow,
|
|
},
|
|
}
|
|
|
|
impl Suggestion {
|
|
pub fn icon(&self) -> Icon {
|
|
match self {
|
|
Suggestion::Rule { .. } => Icon::BookOpen,
|
|
Suggestion::AgentModeWorkflow { .. } => Icon::Prompt,
|
|
}
|
|
}
|
|
|
|
pub fn tooltip(&self) -> String {
|
|
match self {
|
|
Suggestion::Rule { rule, .. } => {
|
|
format!("Add rule: {}", rule.content.clone())
|
|
}
|
|
Suggestion::AgentModeWorkflow { workflow, .. } => {
|
|
let prompt = if workflow.prompt.chars().count() > MAX_PROMPT_TOOLTIP_LENGTH {
|
|
let truncated: String = workflow
|
|
.prompt
|
|
.chars()
|
|
.take(MAX_PROMPT_TOOLTIP_LENGTH - 3)
|
|
.collect();
|
|
format!("{truncated}...")
|
|
} else {
|
|
workflow.prompt.clone()
|
|
};
|
|
format!("Suggested prompt:\n{prompt}")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn position_id(&self) -> String {
|
|
match self {
|
|
Suggestion::Rule { rule, .. } => format!("rule_position_{}", rule.logging_id),
|
|
Suggestion::AgentModeWorkflow { workflow, .. } => {
|
|
format!("agent_mode_workflow_position_{}", workflow.logging_id)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn chip_label(&self) -> String {
|
|
match self {
|
|
Suggestion::Rule { rule, .. } => rule.content.clone(),
|
|
Suggestion::AgentModeWorkflow { workflow, .. } => workflow.name.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Data required to render a single suggested rule or agent mode workflow.
|
|
#[derive(Clone)]
|
|
pub struct SuggestionChipView {
|
|
suggestion: Suggestion,
|
|
chip: ViewHandle<ActionButton>,
|
|
sync_id: SyncId,
|
|
is_saved: bool,
|
|
}
|
|
|
|
impl SuggestionChipView {
|
|
pub fn new_rule_chip(rule: SuggestedRule, ctx: &mut ViewContext<Self>) -> Self {
|
|
Self::listen_for_warp_drive_events(ctx);
|
|
|
|
let chip = ctx.add_typed_action_view(|_| {
|
|
ActionButton::new(rule.content.clone(), SecondaryTheme)
|
|
.with_max_label_width(MAX_CHIP_WIDTH)
|
|
.on_click(|ctx| {
|
|
ctx.dispatch_typed_action(SuggestedViewAction::ChipClicked);
|
|
})
|
|
});
|
|
|
|
let suggestion = Suggestion::Rule { rule };
|
|
let mut me = Self {
|
|
suggestion,
|
|
sync_id: SyncId::ClientId(ClientId::default()),
|
|
chip,
|
|
is_saved: false,
|
|
};
|
|
me.reset_suggestion(ctx);
|
|
me
|
|
}
|
|
|
|
pub fn new_agent_mode_workflow_chip(
|
|
workflow: SuggestedAgentModeWorkflow,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::ShowedSuggestedAgentModeWorkflowChip {
|
|
logging_id: workflow.logging_id.clone(),
|
|
},
|
|
ctx
|
|
);
|
|
|
|
Self::listen_for_warp_drive_events(ctx);
|
|
let sync_id = SyncId::ClientId(ClientId::default());
|
|
|
|
let chip = ctx.add_typed_action_view(|_| {
|
|
ActionButton::new(workflow.name.clone(), SecondaryTheme)
|
|
.with_max_label_width(MAX_CHIP_WIDTH)
|
|
.on_click(|ctx| {
|
|
ctx.dispatch_typed_action(SuggestedViewAction::ChipClicked);
|
|
})
|
|
});
|
|
|
|
let suggestion = Suggestion::AgentModeWorkflow { workflow };
|
|
let mut me = Self {
|
|
suggestion,
|
|
sync_id,
|
|
chip,
|
|
is_saved: false,
|
|
};
|
|
me.reset_suggestion(ctx);
|
|
me
|
|
}
|
|
|
|
pub fn logging_id(&self) -> SuggestedLoggingId {
|
|
match &self.suggestion {
|
|
Suggestion::Rule { rule, .. } => rule.logging_id.clone(),
|
|
Suggestion::AgentModeWorkflow { workflow, .. } => workflow.logging_id.clone(),
|
|
}
|
|
}
|
|
|
|
fn listen_for_warp_drive_events(ctx: &mut ViewContext<Self>) {
|
|
let update_manager = UpdateManager::handle(ctx);
|
|
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
|
|
me.handle_update_manager_event(event, ctx);
|
|
});
|
|
|
|
let cloud_model = CloudModel::handle(ctx);
|
|
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| {
|
|
me.handle_cloud_model_event(event, ctx);
|
|
});
|
|
}
|
|
|
|
fn handle_update_manager_event(
|
|
&mut self,
|
|
event: &UpdateManagerEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
|
|
return;
|
|
};
|
|
|
|
if let (ObjectOperation::Create { .. }, OperationSuccessType::Success) =
|
|
(&result.operation, &result.success_type)
|
|
{
|
|
if self.sync_id.into_client() == result.client_id {
|
|
if let Some(server_id) = result.server_id {
|
|
self.sync_id = SyncId::ServerId(server_id);
|
|
// Reload the rule from the cloud model.
|
|
match &mut self.suggestion {
|
|
Suggestion::Rule { .. } => {
|
|
self.load_suggestion(ctx);
|
|
}
|
|
Suggestion::AgentModeWorkflow { .. } => {
|
|
// Loading agent mode workflows is not supported
|
|
// as there is no editing flow for them.
|
|
}
|
|
}
|
|
self.on_add_suggestion(ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ViewContext<Self>) {
|
|
match event {
|
|
CloudModelEvent::ObjectUpdated {
|
|
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
|
|
..
|
|
} => {
|
|
if self.sync_id.into_client() == id.into_client() {
|
|
self.load_suggestion(ctx);
|
|
}
|
|
}
|
|
CloudModelEvent::ObjectTrashed {
|
|
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
|
|
..
|
|
}
|
|
| CloudModelEvent::ObjectDeleted {
|
|
type_and_id: CloudObjectTypeAndId::GenericStringObject { id, .. },
|
|
..
|
|
} => {
|
|
// If the rule or workflow has been deleted, then we should reset it such that
|
|
// the suggestion can be added again.
|
|
if self.sync_id == *id {
|
|
self.reset_suggestion(ctx);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Resets the rule or agent mode workflow to its initial state.
|
|
fn reset_suggestion(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.sync_id = SyncId::ClientId(ClientId::default());
|
|
self.is_saved = false;
|
|
let icon: Icon = self.suggestion.icon();
|
|
let tooltip: String = self.suggestion.tooltip();
|
|
let label: String = self.suggestion.chip_label();
|
|
self.chip.update(ctx, |chip, ctx| {
|
|
chip.set_icon(Some(icon), ctx);
|
|
chip.set_label(label.clone(), ctx);
|
|
chip.set_tooltip(Some(tooltip.clone()), ctx);
|
|
chip.set_theme(SecondaryTheme, ctx);
|
|
chip.set_active(false, ctx);
|
|
});
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Fetches the rule from the cloud model, and updates the UI to reflect that.
|
|
fn load_suggestion(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let cloud_model = CloudModel::handle(ctx);
|
|
let tooltip = self.suggestion.tooltip();
|
|
|
|
match &mut self.suggestion {
|
|
Suggestion::Rule { .. } => {
|
|
if let Some(rule) = cloud_model
|
|
.as_ref(ctx)
|
|
.get_object_of_type::<GenericStringObjectId, CloudAIFactModel>(&self.sync_id)
|
|
{
|
|
let AIFact::Memory(AIMemory { content, .. }) =
|
|
rule.model().string_model.clone();
|
|
self.chip.update(ctx, |chip, ctx| {
|
|
chip.set_label(content.clone(), ctx);
|
|
chip.set_tooltip(Some(tooltip.clone()), ctx);
|
|
});
|
|
ctx.notify();
|
|
}
|
|
}
|
|
Suggestion::AgentModeWorkflow { .. } => {
|
|
// Loading agent mode workflows is not yet supported as there is no editing flow.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Updates the UI state to reflect that a rule has been added.
|
|
fn on_add_suggestion(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.is_saved = true;
|
|
self.chip.update(ctx, |chip, ctx| {
|
|
chip.set_icon(Some(Icon::Check), ctx);
|
|
chip.set_theme(SuggestionButtonTheme, ctx);
|
|
});
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
impl Entity for SuggestionChipView {
|
|
type Event = SuggestedChipViewEvent;
|
|
}
|
|
|
|
impl View for SuggestionChipView {
|
|
fn ui_name() -> &'static str {
|
|
"SuggestionChipView"
|
|
}
|
|
|
|
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
|
let stack = Stack::new();
|
|
let position_id = self.suggestion.position_id();
|
|
|
|
let chip_element = Container::new(ChildView::new(&self.chip).finish())
|
|
.with_margin_right(8.)
|
|
.finish();
|
|
|
|
stack
|
|
.with_child(
|
|
Align::new(
|
|
Container::new(SavePosition::new(chip_element, position_id.as_str()).finish())
|
|
.finish(),
|
|
)
|
|
.left()
|
|
.finish(),
|
|
)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl TypedActionView for SuggestionChipView {
|
|
type Action = SuggestedViewAction;
|
|
|
|
fn handle_action(&mut self, action: &SuggestedViewAction, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
SuggestedViewAction::ChipClicked => match &self.suggestion {
|
|
Suggestion::Rule { rule, .. } => {
|
|
if CloudModel::as_ref(ctx)
|
|
.get_object_of_type::<GenericStringObjectId, CloudAIFactModel>(
|
|
&self.sync_id,
|
|
)
|
|
.is_some()
|
|
{
|
|
ctx.emit(SuggestedChipViewEvent::OpenAIFactCollection {
|
|
sync_id: Some(self.sync_id),
|
|
});
|
|
} else {
|
|
ctx.emit(SuggestedChipViewEvent::ShowSuggestedRuleDialog {
|
|
rule_and_id: SuggestedRuleAndId {
|
|
rule: rule.clone(),
|
|
sync_id: self.sync_id,
|
|
},
|
|
});
|
|
self.chip.update(ctx, |chip, ctx| {
|
|
chip.set_active(true, ctx);
|
|
});
|
|
}
|
|
}
|
|
Suggestion::AgentModeWorkflow { workflow, .. } => {
|
|
if CloudModel::as_ref(ctx)
|
|
.get_workflow(&self.sync_id)
|
|
.is_some()
|
|
{
|
|
ctx.emit(SuggestedChipViewEvent::OpenWorkflow {
|
|
sync_id: self.sync_id,
|
|
});
|
|
} else {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::ShowedSuggestedAgentModeWorkflowModal {
|
|
logging_id: workflow.logging_id.clone(),
|
|
},
|
|
ctx
|
|
);
|
|
|
|
ctx.emit(
|
|
SuggestedChipViewEvent::ShowSuggestedAgentModeWorkflowModal {
|
|
workflow_and_id: SuggestedAgentModeWorkflowAndId {
|
|
workflow: workflow.clone(),
|
|
sync_id: self.sync_id,
|
|
},
|
|
},
|
|
);
|
|
self.chip.update(ctx, |chip, ctx| {
|
|
chip.set_active(true, ctx);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|