first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
use crate::context_chips::{
|
||||
display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent},
|
||||
git_line_changes_from_chips,
|
||||
prompt_type::PromptType,
|
||||
ChipResult,
|
||||
};
|
||||
use galaxyui::{ModelHandle, ViewContext, ViewHandle};
|
||||
|
||||
use super::{AgentInputFooter, AgentInputFooterEvent};
|
||||
use crate::context_chips::display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent};
|
||||
use crate::context_chips::prompt_type::PromptType;
|
||||
use crate::context_chips::{git_line_changes_from_chips, ChipResult};
|
||||
|
||||
impl AgentInputFooter {
|
||||
/// Returns `true` if `DisplayChip`s should be recreated based on updated metadata values.
|
||||
@@ -22,14 +19,9 @@ impl AgentInputFooter {
|
||||
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
|
||||
let existing_chip = &existing_chips[i];
|
||||
existing_chip.read(ctx, |chip, _| {
|
||||
chip.text()
|
||||
!= chip_result
|
||||
.value()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
chip.value() != chip_result.value()
|
||||
|| chip.chip_kind() != chip_result.kind()
|
||||
// For parity with PromptDisplay: compare the first on-click value only.
|
||||
|| chip.first_on_click_value() != chip_result.on_click_values().first()
|
||||
|| chip.on_click_values() != chip_result.on_click_values()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,25 +3,22 @@
|
||||
//! Uses the shared [`ChipConfigurator`] with `LeftRightZones` layout to let users
|
||||
//! drag/drop chips between left, right, and unused banks.
|
||||
|
||||
use settings::Setting as _;
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::toolbar_item::AgentToolbarItemKind;
|
||||
use crate::appearance::AppearanceEvent;
|
||||
use crate::chip_configurator::{
|
||||
render_chip_editor_modal, render_chip_editor_sections, ChipConfigurator,
|
||||
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
|
||||
ChipEditorSectionsConfig,
|
||||
};
|
||||
use crate::report_if_error;
|
||||
use crate::terminal::session_settings::{
|
||||
AgentToolbarChipSelection, CLIAgentToolbarChipSelection, SessionSettings,
|
||||
SessionSettingsChangedEvent, ToolbarChipSelection,
|
||||
};
|
||||
use crate::Appearance;
|
||||
|
||||
use settings::Setting as _;
|
||||
|
||||
use super::toolbar_item::AgentToolbarItemKind;
|
||||
use crate::{report_if_error, Appearance};
|
||||
|
||||
const AGENT_MODAL_TITLE: &str = "Edit agent toolbelt";
|
||||
const CLI_MODAL_TITLE: &str = "Edit CLI agent toolbelt";
|
||||
@@ -93,6 +90,25 @@ fn open_toolbar_items_from_settings<V: View>(
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out items that are unavailable due to runtime state (user settings,
|
||||
// workspace config, etc.) on top of the feature-flag checks in all_available().
|
||||
let available: Vec<AgentToolbarItemKind> = available
|
||||
.into_iter()
|
||||
.filter(|item| item.is_available(ctx))
|
||||
.collect();
|
||||
|
||||
// Drop saved items that are no longer available (e.g. their feature flag was disabled
|
||||
// or a setting was turned off).
|
||||
let filter_unavailable = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|item| available.contains(item))
|
||||
.collect()
|
||||
};
|
||||
let current_left = filter_unavailable(current_left);
|
||||
let current_right = filter_unavailable(current_right);
|
||||
|
||||
chip_configurator.open_left_right_zones_with_items(
|
||||
current_left,
|
||||
current_right,
|
||||
@@ -108,6 +124,15 @@ fn open_default_toolbar_items<V: View>(
|
||||
) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let (left, right, available) = AgentToolbarItemKind::defaults_for_mode(mode);
|
||||
let filter_runtime = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|item| item.is_available(ctx))
|
||||
.collect()
|
||||
};
|
||||
let left = filter_runtime(left);
|
||||
let right = filter_runtime(right);
|
||||
let available = filter_runtime(available);
|
||||
chip_configurator.open_left_right_zones_with_items(left, right, available, appearance);
|
||||
}
|
||||
|
||||
@@ -156,6 +181,19 @@ impl AgentToolbarInlineEditor {
|
||||
}
|
||||
});
|
||||
|
||||
// Chip colors are derived from the theme, so rebuild the chips from
|
||||
// settings when the theme changes to keep an open editor readable after a
|
||||
// theme switch (this inline editor persists its arrangement on every
|
||||
// edit, so reloading preserves the user's layout).
|
||||
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(event, AppearanceEvent::ThemeChanged)
|
||||
&& me.chip_configurator.current_dragging_state.is_none()
|
||||
{
|
||||
me.reset_from_settings(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
@@ -270,7 +308,21 @@ fn save_toolbar_selection<V: View>(
|
||||
}
|
||||
|
||||
impl AgentToolbarEditorModal {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
// Chip colors are derived from the theme, so rebuild the chips when the
|
||||
// theme changes to keep an open editor readable after a theme switch.
|
||||
// Only rebuild while the modal is actually open (has chips) and not
|
||||
// mid-drag.
|
||||
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(event, AppearanceEvent::ThemeChanged)
|
||||
&& me.chip_configurator.current_dragging_state.is_none()
|
||||
&& me.chip_configurator.has_items()
|
||||
{
|
||||
let mode = me.mode;
|
||||
open_toolbar_items_from_settings(&mut me.chip_configurator, mode, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
Self {
|
||||
mouse_handles: Default::default(),
|
||||
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
|
||||
|
||||
@@ -1,38 +1,101 @@
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
|
||||
ai::{
|
||||
cloud_agent_settings::CloudAgentSettings, cloud_environments::CloudAmbientAgentEnvironment,
|
||||
},
|
||||
appearance::Appearance,
|
||||
cloud_object::model::{generic_string_model::StringModel, persistence::CloudModel},
|
||||
context_chips::display_menu::{
|
||||
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
|
||||
},
|
||||
report_if_error,
|
||||
server::ids::SyncId,
|
||||
terminal::input::{MenuPositioning, MenuPositioningProvider},
|
||||
ui_components::icons::Icon,
|
||||
view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize},
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use super::{AgentInputButtonTheme, AmbientAgentViewModel};
|
||||
use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent;
|
||||
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
||||
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObjectLookup as _;
|
||||
use crate::context_chips::display_menu::{
|
||||
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
|
||||
};
|
||||
use crate::report_if_error;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::terminal::input::{
|
||||
HandoffComposeState, HandoffComposeStateEvent, MenuPositioning, MenuPositioningProvider,
|
||||
};
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
|
||||
|
||||
/// Normalizes ambient-agent and handoff environment selection state behind one API.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum EnvironmentSelectorTarget {
|
||||
CloudPane(ModelHandle<AmbientAgentViewModel>),
|
||||
Handoff(ModelHandle<HandoffComposeState>),
|
||||
}
|
||||
|
||||
impl EnvironmentSelectorTarget {
|
||||
fn selected_environment_id(&self, ctx: &AppContext) -> Option<SyncId> {
|
||||
match self {
|
||||
Self::CloudPane(model) => model.as_ref(ctx).selected_environment_id().cloned(),
|
||||
Self::Handoff(state) => state.as_ref(ctx).selected_environment_id().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_environment_id(
|
||||
&self,
|
||||
environment_id: Option<SyncId>,
|
||||
is_explicit: bool,
|
||||
ctx: &mut ViewContext<EnvironmentSelector>,
|
||||
) {
|
||||
match self {
|
||||
Self::CloudPane(model) => {
|
||||
model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(environment_id, ctx);
|
||||
});
|
||||
}
|
||||
Self::Handoff(state) => {
|
||||
state.update(ctx, |state, ctx| {
|
||||
state.set_environment_id(environment_id, is_explicit, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_default_environment_id(
|
||||
&self,
|
||||
environment_id: SyncId,
|
||||
ctx: &mut ViewContext<EnvironmentSelector>,
|
||||
) {
|
||||
match self {
|
||||
Self::CloudPane(model) => {
|
||||
model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(environment_id), ctx);
|
||||
});
|
||||
}
|
||||
Self::Handoff(state) => {
|
||||
state.update(ctx, |state, ctx| {
|
||||
state.ensure_default_environment_id(environment_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_configuring(&self, ctx: &AppContext) -> bool {
|
||||
match self {
|
||||
Self::CloudPane(model) => model.as_ref(ctx).is_configuring_ambient_agent(),
|
||||
Self::Handoff(state) => state.as_ref(ctx).is_active(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selector component for choosing an ambient agent environment.
|
||||
pub struct EnvironmentSelector {
|
||||
@@ -40,7 +103,7 @@ pub struct EnvironmentSelector {
|
||||
dropdown: ViewHandle<DisplayChipMenu>,
|
||||
is_menu_open: bool,
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
|
||||
target: EnvironmentSelectorTarget,
|
||||
}
|
||||
|
||||
pub enum EnvironmentSelectorEvent {
|
||||
@@ -117,7 +180,7 @@ impl GenericMenuItem for NewEnvironmentMenuItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
|
||||
pub(crate) fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
|
||||
environments.sort_by(|a, b| {
|
||||
// Sort by last-used timestamp descending (most recent first), then by display name ascending
|
||||
b.metadata
|
||||
@@ -136,7 +199,7 @@ fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment
|
||||
impl EnvironmentSelector {
|
||||
pub fn new(
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
|
||||
target: EnvironmentSelectorTarget,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let button = ctx.add_typed_action_view(|_ctx| {
|
||||
@@ -190,9 +253,7 @@ impl EnvironmentSelector {
|
||||
ctx
|
||||
);
|
||||
if me.is_configuring(ctx) {
|
||||
me.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(env_item.id), ctx);
|
||||
});
|
||||
me.target.set_environment_id(Some(env_item.id), true, ctx);
|
||||
// Persist the selection to settings for next time.
|
||||
me.save_selected_environment_to_settings(env_item.id, ctx);
|
||||
}
|
||||
@@ -212,19 +273,39 @@ impl EnvironmentSelector {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&ambient_agent_model, |me, _, event, ctx| {
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
|
||||
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
|
||||
me.refresh_menu(ctx);
|
||||
match &target {
|
||||
EnvironmentSelectorTarget::CloudPane(model) => {
|
||||
ctx.subscribe_to_model(model, |me, _, event, ctx| {
|
||||
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
|
||||
me.refresh_menu(ctx);
|
||||
}
|
||||
me.refresh_button(ctx);
|
||||
});
|
||||
}
|
||||
me.refresh_button(ctx);
|
||||
});
|
||||
EnvironmentSelectorTarget::Handoff(state) => {
|
||||
ctx.subscribe_to_model(state, |me, _, event, ctx| {
|
||||
match event {
|
||||
HandoffComposeStateEvent::ActiveChanged => {
|
||||
if !me.is_configuring(ctx) {
|
||||
me.set_menu_visibility(false, ctx);
|
||||
}
|
||||
me.ensure_default_selection(ctx);
|
||||
me.refresh_menu(ctx);
|
||||
}
|
||||
HandoffComposeStateEvent::EnvironmentSelected => {
|
||||
me.refresh_menu(ctx);
|
||||
}
|
||||
}
|
||||
me.refresh_button(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut me = Self {
|
||||
button,
|
||||
dropdown,
|
||||
is_menu_open: false,
|
||||
menu_positioning_provider,
|
||||
ambient_agent_model,
|
||||
target,
|
||||
};
|
||||
me.refresh_menu(ctx);
|
||||
me.refresh_button(ctx);
|
||||
@@ -236,13 +317,34 @@ impl EnvironmentSelector {
|
||||
self.is_menu_open
|
||||
}
|
||||
|
||||
fn is_configuring(&self, ctx: &AppContext) -> bool {
|
||||
self.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.is_configuring_ambient_agent()
|
||||
pub fn open_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.is_configuring(ctx) {
|
||||
return;
|
||||
}
|
||||
self.set_menu_visibility(true, ctx);
|
||||
}
|
||||
|
||||
fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
|
||||
fn is_configuring(&self, ctx: &AppContext) -> bool {
|
||||
self.target.is_configuring(ctx)
|
||||
}
|
||||
|
||||
fn highlight_selected_environment(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(selected_id) = self.target.selected_environment_id(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
let Some(index) = environments.iter().position(|env| env.id == selected_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.dropdown.update(ctx, |menu, ctx| {
|
||||
menu.select_index(index, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
|
||||
if self.is_menu_open == is_open {
|
||||
return;
|
||||
}
|
||||
@@ -251,6 +353,7 @@ impl EnvironmentSelector {
|
||||
if is_open {
|
||||
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::EnvironmentSelectorOpened, ctx);
|
||||
ctx.focus(&self.dropdown);
|
||||
self.highlight_selected_environment(ctx);
|
||||
}
|
||||
ctx.emit(EnvironmentSelectorEvent::MenuVisibilityChanged { open: is_open });
|
||||
ctx.notify();
|
||||
@@ -258,10 +361,7 @@ impl EnvironmentSelector {
|
||||
|
||||
/// Ensures a default environment is selected if none is currently selected.
|
||||
fn ensure_default_selection(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let current_selection = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id();
|
||||
let current_selection = self.target.selected_environment_id(ctx);
|
||||
if current_selection.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -270,9 +370,7 @@ impl EnvironmentSelector {
|
||||
if let Some(env_id) = self.get_saved_environment_from_settings(ctx) {
|
||||
// Verify the environment still exists.
|
||||
if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() {
|
||||
self.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(env_id), ctx);
|
||||
});
|
||||
self.target.ensure_default_environment_id(env_id, ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -281,9 +379,7 @@ impl EnvironmentSelector {
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
if let Some(first_env) = environments.first() {
|
||||
self.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(first_env.id), ctx);
|
||||
});
|
||||
self.target.ensure_default_environment_id(first_env.id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,11 +403,7 @@ impl EnvironmentSelector {
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
|
||||
let selected_id = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id()
|
||||
.cloned();
|
||||
let selected_id = self.target.selected_environment_id(ctx);
|
||||
|
||||
let menu_items: Vec<EnvironmentMenuItem> = environments
|
||||
.iter()
|
||||
@@ -328,16 +420,20 @@ impl EnvironmentSelector {
|
||||
self.dropdown.update(ctx, |menu, ctx| {
|
||||
menu.update_menu_items(menu_items, ctx);
|
||||
});
|
||||
|
||||
if self.is_menu_open {
|
||||
self.highlight_selected_environment(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_button(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let label = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id()
|
||||
.and_then(|id| CloudAmbientAgentEnvironment::get_by_id(id, ctx))
|
||||
.map(|env| env.model().string_model.display_name())
|
||||
.unwrap_or_else(|| "New environment".to_string());
|
||||
let label = if let Some(id) = self.target.selected_environment_id(ctx) {
|
||||
CloudAmbientAgentEnvironment::get_by_id(&id, ctx)
|
||||
.map(|env| env.model().string_model.display_name())
|
||||
.unwrap_or_else(|| "New environment".to_string())
|
||||
} else {
|
||||
"New environment".to_string()
|
||||
};
|
||||
|
||||
let is_configuring = self.is_configuring(ctx);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::shared_session::SharedSessionStatus;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
use super::editor::AgentToolbarEditorMode;
|
||||
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::shared_session::SharedSessionStatus;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
/// Declares which footer(s) a toolbar item is available in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -67,6 +68,9 @@ pub enum AgentToolbarItemKind {
|
||||
|
||||
// Agent view only – shows fast-forward (auto-approve) toggle in the footer
|
||||
FastForwardToggle,
|
||||
|
||||
// Agent view only – "Hand off to cloud" chip.
|
||||
HandoffToCloud,
|
||||
}
|
||||
|
||||
impl AgentToolbarItemKind {
|
||||
@@ -76,7 +80,8 @@ impl AgentToolbarItemKind {
|
||||
Self::ModelSelector
|
||||
| Self::NLDToggle
|
||||
| Self::ContextWindowUsage
|
||||
| Self::FastForwardToggle => ToolbarAvailability::AgentViewOnly,
|
||||
| Self::FastForwardToggle
|
||||
| Self::HandoffToCloud => ToolbarAvailability::AgentViewOnly,
|
||||
Self::FileExplorer | Self::RichInput | Self::Settings => {
|
||||
ToolbarAvailability::CLIAgentOnly
|
||||
}
|
||||
@@ -95,6 +100,8 @@ impl AgentToolbarItemKind {
|
||||
Self::Settings | Self::FileExplorer => !status.is_viewer(),
|
||||
Self::FileAttach => !status.is_viewer() || is_cloud_mode,
|
||||
Self::FastForwardToggle => !status.is_viewer() || status.is_executor(),
|
||||
// Handoff is host-initiated; viewers cannot hand off another user's conversation.
|
||||
Self::HandoffToCloud => !status.is_viewer(),
|
||||
Self::ContextChip(_)
|
||||
| Self::ModelSelector
|
||||
| Self::NLDToggle
|
||||
@@ -116,6 +123,7 @@ impl AgentToolbarItemKind {
|
||||
Self::RichInput => "Rich Input",
|
||||
Self::Settings => "Settings",
|
||||
Self::FastForwardToggle => "Fast Forward",
|
||||
Self::HandoffToCloud => "Hand off to cloud",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +134,44 @@ impl AgentToolbarItemKind {
|
||||
Self::NLDToggle => Some(Icon::NLD),
|
||||
Self::VoiceInput => Some(Icon::Microphone),
|
||||
Self::FileAttach => Some(Icon::Plus),
|
||||
Self::ContextWindowUsage => Some(Icon::ConversationContext0),
|
||||
Self::ContextWindowUsage => Some(Icon::ContextRemaining100),
|
||||
Self::FileExplorer => Some(Icon::FileCopy),
|
||||
Self::RichInput => Some(Icon::TextInput),
|
||||
Self::Settings => Some(Icon::Settings),
|
||||
Self::FastForwardToggle => Some(Icon::FastForward),
|
||||
// The bundled `upload-cloud-01.svg` (cloud-with-upward-arrow) is the
|
||||
// closest fit among the existing icons for V0; design may swap it later.
|
||||
Self::HandoffToCloud => Some(Icon::UploadCloud),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item should remain visible during `&` handoff-compose mode.
|
||||
/// Only items relevant to composing a cloud run are shown.
|
||||
pub(super) fn is_available_during_handoff_compose(&self) -> bool {
|
||||
match self {
|
||||
Self::ContextChip(
|
||||
ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus,
|
||||
) => true,
|
||||
Self::ModelSelector | Self::VoiceInput | Self::FileAttach => true,
|
||||
Self::ContextChip(_)
|
||||
| Self::NLDToggle
|
||||
| Self::ContextWindowUsage
|
||||
| Self::FastForwardToggle
|
||||
| Self::HandoffToCloud
|
||||
| Self::ShareSession
|
||||
| Self::FileExplorer
|
||||
| Self::RichInput
|
||||
| Self::Settings => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item should be included in the toolbar given the current app state.
|
||||
/// Feature-flag checks live in `all_available()` / `default_*()`. This method
|
||||
/// handles runtime conditions that depend on user settings or workspace state.
|
||||
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
|
||||
match self {
|
||||
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +208,17 @@ impl AgentToolbarItemKind {
|
||||
Self::ContextWindowUsage,
|
||||
Self::ModelSelector,
|
||||
];
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
if FeatureFlag::OzHandoff.is_enabled()
|
||||
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
||||
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
||||
{
|
||||
items.push(Self::HandoffToCloud);
|
||||
}
|
||||
items.push(Self::VoiceInput);
|
||||
items.push(Self::FileAttach);
|
||||
items
|
||||
@@ -188,6 +240,17 @@ impl AgentToolbarItemKind {
|
||||
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
|
||||
items.push(Self::FastForwardToggle);
|
||||
}
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
if FeatureFlag::OzHandoff.is_enabled()
|
||||
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
||||
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
||||
{
|
||||
items.push(Self::HandoffToCloud);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
use galaxyui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
|
||||
use parking_lot::FairMutex;
|
||||
use galaxyui::{
|
||||
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use super::{AgentViewState, EphemeralMessageModel, EphemeralMessageModelEvent};
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
@@ -18,19 +21,20 @@ use crate::ai::agent::{
|
||||
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
|
||||
use crate::ai::blocklist::agent_view::zero_state_block::render_ambient_credits_banner;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
agent_view_bg_fill, AgentViewController, AgentViewControllerEvent,
|
||||
agent_view_bg_fill, is_in_cloud_context, AgentViewController, AgentViewControllerEvent,
|
||||
};
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
|
||||
ai_brand_color, BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
|
||||
BlocklistAIInputEvent, BlocklistAIInputModel,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentModelEvent};
|
||||
use crate::ai::mcp::{
|
||||
templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent},
|
||||
TemplatableMCPServerManager,
|
||||
use crate::ai::mcp::templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent};
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
use crate::ai::request_usage_model::{
|
||||
AIRequestUsageModel, AIRequestUsageModelEvent, AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD,
|
||||
};
|
||||
use crate::ai::request_usage_model::{AIRequestUsageModel, AIRequestUsageModelEvent};
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::message_bar::attached_context::{
|
||||
AttachedBlocksMessageProducer, AttachedContextArgs, AttachedTextSelectionMessageProducer,
|
||||
@@ -45,7 +49,7 @@ use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashC
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::input::{InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
|
||||
use crate::terminal::input::{HandoffComposeState, InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
|
||||
use crate::terminal::model::TerminalModel;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
@@ -66,11 +70,14 @@ pub struct AgentMessageBarMouseStates {
|
||||
pub toggle_plan: MouseStateHandle,
|
||||
pub toggle_conversation_menu: MouseStateHandle,
|
||||
pub toggle_code_review: MouseStateHandle,
|
||||
pub handoff_to_cloud: MouseStateHandle,
|
||||
pub clear_attached_context: MouseStateHandle,
|
||||
/// Mouse state handle for the "Get Figma MCP" contextual button.
|
||||
pub figma_install_button: MouseStateHandle,
|
||||
/// Mouse state handle for the "Enable Figma MCP" contextual button.
|
||||
pub figma_enable_button: MouseStateHandle,
|
||||
/// Mouse state handle for dismissing the ambient credits banner.
|
||||
pub ambient_credits_banner_close: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Renders contextual hint text at the bottom of the agent view status bar.
|
||||
@@ -83,6 +90,7 @@ pub struct AgentMessageBar {
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
slash_command_model: ModelHandle<SlashCommandModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
handoff_compose_state: ModelHandle<HandoffComposeState>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
mouse_states: AgentMessageBarMouseStates,
|
||||
/// Whether the word "figma" has been detected in the current input buffer or attached images.
|
||||
@@ -93,6 +101,10 @@ pub struct AgentMessageBar {
|
||||
impl Entity for AgentMessageBar {
|
||||
type Event = ();
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AgentMessageBarAction {
|
||||
DismissAmbientCreditsBanner,
|
||||
}
|
||||
|
||||
impl AgentMessageBar {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -105,6 +117,7 @@ impl AgentMessageBar {
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
slash_command_model: ModelHandle<SlashCommandModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
handoff_compose_state: ModelHandle<HandoffComposeState>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
@@ -166,6 +179,9 @@ impl AgentMessageBar {
|
||||
ctx.subscribe_to_model(&slash_command_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&handoff_compose_state, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, |_, _, event, ctx| {
|
||||
@@ -215,7 +231,11 @@ impl AgentMessageBar {
|
||||
}
|
||||
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, event, ctx| {
|
||||
if matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
|
||||
if matches!(
|
||||
event,
|
||||
AIRequestUsageModelEvent::RequestUsageUpdated
|
||||
| AIRequestUsageModelEvent::AmbientCreditsBannerDismissed
|
||||
) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
@@ -229,6 +249,7 @@ impl AgentMessageBar {
|
||||
input_suggestions_model,
|
||||
slash_command_model,
|
||||
context_model,
|
||||
handoff_compose_state,
|
||||
terminal_model,
|
||||
mouse_states: AgentMessageBarMouseStates::default(),
|
||||
figma_detected: false,
|
||||
@@ -293,6 +314,7 @@ impl View for AgentMessageBar {
|
||||
let agent_view_controller = self.agent_view_controller.as_ref(app);
|
||||
let context_model = self.context_model.as_ref(app);
|
||||
let slash_command_model = self.slash_command_model.as_ref(app);
|
||||
let handoff_compose_state = self.handoff_compose_state.as_ref(app);
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
@@ -317,6 +339,7 @@ impl View for AgentMessageBar {
|
||||
input_model,
|
||||
slash_command_model,
|
||||
context_model,
|
||||
handoff_compose_state,
|
||||
terminal_model: &terminal_model,
|
||||
appearance,
|
||||
app,
|
||||
@@ -331,6 +354,7 @@ impl View for AgentMessageBar {
|
||||
.or_else(|| AttachedBlocksMessageProducer.produce_message(args))
|
||||
.or_else(|| AttachedTextSelectionMessageProducer.produce_message(args))
|
||||
.or_else(|| AutodetectedBashModeMessageProducer.produce_message(args))
|
||||
.or_else(|| ExitCloudHandoffModeMessageProducer.produce_message(args))
|
||||
.or_else(|| ExitBashModeMessageProducer.produce_message(args))
|
||||
.or_else(|| HideShortcutsMessageProducer.produce_message(args))
|
||||
.or_else(|| ZeroStateMessageProducer.produce_message(args))
|
||||
@@ -340,19 +364,26 @@ impl View for AgentMessageBar {
|
||||
};
|
||||
|
||||
// Show credits banner when user has ambient credits remaining.
|
||||
use crate::ai::request_usage_model::AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD;
|
||||
let right_element = if cfg!(target_family = "wasm") {
|
||||
None
|
||||
} else if let Some(credits) =
|
||||
AIRequestUsageModel::as_ref(app).ambient_only_credits_remaining()
|
||||
{
|
||||
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD {
|
||||
Some(render_ambient_credits_banner(credits, app))
|
||||
} else {
|
||||
let request_usage_model = AIRequestUsageModel::as_ref(app);
|
||||
if let Some(credits) = request_usage_model.ambient_only_credits_remaining() {
|
||||
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD
|
||||
&& !request_usage_model.is_ambient_credits_banner_dismissed()
|
||||
{
|
||||
Some(render_ambient_credits_banner(
|
||||
credits,
|
||||
self.mouse_states.ambient_credits_banner_close.clone(),
|
||||
AgentMessageBarAction::DismissAmbientCreditsBanner,
|
||||
app,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Append a Figma MCP chip to the message if applicable.
|
||||
@@ -395,6 +426,19 @@ impl View for AgentMessageBar {
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentMessageBar {
|
||||
type Action = AgentMessageBarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AgentMessageBarAction::DismissAmbientCreditsBanner => {
|
||||
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.dismiss_ambient_credits_banner(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Arguments for agent message producers.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct AgentMessageArgs<'a> {
|
||||
@@ -406,6 +450,7 @@ pub struct AgentMessageArgs<'a> {
|
||||
pub input_model: &'a BlocklistAIInputModel,
|
||||
pub slash_command_model: &'a SlashCommandModel,
|
||||
pub context_model: &'a BlocklistAIContextModel,
|
||||
pub handoff_compose_state: &'a HandoffComposeState,
|
||||
pub terminal_model: &'a TerminalModel,
|
||||
pub appearance: &'a Appearance,
|
||||
pub app: &'a AppContext,
|
||||
@@ -565,10 +610,36 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
|
||||
.with_is_disabled(!is_buffer_empty),
|
||||
);
|
||||
|
||||
let is_cloud_agent = matches!(
|
||||
agent_view_controller.agent_view_state(),
|
||||
AgentViewState::Active { origin, .. } if origin.is_cloud_agent()
|
||||
);
|
||||
let is_cloud_agent =
|
||||
is_in_cloud_context(agent_view_controller.agent_view_state(), terminal_model);
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
|
||||
// Handoff to cloud only available for local agents.
|
||||
if !is_cloud_agent && ai_settings.is_ampersand_handoff_enabled(app) {
|
||||
items.push(
|
||||
MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "&".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
background_color: bg_color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "send task to the cloud".into(),
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ActivateCloudHandoff);
|
||||
},
|
||||
mouse_states.handoff_to_cloud.clone(),
|
||||
)
|
||||
.with_is_disabled(!is_buffer_empty),
|
||||
);
|
||||
}
|
||||
|
||||
let plan_count = AIDocumentModel::as_ref(app)
|
||||
.get_all_documents_for_conversation(active_conversation.id())
|
||||
@@ -596,7 +667,10 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
|
||||
|
||||
// Code review only works locally.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if !is_cloud_agent && *TabSettings::as_ref(app).show_code_review_button {
|
||||
if !is_cloud_agent
|
||||
&& !ai_settings.is_cloud_handoff_enabled(app)
|
||||
&& *TabSettings::as_ref(app).show_code_review_button
|
||||
{
|
||||
let code_review_keystroke = if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-shift-+").expect("keystroke should parse")
|
||||
} else {
|
||||
@@ -711,15 +785,21 @@ fn should_fork_from_last_known_good_state(
|
||||
};
|
||||
|
||||
match error {
|
||||
RenderableAIError::QuotaLimit
|
||||
RenderableAIError::QuotaLimit { .. }
|
||||
| RenderableAIError::ServerOverloaded
|
||||
| RenderableAIError::ContextWindowExceeded(_)
|
||||
| RenderableAIError::InvalidApiKey { .. }
|
||||
| RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid { .. } => false,
|
||||
RenderableAIError::InternalWarpError => true,
|
||||
// A shell-exit failure can't resume in this (now-dead) pane, but the user
|
||||
// can fork from the last known good state to continue in a fresh one.
|
||||
RenderableAIError::InternalWarpError | RenderableAIError::AgentExitedShell => true,
|
||||
RenderableAIError::Other {
|
||||
will_attempt_resume,
|
||||
..
|
||||
}
|
||||
| RenderableAIError::TransientNetworkError {
|
||||
will_attempt_resume,
|
||||
..
|
||||
} => !will_attempt_resume,
|
||||
}
|
||||
}
|
||||
@@ -734,10 +814,14 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
|
||||
return None;
|
||||
};
|
||||
let command_name = detected_command.command.name;
|
||||
if command_name != commands::FORK.name
|
||||
&& command_name != commands::FORK_FROM.name
|
||||
&& command_name != commands::FORK_AND_COMPACT.name
|
||||
{
|
||||
let is_fork_family = command_name == commands::FORK.name
|
||||
|| command_name == commands::FORK_FROM.name
|
||||
|| command_name == commands::FORK_AND_COMPACT.name;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let is_continue_locally = command_name == commands::CONTINUE_LOCALLY.name;
|
||||
#[cfg(target_family = "wasm")]
|
||||
let is_continue_locally = false;
|
||||
if !is_fork_family && !is_continue_locally {
|
||||
return None;
|
||||
}
|
||||
let modifier_keystroke = if cfg!(target_os = "macos") {
|
||||
@@ -755,23 +839,15 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
|
||||
}
|
||||
};
|
||||
|
||||
// `/fork` opens in a new pane with Enter and a new tab with Cmd/Ctrl+Enter.
|
||||
// Other fork-like commands open in the current pane with Enter and a new pane
|
||||
// with Cmd/Ctrl+Enter.
|
||||
let (primary_label, secondary_label) = if command_name == commands::FORK.name {
|
||||
(" new pane", " new tab")
|
||||
} else {
|
||||
(" current pane", " new pane")
|
||||
};
|
||||
|
||||
// All fork-style commands open a new pane on Enter and a new tab on Cmd/Ctrl+Enter.
|
||||
Some(Message::new(vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(primary_label),
|
||||
MessageItem::text(" new pane"),
|
||||
MessageItem::keystroke(modifier_keystroke),
|
||||
MessageItem::text(secondary_label),
|
||||
MessageItem::text(" new tab"),
|
||||
]))
|
||||
}
|
||||
}
|
||||
@@ -808,8 +884,8 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
appearance,
|
||||
slash_command_model,
|
||||
app,
|
||||
slash_command_model,
|
||||
..
|
||||
} = args;
|
||||
if input_model.is_ai_input_enabled()
|
||||
@@ -834,47 +910,90 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
|
||||
}
|
||||
}
|
||||
|
||||
struct ExitCloudHandoffModeMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for ExitCloudHandoffModeMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let AgentMessageArgs {
|
||||
input_buffer_model,
|
||||
handoff_compose_state,
|
||||
appearance,
|
||||
..
|
||||
} = args;
|
||||
if !handoff_compose_state.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let active_color = ai_brand_color(appearance.theme());
|
||||
let is_buffer_empty = input_buffer_model.current_value().is_empty();
|
||||
let (dismiss_text_color, dismiss_key_color, dismiss_key_bg) = if is_buffer_empty {
|
||||
(active_color, None, None)
|
||||
} else {
|
||||
(
|
||||
Fill::from(active_color).with_opacity(60).into_solid(),
|
||||
Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
Some(blended_colors::neutral_1(appearance.theme())),
|
||||
)
|
||||
};
|
||||
|
||||
Some(Message::new(vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: None,
|
||||
background_color: None,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "to hand off to cloud".into(),
|
||||
color: Some(active_color),
|
||||
},
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "backspace".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: dismiss_key_color,
|
||||
background_color: dismiss_key_bg,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "to dismiss".into(),
|
||||
color: Some(dismiss_text_color),
|
||||
},
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
struct ExitBashModeMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for ExitBashModeMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let AgentMessageArgs {
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
appearance,
|
||||
app,
|
||||
..
|
||||
} = args;
|
||||
if input_model.is_ai_input_enabled() || !input_model.is_input_type_locked() {
|
||||
return None;
|
||||
}
|
||||
let set_input_mode_agent_keystroke =
|
||||
keybinding_name_to_keystroke(SET_INPUT_MODE_AGENT_ACTION_NAME, app)?;
|
||||
|
||||
let (text_color, keystroke_color_override, keystroke_bg_color_override) =
|
||||
if input_buffer_model.current_value().is_empty() {
|
||||
(appearance.theme().ansi_fg_blue(), None, None)
|
||||
} else {
|
||||
(
|
||||
Fill::from(appearance.theme().ansi_fg_blue())
|
||||
.with_opacity(60)
|
||||
.into_solid(),
|
||||
Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
Some(blended_colors::neutral_1(appearance.theme())),
|
||||
)
|
||||
};
|
||||
let text_color = appearance.theme().ansi_fg_blue();
|
||||
|
||||
Some(
|
||||
Message::new(vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "backspace".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: keystroke_color_override,
|
||||
background_color: keystroke_bg_color_override,
|
||||
keystroke: set_input_mode_agent_keystroke,
|
||||
color: None,
|
||||
background_color: None,
|
||||
},
|
||||
MessageItem::text("to exit shell mode"),
|
||||
])
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
use galaxy_core::ui::{appearance::Appearance, Icon};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Style, Weight::Bold},
|
||||
platform::Cursor,
|
||||
prelude::{Border, CornerRadius, Radius},
|
||||
text_layout::ClipConfig,
|
||||
Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use settings::Setting;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
active_agent_views_model::ActiveAgentViewsModel,
|
||||
agent::conversation::{AIConversationId, ConversationStatus},
|
||||
blocklist::BlocklistAIHistoryEvent,
|
||||
},
|
||||
terminal::BlockListSettings,
|
||||
ui_components::blended_colors,
|
||||
view_components::DismissibleToast,
|
||||
workspace::{ToastStack, WorkspaceAction},
|
||||
BlocklistAIHistoryModel,
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, SavePosition, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::fonts::Weight::Bold;
|
||||
use galaxyui::fonts::{Properties, Style};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::prelude::{Border, CornerRadius, Radius};
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
|
||||
TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use super::{AgentViewController, AgentViewEntryOrigin};
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryEvent;
|
||||
use crate::terminal::BlockListSettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icon_with_status::{render_icon_with_status, IconWithStatusVariant};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::{ToastStack, WorkspaceAction};
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
block: MouseStateHandle,
|
||||
fork_button: MouseStateHandle,
|
||||
open_conversation_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct AgentViewEntryBlockParams {
|
||||
@@ -52,6 +54,8 @@ pub struct AgentViewEntryBlock {
|
||||
/// Cached title for rendering when conversation no longer exists (i.e. after deletion).
|
||||
cached_title: Option<String>,
|
||||
state_handles: StateHandles,
|
||||
/// UI instance anchor for positioning the entry context menu.
|
||||
view_id: EntityId,
|
||||
}
|
||||
|
||||
impl AgentViewEntryBlock {
|
||||
@@ -84,7 +88,6 @@ impl AgentViewEntryBlock {
|
||||
_ => (),
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
|
||||
|
||||
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&active_agent_views_model, |_, _, _, ctx| ctx.notify());
|
||||
|
||||
@@ -96,10 +99,15 @@ impl AgentViewEntryBlock {
|
||||
origin,
|
||||
cached_title: Default::default(),
|
||||
state_handles: Default::default(),
|
||||
view_id: ctx.view_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_agent_view_entry_block_position_id(view_id: EntityId) -> String {
|
||||
format!("agent_view_entry_block_{view_id}")
|
||||
}
|
||||
|
||||
pub fn render_block_container(
|
||||
origin: AgentViewEntryOrigin,
|
||||
content: Box<dyn Element>,
|
||||
@@ -155,6 +163,45 @@ fn render_subtext(text: String, appearance: &Appearance) -> Box<dyn Element> {
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a custom button with icon and hover/click states for the Agent View Block.
|
||||
///
|
||||
/// This button component is currently used for the "fork" and "open agent conversation" actions
|
||||
/// within the Agent View. Future actions re-using this pattern should use this component for
|
||||
/// visually consistent icon-based interaction.
|
||||
fn render_agent_view_block_button<F>(
|
||||
icon: Icon,
|
||||
icon_color: ColorU,
|
||||
mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
on_click: F,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
Hoverable::new(mouse_state, move |state| {
|
||||
let container = Container::new(
|
||||
ConstrainedBox::new(icon.to_warpui_icon(icon_color.into()).finish())
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
|
||||
|
||||
let container = if state.is_clicked() {
|
||||
container.with_background(blended_colors::fg_overlay_4(appearance.theme()))
|
||||
} else if state.is_hovered() {
|
||||
container.with_background(blended_colors::fg_overlay_3(appearance.theme()))
|
||||
} else {
|
||||
container
|
||||
};
|
||||
|
||||
container.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(on_click)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_deleted_state(
|
||||
origin: AgentViewEntryOrigin,
|
||||
cached_title: Option<String>,
|
||||
@@ -203,6 +250,7 @@ impl View for AgentViewEntryBlock {
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let are_block_dividers_enabled =
|
||||
*BlockListSettings::as_ref(app).show_block_dividers.value();
|
||||
|
||||
@@ -211,7 +259,7 @@ impl View for AgentViewEntryBlock {
|
||||
// If the agent_view_block's conversation no longer exists,
|
||||
// we assume that it has been deleted.
|
||||
return render_deleted_state(
|
||||
self.origin,
|
||||
self.origin.clone(),
|
||||
self.cached_title.clone(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
@@ -222,42 +270,46 @@ impl View for AgentViewEntryBlock {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
fn with_opacity(mut color: ColorU, opacity: u8) -> ColorU {
|
||||
color.a = opacity;
|
||||
color
|
||||
}
|
||||
let agent_icon = render_icon_with_status(
|
||||
IconWithStatusVariant::OzAgent {
|
||||
status: Some(conversation.status().clone()),
|
||||
is_ambient: false,
|
||||
},
|
||||
24.,
|
||||
0.,
|
||||
theme,
|
||||
theme.background(),
|
||||
);
|
||||
|
||||
let status_icon = conversation.status().render_icon(appearance);
|
||||
let status_icon_bg = match conversation.status() {
|
||||
ConversationStatus::InProgress => {
|
||||
with_opacity(appearance.theme().ansi_fg_magenta(), 25)
|
||||
}
|
||||
ConversationStatus::Success => with_opacity(appearance.theme().ansi_fg_green(), 25),
|
||||
ConversationStatus::Error => with_opacity(appearance.theme().ansi_fg_red(), 25),
|
||||
ConversationStatus::Cancelled => {
|
||||
with_opacity(blended_colors::neutral_5(appearance.theme()), 25)
|
||||
}
|
||||
ConversationStatus::Blocked { .. } => {
|
||||
with_opacity(appearance.theme().ansi_fg_yellow(), 25)
|
||||
}
|
||||
let is_active =
|
||||
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
|
||||
let is_active_in_this_pane = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
== Some(self.conversation_id);
|
||||
let is_open_elsewhere = is_active && !is_active_in_this_pane;
|
||||
|
||||
let subtext = if is_open_elsewhere {
|
||||
Some("Open in different pane")
|
||||
} else if self.is_restored {
|
||||
Some("Restored")
|
||||
} else if !self.is_new
|
||||
&& !matches!(
|
||||
&self.origin,
|
||||
AgentViewEntryOrigin::LongRunningCommand
|
||||
| AgentViewEntryOrigin::AgentRequestedNewConversation
|
||||
)
|
||||
{
|
||||
Some("Continued")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut row = Flex::row()
|
||||
// Build the title+subtext section.
|
||||
let mut title_section = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(status_icon.finish())
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(2.)
|
||||
.with_background_color(status_icon_bg)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
@@ -282,86 +334,114 @@ impl View for AgentViewEntryBlock {
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let is_active =
|
||||
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
|
||||
let is_active_in_this_pane = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
== Some(self.conversation_id);
|
||||
let is_open_elsewhere = is_active && !is_active_in_this_pane;
|
||||
|
||||
let subtext = if is_open_elsewhere {
|
||||
Some("Open in different pane")
|
||||
} else if self.is_restored {
|
||||
Some("Restored")
|
||||
} else if !self.is_new
|
||||
&& !matches!(
|
||||
self.origin,
|
||||
AgentViewEntryOrigin::LongRunningCommand
|
||||
| AgentViewEntryOrigin::AgentRequestedNewConversation
|
||||
)
|
||||
{
|
||||
Some("Continued")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(subtext) = subtext {
|
||||
row.add_child(render_subtext(subtext.to_string(), appearance));
|
||||
title_section.add_child(render_subtext(subtext.to_string(), appearance));
|
||||
}
|
||||
|
||||
row.add_child(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
row.add_child(
|
||||
ConstrainedBox::new(
|
||||
Icon::ChevronRight
|
||||
.to_galaxyui_icon(
|
||||
blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let conversation_id = self.conversation_id;
|
||||
let origin = self.origin;
|
||||
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
|
||||
let background = if hoverable_state.is_hovered() {
|
||||
blended_colors::fg_overlay_2(appearance.theme())
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(appearance.theme())
|
||||
};
|
||||
render_block_container(
|
||||
origin,
|
||||
row.finish(),
|
||||
background.into(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode { conversation_id });
|
||||
})
|
||||
let icon_color = blended_colors::text_sub(theme, theme.background());
|
||||
let fork_button = ConstrainedBox::new(render_agent_view_block_button(
|
||||
Icon::ArrowSplit,
|
||||
icon_color,
|
||||
self.state_handles.fork_button.clone(),
|
||||
appearance,
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::ForkConversation {
|
||||
conversation_id,
|
||||
});
|
||||
},
|
||||
))
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish();
|
||||
|
||||
let open_conversation_button = ConstrainedBox::new(render_agent_view_block_button(
|
||||
Icon::ChevronRight,
|
||||
icon_color,
|
||||
self.state_handles.open_conversation_button.clone(),
|
||||
appearance,
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
|
||||
conversation_id,
|
||||
});
|
||||
},
|
||||
))
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Container::new(agent_icon).with_margin_right(8.).finish())
|
||||
// Expanded fills all remaining space, pushing buttons to the right.
|
||||
.with_child(Expanded::new(1., title_section.finish()).finish())
|
||||
.with_child(Container::new(fork_button).with_margin_left(8.).finish())
|
||||
.with_child(open_conversation_button);
|
||||
|
||||
let origin = self.origin.clone();
|
||||
let entry_block_id = self.view_id;
|
||||
let entry_block_position_id = get_agent_view_entry_block_position_id(entry_block_id);
|
||||
SavePosition::new(
|
||||
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
|
||||
let background = if hoverable_state.is_hovered() {
|
||||
blended_colors::fg_overlay_2(appearance.theme())
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(appearance.theme())
|
||||
};
|
||||
render_block_container(
|
||||
origin.clone(),
|
||||
row.finish(),
|
||||
background.into(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
|
||||
conversation_id,
|
||||
});
|
||||
})
|
||||
.on_right_click(move |ctx, _, position| {
|
||||
let Some(entry_bounds) = ctx.element_position_by_id(&entry_block_position_id)
|
||||
else {
|
||||
log::warn!(
|
||||
"Could not retrieve the position of the agent view entry block for context menu display."
|
||||
);
|
||||
return;
|
||||
};
|
||||
// Subtract the block's origin so the position is expressed relative
|
||||
// to the block element. OffsetPositioning::offset_from_save_position_element
|
||||
// then offsets the menu from the saved block position, placing it at
|
||||
// the exact mouse cursor location.
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::OpenConversationContextMenu {
|
||||
conversation_id,
|
||||
agent_view_entry_block_id: entry_block_id,
|
||||
position: position - entry_bounds.origin(),
|
||||
});
|
||||
})
|
||||
.with_defer_events_to_children()
|
||||
.finish(),
|
||||
&get_agent_view_entry_block_position_id(entry_block_id),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentViewEntryBlockEvent {
|
||||
EnterAgentView { conversation_id: AIConversationId },
|
||||
EnterAgentView {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
OpenConversationContextMenu {
|
||||
conversation_id: AIConversationId,
|
||||
agent_view_entry_block_id: EntityId,
|
||||
position: Vector2F,
|
||||
},
|
||||
ForkConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for AgentViewEntryBlock {
|
||||
@@ -370,7 +450,17 @@ impl Entity for AgentViewEntryBlock {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EnterAgentBlockAction {
|
||||
EnterAgentMode { conversation_id: AIConversationId },
|
||||
EnterAgentMode {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
OpenConversationContextMenu {
|
||||
conversation_id: AIConversationId,
|
||||
agent_view_entry_block_id: EntityId,
|
||||
position: Vector2F,
|
||||
},
|
||||
ForkConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentViewEntryBlock {
|
||||
@@ -416,6 +506,22 @@ impl TypedActionView for AgentViewEntryBlock {
|
||||
});
|
||||
}
|
||||
}
|
||||
EnterAgentBlockAction::OpenConversationContextMenu {
|
||||
conversation_id,
|
||||
agent_view_entry_block_id,
|
||||
position,
|
||||
} => {
|
||||
ctx.emit(AgentViewEntryBlockEvent::OpenConversationContextMenu {
|
||||
conversation_id: *conversation_id,
|
||||
agent_view_entry_block_id: *agent_view_entry_block_id,
|
||||
position: *position,
|
||||
});
|
||||
}
|
||||
EnterAgentBlockAction::ForkConversation { conversation_id } => {
|
||||
ctx.emit(AgentViewEntryBlockEvent::ForkConversation {
|
||||
conversation_id: *conversation_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use instant::Instant;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::AppContext;
|
||||
use galaxyui::{
|
||||
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
|
||||
};
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::input::slash_commands::SlashCommandTrigger;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::{
|
||||
ai::agent::conversation::AIConversationId,
|
||||
terminal::{view::ambient_agent::AmbientAgentViewModel, TerminalModel},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{DismissalStrategy, EphemeralMessage, EphemeralMessageModel};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::orchestration_topology::{
|
||||
adjacent_orchestration_child_conversation_id, OrchestrationNavigationDirection,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::input::slash_commands::SlashCommandTrigger;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
/// Error returned when entering the agent view fails.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -100,7 +98,7 @@ impl PendingConfirmation {
|
||||
///
|
||||
/// Depending on the entrypoint, an `AgentView` block representing the entry may be inserted into
|
||||
/// the terminal blocklist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentViewEntryOrigin {
|
||||
/// Entered agent view from user input (e.g. /agent or cmd-enter keypress).
|
||||
Input {
|
||||
@@ -138,6 +136,8 @@ pub enum AgentViewEntryOrigin {
|
||||
ThirdPartyCloudAgent,
|
||||
/// Entered agent view via the CLI (e.g. `warp agent run`).
|
||||
Cli,
|
||||
/// Entered agent view via the headless TUI frontend.
|
||||
Tui,
|
||||
/// Entered agent view by adding an image (drag-and-drop or paste).
|
||||
ImageAdded,
|
||||
/// Entered agent view by executing a slash command that requires agent mode.
|
||||
@@ -146,8 +146,8 @@ pub enum AgentViewEntryOrigin {
|
||||
},
|
||||
SlashInit,
|
||||
CreateEnvironment,
|
||||
/// Entered agent view by executing a slash command that requires agent mode.
|
||||
Keybinding,
|
||||
/// Entered agent view from the new-conversation keybinding.
|
||||
Keybinding(Keystroke),
|
||||
/// Entered agent view by attaching context from the code review panel.
|
||||
CodeReviewContext,
|
||||
/// Entered agent view from codex integration modal.
|
||||
@@ -169,6 +169,11 @@ pub enum AgentViewEntryOrigin {
|
||||
/// Entered agent view because a parent agent started this child agent via StartAgent.
|
||||
ChildAgent,
|
||||
|
||||
/// Entered agent view by clicking a pill / breadcrumb in the orchestration
|
||||
/// pill bar (or breadcrumb row) to navigate the current pane to a sibling
|
||||
/// or parent conversation in the same orchestration tree.
|
||||
OrchestrationPillBar,
|
||||
|
||||
/// Entered agent view after opening project from OS directory picker.
|
||||
ProjectEntry,
|
||||
|
||||
@@ -178,6 +183,10 @@ pub enum AgentViewEntryOrigin {
|
||||
/// Entered agent view by clearing the buffer (Cmd+K) while already in agent view.
|
||||
ClearBuffer,
|
||||
|
||||
/// Entered agent view via the "Jump to Latest Agent Message" command, which
|
||||
/// returns to the most recent conversation from the terminal.
|
||||
JumpToLatestAgentMessage,
|
||||
|
||||
// The variants below actually correspond to callsites where the selected conversation is
|
||||
// updated, but don't actually correspond to entering the agent view. They exist so we can
|
||||
// continue to call `set_pending_query_state_for_(new|existing)_conversation`, but you'll find
|
||||
@@ -257,6 +266,13 @@ impl AgentViewState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> Option<AgentViewEntryOrigin> {
|
||||
match self {
|
||||
AgentViewState::Active { origin, .. } => Some(origin.clone()),
|
||||
AgentViewState::Inactive => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if in an active agent view state.
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self, AgentViewState::Active { .. })
|
||||
@@ -334,7 +350,7 @@ const NEW_CONVERSATION_KEYBINDING_CONFIRMATION_MESSAGE_ID: &str =
|
||||
/// Controller responsible for managing and updating agent view state for a given terminal pane.
|
||||
///
|
||||
/// `AgentViewState` is stored on the terminal model but should only be updated via the APIs on
|
||||
/// this constroller, which ensures the correct events are emitted and downstream effects take
|
||||
/// this controller, which ensures the correct events are emitted and downstream effects take
|
||||
/// place.
|
||||
pub struct AgentViewController {
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
@@ -343,7 +359,6 @@ pub struct AgentViewController {
|
||||
/// Set during terminal pane attach; used for pane-group-scoped visibility checks.
|
||||
pane_group_id: Option<EntityId>,
|
||||
agent_view_state: AgentViewState,
|
||||
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
pending_confirmation: Option<PendingConfirmation>,
|
||||
pending_confirmation_abort_handle: Option<SpawnedFutureHandle>,
|
||||
@@ -368,16 +383,13 @@ impl AgentViewController {
|
||||
pub fn new(
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
terminal_view_id: EntityId,
|
||||
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminal_model,
|
||||
terminal_view_id,
|
||||
pane_group_id: None,
|
||||
agent_view_state: AgentViewState::Inactive,
|
||||
ambient_agent_view_model,
|
||||
ephemeral_message_model,
|
||||
pending_confirmation: None,
|
||||
pending_confirmation_abort_handle: None,
|
||||
@@ -388,6 +400,11 @@ impl AgentViewController {
|
||||
self.pane_group_id
|
||||
}
|
||||
|
||||
/// Returns the [`EntityId`] of the [`TerminalView`] that owns this controller.
|
||||
pub fn terminal_view_id(&self) -> EntityId {
|
||||
self.terminal_view_id
|
||||
}
|
||||
|
||||
pub fn set_pane_group_id(&mut self, pane_group_id: EntityId) {
|
||||
self.pane_group_id = Some(pane_group_id);
|
||||
}
|
||||
@@ -408,35 +425,37 @@ impl AgentViewController {
|
||||
&self.agent_view_state
|
||||
}
|
||||
|
||||
/// Resolves the conversation adjacent to the active agent-view conversation
|
||||
/// in the canonical orchestration pill order.
|
||||
pub fn adjacent_orchestration_conversation_id(
|
||||
&self,
|
||||
direction: OrchestrationNavigationDirection,
|
||||
app: &AppContext,
|
||||
) -> Option<AIConversationId> {
|
||||
let active_conversation_id = self.agent_view_state.active_conversation_id()?;
|
||||
adjacent_orchestration_child_conversation_id(
|
||||
BlocklistAIHistoryModel::as_ref(app),
|
||||
active_conversation_id,
|
||||
direction,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns whether the user is allowed to exit agent view.
|
||||
/// This is used to determine both whether the escape key should work
|
||||
/// and whether the escape keybinding should be displayed.
|
||||
pub fn can_exit_agent_view(
|
||||
&self,
|
||||
ctx: &impl galaxyui::ModelAsRef,
|
||||
) -> Result<(), ExitAgentViewError> {
|
||||
pub fn can_exit_agent_view(&self) -> Result<(), ExitAgentViewError> {
|
||||
let model = self.terminal_model.lock();
|
||||
|
||||
let ambient_agent_model = self.ambient_agent_view_model.as_ref(ctx);
|
||||
let is_fullscreen_with_long_running = self.agent_view_state.is_fullscreen()
|
||||
&& model
|
||||
.block_list()
|
||||
.active_block()
|
||||
.is_active_and_long_running();
|
||||
|
||||
// Ambient agent sessions should not allow exiting agent view if it's not backed by a terminal session because there's nothing to escape to.
|
||||
if ambient_agent_model.is_ambient_agent() {
|
||||
if !ambient_agent_model.has_parent_terminal() {
|
||||
return Err(ExitAgentViewError::AmbientAgent);
|
||||
}
|
||||
// But if there is a terminal backing and we're in a LRC, we should be able to escape
|
||||
else if is_fullscreen_with_long_running {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// In a non-ambient agent case, users cannot exit the fullscreen agent view with an active long running command.
|
||||
if is_fullscreen_with_long_running {
|
||||
// Cloud agent panes do not have the same underlying terminal ownership
|
||||
// constraint (no local shell process), so long-running third party agent
|
||||
// commands should not trap the user in agent view.
|
||||
if is_fullscreen_with_long_running && !model.is_dummy_cloud_mode_session() {
|
||||
return Err(ExitAgentViewError::LongRunningCommand);
|
||||
}
|
||||
|
||||
@@ -569,6 +588,26 @@ impl AgentViewController {
|
||||
&mut self,
|
||||
keybinding_name: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
|
||||
log::warn!(
|
||||
"Expected keybinding for slash command {keybinding_name}, but none was found"
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
self.should_start_new_conversation_for_keystroke(keystroke, ctx)
|
||||
}
|
||||
|
||||
/// Decides whether a keybinding-triggered new conversation should proceed immediately.
|
||||
///
|
||||
/// We only require a second press when the user is already in an active, non-empty
|
||||
/// conversation. This protects against accidental conversation resets from muscle-memory
|
||||
/// keypresses, while preserving single-step behavior for explicit typed/slash-menu execution.
|
||||
pub fn should_start_new_conversation_for_keystroke(
|
||||
&mut self,
|
||||
keystroke: Keystroke,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
enum Decision {
|
||||
StartNewConversation,
|
||||
@@ -596,13 +635,6 @@ impl AgentViewController {
|
||||
break 'decision Decision::StartNewConversation;
|
||||
}
|
||||
|
||||
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
|
||||
log::warn!(
|
||||
"Expected keybinding for slash command {keybinding_name}, but none was found"
|
||||
);
|
||||
break 'decision Decision::StartNewConversation;
|
||||
};
|
||||
|
||||
let normalized_keystroke = keystroke.normalized();
|
||||
if self.is_new_conversation_keybinding_confirmation_active_for(
|
||||
conversation_id,
|
||||
@@ -711,7 +743,7 @@ impl AgentViewController {
|
||||
.active_block()
|
||||
.is_active_and_long_running()
|
||||
&& !terminal_model.is_conversation_transcript_viewer()
|
||||
&& !matches!(origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
|
||||
&& !matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
|
||||
};
|
||||
|
||||
if is_long_running {
|
||||
@@ -781,7 +813,8 @@ impl AgentViewController {
|
||||
history_model.start_new_conversation(
|
||||
self.terminal_view_id,
|
||||
false,
|
||||
matches!(origin, AgentViewEntryOrigin::CloudAgent),
|
||||
matches!(&origin, AgentViewEntryOrigin::CloudAgent),
|
||||
matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
@@ -793,7 +826,7 @@ impl AgentViewController {
|
||||
|
||||
self.agent_view_state = AgentViewState::Active {
|
||||
conversation_id,
|
||||
origin,
|
||||
origin: origin.clone(),
|
||||
display_mode,
|
||||
original_conversation_length: exchange_count,
|
||||
};
|
||||
@@ -802,21 +835,6 @@ impl AgentViewController {
|
||||
.block_list_mut()
|
||||
.set_agent_view_state(self.agent_view_state.clone());
|
||||
|
||||
if origin == AgentViewEntryOrigin::CloudAgent {
|
||||
// Only enter setup state if there are no existing environments.
|
||||
// If environments exist, the user should go directly to composing.
|
||||
let has_environments =
|
||||
!crate::ai::cloud_environments::CloudAmbientAgentEnvironment::get_all(ctx)
|
||||
.is_empty();
|
||||
self.ambient_agent_view_model.update(ctx, |model, ctx| {
|
||||
if has_environments {
|
||||
model.enter_composing_from_setup(ctx);
|
||||
} else {
|
||||
model.enter_setup(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.emit(AgentViewControllerEvent::EnteredAgentView {
|
||||
conversation_id,
|
||||
is_new: exchange_count == 0,
|
||||
@@ -882,7 +900,7 @@ impl AgentViewController {
|
||||
) {
|
||||
self.clear_new_conversation_keybinding_confirmation(ctx);
|
||||
// Check if exiting agent view is allowed.
|
||||
if self.can_exit_agent_view(ctx).is_err() {
|
||||
if self.can_exit_agent_view().is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -942,16 +960,6 @@ impl AgentViewController {
|
||||
.block_list_mut()
|
||||
.set_agent_view_state(self.agent_view_state.clone());
|
||||
|
||||
// Capture ambient agent status before resetting it
|
||||
let was_ambient_agent = self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent();
|
||||
|
||||
// Reset ambient agent status when exiting agent view
|
||||
if was_ambient_agent {
|
||||
self.ambient_agent_view_model.update(ctx, |model, ctx| {
|
||||
model.reset_status(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
let final_exchange_count = history_model
|
||||
.as_ref(ctx)
|
||||
@@ -959,6 +967,7 @@ impl AgentViewController {
|
||||
.map(|conversation| conversation.exchange_count())
|
||||
.unwrap_or(0);
|
||||
|
||||
let was_ambient_agent = origin == AgentViewEntryOrigin::CloudAgent;
|
||||
ctx.emit(AgentViewControllerEvent::ExitedAgentView {
|
||||
conversation_id,
|
||||
origin,
|
||||
@@ -1012,7 +1021,6 @@ fn exit_confirmation_message(
|
||||
) -> Message {
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
use warpui::{AppContext, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{
|
||||
AgentViewController, AgentViewControllerEvent, AgentViewEntryOrigin, EnterAgentViewError,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, AIConversationId};
|
||||
use crate::ai::blocklist::conversation_selection::{
|
||||
ConversationSelection, ConversationSelectionEvent,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
|
||||
/// GUI conversation selection backed unconditionally by Agent View.
|
||||
pub(crate) struct AgentViewConversationSelection {
|
||||
terminal_surface_id: EntityId,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
}
|
||||
|
||||
impl AgentViewConversationSelection {
|
||||
/// Creates GUI conversation selection for a terminal view.
|
||||
pub(crate) fn new(
|
||||
terminal_surface_id: warpui::EntityId,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| match event {
|
||||
AgentViewControllerEvent::EnteredAgentView {
|
||||
display_mode,
|
||||
origin,
|
||||
..
|
||||
} => {
|
||||
ctx.emit(ConversationSelectionEvent::Changed);
|
||||
ctx.emit(ConversationSelectionEvent::Activated {
|
||||
is_fullscreen: display_mode.is_fullscreen(),
|
||||
origin: origin.clone(),
|
||||
});
|
||||
}
|
||||
AgentViewControllerEvent::ExitedAgentView {
|
||||
conversation_id,
|
||||
final_exchange_count,
|
||||
is_exit_before_new_entrance,
|
||||
..
|
||||
} => {
|
||||
ctx.emit(ConversationSelectionEvent::Changed);
|
||||
ctx.emit(ConversationSelectionEvent::Deactivated {
|
||||
conversation_id: *conversation_id,
|
||||
final_exchange_count: *final_exchange_count,
|
||||
is_exit_before_new_entrance: *is_exit_before_new_entrance,
|
||||
});
|
||||
}
|
||||
AgentViewControllerEvent::ExitConfirmed { .. } => {}
|
||||
});
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|selection, _, event, ctx| selection.handle_history_event(event, ctx),
|
||||
);
|
||||
Self {
|
||||
terminal_surface_id,
|
||||
agent_view_controller,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationSelection for AgentViewConversationSelection {
|
||||
fn selected_conversation_id(&self, app: &AppContext) -> Option<AIConversationId> {
|
||||
self.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
}
|
||||
|
||||
fn is_conversation_active(&self, app: &AppContext) -> bool {
|
||||
self.agent_view_controller.as_ref(app).is_active()
|
||||
}
|
||||
|
||||
fn is_conversation_fullscreen(&self, app: &AppContext) -> bool {
|
||||
self.agent_view_controller.as_ref(app).is_fullscreen()
|
||||
}
|
||||
|
||||
fn select_existing_conversation(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
origin: AgentViewEntryOrigin,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if let Err(error) = self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller.try_enter_agent_view(Some(conversation_id), origin, ctx)
|
||||
}) {
|
||||
log::error!("Failed to enter agent view for existing conversation: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn select_new_conversation(
|
||||
&mut self,
|
||||
origin: AgentViewEntryOrigin,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if let Err(error) = self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller.try_enter_agent_view(None, origin, ctx)
|
||||
}) {
|
||||
log::error!("Failed to enter agent view for new conversation: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn try_start_new_conversation(
|
||||
&mut self,
|
||||
origin: AgentViewEntryOrigin,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) -> Result<AIConversationId, EnterAgentViewError> {
|
||||
self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller.try_enter_agent_view(None, origin, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
fn pending_query_autoexecute_override(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> AIConversationAutoexecuteMode {
|
||||
self.selected_conversation_id(app)
|
||||
.as_ref()
|
||||
.and_then(|conversation_id| {
|
||||
BlocklistAIHistoryModel::as_ref(app).conversation(conversation_id)
|
||||
})
|
||||
.map(|conversation| conversation.autoexecute_override())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn toggle_pending_query_autoexecute(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if let Some(conversation_id) = self.selected_conversation_id(ctx) {
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.toggle_autoexecute_override(
|
||||
&conversation_id,
|
||||
self.terminal_surface_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_history_event(
|
||||
&mut self,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if event
|
||||
.terminal_surface_id()
|
||||
.is_some_and(|id| id != self.terminal_surface_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
|
||||
self.agent_view_controller
|
||||
.update(ctx, |controller, ctx| controller.exit_agent_view(ctx));
|
||||
}
|
||||
BlocklistAIHistoryEvent::SplitConversation {
|
||||
old_conversation_id,
|
||||
new_conversation_id,
|
||||
..
|
||||
} if self.selected_conversation_id(ctx) == Some(*old_conversation_id) => {
|
||||
self.select_existing_conversation(
|
||||
*new_conversation_id,
|
||||
AgentViewEntryOrigin::AgentRequestedNewConversation,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "conversation_selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::AgentViewConversationSelection;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
AgentViewController, AgentViewEntryOrigin, EphemeralMessageModel,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, ConversationSelection};
|
||||
use crate::terminal::color::{self, Colors};
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::test_utils::block_size;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
#[test]
|
||||
fn gui_selection_delegates_to_agent_view() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let terminal_surface_id = EntityId::new();
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::new_for_test(
|
||||
block_size(),
|
||||
color::List::from(&Colors::default()),
|
||||
ChannelEventListener::new_for_test(),
|
||||
Arc::new(Background::default()),
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
)));
|
||||
let ephemeral_message_model = app.add_model(|_| EphemeralMessageModel::new());
|
||||
let agent_view_controller = app.add_model(|_| {
|
||||
AgentViewController::new(terminal_model, terminal_surface_id, ephemeral_message_model)
|
||||
});
|
||||
let selection = app.add_model(|ctx| {
|
||||
Box::new(AgentViewConversationSelection::new(
|
||||
terminal_surface_id,
|
||||
agent_view_controller.clone(),
|
||||
ctx,
|
||||
)) as Box<dyn ConversationSelection>
|
||||
});
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_surface_id, false, false, false, ctx)
|
||||
});
|
||||
|
||||
selection.update(&mut app, |selection, ctx| {
|
||||
selection.select_existing_conversation(
|
||||
conversation_id,
|
||||
AgentViewEntryOrigin::ConversationSelector,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
selection.read(&app, |selection, ctx| {
|
||||
assert_eq!(
|
||||
selection.selected_conversation_id(ctx),
|
||||
Some(conversation_id)
|
||||
);
|
||||
});
|
||||
agent_view_controller.read(&app, |controller, _| {
|
||||
assert!(controller.is_active());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,9 +4,8 @@ use std::time::Duration;
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use crate::terminal::input::message_bar::{Message, MessageProvider};
|
||||
|
||||
use super::agent_message_bar::AgentMessageArgs;
|
||||
use crate::terminal::input::message_bar::{Message, MessageProvider};
|
||||
|
||||
const DEFAULT_MESSAGE_DURATION: Duration = Duration::from_millis(1500);
|
||||
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::agent::action::{AIAgentActionType, ShellCommandDelay};
|
||||
use parking_lot::FairMutex;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::{CornerRadius, Radius};
|
||||
use galaxyui::{
|
||||
elements::{CornerRadius, Radius},
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
|
||||
};
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::icons,
|
||||
blocklist::{
|
||||
block::cli_controller::LongRunningCommandControlState,
|
||||
inline_action::inline_action_header::HeaderConfig, BlocklistAIActionModel,
|
||||
BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
|
||||
},
|
||||
},
|
||||
terminal::{model::session::Sessions, TerminalModel},
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
use crate::ai::agent::icons;
|
||||
use crate::ai::blocklist::block::cli_controller::LongRunningCommandControlState;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::HeaderConfig;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIActionModel, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
|
||||
};
|
||||
use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const AGENT_PROMPT_TO_INTERACT_MESSAGE: &str = "Prompt agent to interact with";
|
||||
const AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE: &str = "Agent is waiting on instructions";
|
||||
@@ -47,7 +45,7 @@ impl InlineAgentViewHeader {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
|
||||
if event
|
||||
.terminal_view_id()
|
||||
.terminal_surface_id()
|
||||
.is_some_and(|id| id != me.terminal_view_id)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
pub(crate) mod agent_input_footer;
|
||||
mod agent_message_bar;
|
||||
mod agent_view_block;
|
||||
pub mod child_agent_status_card;
|
||||
mod controller;
|
||||
mod conversation_selection;
|
||||
mod ephemeral_message_model;
|
||||
mod inline_agent_view_header;
|
||||
// TODO: Move orchestration_conversation_links module import elsewhere.
|
||||
pub(crate) mod orchestration_avatar;
|
||||
pub(crate) mod orchestration_conversation_links;
|
||||
pub mod orchestration_pill_bar;
|
||||
pub mod orchestration_pill_bar_model;
|
||||
pub mod shortcuts;
|
||||
pub(crate) mod subagent_inline_panel;
|
||||
mod zero_state_block;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub use agent_input_footer::*;
|
||||
pub use agent_message_bar::*;
|
||||
pub use agent_view_block::*;
|
||||
pub use controller::*;
|
||||
pub(crate) use conversation_selection::AgentViewConversationSelection;
|
||||
pub use ephemeral_message_model::*;
|
||||
use galaxyui::fonts::Properties;
|
||||
pub use inline_agent_view_header::*;
|
||||
pub use zero_state_block::*;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub use orchestration_pill_bar::{render_orchestration_breadcrumbs, OrchestrationPillBar};
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_core::ui::{appearance::Appearance, color::blend::Blend};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
pub use zero_state_block::*;
|
||||
|
||||
use crate::terminal::model::TerminalModel;
|
||||
use crate::view_components::action_button::ActionButtonTheme;
|
||||
|
||||
pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| {
|
||||
@@ -49,6 +55,45 @@ pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = La
|
||||
}
|
||||
});
|
||||
|
||||
pub static ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> =
|
||||
LazyLock::new(|| {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
Keystroke {
|
||||
cmd: true,
|
||||
alt: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// Returns `true` when the current pane is in a cloud or remote context.
|
||||
pub fn is_in_cloud_context(
|
||||
agent_view_state: &AgentViewState,
|
||||
terminal_model: &TerminalModel,
|
||||
) -> bool {
|
||||
let origin_is_cloud = matches!(
|
||||
agent_view_state,
|
||||
AgentViewState::Active { origin, .. }
|
||||
if matches!(
|
||||
origin,
|
||||
AgentViewEntryOrigin::CloudAgent | AgentViewEntryOrigin::ThirdPartyCloudAgent
|
||||
)
|
||||
);
|
||||
origin_is_cloud
|
||||
|| terminal_model.is_conversation_transcript_viewer()
|
||||
|| terminal_model.is_dummy_cloud_mode_session()
|
||||
}
|
||||
|
||||
pub fn agent_view_bg_fill(app: &AppContext) -> Fill {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
appearance.theme().surface_overlay_1()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use warpui::elements::Element;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::agent_view::orchestration_pill_bar::{
|
||||
render_agent_avatar_disc, render_orchestrator_avatar_disc,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
const TRANSCRIPT_AVATAR_SCALE: f32 = 1.25;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum OrchestrationAvatar {
|
||||
Orchestrator,
|
||||
Agent { display_name: String },
|
||||
}
|
||||
|
||||
impl OrchestrationAvatar {
|
||||
pub(crate) fn agent(display_name: String) -> Self {
|
||||
Self::Agent { display_name }
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let size = app.font_cache().line_height(
|
||||
appearance.monospace_font_size(),
|
||||
appearance.line_height_ratio(),
|
||||
) * TRANSCRIPT_AVATAR_SCALE;
|
||||
|
||||
match self {
|
||||
Self::Orchestrator => render_orchestrator_avatar_disc(size, theme, appearance),
|
||||
Self::Agent { display_name } => {
|
||||
render_agent_avatar_disc(display_name, size, theme, appearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "orchestration_avatar_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,11 @@
|
||||
use super::OrchestrationAvatar;
|
||||
|
||||
#[test]
|
||||
fn agent_avatar_identity_is_display_name_based_for_pill_consistency() {
|
||||
assert_eq!(
|
||||
OrchestrationAvatar::agent("Agent 1".to_string()),
|
||||
OrchestrationAvatar::Agent {
|
||||
display_name: "Agent 1".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex,
|
||||
Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight::Bold},
|
||||
platform::Cursor,
|
||||
text_layout::ClipConfig,
|
||||
AppContext, Element, EventContext, SingletonEntity,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
api::ServerConversationToken,
|
||||
conversation::{AIConversation, AIConversationId},
|
||||
},
|
||||
agent_conversations_model::AgentConversationsModel,
|
||||
blocklist::BlocklistAIHistoryModel,
|
||||
},
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
workspace::{RestoreConversationLayout, WorkspaceAction},
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::fonts::Weight::Bold;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{AppContext, Element, EntityId, EventContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||
use crate::ai::agent_conversations_model::entry::AgentConversationEntryId;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationNavigationSubject, AgentConversationsModel,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspace::{RestoreConversationLayout, WorkspaceAction, WorkspaceRegistry};
|
||||
|
||||
pub(crate) fn conversation_id_for_agent_id(
|
||||
agent_id: &str,
|
||||
@@ -40,6 +38,79 @@ pub(crate) fn conversation_id_for_agent_id(
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the conversation is open in some other visible pane. Hidden
|
||||
/// child-agent panes are excluded so unopened children don't look
|
||||
/// "already open".
|
||||
pub(crate) fn is_conversation_open_in_other_visible_view(
|
||||
conversation_id: AIConversationId,
|
||||
self_terminal_view_id: EntityId,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let Some(owner) =
|
||||
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if owner == self_terminal_view_id {
|
||||
return false;
|
||||
}
|
||||
pane_group_id_containing_terminal_view(owner, app).is_some()
|
||||
}
|
||||
|
||||
/// Finds the pane group containing the given terminal view across all
|
||||
/// visible panes/tabs. Used to distinguish same-tab vs cross-tab focus.
|
||||
pub(crate) fn pane_group_id_containing_terminal_view(
|
||||
terminal_view_id: EntityId,
|
||||
app: &AppContext,
|
||||
) -> Option<EntityId> {
|
||||
let registry = WorkspaceRegistry::as_ref(app);
|
||||
for (_, workspace_handle) in registry.all_workspaces(app) {
|
||||
let workspace = workspace_handle.as_ref(app);
|
||||
for pane_group_handle in workspace.tab_views() {
|
||||
let pane_group = pane_group_handle.as_ref(app);
|
||||
for pane_id in pane_group.visible_pane_ids() {
|
||||
if let Some(terminal_view) = pane_group.terminal_view_from_pane_id(pane_id, app) {
|
||||
if terminal_view.id() == terminal_view_id {
|
||||
return Some(pane_group_handle.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Navigates to a child agent's pane: focuses an existing sibling pane,
|
||||
/// activates the owning tab, or splits off a new pane.
|
||||
pub(crate) fn dispatch_focus_or_open_child_agent_pane(
|
||||
conversation_id: AIConversationId,
|
||||
self_terminal_view_id: EntityId,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
if let Some(owner_view_id) =
|
||||
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
|
||||
{
|
||||
if owner_view_id != self_terminal_view_id {
|
||||
if let Some(owner_pane_group_id) =
|
||||
pane_group_id_containing_terminal_view(owner_view_id, app)
|
||||
{
|
||||
let self_pane_group_id =
|
||||
pane_group_id_containing_terminal_view(self_terminal_view_id, app);
|
||||
if Some(owner_pane_group_id) == self_pane_group_id {
|
||||
ctx.dispatch_typed_action(TerminalAction::RevealChildAgent { conversation_id });
|
||||
} else {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::FocusTerminalViewInWorkspace {
|
||||
terminal_view_id: owner_view_id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.dispatch_typed_action(TerminalAction::OpenChildAgentInNewPane { conversation_id });
|
||||
}
|
||||
|
||||
pub(crate) fn parent_conversation_id(
|
||||
active_conversation: &AIConversation,
|
||||
app: &AppContext,
|
||||
@@ -54,19 +125,14 @@ pub(crate) fn parent_conversation_id(
|
||||
pub(crate) fn conversation_navigation_action(
|
||||
conversation_id: AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> WorkspaceAction {
|
||||
AgentConversationsModel::as_ref(app)
|
||||
.get_conversation(&conversation_id)
|
||||
.and_then(|conversation| {
|
||||
conversation.get_open_action(Some(RestoreConversationLayout::SplitPane), app)
|
||||
})
|
||||
.unwrap_or(WorkspaceAction::RestoreOrNavigateToConversation {
|
||||
pane_view_locator: None,
|
||||
window_id: None,
|
||||
) -> Option<WorkspaceAction> {
|
||||
AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation(
|
||||
conversation_id,
|
||||
terminal_view_id: None,
|
||||
restore_layout: Some(RestoreConversationLayout::SplitPane),
|
||||
})
|
||||
)),
|
||||
Some(RestoreConversationLayout::SplitPane),
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parent_conversation_navigation_card(
|
||||
@@ -79,7 +145,7 @@ pub(crate) fn parent_conversation_navigation_card(
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|conversation| conversation.title())
|
||||
.unwrap_or_else(|| "Parent conversation".to_string());
|
||||
let action = conversation_navigation_action(parent_conversation_id, app);
|
||||
let action = conversation_navigation_action(parent_conversation_id, app)?;
|
||||
Some(conversation_navigation_card(
|
||||
parent_title,
|
||||
Some("Back to parent conversation".to_string()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
//! Shared singleton holding cross-pane UI state for the orchestration pill
|
||||
//! bar: the set of pinned child-agent conversations (persisted so pins
|
||||
//! survive restarts) and a per-orchestrator horizontal scroll handle so
|
||||
//! the pill row's scroll offset survives switching between sibling panes.
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use warpui::elements::ClippedScrollStateHandle;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
|
||||
/// Toggle `id` in `pinned` — insert when absent, remove when present.
|
||||
/// Returns the new membership state (`true` = now pinned).
|
||||
/// Extracted so toggle semantics can be unit tested without a singleton.
|
||||
pub(super) fn toggle_pin_in_set(
|
||||
pinned: &mut HashSet<AIConversationId>,
|
||||
id: AIConversationId,
|
||||
) -> bool {
|
||||
if pinned.remove(&id) {
|
||||
false
|
||||
} else {
|
||||
pinned.insert(id);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton owning cross-pane UI state for the orchestration pill bar.
|
||||
pub struct OrchestrationPillBarModel {
|
||||
/// In-memory mirror of which child conversations are pinned. The
|
||||
/// persisted source of truth lives on each conversation; this set
|
||||
/// exists for fast lookups and cross-pane event notification.
|
||||
pinned: HashSet<AIConversationId>,
|
||||
/// One scroll handle per orchestrator conversation id. Every pill
|
||||
/// bar rendering the same orchestration tree clones the same handle
|
||||
/// so panning in one pane is reflected in sibling panes. `RefCell`
|
||||
/// so handles can be lazily created from `&AppContext` paths.
|
||||
horizontal_scroll_states: RefCell<HashMap<AIConversationId, ClippedScrollStateHandle>>,
|
||||
}
|
||||
|
||||
impl OrchestrationPillBarModel {
|
||||
/// Construct the singleton seeded with the set of conversation ids that
|
||||
/// were already pinned in persisted storage at startup.
|
||||
pub fn new(initial_pinned: HashSet<AIConversationId>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
// Prune deleted conversations globally so per-pane code never
|
||||
// has to (and can't accidentally clobber sibling panes' pins).
|
||||
let history_handle = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_handle, |this, _, event, ctx| match event {
|
||||
BlocklistAIHistoryEvent::RemoveConversation {
|
||||
conversation_id, ..
|
||||
}
|
||||
| BlocklistAIHistoryEvent::DeletedConversation {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
if this.pinned.remove(conversation_id) {
|
||||
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
|
||||
}
|
||||
// Drop the matching scroll handle so deleted orchestrators
|
||||
// don't leak entries. No event needed — nothing subscribes
|
||||
// to scroll-state changes.
|
||||
this.horizontal_scroll_states
|
||||
.borrow_mut()
|
||||
.remove(conversation_id);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Self {
|
||||
pinned: initial_pinned,
|
||||
horizontal_scroll_states: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the given conversation id is currently pinned.
|
||||
pub fn is_pinned(&self, conversation_id: &AIConversationId) -> bool {
|
||||
self.pinned.contains(conversation_id)
|
||||
}
|
||||
|
||||
/// Returns the shared horizontal scroll handle for the given
|
||||
/// orchestration tree, lazily creating one on first access. Every
|
||||
/// pill bar in that tree clones the same handle, so panning in one
|
||||
/// pane stays in sync with sibling panes.
|
||||
pub fn horizontal_scroll_state_for(
|
||||
&self,
|
||||
orchestrator_id: AIConversationId,
|
||||
) -> ClippedScrollStateHandle {
|
||||
self.horizontal_scroll_states
|
||||
.borrow_mut()
|
||||
.entry(orchestrator_id)
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Toggle whether `conversation_id` is pinned, persist the new state to
|
||||
/// the underlying conversation, and notify subscribers.
|
||||
pub fn toggle_pin(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext<Self>) {
|
||||
let now_pinned = toggle_pin_in_set(&mut self.pinned, conversation_id);
|
||||
// Push the change down to the conversation so the next session
|
||||
// restore reflects it. Doing this even if the conversation isn't
|
||||
// yet loaded is fine — the in-memory set is the active source
|
||||
// until that conversation rehydrates.
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.set_conversation_pinned(conversation_id, now_pinned, ctx);
|
||||
});
|
||||
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
|
||||
}
|
||||
|
||||
/// Clears the in-memory pinned set and the scroll handle cache.
|
||||
/// Invoked on logout so the next user does not inherit the previous
|
||||
/// account's UI state; persisted pins are wiped by the sqlite reset
|
||||
/// that runs alongside logout.
|
||||
pub fn reset(&mut self) {
|
||||
self.pinned.clear();
|
||||
self.horizontal_scroll_states.borrow_mut().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OrchestrationPillBarModel {
|
||||
type Event = OrchestrationPillBarEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for OrchestrationPillBarModel {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OrchestrationPillBarEvent {
|
||||
/// The pinned set changed (toggle or history-driven prune).
|
||||
PinSetChanged,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "orchestration_pill_bar_model_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use warpui::App;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
#[test]
|
||||
fn toggle_pin_in_set_flips_membership_for_each_call() {
|
||||
let mut pinned: HashSet<AIConversationId> = HashSet::new();
|
||||
let id = AIConversationId::new();
|
||||
|
||||
// Initially empty.
|
||||
assert!(!pinned.contains(&id));
|
||||
|
||||
// First toggle pins the id and reports the new state.
|
||||
assert!(toggle_pin_in_set(&mut pinned, id));
|
||||
assert!(pinned.contains(&id));
|
||||
assert_eq!(pinned.len(), 1);
|
||||
|
||||
// Second toggle unpins it.
|
||||
assert!(!toggle_pin_in_set(&mut pinned, id));
|
||||
assert!(!pinned.contains(&id));
|
||||
assert!(pinned.is_empty());
|
||||
|
||||
// Third toggle re-pins, confirming the function is idempotent under
|
||||
// pairs and doesn't accumulate state.
|
||||
assert!(toggle_pin_in_set(&mut pinned, id));
|
||||
assert!(pinned.contains(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_pin_in_set_only_affects_target_id() {
|
||||
let mut pinned: HashSet<AIConversationId> = HashSet::new();
|
||||
let a = AIConversationId::new();
|
||||
let b = AIConversationId::new();
|
||||
|
||||
toggle_pin_in_set(&mut pinned, a);
|
||||
toggle_pin_in_set(&mut pinned, b);
|
||||
assert!(pinned.contains(&a));
|
||||
assert!(pinned.contains(&b));
|
||||
|
||||
// Unpinning `a` leaves `b` alone.
|
||||
toggle_pin_in_set(&mut pinned, a);
|
||||
assert!(!pinned.contains(&a));
|
||||
assert!(pinned.contains(&b));
|
||||
}
|
||||
|
||||
/// End-to-end check that toggling a pin (a) updates the in-memory
|
||||
/// `AIConversation.pinned` flag on the history model and (b) emits an
|
||||
/// `UpdateMultiAgentConversation` SQLite event carrying `pinned: true`,
|
||||
/// which is what makes the pin survive an app restart.
|
||||
#[test]
|
||||
fn toggle_pin_persists_pinned_state_to_sqlite_event() {
|
||||
App::test((), |mut app| async move {
|
||||
// `write_updated_conversation_state` reads `GeneralSettings`,
|
||||
// `AppExecutionMode`, and the global model_event_sender; wire all
|
||||
// three up so the SQLite write path actually runs.
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel::<ModelEvent>(4);
|
||||
let mut global_resource_handles = GlobalResourceHandles::mock(&mut app);
|
||||
global_resource_handles.model_event_sender = Some(sender);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
|
||||
// Restore a conversation that starts unpinned. The history model is
|
||||
// the source of truth for the persisted flag.
|
||||
let conversation = AIConversation::new(false, false);
|
||||
let conversation_id = conversation.id();
|
||||
let terminal_view_id = warpui::EntityId::new();
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
});
|
||||
|
||||
// Pin model must be registered after the history model because it
|
||||
// subscribes to history events on construction.
|
||||
let pin_model =
|
||||
app.add_singleton_model(|ctx| OrchestrationPillBarModel::new(HashSet::new(), ctx));
|
||||
|
||||
pin_model.update(&mut app, |model, ctx| {
|
||||
model.toggle_pin(conversation_id, ctx);
|
||||
});
|
||||
|
||||
// In-memory pin set reflects the toggle.
|
||||
pin_model.read(&app, |model, _| {
|
||||
assert!(
|
||||
model.is_pinned(&conversation_id),
|
||||
"pin set should contain the conversation after toggle"
|
||||
);
|
||||
});
|
||||
|
||||
// The conversation's persisted `pinned` flag is now true.
|
||||
history_model.read(&app, |model, _| {
|
||||
let pinned = model.conversation(&conversation_id).map(|c| c.is_pinned());
|
||||
assert_eq!(
|
||||
pinned,
|
||||
Some(true),
|
||||
"AIConversation.is_pinned() should reflect the toggle"
|
||||
);
|
||||
});
|
||||
|
||||
// The SQLite writer should have received an UpdateMultiAgentConversation
|
||||
// event with pinned=true. The fire-and-forget send happens on the
|
||||
// executor, so allow a short timeout.
|
||||
let event = receiver
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.expect("expected an UpdateMultiAgentConversation event after toggle_pin");
|
||||
match event {
|
||||
ModelEvent::UpdateMultiAgentConversation {
|
||||
conversation_id: persisted_id,
|
||||
conversation_data,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(persisted_id, conversation_id.to_string());
|
||||
assert!(
|
||||
conversation_data.pinned,
|
||||
"persisted AgentConversationData.pinned should be true"
|
||||
);
|
||||
}
|
||||
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
|
||||
}
|
||||
|
||||
// Toggling again should write pinned=false through the same path.
|
||||
pin_model.update(&mut app, |model, ctx| {
|
||||
model.toggle_pin(conversation_id, ctx);
|
||||
});
|
||||
let event = receiver
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.expect("expected a second UpdateMultiAgentConversation event after unpin");
|
||||
match event {
|
||||
ModelEvent::UpdateMultiAgentConversation {
|
||||
conversation_data, ..
|
||||
} => {
|
||||
assert!(
|
||||
!conversation_data.pinned,
|
||||
"persisted AgentConversationData.pinned should be false after unpin"
|
||||
);
|
||||
}
|
||||
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use super::*;
|
||||
|
||||
// Traversal and canonical pill-order correctness are exercised in
|
||||
// `app/src/ai/blocklist/orchestration_topology_tests.rs`. These tests stay
|
||||
// focused on the pill bar's own dispatch behavior.
|
||||
|
||||
/// The data layer that `OrchestrationPillBar::pill_specs` reads must
|
||||
/// surface restored orchestration children before any pane has been created.
|
||||
///
|
||||
/// `pill_specs` (defined privately on `OrchestrationPillBar`) walks
|
||||
/// `descendant_conversation_ids_in_spawn_order(history, orchestrator_id)` and
|
||||
/// then `filter_map(|id| history.conversation(&id))`. The
|
||||
/// `history.conversation(&id)` lookup must return `Some` for restored
|
||||
/// children even before the parent's hidden pane materializes, or the pill
|
||||
/// bar renders nothing. This test asserts both layers work after
|
||||
/// `BlocklistAIHistoryModel::new` runs, before any `restore_conversations` /
|
||||
/// pane materialization.
|
||||
#[test]
|
||||
fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
use warpui::App;
|
||||
|
||||
use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::persistence::model::{
|
||||
AgentConversation, AgentConversationData, AgentConversationRecord,
|
||||
};
|
||||
|
||||
App::test((), |app| async move {
|
||||
let parent_id = AIConversationId::new();
|
||||
let child_id = AIConversationId::new();
|
||||
let parent_run_id = Uuid::new_v4().to_string();
|
||||
let child_run_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let conversations = vec![
|
||||
AgentConversation {
|
||||
conversation: AgentConversationRecord {
|
||||
id: 1,
|
||||
conversation_id: child_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: Some(parent_run_id.clone()),
|
||||
agent_name: Some("Agent 1".to_string()),
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: Some(parent_id.to_string()),
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: Some(child_run_id.clone()),
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
})
|
||||
.expect("child conversation data should serialize"),
|
||||
last_modified_at: now,
|
||||
},
|
||||
tasks: vec![warp_multi_agent_api::Task {
|
||||
id: format!("task-{child_id}"),
|
||||
messages: vec![warp_multi_agent_api::Message {
|
||||
fetched_memories: vec![],
|
||||
id: "child-msg".to_string(),
|
||||
task_id: format!("task-{child_id}"),
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(warp_multi_agent_api::message::Message::UserQuery(
|
||||
warp_multi_agent_api::message::UserQuery {
|
||||
query: "Child query".to_string(),
|
||||
context: None,
|
||||
referenced_attachments: Default::default(),
|
||||
mode: None,
|
||||
intended_agent: Default::default(),
|
||||
},
|
||||
)),
|
||||
request_id: "request-1".to_string(),
|
||||
timestamp: None,
|
||||
}],
|
||||
dependencies: None,
|
||||
description: "Child query".to_string(),
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}],
|
||||
},
|
||||
AgentConversation {
|
||||
conversation: AgentConversationRecord {
|
||||
id: 2,
|
||||
conversation_id: parent_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: Some(parent_run_id.clone()),
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
})
|
||||
.expect("parent conversation data should serialize"),
|
||||
last_modified_at: now - chrono::Duration::seconds(1),
|
||||
},
|
||||
tasks: vec![warp_multi_agent_api::Task {
|
||||
id: format!("task-{parent_id}"),
|
||||
messages: vec![warp_multi_agent_api::Message {
|
||||
fetched_memories: vec![],
|
||||
id: "parent-msg".to_string(),
|
||||
task_id: format!("task-{parent_id}"),
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(warp_multi_agent_api::message::Message::UserQuery(
|
||||
warp_multi_agent_api::message::UserQuery {
|
||||
query: "Parent query".to_string(),
|
||||
context: None,
|
||||
referenced_attachments: Default::default(),
|
||||
mode: None,
|
||||
intended_agent: Default::default(),
|
||||
},
|
||||
)),
|
||||
request_id: "request-2".to_string(),
|
||||
timestamp: None,
|
||||
}],
|
||||
dependencies: None,
|
||||
description: "Parent query".to_string(),
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let history_model =
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &conversations));
|
||||
|
||||
history_model.read(&app, |model, _| {
|
||||
// pill_specs walks `descendant_conversation_ids_in_spawn_order`
|
||||
// first. This index must be populated for restored children at
|
||||
// app startup, before any pane materializes.
|
||||
let descendants = descendant_conversation_ids_in_spawn_order(model, parent_id);
|
||||
assert_eq!(
|
||||
descendants,
|
||||
vec![child_id],
|
||||
"orchestration topology must surface restored children before any pane is created",
|
||||
);
|
||||
|
||||
// pill_specs then collects pill specs via
|
||||
// `descendants.into_iter().filter_map(|id| history.conversation(&id))`.
|
||||
// The child must be hydrated eagerly so this lookup succeeds and
|
||||
// the pill bar renders; otherwise the filter_map would drop the
|
||||
// child (because `conversation(&child_id)` returned `None`) and
|
||||
// `pill_specs` would return `None` from the
|
||||
// `children.is_empty()` early-exit.
|
||||
let resolved_children: Vec<&AIConversation> = descendants
|
||||
.iter()
|
||||
.filter_map(|id| model.conversation(id))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
resolved_children.len(),
|
||||
1,
|
||||
"restored child conversation must be available in conversations_by_id so \
|
||||
OrchestrationPillBar::pill_specs renders a child pill",
|
||||
);
|
||||
assert_eq!(resolved_children[0].id(), child_id);
|
||||
assert_eq!(resolved_children[0].agent_name(), Some("Agent 1"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_action_for_child_pill_reveals_existing_child_pane() {
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
assert!(matches!(
|
||||
navigation_action_for_pill(PillKind::Child, conversation_id),
|
||||
TerminalAction::RevealChildAgent {
|
||||
conversation_id: actual_id,
|
||||
} if actual_id == conversation_id
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_action_for_orchestrator_pill_switches_in_place() {
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
assert!(matches!(
|
||||
navigation_action_for_pill(PillKind::Orchestrator, conversation_id),
|
||||
TerminalAction::SwitchAgentViewToConversation {
|
||||
conversation_id: actual_id,
|
||||
} if actual_id == conversation_id
|
||||
));
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
mod model;
|
||||
|
||||
pub use model::*;
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use galaxy_core::{features::FeatureFlag, ui::appearance::Appearance};
|
||||
use galaxyui::{
|
||||
elements::{Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
|
||||
keymap::Keystroke,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
pub use model::*;
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::{
|
||||
Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text,
|
||||
};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
cmd_or_ctrl_shift,
|
||||
terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING},
|
||||
ui_components::blended_colors,
|
||||
util::bindings::keybinding_name_to_keystroke,
|
||||
workspace::view::{
|
||||
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
|
||||
},
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
};
|
||||
use crate::cmd_or_ctrl_shift;
|
||||
use crate::terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::workspace::view::{
|
||||
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
@@ -259,7 +259,6 @@ pub fn render_agent_shortcuts_view(
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
|
||||
pub fn keystroke_size(appearance: &Appearance) -> f32 {
|
||||
font_size(appearance) + 2.
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle};
|
||||
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent},
|
||||
server::telemetry::TelemetryEvent,
|
||||
terminal::input::buffer_model::InputBufferModel,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
|
||||
/// Model responsible for managing state required to conditionally render the shortcuts view.
|
||||
pub struct AgentShortcutViewModel {
|
||||
@@ -19,12 +16,12 @@ impl AgentShortcutViewModel {
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&input_buffer_model, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&input_buffer_model, |me, _, event, ctx| {
|
||||
if me.is_shortcut_view_open && !event.new_content.is_empty() {
|
||||
me.hide_shortcut_view(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| {
|
||||
if matches!(event, AgentViewControllerEvent::ExitedAgentView { .. }) {
|
||||
me.hide_shortcut_view(ctx);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,53 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use galaxy_core::{features::FeatureFlag, report_if_error, ui::Icon};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable,
|
||||
Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
keymap::Keystroke,
|
||||
prelude::{Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition},
|
||||
scene::Border,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::Reverse;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use itertools::Itertools as _;
|
||||
use markdown_parser::{parse_markdown, FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use parking_lot::FairMutex;
|
||||
use settings::Setting;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::report_if_error;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::{
|
||||
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::prelude::{
|
||||
Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition,
|
||||
};
|
||||
use galaxyui::scene::Border;
|
||||
use galaxyui::ui_components::components::{UiComponent as _, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
use std::{borrow::Cow, cmp::Reverse, path::Path, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId},
|
||||
agent::conversation::AIConversationId,
|
||||
blocklist::{
|
||||
agent_view::{
|
||||
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
},
|
||||
history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel},
|
||||
},
|
||||
conversation_navigation::ConversationNavigationData,
|
||||
},
|
||||
appearance::Appearance,
|
||||
changelog_model::{self, ChangelogModel},
|
||||
settings::{AISettings, AISettingsChangedEvent},
|
||||
terminal::{
|
||||
self,
|
||||
event::BlockType,
|
||||
input::message_bar::{common::render_standard_message, Message, MessageItem},
|
||||
model::{
|
||||
blocks::BlockHeightItem,
|
||||
session::{BootstrapSessionType, Session, SessionType, Sessions},
|
||||
},
|
||||
model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher},
|
||||
prompt,
|
||||
view::{
|
||||
ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent},
|
||||
TerminalAction,
|
||||
},
|
||||
TerminalModel,
|
||||
},
|
||||
util::time_format::format_approx_duration_from_now_utc,
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
};
|
||||
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::changelog_model::{self, ChangelogModel};
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent};
|
||||
use crate::terminal::event::BlockType;
|
||||
use crate::terminal::input::message_bar::common::render_standard_message;
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::model::blocks::BlockHeightItem;
|
||||
use crate::terminal::model::session::{BootstrapSessionType, Session, SessionType, Sessions};
|
||||
use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent};
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::terminal::{self, prompt, TerminalModel};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
const CLOUD_AGENT_DOCS_URL: &str = "https://docs.warp.dev/agent-platform/cloud-agents/overview";
|
||||
const OZ_UPDATES_SECTION_HEADER: &str = "What's new in Oz";
|
||||
@@ -97,13 +93,14 @@ impl AgentViewZeroStateBlock {
|
||||
origin: AgentViewEntryOrigin,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
sessions: &ModelHandle<Sessions>,
|
||||
cloud_agent_view_model: &ModelHandle<AmbientAgentViewModel>,
|
||||
cloud_agent_view_model: Option<&ModelHandle<AmbientAgentViewModel>>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
model_events_dispatcher: &ModelHandle<ModelEventDispatcher>,
|
||||
should_show_init_callout: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
|
||||
|
||||
let model_events_clone = model_events_dispatcher.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
@@ -116,7 +113,10 @@ impl AgentViewZeroStateBlock {
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&history_model);
|
||||
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
|
||||
if let Some(cloud_agent_view_model) = cloud_agent_view_model_clone.as_ref()
|
||||
{
|
||||
ctx.unsubscribe_to_model(cloud_agent_view_model);
|
||||
}
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +140,7 @@ impl AgentViewZeroStateBlock {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
|
||||
ctx.subscribe_to_model(
|
||||
model_events_dispatcher,
|
||||
move |me, model_events_dispatcher, event, ctx| {
|
||||
@@ -152,7 +152,11 @@ impl AgentViewZeroStateBlock {
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model_events_dispatcher);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
|
||||
if let Some(cloud_agent_view_model) =
|
||||
cloud_agent_view_model_clone.as_ref()
|
||||
{
|
||||
ctx.unsubscribe_to_model(cloud_agent_view_model);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
@@ -166,34 +170,46 @@ impl AgentViewZeroStateBlock {
|
||||
},
|
||||
);
|
||||
|
||||
let model_events_clone = model_events_dispatcher.clone();
|
||||
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
|
||||
if FeatureFlag::CloudModeSetupV2.is_enabled() {
|
||||
match event {
|
||||
AmbientAgentViewModelEvent::DispatchedAgent
|
||||
| AmbientAgentViewModelEvent::Cancelled
|
||||
if !me.should_hide =>
|
||||
{
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model);
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
if let Some(cloud_agent_view_model) = cloud_agent_view_model {
|
||||
let model_events_clone = model_events_dispatcher.clone();
|
||||
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
|
||||
if me.should_hide {
|
||||
return;
|
||||
}
|
||||
} else if model.as_ref(ctx).should_show_status_footer() {
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model);
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
let cloud_agent_view = cloud_agent_view_model.as_ref(ctx);
|
||||
// Hide the zero state when this pane becomes a local-to-cloud handoff
|
||||
// pane (REMOTE-1486). The fresh cloud-mode banner is suppressed because
|
||||
// the pane is actually pre-loaded with a forked source conversation, not
|
||||
// a brand-new one.
|
||||
if matches!(event, AmbientAgentViewModelEvent::PendingHandoffChanged)
|
||||
&& model.as_ref(ctx).is_local_to_cloud_handoff()
|
||||
{
|
||||
me.should_hide = true;
|
||||
} else if FeatureFlag::CloudModeSetupV2.is_enabled() {
|
||||
if matches!(
|
||||
event,
|
||||
AmbientAgentViewModelEvent::DispatchedAgent
|
||||
| AmbientAgentViewModelEvent::Cancelled
|
||||
) {
|
||||
me.should_hide = true;
|
||||
}
|
||||
} else if model.as_ref(ctx).should_show_status_footer() {
|
||||
me.should_hide = true;
|
||||
}
|
||||
|
||||
if me.should_hide {
|
||||
ctx.unsubscribe_to_model(&model);
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let has_parent_terminal =
|
||||
!cloud_agent_view.is_ambient_agent() || cloud_agent_view.has_parent_terminal();
|
||||
cloud_agent_view_model.is_none_or(|model| !model.as_ref(ctx).is_ambient_agent());
|
||||
let is_local_to_cloud_handoff = cloud_agent_view_model
|
||||
.is_some_and(|model| model.as_ref(ctx).is_local_to_cloud_handoff());
|
||||
let changelog_model = ChangelogModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&changelog_model, |me, changelog_model, event, ctx| {
|
||||
if let changelog_model::Event::ChangelogRequestComplete { .. } = event {
|
||||
@@ -230,6 +246,10 @@ impl AgentViewZeroStateBlock {
|
||||
Self::recent_conversations_for_working_directory(current_working_directory, ctx)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let should_hide = matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff)
|
||||
|| is_local_to_cloud_handoff;
|
||||
let is_oz_updates_expanded = !origin.is_cloud_agent()
|
||||
&& *AISettings::handle(ctx).as_ref(ctx).should_expand_oz_updates;
|
||||
|
||||
Self {
|
||||
conversation_id,
|
||||
@@ -239,11 +259,11 @@ impl AgentViewZeroStateBlock {
|
||||
terminal_model,
|
||||
current_working_directory,
|
||||
cached_recent_conversations,
|
||||
should_hide: matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff),
|
||||
should_hide,
|
||||
should_show_init_callout,
|
||||
has_parent_terminal,
|
||||
state_handles,
|
||||
is_oz_updates_expanded: false,
|
||||
is_oz_updates_expanded,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +422,7 @@ impl View for AgentViewZeroStateBlock {
|
||||
let active_session = self.active_session(app);
|
||||
let body = render_body(
|
||||
ZeroStateBodyProps {
|
||||
origin: self.origin,
|
||||
origin: self.origin.clone(),
|
||||
has_parent_terminal: self.has_parent_terminal,
|
||||
should_show_init_callout: self.should_show_init_callout,
|
||||
recent_conversations: &self.cached_recent_conversations,
|
||||
@@ -681,7 +701,11 @@ fn render_body(props: ZeroStateBodyProps<'_>, app: &AppContext) -> Vec<Box<dyn E
|
||||
MessageItem::text("start a new agent conversation"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation);
|
||||
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation {
|
||||
origin: AgentViewEntryOrigin::Input {
|
||||
was_prompt_autodetected: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
state_handles.start_new_conversation.clone(),
|
||||
)]),
|
||||
@@ -1145,14 +1169,19 @@ fn render_oz_updates(props: OzUpdatesProps<'_>, app: &AppContext) -> Option<Box<
|
||||
}
|
||||
|
||||
/// Renders the ambient credits banner showing free cloud credits.
|
||||
/// If `link_mouse_state` is provided, a "Launch cloud agent" link is shown.
|
||||
pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn Element> {
|
||||
pub fn render_ambient_credits_banner<A>(
|
||||
credits: i32,
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
dismiss_action: A,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
A: Action + Clone + 'static,
|
||||
{
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = styles::CREDITS_BANNER_FONT_SIZE;
|
||||
|
||||
// Use ANSI terminal colors for the pill styling.
|
||||
let text_color = theme.terminal_colors().normal.blue;
|
||||
|
||||
let credits_text = format!("{credits} free cloud agent credits");
|
||||
@@ -1161,8 +1190,26 @@ pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.soft_wrap(false)
|
||||
.finish();
|
||||
let close_button = appearance
|
||||
.ui_builder()
|
||||
.close_button(12., close_button_mouse_state)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(text_color.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(dismiss_action.clone());
|
||||
})
|
||||
.finish();
|
||||
|
||||
Container::new(text)
|
||||
let content = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(text)
|
||||
.with_child(Container::new(close_button).with_margin_left(4.).finish())
|
||||
.finish();
|
||||
|
||||
Container::new(content)
|
||||
.with_border(Border::all(1.).with_border_color(text_color.into()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_vertical_padding(2.)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
use super::{display_working_directory, format_session_location, should_render_oz_updates_section};
|
||||
use crate::ai::blocklist::agent_view::zero_state_block::current_working_directory_for_zero_state;
|
||||
use crate::terminal::model::ansi::{Handler, InitShellValue, PrecmdValue, SSHValue};
|
||||
use crate::terminal::color::{self, Colors};
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::ansi::{Handler, InitShellValue, PromptMetadata, SSHValue};
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::model::test_utils::block_size;
|
||||
use crate::terminal::model::{session::Session, TerminalModel};
|
||||
use crate::terminal::{
|
||||
color::{self, Colors},
|
||||
event_listener::ChannelEventListener,
|
||||
};
|
||||
use galaxyui::r#async::executor::Background;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use crate::terminal::model::TerminalModel;
|
||||
|
||||
fn terminal_with_startup_path(startup_path: Option<&str>) -> TerminalModel {
|
||||
TerminalModel::new_for_test(
|
||||
@@ -76,9 +78,9 @@ fn display_working_directory_abbreviates_subdirectory_under_home() {
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_prefers_active_block_pwd() {
|
||||
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
terminal.precmd(PrecmdValue {
|
||||
terminal.prompt_only_precmd(PromptMetadata {
|
||||
pwd: Some("/active/path".to_owned()),
|
||||
session_id: Some(0),
|
||||
session_id: Some(123),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -96,7 +98,12 @@ fn cwd_for_recent_conversations_uses_startup_path_before_bootstrap_for_local_ses
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_ssh_bootstrap() {
|
||||
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
terminal.ssh(SSHValue::default());
|
||||
|
||||
terminal.ssh(SSHValue {
|
||||
session_id: Some(123),
|
||||
remote_session_id: Some(456),
|
||||
..Default::default()
|
||||
});
|
||||
let cwd = current_working_directory_for_zero_state(&terminal);
|
||||
assert_eq!(cwd, None);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user