Files
galaxy/app/src/ai/document/orchestration_config_block.rs
T

901 lines
37 KiB
Rust

//! Inline config block rendered on plan cards when the conversation has
//! an active `OrchestrationConfigSnapshot`. Shows a "Use orchestration"
//! toggle, Cloud/Local picker, and run-wide config dropdowns.
use std::collections::HashMap;
use ai::agent::action::RunAgentsExecutionMode;
use ai::agent::orchestration_config::OrchestrationConfigStatus;
use galaxy_core::send_telemetry_from_ctx;
use pathfinder_geometry::vector::vec2f;
use warp_cli::agent::Harness;
use warpui::elements::{
ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Stack, Text,
};
use warpui::fonts::{Properties, Weight};
use warpui::platform::Cursor;
use warpui::ui_components::components::UiComponent;
use warpui::ui_components::switch::SwitchStateHandle;
use warpui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::inline_action::create_environment_modal::{
CreateEnvironmentModal, CreateEnvironmentModalEvent,
};
use crate::ai::blocklist::inline_action::host_picker::{HostPicker, HostPickerEvent};
use crate::ai::blocklist::inline_action::orchestration_controls::{
self as oc, AuthSecretSelection, OrchestrationControlAction, OrchestrationEditState,
OrchestrationPickerHandles,
};
use crate::ai::blocklist::telemetry::{
AgentProposedConfigEvent, BlocklistOrchestrationTelemetryEvent, OrchestrationApprovalStatus,
OrchestrationExecutionModeKind, OrchestrationHarnessKind, PlanConfigApprovalToggledEvent,
};
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::ai::connected_self_hosted_workers::{
ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,
};
use crate::ai::document::ai_document_model::AIDocumentModel;
use crate::ai::harness_availability::{
AuthSecretFetchState, HarnessAvailabilityEvent, HarnessAvailabilityModel,
};
use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent};
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use crate::workspace::WorkspaceAction;
use crate::BlocklistAIHistoryModel;
/// True when the mode is remote and `environment_id` is non-empty.
fn env_presence(execution_mode: &RunAgentsExecutionMode) -> bool {
matches!(
execution_mode,
RunAgentsExecutionMode::Remote { environment_id, .. } if !environment_id.is_empty()
)
}
/// True when the mode is remote and `worker_host` is non-empty.
fn host_presence(execution_mode: &RunAgentsExecutionMode) -> bool {
matches!(
execution_mode,
RunAgentsExecutionMode::Remote { worker_host, .. } if !worker_host.is_empty()
)
}
const CONFIG_BLOCK_HEADER: &str = "Use orchestration";
const CONFIG_BLOCK_DESCRIPTION: &str =
"Break this work into coordinated streams with multiple agents.";
const BASE_MODEL_HELPER: &str = "The primary model all agents will use.";
// ── Action type ─────────────────────────────────────────────────────
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OrchestrationConfigBlockAction {
ToggleApproval,
ToggleDetails,
ExecutionModeToggled {
is_remote: bool,
},
ModelChanged {
model_id: String,
},
HarnessChanged {
harness_type: String,
},
EnvironmentChanged {
environment_id: String,
},
CreateEnvironmentRequested,
WorkerHostChanged {
worker_host: String,
},
AuthSecretChanged {
auth_secret_name: Option<String>,
},
/// User picked the "New API key…" item; opens the workspace create modal.
CreateNewAuthSecretRequested,
}
impl OrchestrationControlAction for OrchestrationConfigBlockAction {
fn execution_mode_toggled(is_remote: bool) -> Self {
Self::ExecutionModeToggled { is_remote }
}
fn model_changed(model_id: String) -> Self {
Self::ModelChanged { model_id }
}
fn harness_changed(harness_type: String) -> Self {
Self::HarnessChanged { harness_type }
}
fn environment_changed(environment_id: String) -> Self {
Self::EnvironmentChanged { environment_id }
}
fn create_environment_requested() -> Self {
Self::CreateEnvironmentRequested
}
fn auth_secret_changed(auth_secret_name: Option<String>) -> Self {
Self::AuthSecretChanged { auth_secret_name }
}
fn create_new_auth_secret_requested() -> Self {
Self::CreateNewAuthSecretRequested
}
}
// ── View ────────────────────────────────────────────────────────────
pub struct OrchestrationConfigBlockView {
conversation_id: AIConversationId,
plan_id: String,
edit_state: OrchestrationEditState,
pickers: OrchestrationPickerHandles<OrchestrationConfigBlockAction>,
create_environment_modal: ViewHandle<CreateEnvironmentModal>,
is_approved: bool,
details_expanded: bool,
pickers_initialized: bool,
toggle_switch_state: SwitchStateHandle,
details_mouse_state: MouseStateHandle,
/// UI-only per-harness model memory so switching harnesses preserves
/// the user's previous model selection for each harness.
saved_model_per_harness: HashMap<String, String>,
/// Suppresses self-triggered refresh when `apply_field_change`
/// saves the config and the resulting event re-enters
/// `refresh_from_model`.
suppress_refresh: bool,
/// One-shot guard: cancelling the auto-popped modal must not re-pop.
/// Reset on harness / execution-mode change.
has_auto_opened_create_modal: bool,
/// Required before any auto-pop fires. The plan card is
/// reconstructed on session restore with `is_approved=true`; gating
/// on explicit interaction (toggle approval, change harness, switch
/// mode) suppresses the modal on restore while still firing for
/// live-session enablement.
user_has_interacted: bool,
}
impl OrchestrationConfigBlockView {
pub fn new(
conversation_id: AIConversationId,
plan_id: String,
ctx: &mut ViewContext<Self>,
) -> Self {
let history = BlocklistAIHistoryModel::as_ref(ctx);
let snapshot_loaded = history
.conversation(&conversation_id)
.and_then(|conv| conv.orchestration_config_for_plan(&plan_id))
.is_some();
let (edit_state, is_approved) = history
.conversation(&conversation_id)
.and_then(|conv| {
conv.orchestration_config_for_plan(&plan_id)
.map(|(config, status)| {
(
OrchestrationEditState::from_orchestration_config(config),
status.is_approved(),
)
})
})
.unwrap_or_else(|| {
(
OrchestrationEditState::from_run_agents_fields(
"auto",
"oz",
&RunAgentsExecutionMode::Local,
),
false,
)
});
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
move |me, _, event, ctx| {
if let BlocklistAIHistoryEvent::OrchestrationConfigUpdated {
conversation_id: cid,
..
} = event
{
if *cid == me.conversation_id {
me.refresh_from_model(ctx);
}
}
},
);
// Repopulate the model picker when available LLMs change (Oz
// harness only — non-Oz harnesses get their catalog from
// HarnessAvailabilityModel, not LLMPreferences).
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
if let LLMPreferencesEvent::UpdatedAvailableLLMs = event {
if let Some(handle) = &me.pickers.model_picker {
let is_local = !me.edit_state.execution_mode.is_remote();
oc::populate_model_picker_for_harness(None,
handle,
&me.edit_state.model_id,
&me.edit_state.harness_type,
is_local,
ctx,
);
}
}
});
// Repopulate pickers when the server-provided harness list,
// harness model catalogs, or per-harness auth secrets change.
// Without an `AuthSecretsLoaded` handler the picker stays on
// "Loading…" forever after the lazy fetch completes.
ctx.subscribe_to_model(
&HarnessAvailabilityModel::handle(ctx),
|me, _, event, ctx| match event {
HarnessAvailabilityEvent::AuthSecretCreated { harness, name } => {
if me.pickers_initialized {
oc::apply_created_auth_secret_if_matches(
&mut me.edit_state,
*harness,
name,
ctx,
);
oc::repopulate_all_pickers(&mut me.edit_state, &me.pickers, ctx);
}
ctx.notify();
}
HarnessAvailabilityEvent::Changed
| HarnessAvailabilityEvent::AuthSecretsLoaded
| 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::AuthSecretDeletionFailed { .. } => {}
},
);
let create_environment_modal = ctx.add_typed_action_view(CreateEnvironmentModal::new);
ctx.subscribe_to_view(&create_environment_modal, |me, _, event, ctx| match event {
CreateEnvironmentModalEvent::Created { environment_id } => {
me.select_created_environment(environment_id.clone(), ctx);
}
CreateEnvironmentModalEvent::Cancelled => {
ctx.notify();
}
});
ctx.subscribe_to_model(
&ConnectedSelfHostedWorkersModel::handle(ctx),
|me, _, event, ctx| match event {
ConnectedSelfHostedWorkersEvent::Changed => {
if me.pickers_initialized {
oc::repopulate_all_pickers(&mut me.edit_state, &me.pickers, ctx);
}
ctx.notify();
}
},
);
let mut view = Self {
conversation_id,
plan_id,
edit_state,
pickers: OrchestrationPickerHandles::default(),
create_environment_modal,
is_approved,
details_expanded: false,
pickers_initialized: false,
toggle_switch_state: SwitchStateHandle::default(),
details_mouse_state: MouseStateHandle::default(),
saved_model_per_harness: HashMap::new(),
suppress_refresh: false,
has_auto_opened_create_modal: false,
user_has_interacted: false,
};
if view.is_approved {
view.ensure_pickers(ctx);
// Skip auto-open here: construction is also the restore code
// path. The first user interaction (or `arm_for_fresh_dispatch`
// from a live config update) arms the auto-open instead.
}
// Capture the agent's config proposal once per view instance.
// Gated on `snapshot_loaded` so we don't fire when the view is
// constructed with placeholder defaults (no real snapshot yet).
if snapshot_loaded {
view.emit_agent_proposed_config(ctx);
}
view
}
/// Marks this view as eligible for auto-pop. Called by
/// `AIDocumentView`'s lazy-creation path when the agent dispatches a
/// live config update. Restoration goes through eager construction
/// and never calls this.
pub fn arm_for_fresh_dispatch(&mut self, ctx: &mut ViewContext<Self>) {
self.user_has_interacted = true;
self.maybe_auto_open_create_modal(ctx);
}
/// Auto-pops the create-key modal once per view per harness/mode
/// change when the harness has no loaded secrets. Also auto-expands
/// the details panel so the modal opens with context visible behind it.
fn maybe_auto_open_create_modal(&mut self, ctx: &mut ViewContext<Self>) {
if self.has_auto_opened_create_modal {
return;
}
// Suppress on session restoration — see `user_has_interacted` doc.
if !self.user_has_interacted {
return;
}
if !self.is_approved || !self.pickers_initialized {
return;
}
if !oc::should_show_auth_secret_picker(&self.edit_state) {
return;
}
if !matches!(
self.edit_state.auth_secret_selection,
AuthSecretSelection::Unset
) {
return;
}
let Some(harness) = Harness::parse_orchestration_harness(&self.edit_state.harness_type)
else {
return;
};
// Only auto-open on `Loaded([])`. Other fetch states are
// ambiguous; the `AuthSecretsLoaded` subscription will retry.
let has_zero_loaded = matches!(
HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness),
AuthSecretFetchState::Loaded(secrets) if secrets.is_empty()
);
if !has_zero_loaded {
return;
}
self.has_auto_opened_create_modal = true;
// Show the picker / validation behind the modal.
if !self.details_expanded {
self.details_expanded = true;
}
ctx.dispatch_typed_action(&WorkspaceAction::OpenCreateAuthSecretModal { harness });
ctx.notify();
}
fn refresh_from_model(&mut self, ctx: &mut ViewContext<Self>) {
if self.suppress_refresh {
self.suppress_refresh = false;
return;
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
if let Some(conv) = history.conversation(&self.conversation_id) {
if let Some((config, status)) = conv.orchestration_config_for_plan(&self.plan_id) {
self.edit_state = OrchestrationEditState::from_orchestration_config(config);
self.is_approved = status.is_approved();
if self.pickers_initialized {
oc::repopulate_all_pickers(&mut self.edit_state, &self.pickers, ctx);
}
ctx.notify();
}
}
}
fn ensure_pickers(&mut self, ctx: &mut ViewContext<Self>) {
if self.pickers_initialized {
return;
}
let appearance = Appearance::as_ref(ctx);
let (styles, colors) = oc::picker_styles(appearance);
// When the agent didn't specify a model, fall back to the
// conversation's current base model so the picker isn't blank.
let display_model_id = if self.edit_state.model_id.trim().is_empty() {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&self.conversation_id)
.and_then(|conv| conv.latest_exchange())
.map(|ex| ex.model_id.to_string())
.unwrap_or_default()
} else {
self.edit_state.model_id.clone()
};
let is_local = !self.edit_state.execution_mode.is_remote();
let model_handle = oc::new_standard_filterable_picker_dropdown(&styles, ctx);
model_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c));
oc::populate_model_picker_for_harness(None,
&model_handle,
&display_model_id,
&self.edit_state.harness_type,
is_local,
ctx,
);
self.pickers.model_picker = Some(model_handle);
let harness_handle = oc::new_standard_picker_dropdown(&colors, ctx);
harness_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c));
oc::populate_harness_picker(
&harness_handle,
&self.edit_state.harness_type,
is_local,
ctx,
);
self.pickers.harness_picker = Some(harness_handle);
// When restoring a Remote config with empty host or
// environment, fill defaults so the pickers aren't blank.
// If the config is approved, persist the defaults so the
// stored config used by auto-launch has concrete values.
let (needs_host, needs_env) = match &self.edit_state.execution_mode {
RunAgentsExecutionMode::Remote {
worker_host,
environment_id,
..
} => (worker_host.is_empty(), environment_id.is_empty()),
RunAgentsExecutionMode::Local => (false, false),
};
let mut filled_defaults = false;
if needs_host {
// Prefer the workspace default (or the dev env-var override)
// over the bare "warp" fallback so self-hosted teams see
// their default pre-selected. Mirrors the Oz webapp's
// `HostSelector` initial-selection behavior.
let default_host = oc::resolve_default_host_slug(ctx)
.unwrap_or_else(|| oc::ORCHESTRATION_WARP_WORKER_HOST.to_string());
self.edit_state.set_worker_host(default_host);
filled_defaults = true;
}
if needs_env {
if let Some(default_env) = oc::resolve_default_environment_id(ctx) {
self.edit_state.set_environment_id(default_env);
filled_defaults = true;
}
}
if filled_defaults && self.is_approved {
self.apply_field_change(ctx);
}
let initial_env = match &self.edit_state.execution_mode {
RunAgentsExecutionMode::Remote { environment_id, .. } => environment_id.as_str(),
RunAgentsExecutionMode::Local => "",
};
let env_handle = oc::create_environment_picker(initial_env, &styles, ctx);
env_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c));
self.pickers.environment_picker = Some(env_handle);
let initial_host = match &self.edit_state.execution_mode {
RunAgentsExecutionMode::Remote { worker_host, .. } => worker_host.as_str(),
RunAgentsExecutionMode::Local => oc::ORCHESTRATION_WARP_WORKER_HOST,
};
let host_handle = ctx.add_typed_action_view(HostPicker::new);
// Paint the open menu in the overlay layer so it doesn't get covered
// by sibling pickers, matching the other pickers in this view.
host_handle.update(ctx, |picker, picker_ctx| {
picker.set_use_overlay_layer(true, picker_ctx);
});
oc::populate_host_picker(&host_handle, initial_host, ctx);
ctx.subscribe_to_view(&host_handle, |_me, _, event, ctx| match event {
HostPickerEvent::Opened => {
ConnectedSelfHostedWorkersModel::handle(ctx).update(ctx, |model, ctx| {
model.refresh(ctx);
});
}
HostPickerEvent::HostChanged { slug } => {
ctx.dispatch_typed_action(&OrchestrationConfigBlockAction::WorkerHostChanged {
worker_host: slug.clone(),
});
}
HostPickerEvent::Closed => {}
});
self.pickers.host_picker = Some(host_handle);
// Seed the auth secret from persisted per-harness settings before
// building the picker so the dropdown shows the last selection.
// The full selection resolver honors an explicit `Inherit` choice
// (which isn't carried on the OrchestrationConfig wire payload).
if matches!(
self.edit_state.auth_secret_selection,
AuthSecretSelection::Unset
) {
self.edit_state.auth_secret_selection =
oc::resolve_auth_secret_selection_for_harness(&self.edit_state.harness_type, ctx);
}
let auth_secret_handle = oc::new_standard_picker_dropdown(&colors, ctx);
auth_secret_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c));
oc::populate_auth_secret_picker_for_harness(
&auth_secret_handle,
&self.edit_state.auth_secret_selection,
&self.edit_state.harness_type,
ctx,
);
self.pickers.auth_secret_picker = Some(auth_secret_handle);
self.pickers_initialized = true;
oc::sync_picker_selections(&self.edit_state, &self.pickers, ctx);
}
fn open_create_environment_modal(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(environment_picker) = &self.pickers.environment_picker {
environment_picker.update(ctx, |dropdown, ctx| dropdown.close(ctx));
}
self.create_environment_modal.update(ctx, |modal, ctx| {
modal.show(ctx);
});
ctx.notify();
}
fn select_created_environment(&mut self, environment_id: String, ctx: &mut ViewContext<Self>) {
self.edit_state.set_environment_id(environment_id.clone());
if let Some(environment_picker) = &self.pickers.environment_picker {
oc::populate_environment_picker(environment_picker, &environment_id, ctx);
}
self.apply_field_change(ctx);
ctx.notify();
}
fn apply_field_change(&mut self, ctx: &mut ViewContext<Self>) {
self.suppress_refresh = true;
let config = self.edit_state.to_orchestration_config();
let status = if self.is_approved {
OrchestrationConfigStatus::Approved
} else {
OrchestrationConfigStatus::Disapproved
};
let conversation_id = self.conversation_id;
let plan_id = self.plan_id.clone();
AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
model.set_orchestration_config_for_plan(conversation_id, plan_id, config, status, ctx);
});
}
}
impl Entity for OrchestrationConfigBlockView {
type Event = ();
}
impl View for OrchestrationConfigBlockView {
fn ui_name() -> &'static str {
"OrchestrationConfigBlockView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Header row: "Use orchestration" + pill toggle switch
let header_label = Text::new(
CONFIG_BLOCK_HEADER.to_string(),
appearance.ui_font_family(),
16.,
)
.with_color(blended_colors::text_main(theme, theme.background()))
.with_style(Properties::default().weight(Weight::Bold))
.finish();
let is_on = self.is_approved;
let ui_builder = appearance.ui_builder();
let header_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(warpui::elements::Expanded::new(1.0, header_label).finish())
.with_child(
ui_builder
.switch(self.toggle_switch_state.clone())
.check(is_on)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(OrchestrationConfigBlockAction::ToggleApproval);
})
.finish(),
)
.finish();
column.add_child(header_row);
// Description
let description = Text::new(
CONFIG_BLOCK_DESCRIPTION.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(blended_colors::text_main(theme, theme.background()))
.finish();
column.add_child(Container::new(description).with_margin_top(8.).finish());
// "View details" row + expandable controls (only when approved)
if self.is_approved {
// Divider
let divider = Container::new(
ConstrainedBox::new(Empty::new().finish())
.with_height(1.)
.finish(),
)
.with_background_color(theme.surface_2().into_solid())
.finish();
column.add_child(Container::new(divider).with_margin_top(8.).finish());
// "View details" link row
let chevron_icon = if self.details_expanded {
galaxy_core::ui::Icon::ChevronDown
} else {
galaxy_core::ui::Icon::ChevronRight
};
let disabled_text_color = blended_colors::text_disabled(theme, theme.background());
let details_text = Text::new(
"View details".to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size() + 1.,
)
.with_color(disabled_text_color)
.finish();
let chevron = ConstrainedBox::new(
chevron_icon
.to_warpui_icon(galaxy_core::ui::theme::Fill::Solid(disabled_text_color))
.finish(),
)
.with_width(14.)
.with_height(14.)
.finish();
let details_link = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(2.)
.with_child(details_text)
.with_child(chevron)
.finish();
let details_link_hoverable =
Hoverable::new(self.details_mouse_state.clone(), move |_| details_link)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(OrchestrationConfigBlockAction::ToggleDetails);
})
.with_cursor(Cursor::PointingHand)
.finish();
let details_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(details_link_hoverable)
.finish();
column.add_child(Container::new(details_row).with_margin_top(8.).finish());
// Expanded controls
if self.details_expanded {
// Galaxy orchestration is local-only.
let active_seg_bg =
galaxy_core::ui::theme::color::internal_colors::accent_overlay_2(theme);
column.add_child(
Container::new(oc::render_mode_toggle(
&self.pickers,
appearance,
Some(active_seg_bg),
true,
))
.with_margin_top(12.)
.finish(),
);
// Pickers stacked vertically
column.add_child(oc::render_picker_row_with_layout(
&self.edit_state,
&self.pickers,
appearance,
true,
));
// Helper text
let helper = Text::new(
BASE_MODEL_HELPER.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.,
)
.with_color(blended_colors::text_disabled(theme, theme.background()))
.finish();
column.add_child(Container::new(helper).with_margin_top(4.).finish());
// Validation
if let Some(reason) = oc::accept_disabled_reason_with_auth(&self.edit_state, app) {
column.add_child(oc::render_validation_error(
reason,
theme.ui_error_color(),
appearance,
));
} else if let Some(message) =
oc::empty_env_recommendation_message(&self.edit_state.execution_mode, app)
{
column.add_child(oc::render_validation_error(
message,
theme.ui_warning_color(),
appearance,
));
}
}
}
// Outer container with accent styling per Figma
let card = Container::new(column.finish())
.with_uniform_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(
galaxy_core::ui::theme::color::internal_colors::accent_overlay_1(theme),
)
.with_border(warpui::elements::Border::all(1.).with_border_fill(theme.accent()))
.finish();
let mut stack = Stack::new().with_child(card);
if self.create_environment_modal.as_ref(app).is_visible() {
stack.add_positioned_overlay_child(
ChildView::new(&self.create_environment_modal).finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
stack.finish()
}
}
impl TypedActionView for OrchestrationConfigBlockView {
type Action = OrchestrationConfigBlockAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
OrchestrationConfigBlockAction::ToggleApproval => {
self.is_approved = !self.is_approved;
if self.is_approved && !self.pickers_initialized {
self.ensure_pickers(ctx);
}
let status = if self.is_approved {
OrchestrationApprovalStatus::Approved
} else {
OrchestrationApprovalStatus::Disapproved
};
self.emit_plan_config_approval_toggled(status, ctx);
self.apply_field_change(ctx);
// First moment the picker exists — arm and evaluate.
if self.is_approved {
self.user_has_interacted = true;
self.maybe_auto_open_create_modal(ctx);
}
ctx.notify();
}
OrchestrationConfigBlockAction::ToggleDetails => {
self.details_expanded = !self.details_expanded;
if self.details_expanded && !self.pickers_initialized {
self.ensure_pickers(ctx);
}
ctx.notify();
}
OrchestrationConfigBlockAction::ExecutionModeToggled { is_remote } => {
let conversation_id = self.conversation_id;
oc::apply_execution_mode_change(
&mut self.edit_state,
&self.pickers,
*is_remote,
|ctx| {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conv| conv.latest_exchange())
.map(|ex| ex.model_id.to_string())
},
ctx,
);
self.apply_field_change(ctx);
// Local → Cloud can newly reveal the auth picker.
self.user_has_interacted = true;
self.has_auto_opened_create_modal = false;
self.maybe_auto_open_create_modal(ctx);
ctx.notify();
}
OrchestrationConfigBlockAction::ModelChanged { model_id } => {
self.edit_state.model_id = model_id.clone();
self.apply_field_change(ctx);
ctx.notify();
}
OrchestrationConfigBlockAction::HarnessChanged { harness_type } => {
let conversation_id = self.conversation_id;
oc::apply_harness_change(
&mut self.edit_state,
&mut self.saved_model_per_harness,
&self.pickers,
harness_type,
|ctx| {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conv| conv.latest_exchange())
.map(|ex| ex.model_id.to_string())
},
ctx,
);
self.apply_field_change(ctx);
// Per-harness state reset — re-arm for the new harness.
self.user_has_interacted = true;
self.has_auto_opened_create_modal = false;
self.maybe_auto_open_create_modal(ctx);
ctx.notify();
}
OrchestrationConfigBlockAction::EnvironmentChanged { environment_id } => {
self.edit_state.set_environment_id(environment_id.clone());
oc::persist_environment_selection(environment_id, ctx);
self.apply_field_change(ctx);
ctx.notify();
}
OrchestrationConfigBlockAction::CreateEnvironmentRequested => {
self.open_create_environment_modal(ctx);
}
OrchestrationConfigBlockAction::WorkerHostChanged { worker_host } => {
self.edit_state.set_worker_host(worker_host.clone());
oc::persist_host_selection(worker_host, ctx);
self.apply_field_change(ctx);
ctx.notify();
}
OrchestrationConfigBlockAction::AuthSecretChanged { auth_secret_name } => {
// No `apply_field_change`: secrets are user-scoped and
// persisted side-channel, not baked into `OrchestrationConfig`.
oc::apply_auth_secret_change(
&mut self.edit_state,
&self.pickers,
auth_secret_name.clone(),
ctx,
);
ctx.notify();
}
OrchestrationConfigBlockAction::CreateNewAuthSecretRequested => {
oc::apply_create_new_auth_secret_requested(&mut self.edit_state, ctx);
if let Some(harness) =
Harness::parse_orchestration_harness(&self.edit_state.harness_type)
{
ctx.dispatch_typed_action(&WorkspaceAction::OpenCreateAuthSecretModal {
harness,
});
}
ctx.notify();
}
}
}
}
impl OrchestrationConfigBlockView {
fn emit_plan_config_approval_toggled(
&self,
status: OrchestrationApprovalStatus,
ctx: &mut ViewContext<Self>,
) {
send_telemetry_from_ctx!(
BlocklistOrchestrationTelemetryEvent::PlanConfigApprovalToggled(
PlanConfigApprovalToggledEvent {
conversation_id: self.conversation_id,
plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()),
status,
execution_mode: OrchestrationExecutionModeKind::from_run_agents(
&self.edit_state.execution_mode,
),
harness: OrchestrationHarnessKind::from_str(&self.edit_state.harness_type),
has_model: !self.edit_state.model_id.trim().is_empty(),
has_environment: env_presence(&self.edit_state.execution_mode),
has_worker_host: host_presence(&self.edit_state.execution_mode),
has_auth_secret: self.edit_state.auth_secret_name().is_some(),
}
),
ctx
);
}
fn emit_agent_proposed_config(&self, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(
BlocklistOrchestrationTelemetryEvent::AgentProposedConfig(AgentProposedConfigEvent {
conversation_id: self.conversation_id,
plan_id: (!self.plan_id.is_empty()).then(|| self.plan_id.clone()),
harness: OrchestrationHarnessKind::from_str(&self.edit_state.harness_type),
execution_mode: OrchestrationExecutionModeKind::from_run_agents(
&self.edit_state.execution_mode,
),
has_model: !self.edit_state.model_id.trim().is_empty(),
has_environment: env_presence(&self.edit_state.execution_mode),
has_worker_host: host_presence(&self.edit_state.execution_mode),
}),
ctx
);
}
}