diff --git a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs index cb0246cdc6..bf48bf8b3b 100644 --- a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs +++ b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs @@ -44,6 +44,10 @@ use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::{ }; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order; +use crate::ai::blocklist::telemetry::{ + BlocklistOrchestrationTelemetryEvent, PillBarActionKind, PillBarInteractionEvent, + PillBarPillKind, PillSwitchOutcome, +}; use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use crate::ai::harness_display; use crate::features::FeatureFlag; @@ -55,6 +59,7 @@ use crate::ui_components::icon_with_status::{ }; use crate::ui_components::icons::Icon; use crate::workspace::WorkspaceAction; +use warp_core::send_telemetry_from_ctx; use warp_core::ui::theme::color::internal_colors; use warpui::EntityId; @@ -131,12 +136,21 @@ pub(crate) fn render_agent_avatar_disc( } /// What kind of pill we are rendering, which determines click behavior. -#[derive(Clone, Copy, Debug)] -enum PillKind { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PillKind { Orchestrator, Child, } +impl PillKind { + fn telemetry_kind(self) -> PillBarPillKind { + match self { + Self::Orchestrator => PillBarPillKind::Orchestrator, + Self::Child => PillBarPillKind::Child, + } + } +} + /// Whether the user has pinned this pill to the leading section of the bar. #[derive(Clone, Copy, PartialEq, Eq)] enum PillPinState { @@ -249,6 +263,13 @@ pub enum OrchestrationPillBarAction { FocusOpenedConversation(AIConversationId), /// Toggle the pin state for the given child conversation. TogglePin(AIConversationId), + /// Pill body was clicked. Dispatched in lieu of the navigation + /// `TerminalAction` so telemetry can be emitted before the + /// downstream navigation runs. + PillClicked { + conversation_id: AIConversationId, + pill_kind: PillKind, + }, } /// Renders the pill bar above the agent view: one pill for the orchestrator @@ -651,12 +672,176 @@ fn orchestrator_label(orchestrator: &AIConversation) -> String { .unwrap_or_else(|| "Orchestrator".to_string()) } +impl OrchestrationPillBar { + /// Resolves the source-conversation / total-pills / total-pinned + /// triple used to enrich every `PillBarInteraction` event. Returns + /// `None` when there is no active orchestration tree to attribute + /// the interaction to. + fn pill_bar_telemetry_context( + &self, + app: &AppContext, + ) -> Option<(AIConversationId, usize, usize)> { + let (orchestrator_id, specs) = self.pill_specs(app)?; + let total_pills = specs.len(); + let total_pinned = specs + .iter() + .filter(|spec| matches!(spec.pin_state, PillPinState::Pinned)) + .count(); + Some((orchestrator_id, total_pills, total_pinned)) + } + + /// Pill kind for `target_id` in the current pill specs. Defaults + /// to `Child` if the id is no longer in the bar. + fn pill_kind_for(&self, target_id: AIConversationId, app: &AppContext) -> PillBarPillKind { + self.pill_specs(app) + .and_then(|(_, specs)| { + specs + .into_iter() + .find(|spec| spec.conversation_id == target_id) + .map(|spec| spec.kind.telemetry_kind()) + }) + .unwrap_or(PillBarPillKind::Child) + } + + fn emit_pill_bar_interaction( + &self, + action: PillBarActionKind, + pill_kind: PillBarPillKind, + target_conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + self.emit_pill_bar_interaction_with_outcome( + action, + pill_kind, + target_conversation_id, + None, + ctx, + ); + } + + /// Same as [`Self::emit_pill_bar_interaction`] but stamps a + /// `switch_outcome` on the payload. Use for `Switch` actions where + /// the analyst needs to know whether the click navigated in place + /// or focused an existing pane. + fn emit_pill_switch( + &self, + pill_kind: PillBarPillKind, + target_conversation_id: AIConversationId, + outcome: PillSwitchOutcome, + ctx: &mut ViewContext, + ) { + self.emit_pill_bar_interaction_with_outcome( + PillBarActionKind::Switch, + pill_kind, + target_conversation_id, + Some(outcome), + ctx, + ); + } + + fn emit_pill_bar_interaction_with_outcome( + &self, + action: PillBarActionKind, + pill_kind: PillBarPillKind, + target_conversation_id: AIConversationId, + switch_outcome: Option, + ctx: &mut ViewContext, + ) { + let Some((source_conversation_id, total_pills, total_pinned)) = + self.pill_bar_telemetry_context(ctx) + else { + return; + }; + send_telemetry_from_ctx!( + BlocklistOrchestrationTelemetryEvent::PillBarInteraction(PillBarInteractionEvent { + action, + pill_kind, + total_pills, + total_pinned, + source_conversation_id, + target_conversation_id, + switch_outcome, + }), + ctx + ); + } + + /// Dispatches the focus-existing-pane navigation. Pulled out of + /// the `FocusOpenedConversation` handler so the `PillClicked` + /// handler can reuse the same nav logic without emitting the + /// menu-driven `FocusOpenedConversation` telemetry event. + fn navigate_to_owner_pane(&self, id: AIConversationId, ctx: &mut ViewContext) { + // "Focus pane" is purely a focus operation: the conversation + // already lives in some other visible terminal view (verified + // by `is_conversation_open_in_other_visible_view` at the call + // site) and we just want to move the user's cursor there. We + // deliberately do *not* go through + // `RestoreOrNavigateToConversation`: that path calls + // `set_active_conversation_id` with whichever + // `terminal_view_id` it receives, which would either + // re-transfer ownership to a stale id pulled from + // `AgentConversationsModel::nav_data` or, worse, blank out + // the real owner pane while the conversation pops back into + // the orchestrator. + // + // Resolve the canonical owner directly from + // `BlocklistAIHistoryModel` (the single source of truth) and + // pick the appropriate focus action based on whether the + // owner pane lives in the same pane group as us: + // * Same pane group (sibling pane in this tab) — + // dispatch `TerminalAction::RevealChildAgent`. The pane + // group's handler walks visible terminal panes and calls + // `group.focus_pane(.., true, ctx)` from its own + // `ViewContext`, which actually shifts focus + // to the sibling pane. Going through the workspace's + // `focus_pane` from a different `ViewContext` doesn't + // reliably move focus when the destination is in the + // same pane group. + // * Different pane group (other tab / window) — + // dispatch `WorkspaceAction::FocusTerminalViewInWorkspace`, + // which walks all tabs/windows and activates the + // containing tab as needed. + let owner_view_id = + BlocklistAIHistoryModel::as_ref(ctx).terminal_view_id_for_conversation(&id); + let Some(owner_view_id) = owner_view_id else { + log::warn!( + "navigate_to_owner_pane: no canonical owner for {id:?}; falling back to switch-in-place" + ); + ctx.dispatch_typed_action( + &PaneHeaderAction::::CustomAction( + TerminalAction::SwitchAgentViewToConversation { + conversation_id: id, + }, + ), + ); + return; + }; + let self_pane_group_id = self.agent_view_controller.as_ref(ctx).pane_group_id(); + let owner_pane_group_id = pane_group_id_containing_terminal_view(owner_view_id, ctx); + if owner_pane_group_id.is_some() && owner_pane_group_id == self_pane_group_id { + ctx.dispatch_typed_action( + &PaneHeaderAction::::CustomAction( + TerminalAction::RevealChildAgent { + conversation_id: id, + }, + ), + ); + } else { + ctx.dispatch_typed_action(&WorkspaceAction::FocusTerminalViewInWorkspace { + terminal_view_id: owner_view_id, + }); + } + } +} + impl TypedActionView for OrchestrationPillBar { type Action = OrchestrationPillBarAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { OrchestrationPillBarAction::OpenMenu(id) => { + let pill_kind = self.pill_kind_for(*id, ctx); + self.emit_pill_bar_interaction(PillBarActionKind::OpenMenu, pill_kind, *id, ctx); self.open_menu_for(*id, ctx); } OrchestrationPillBarAction::CloseMenu => { @@ -670,6 +855,12 @@ impl TypedActionView for OrchestrationPillBar { // dispatch it through the pane header action surface so // it bubbles up the standard way (mirrors the pill-click // path in `render_pill`). + self.emit_pill_bar_interaction( + PillBarActionKind::OpenInNewPane, + PillBarPillKind::Child, + *id, + ctx, + ); self.close_menu(ctx); ctx.dispatch_typed_action( &PaneHeaderAction::::CustomAction( @@ -680,6 +871,12 @@ impl TypedActionView for OrchestrationPillBar { ); } OrchestrationPillBarAction::OpenInNewTab(id) => { + self.emit_pill_bar_interaction( + PillBarActionKind::OpenInNewTab, + PillBarPillKind::Child, + *id, + ctx, + ); self.close_menu(ctx); ctx.dispatch_typed_action( &PaneHeaderAction::::CustomAction( @@ -690,6 +887,12 @@ impl TypedActionView for OrchestrationPillBar { ); } OrchestrationPillBarAction::Stop(id) => { + self.emit_pill_bar_interaction( + PillBarActionKind::Stop, + PillBarPillKind::Child, + *id, + ctx, + ); self.close_menu(ctx); ctx.dispatch_typed_action( &PaneHeaderAction::::CustomAction( @@ -700,6 +903,12 @@ impl TypedActionView for OrchestrationPillBar { ); } OrchestrationPillBarAction::Kill(id) => { + self.emit_pill_bar_interaction( + PillBarActionKind::Kill, + PillBarPillKind::Child, + *id, + ctx, + ); self.close_menu(ctx); ctx.dispatch_typed_action( &PaneHeaderAction::::CustomAction( @@ -716,75 +925,61 @@ impl TypedActionView for OrchestrationPillBar { // Singleton emits an event that drives the re-render in every // pill bar, so no `ctx.notify()` needed here. let id = *id; + // Determine which way the toggle is going before applying + // it so the telemetry payload reports the resulting state + // rather than the prior one. + let was_pinned = OrchestrationPillBarModel::as_ref(ctx).is_pinned(&id); + let action_kind = if was_pinned { + PillBarActionKind::TogglePinOff + } else { + PillBarActionKind::TogglePinOn + }; + self.emit_pill_bar_interaction(action_kind, PillBarPillKind::Child, id, ctx); OrchestrationPillBarModel::handle(ctx).update(ctx, |model, ctx| { model.toggle_pin(id, ctx); }); } - OrchestrationPillBarAction::FocusOpenedConversation(id) => { - self.close_menu(ctx); - // "Focus pane" is purely a focus operation: the - // conversation already lives in some other visible - // terminal view (verified by - // `is_conversation_open_in_other_visible_view` before we - // surface this menu item) and we just want to move the - // user's cursor there. We deliberately do *not* go - // through `RestoreOrNavigateToConversation`: that path - // calls `set_active_conversation_id` with whichever - // `terminal_view_id` it receives, which would either - // re-transfer ownership to a stale id pulled from - // `AgentConversationsModel::nav_data` or, worse, blank - // out the real owner pane while the conversation pops - // back into the orchestrator. - // - // Resolve the canonical owner directly from - // `BlocklistAIHistoryModel` (the single source of truth) - // and pick the appropriate focus action based on whether - // the owner pane lives in the same pane group as us: - // * Same pane group (sibling pane in this tab) — - // dispatch `TerminalAction::RevealChildAgent`. The - // pane group's handler walks visible terminal panes - // and calls `group.focus_pane(.., true, ctx)` from - // its own `ViewContext`, which actually - // shifts focus to the sibling pane. Going through - // the workspace's `focus_pane` from a different - // `ViewContext` doesn't reliably move focus when the - // destination is in the same pane group. - // * Different pane group (other tab / window) — - // dispatch `WorkspaceAction::FocusTerminalViewInWorkspace`, - // which walks all tabs/windows and activates the - // containing tab as needed. - let owner_view_id = - BlocklistAIHistoryModel::as_ref(ctx).terminal_view_id_for_conversation(id); - let Some(owner_view_id) = owner_view_id else { - log::warn!( - "FocusOpenedConversation: no canonical owner for {id:?}; falling back to switch-in-place" - ); - ctx.dispatch_typed_action( - &PaneHeaderAction::::CustomAction( - TerminalAction::SwitchAgentViewToConversation { - conversation_id: *id, - }, - ), - ); - return; + OrchestrationPillBarAction::PillClicked { + conversation_id, + pill_kind, + } => { + let id = *conversation_id; + let self_terminal_view_id = + self.agent_view_controller.as_ref(ctx).terminal_view_id(); + let is_open_elsewhere = + is_conversation_open_in_other_visible_view(id, self_terminal_view_id, ctx); + // Pill-body clicks always emit a single `Switch` event, + // with `switch_outcome` capturing what navigation + // actually happened. Analysts can count all pill clicks + // with `action = switch` and slice by outcome — no need + // to UNION with `FocusOpenedConversation` (which is + // reserved for the menu-driven "Focus pane" gesture). + let outcome = if is_open_elsewhere { + PillSwitchOutcome::FocusedExistingPane + } else { + PillSwitchOutcome::SwitchedInPlace }; - let self_pane_group_id = self.agent_view_controller.as_ref(ctx).pane_group_id(); - let owner_pane_group_id = - pane_group_id_containing_terminal_view(owner_view_id, ctx); - if owner_pane_group_id.is_some() && owner_pane_group_id == self_pane_group_id { + self.emit_pill_switch(pill_kind.telemetry_kind(), id, outcome, ctx); + if is_open_elsewhere { + self.navigate_to_owner_pane(id, ctx); + } else { ctx.dispatch_typed_action( &PaneHeaderAction::::CustomAction( - TerminalAction::RevealChildAgent { - conversation_id: *id, - }, + navigation_action_for_pill(*pill_kind, id), ), ); - } else { - ctx.dispatch_typed_action(&WorkspaceAction::FocusTerminalViewInWorkspace { - terminal_view_id: owner_view_id, - }); } } + OrchestrationPillBarAction::FocusOpenedConversation(id) => { + self.emit_pill_bar_interaction( + PillBarActionKind::FocusOpenedConversation, + PillBarPillKind::Child, + *id, + ctx, + ); + self.close_menu(ctx); + self.navigate_to_owner_pane(*id, ctx); + } } } } @@ -1714,40 +1909,19 @@ fn render_pill( }; ctx.dispatch_typed_action(OrchestrationPillBarAction::SetHoveredPill(payload)); }) - .on_click(move |ctx, app, _| { + .on_click(move |ctx, _app, _| { if is_selected { return; } - // Single source of truth: if the conversation is currently owned - // by a *different* visible terminal view than this orchestrator - // pane (because it was split off into a separate pane or tab), - // the pill should focus that existing pane rather than re-render - // the conversation in place. Route through the pill bar's own - // `FocusOpenedConversation` action so this path and the 3-dot - // menu's "Focus pane" item share a single implementation — the - // pill bar's `handle_action` then dispatches - // `WorkspaceAction::FocusTerminalViewInWorkspace` from a - // `ViewContext`, which reliably reaches the workspace. - let is_open_elsewhere = - is_conversation_open_in_other_visible_view(conversation_id, self_terminal_view_id, app); - - if is_open_elsewhere { - ctx.dispatch_typed_action(OrchestrationPillBarAction::FocusOpenedConversation( - conversation_id, - )); - return; - } - // Child pills reveal the existing child pane/session and orchestrator - // pills swap back to the orchestrator's pane. The hidden child pane - // owns the live harness/ambient session (locally) or the shared- - // session viewer that joins the child's session (for shared session - // viewers, materialized lazily by `OrchestrationViewerModel` when a - // `session_id` is known). In both cases the swap-based navigation is - // the correct destination. - let action = navigation_action_for_pill(kind, conversation_id); - ctx.dispatch_typed_action( - PaneHeaderAction::::CustomAction(action), - ); + // Route the click through `PillClicked` so the pill bar can + // emit telemetry before forwarding the navigation. The + // handler reads `self_terminal_view_id` from its own + // controller, so we no longer need the value captured here. + let _ = self_terminal_view_id; + ctx.dispatch_typed_action(OrchestrationPillBarAction::PillClicked { + conversation_id, + pill_kind: kind, + }); }) .finish(); diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 7d46d69a2d..758660ca60 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -678,6 +678,21 @@ impl BlocklistAIController { let (query, user_query_mode) = extract_user_query_mode(query); + // Attribute /orchestrate queries to the slash-command entry surface. + if matches!(user_query_mode, UserQueryMode::Orchestrate) { + send_telemetry_from_ctx!( + super::telemetry::BlocklistOrchestrationTelemetryEvent::OrchestrationEntered( + super::telemetry::OrchestrationEnteredEvent { + conversation_id, + plan_id: None, + entry_source: + super::telemetry::OrchestrationEntrySource::SlashCommandOrchestrate, + } + ), + ctx + ); + } + let should_prepend_finished_action_results = matches!( input_query.input_query, InputQueryType::UserSubmittedQueryFromInput { .. } diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index c513fb4dcb..9e0a971a46 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -10,10 +10,17 @@ use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult}; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; use crate::ai::agent::conversation::AIConversationId; +use crate::ai::blocklist::telemetry::{ + orchestration_modified_field, BlocklistOrchestrationTelemetryEvent, + OrchestrationApprovalStatus, OrchestrationEnteredEvent, OrchestrationEntrySource, + OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision, + RunAgentsCardDecisionEvent, +}; use crate::BlocklistAIHistoryModel; use ai::skills::SkillReference; use pathfinder_geometry::vector::vec2f; use std::rc::Rc; +use warp_core::send_telemetry_from_ctx; use warpui::elements::{ Border, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, MainAxisSize, OffsetPositioning, ParentElement, Radius, Stack, Text, @@ -225,6 +232,14 @@ pub struct RunAgentsCardView { /// UI-only per-harness model memory so switching harnesses preserves /// the user's previous model selection for each harness. saved_model_per_harness: HashMap, + /// Snapshot of the latest raw `RunAgentsRequest` from the LLM + /// stream. Used at decision time to diff the run-wide config + /// fields the user changed before accepting. + original_tool_call_request: RunAgentsRequest, + /// Guards `OrchestrationEntered` against double-fires on re-renders. + entered_event_emitted: bool, + /// Guards the terminal decision event against double-fires. + decision_event_emitted: 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, @@ -322,6 +337,9 @@ impl RunAgentsCardView { // (called after streaming finishes and agent_run_configs is populated). let state = RunAgentsEditState::from_request(request); let auto_launched = false; + // Snapshot the raw incoming request so we can diff against the + // edited state at Accept time. + let original_tool_call_request = request.clone(); let reject_keystroke = CTRL_C_KEYSTROKE.clone(); let accept_keystroke = ENTER_KEYSTROKE.clone(); @@ -494,6 +512,9 @@ impl RunAgentsCardView { action_model, block_model, saved_model_per_harness: HashMap::new(), + original_tool_call_request, + entered_event_emitted: false, + decision_event_emitted: false, has_auto_opened_create_modal: false, }; @@ -511,6 +532,9 @@ impl RunAgentsCardView { if self.spawning.is_some() || self.auto_launched || self.is_denied { return; } + // Keep the raw-tool-call snapshot in sync with the latest + // streamed chunk. + self.original_tool_call_request = request.clone(); let mut new_state = RunAgentsEditState::from_request(request); // Resolve empty fields from the active config (same as in new()). if let Some((config, status)) = &self.active_config { @@ -619,6 +643,10 @@ impl RunAgentsCardView { // First-chance fallback; don't reset the one-shot or we'd re-pop // after the user cancelled. self.maybe_auto_open_create_modal(ctx); + + // The card is about to be shown — log it as an orchestration + // entry point. + self.emit_orchestration_entered_once(conversation_id, ctx); } /// Validates and dispatches the resolved request. @@ -635,12 +663,85 @@ impl RunAgentsCardView { return; } let request = self.state.to_request(); + self.emit_decision(RunAgentsCardDecision::Accept, ctx); let action_id = self.action_id.clone(); self.action_model.update(ctx, |action_model, action_ctx| { action_model.execute_run_agents(&action_id, request, action_ctx); }); } + /// Emits `OrchestrationEntered::RunAgentsCardShown` at most once + /// per card instance. + fn emit_orchestration_entered_once( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + if self.entered_event_emitted { + return; + } + self.entered_event_emitted = true; + send_telemetry_from_ctx!( + BlocklistOrchestrationTelemetryEvent::OrchestrationEntered(OrchestrationEnteredEvent { + conversation_id, + plan_id: (!self.state.plan_id.is_empty()).then(|| self.state.plan_id.clone()), + entry_source: OrchestrationEntrySource::RunAgentsCardShown, + }), + ctx + ); + } + + /// Emits `RunAgentsCardDecision` at most once per card instance. + fn emit_decision(&mut self, decision: RunAgentsCardDecision, ctx: &mut ViewContext) { + if self.decision_event_emitted { + return; + } + self.decision_event_emitted = true; + let Some(conversation_id) = self.block_model.conversation_id(ctx) else { + return; + }; + let modified_fields_from_tool_call = + diverged_orch_fields(&self.state.orch, &self.original_tool_call_request); + let (had_active_config, active_config_status, modified_fields_from_active_config) = + match &self.active_config { + Some((cfg, status)) => { + let status_enum = if status.is_approved() { + Some(OrchestrationApprovalStatus::Approved) + } else if status.is_disapproved() { + Some(OrchestrationApprovalStatus::Disapproved) + } else { + None + }; + let diff = if status.is_approved() { + diverged_orch_fields_against_config(&self.state.orch, cfg) + } else { + Vec::new() + }; + (true, status_enum, diff) + } + None => (false, None, Vec::new()), + }; + send_telemetry_from_ctx!( + BlocklistOrchestrationTelemetryEvent::RunAgentsCardDecision( + RunAgentsCardDecisionEvent { + conversation_id, + plan_id: (!self.state.plan_id.is_empty()).then(|| self.state.plan_id.clone()), + decision, + agent_count: self.state.agent_run_configs.len(), + harness: OrchestrationHarnessKind::from_str(&self.state.orch.harness_type), + execution_mode: OrchestrationExecutionModeKind::from_run_agents( + &self.state.orch.execution_mode, + ), + modified_fields_from_tool_call, + modified_fields_from_active_config, + had_active_config, + active_config_status, + } + ), + ctx + ); + } + /// Auto-pops the create-key modal once per card per harness/mode /// change when the harness has no loaded secrets and selection is /// `Unset`. Cancelling leaves the picker on "+ New API key…"; the @@ -1000,6 +1101,7 @@ impl TypedActionView for RunAgentsCardView { self.handle_accept(ctx); } RunAgentsCardViewAction::AcceptWithoutOrchestration => { + self.emit_decision(RunAgentsCardDecision::AcceptWithoutOrchestration, ctx); let action_id = self.action_id.clone(); self.action_model.update(ctx, |action_model, action_ctx| { action_model.deny_run_agents(&action_id, String::new(), action_ctx); @@ -1009,6 +1111,7 @@ impl TypedActionView for RunAgentsCardView { self.toggle_accept_menu(ctx); } RunAgentsCardViewAction::Reject => { + self.emit_decision(RunAgentsCardDecision::Reject, ctx); ctx.emit(RunAgentsCardViewEvent::RejectRequested); } RunAgentsCardViewAction::ExecutionModeToggled { is_remote } => { @@ -1087,6 +1190,94 @@ impl TypedActionView for RunAgentsCardView { } } +/// Field names from [`orchestration_modified_field`] that differ +/// between the user-edited `state` and the LLM's original +/// `RunAgentsRequest`. +fn diverged_orch_fields( + state: &oc::OrchestrationEditState, + original: &RunAgentsRequest, +) -> Vec<&'static str> { + let mut fields = Vec::new(); + if state.model_id != original.model_id { + fields.push(orchestration_modified_field::MODEL_ID); + } + if state.harness_type != original.harness_type { + fields.push(orchestration_modified_field::HARNESS); + } + let state_remote = state.execution_mode.is_remote(); + let original_remote = original.execution_mode.is_remote(); + if state_remote != original_remote { + fields.push(orchestration_modified_field::EXECUTION_MODE); + } else if let ( + RunAgentsExecutionMode::Remote { + environment_id: state_env, + worker_host: state_host, + .. + }, + RunAgentsExecutionMode::Remote { + environment_id: orig_env, + worker_host: orig_host, + .. + }, + ) = (&state.execution_mode, &original.execution_mode) + { + if state_env != orig_env { + fields.push(orchestration_modified_field::ENVIRONMENT_ID); + } + if state_host != orig_host { + fields.push(orchestration_modified_field::WORKER_HOST); + } + } + if state.auth_secret_name() != original.harness_auth_secret_name.as_deref() { + fields.push(orchestration_modified_field::AUTH_SECRET); + } + fields +} + +/// Same shape as [`diverged_orch_fields`] but compares against an +/// approved `OrchestrationConfig`. auth_secret is omitted: managed +/// secrets are per-user, not stored on the config. +fn diverged_orch_fields_against_config( + state: &oc::OrchestrationEditState, + config: &OrchestrationConfig, +) -> Vec<&'static str> { + use ai::agent::orchestration_config::OrchestrationExecutionMode; + let mut fields = Vec::new(); + if state.model_id != config.model_id { + fields.push(orchestration_modified_field::MODEL_ID); + } + if state.harness_type != config.harness_type { + fields.push(orchestration_modified_field::HARNESS); + } + let state_remote = state.execution_mode.is_remote(); + let config_remote = matches!( + config.execution_mode, + OrchestrationExecutionMode::Remote { .. } + ); + if state_remote != config_remote { + fields.push(orchestration_modified_field::EXECUTION_MODE); + } else if let ( + RunAgentsExecutionMode::Remote { + environment_id: state_env, + worker_host: state_host, + .. + }, + OrchestrationExecutionMode::Remote { + environment_id: cfg_env, + worker_host: cfg_host, + }, + ) = (&state.execution_mode, &config.execution_mode) + { + if state_env != cfg_env { + fields.push(orchestration_modified_field::ENVIRONMENT_ID); + } + if state_host != cfg_host { + fields.push(orchestration_modified_field::WORKER_HOST); + } + } + fields +} + fn render_confirmation_card( state: &RunAgentsEditState, handles: &RunAgentsCardHandles, diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index ab48432aa1..21385ee719 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -8,6 +8,11 @@ use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc}; #[strum_discriminants(derive(EnumIter))] pub(crate) enum BlocklistOrchestrationTelemetryEvent { TeamAgentCommunicationFailed(TeamAgentCommunicationFailedEvent), + PlanConfigApprovalToggled(PlanConfigApprovalToggledEvent), + RunAgentsCardDecision(RunAgentsCardDecisionEvent), + PillBarInteraction(PillBarInteractionEvent), + OrchestrationEntered(OrchestrationEnteredEvent), + AgentProposedConfig(AgentProposedConfigEvent), } #[derive(Clone, Copy, Debug, Serialize)] @@ -58,6 +63,217 @@ pub(crate) struct TeamAgentCommunicationFailedEvent { pub error_message: Option, } +/// Coarse approval transition for the plan card's +/// `Use orchestration` toggle. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OrchestrationApprovalStatus { + Approved, + Disapproved, +} + +/// Run-wide execution mode reported on telemetry payloads. A flat +/// enum so the payload stays metadata-only and never carries an +/// environment id or worker host. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OrchestrationExecutionModeKind { + Local, + Remote, +} + +impl OrchestrationExecutionModeKind { + pub(crate) fn from_run_agents(mode: &ai::agent::action::RunAgentsExecutionMode) -> Self { + if mode.is_remote() { + Self::Remote + } else { + Self::Local + } + } +} + +/// Closed-set bucket for the run-wide harness selection. Anything +/// unrecognized collapses to `Unknown` to keep the analytics column +/// low-cardinality. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OrchestrationHarnessKind { + Oz, + ClaudeCode, + Codex, + OpenCode, + Gemini, + Unknown, +} + +impl OrchestrationHarnessKind { + pub(crate) fn from_str(harness_type: &str) -> Self { + match harness_type { + "oz" | "" => Self::Oz, + "claude" | "claude-code" | "claude_code" => Self::ClaudeCode, + "codex" => Self::Codex, + "opencode" | "open-code" | "open_code" => Self::OpenCode, + "gemini" => Self::Gemini, + _ => Self::Unknown, + } + } +} + +/// Stable names for run-wide config fields that can diverge between +/// the dispatched orchestration request and either the original tool +/// call or an active approved config. Match the server's equivalent +/// field-name constants so the two telemetry streams can be joined. +pub(crate) mod orchestration_modified_field { + pub const MODEL_ID: &str = "model_id"; + pub const HARNESS: &str = "harness"; + pub const EXECUTION_MODE: &str = "execution_mode"; + pub const ENVIRONMENT_ID: &str = "environment_id"; + pub const WORKER_HOST: &str = "worker_host"; + pub const AUTH_SECRET: &str = "auth_secret"; +} + +#[derive(Debug, Serialize)] +pub(crate) struct PlanConfigApprovalToggledEvent { + pub conversation_id: AIConversationId, + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_id: Option, + /// State after the toggle. The pre-toggle state is the opposite + /// since this event only fires on a binary flip. + pub status: OrchestrationApprovalStatus, + pub execution_mode: OrchestrationExecutionModeKind, + pub harness: OrchestrationHarnessKind, + pub has_model: bool, + pub has_environment: bool, + pub has_worker_host: bool, + pub has_auth_secret: bool, +} + +/// Decision a user took on the run_agents confirmation card. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RunAgentsCardDecision { + Accept, + AcceptWithoutOrchestration, + Reject, +} + +#[derive(Debug, Serialize)] +pub(crate) struct RunAgentsCardDecisionEvent { + pub conversation_id: AIConversationId, + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_id: Option, + pub decision: RunAgentsCardDecision, + pub agent_count: usize, + pub harness: OrchestrationHarnessKind, + pub execution_mode: OrchestrationExecutionModeKind, + /// Field names from [`orchestration_modified_field`] that diverged + /// between the dispatched request and the LLM's original + /// `RunAgentsRequest`. Empty when the user accepted without edits. + pub modified_fields_from_tool_call: Vec<&'static str>, + /// Same shape, but compared against the approved orchestration + /// config snapshot. Empty when no approved config exists or the + /// dispatched request matches it. + pub modified_fields_from_active_config: Vec<&'static str>, + pub had_active_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_config_status: Option, +} + +/// Surface that first introduced orchestration into a conversation. +/// +/// Plan-card surfacing is intentionally NOT a variant here — that signal +/// is covered by [`AgentProposedConfigEvent`] (fires once per plan card +/// instance when an agent-authored snapshot first becomes visible) plus +/// [`PlanConfigApprovalToggledEvent`] (the user's approval toggle). +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OrchestrationEntrySource { + /// `/orchestrate` slash-command mode on a user query. + SlashCommandOrchestrate, + /// `run_agents` confirmation card was shown (not auto-launched). + RunAgentsCardShown, +} + +#[derive(Debug, Serialize)] +pub(crate) struct OrchestrationEnteredEvent { + pub conversation_id: AIConversationId, + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_id: Option, + pub entry_source: OrchestrationEntrySource, +} + +/// Fires when an agent-authored orchestration config snapshot first +/// becomes visible to the user on a plan card. One emission per +/// `OrchestrationConfigBlockView` instance. +#[derive(Debug, Serialize)] +pub(crate) struct AgentProposedConfigEvent { + pub conversation_id: AIConversationId, + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_id: Option, + pub harness: OrchestrationHarnessKind, + pub execution_mode: OrchestrationExecutionModeKind, + pub has_model: bool, + pub has_environment: bool, + pub has_worker_host: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PillBarPillKind { + Orchestrator, + Child, +} + +/// Concrete user actions against an orchestration pill bar entry. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PillBarActionKind { + /// User clicked the pill body. See `switch_outcome` for what + /// happened next. + Switch, + OpenInNewPane, + OpenInNewTab, + /// User picked "Focus pane" from a pill's 3-dot menu. Distinct + /// from a pill-body click that resolves to the same outcome + /// (those are `Switch` with `switch_outcome = focused_existing_pane`). + FocusOpenedConversation, + Stop, + Kill, + TogglePinOn, + TogglePinOff, + OpenMenu, +} + +/// Outcome of a pill-body click. Closed enum so future navigation +/// outcomes can be added without splitting `Switch` into multiple +/// action variants again. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PillSwitchOutcome { + /// Pill click navigated within the current pane. + SwitchedInPlace, + /// Target conversation was already owned by another visible + /// terminal view; focus moved there instead of switching in place. + FocusedExistingPane, +} + +#[derive(Debug, Serialize)] +pub(crate) struct PillBarInteractionEvent { + pub action: PillBarActionKind, + pub pill_kind: PillBarPillKind, + pub total_pills: usize, + pub total_pinned: usize, + /// The orchestrator that hosts the pill bar. + pub source_conversation_id: AIConversationId, + /// The pill the action targets. + pub target_conversation_id: AIConversationId, + /// Present only when `action == Switch`. Distinguishes whether the + /// pill-body click navigated within the current pane or moved + /// focus to an existing pane already owning the conversation. + #[serde(skip_serializing_if = "Option::is_none")] + pub switch_outcome: Option, +} + impl TelemetryEvent for BlocklistOrchestrationTelemetryEvent { fn name(&self) -> &'static str { BlocklistOrchestrationTelemetryEventDiscriminants::from(self).name() @@ -66,6 +282,11 @@ impl TelemetryEvent for BlocklistOrchestrationTelemetryEvent { fn payload(&self) -> Option { match self { Self::TeamAgentCommunicationFailed(event) => Some(json!(event)), + Self::PlanConfigApprovalToggled(event) => Some(json!(event)), + Self::RunAgentsCardDecision(event) => Some(json!(event)), + Self::PillBarInteraction(event) => Some(json!(event)), + Self::OrchestrationEntered(event) => Some(json!(event)), + Self::AgentProposedConfig(event) => Some(json!(event)), } } @@ -92,6 +313,11 @@ impl TelemetryEventDesc for BlocklistOrchestrationTelemetryEventDiscriminants { Self::TeamAgentCommunicationFailed => { "AgentMode.Orchestration.TeamAgentCommunicationFailed" } + Self::PlanConfigApprovalToggled => "AgentMode.Orchestration.PlanConfigApprovalToggled", + Self::RunAgentsCardDecision => "AgentMode.Orchestration.RunAgentsCardDecision", + Self::PillBarInteraction => "AgentMode.Orchestration.PillBarInteraction", + Self::OrchestrationEntered => "AgentMode.Orchestration.Entered", + Self::AgentProposedConfig => "AgentMode.Orchestration.AgentProposedConfig", } } @@ -100,6 +326,21 @@ impl TelemetryEventDesc for BlocklistOrchestrationTelemetryEventDiscriminants { Self::TeamAgentCommunicationFailed => { "Failed to send an orchestration message or lifecycle event for a TeamAgent" } + Self::PlanConfigApprovalToggled => { + "User toggled the Use orchestration switch on a plan card" + } + Self::RunAgentsCardDecision => { + "User accepted, accepted-without-orchestration, or rejected a run_agents confirmation card. Reports which config fields diverged from the original tool call and/or the active approved config." + } + Self::PillBarInteraction => { + "User interacted with the orchestration pill bar (switch, pin, open in pane/tab, stop, kill, etc.)" + } + Self::OrchestrationEntered => { + "Orchestration was activated in a conversation via /orchestrate or a run_agents confirmation card surfacing. Plan-card entries are tracked separately via AgentProposedConfig + PlanConfigApprovalToggled." + } + Self::AgentProposedConfig => { + "An agent-authored orchestration config snapshot first became visible to the user on a plan card" + } } } diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 98ffa4b376..c0b5dfd8e4 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -7,6 +7,7 @@ use ai::agent::orchestration_config::OrchestrationConfigStatus; use pathfinder_color::ColorU; use std::collections::HashMap; use warp_cli::agent::Harness; +use warp_core::send_telemetry_from_ctx; use warpui::elements::{ ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text, @@ -23,6 +24,10 @@ 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::document::ai_document_model::AIDocumentModel; use crate::ai::harness_availability::{ @@ -34,6 +39,22 @@ use crate::ui_components::blended_colors; use crate::BlocklistAIHistoryModel; use warp_core::ui::theme::WarpTheme; +/// 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() + ) +} + /// Renders a pill-shaped toggle switch (36×18) matching the Figma mock. fn render_pill_toggle(is_on: bool, theme: &WarpTheme) -> Box { let thumb_size = 14.; @@ -165,6 +186,10 @@ impl OrchestrationConfigBlockView { ctx: &mut ViewContext, ) -> 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| { @@ -274,6 +299,12 @@ impl OrchestrationConfigBlockView { // 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 } @@ -671,6 +702,12 @@ impl TypedActionView for OrchestrationConfigBlockView { 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 { @@ -771,3 +808,47 @@ impl TypedActionView for OrchestrationConfigBlockView { } } } + +impl OrchestrationConfigBlockView { + fn emit_plan_config_approval_toggled( + &self, + status: OrchestrationApprovalStatus, + ctx: &mut ViewContext, + ) { + 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) { + 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 + ); + } +}