Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions app/src/ai/active_agent_views_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,6 @@ impl ActiveAgentViewsModel {
.and_then(|state| state.active_conversation_id)
}

pub fn get_focused_terminal_view_id(&self, window_id: WindowId) -> Option<EntityId> {
self.focused_terminal_states
.get(&window_id)
.map(|state| state.focused_terminal_id)
}

/// Get the last focused terminal view id (persisted across non-terminal focus changes).
pub fn get_last_focused_terminal_id(&self) -> Option<EntityId> {
self.last_focused_terminal_state
Expand Down
19 changes: 19 additions & 0 deletions app/src/ai/agent_conversations_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,25 @@ pub struct AgentManagementFilters {
pub harness: HarnessFilter,
}

/// Frontend-specific classification of a normalized conversation-list entry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AgentConversationListEntryState {
Selected,
OpenElsewhere,
Available,
Unavailable,
}

/// Per-frontend policy for classifying normalized conversation-list entries.
pub trait AgentConversationListPolicy: 'static {
/// Classifies `entry` as selected, open elsewhere, available, or unavailable.
fn classify_entry(
&self,
entry: &AgentConversationEntry,
app: &AppContext,
) -> AgentConversationListEntryState;
}

impl AgentManagementFilters {
pub fn reset_all_but_owner(&mut self) {
self.status = StatusFilter::default();
Expand Down
49 changes: 49 additions & 0 deletions app/src/ai/blocklist/agent_view/conversation_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ use warpui::{AppContext, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{
AgentViewController, AgentViewControllerEvent, AgentViewEntryOrigin, EnterAgentViewError,
};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, AIConversationId};
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationListEntryState,
AgentConversationListPolicy,
};
use crate::ai::blocklist::conversation_selection::{
ConversationSelection, ConversationSelectionEvent,
};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::workspace::RestoreConversationLayout;

/// GUI conversation selection backed unconditionally by Agent View.
pub(crate) struct AgentViewConversationSelection {
Expand Down Expand Up @@ -61,6 +67,49 @@ impl AgentViewConversationSelection {
}
}

/// Applies GUI list-state precedence without consulting frontend models.
fn classify_gui_list_entry(
selected_entry_id: Option<AgentConversationEntryId>,
entry_id: AgentConversationEntryId,
open_terminal_view_id: Option<EntityId>,
terminal_surface_id: EntityId,
has_open_action: impl FnOnce() -> bool,
) -> AgentConversationListEntryState {
if selected_entry_id == Some(entry_id) {
return AgentConversationListEntryState::Selected;
}
if open_terminal_view_id.is_some_and(|terminal_view_id| terminal_view_id != terminal_surface_id)
{
return AgentConversationListEntryState::OpenElsewhere;
}
if has_open_action() {
AgentConversationListEntryState::Available
} else {
AgentConversationListEntryState::Unavailable
}
}
/// Classifies entries relative to this GUI Agent View terminal surface.
impl AgentConversationListPolicy for AgentViewConversationSelection {
fn classify_entry(
&self,
entry: &AgentConversationEntry,
app: &AppContext,
) -> AgentConversationListEntryState {
let selected_entry_id = self
.selected_conversation_id(app)
.map(AgentConversationEntryId::Conversation);
let open_terminal_view_id =
ActiveAgentViewsModel::as_ref(app).get_terminal_view_id_for_entry(entry, app);
classify_gui_list_entry(
selected_entry_id,
entry.id,
open_terminal_view_id,
self.terminal_surface_id,
|| entry.has_open_action(Some(RestoreConversationLayout::ActivePane), app),
)
}
}

impl ConversationSelection for AgentViewConversationSelection {
fn selected_conversation_id(&self, app: &AppContext) -> Option<AIConversationId> {
self.agent_view_controller
Expand Down
53 changes: 52 additions & 1 deletion app/src/ai/blocklist/agent_view/conversation_selection_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ use parking_lot::FairMutex;
use warpui::r#async::executor::Background;
use warpui::{App, EntityId};

use super::AgentViewConversationSelection;
use super::{classify_gui_list_entry, AgentViewConversationSelection};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::{
AgentConversationEntryId, AgentConversationListEntryState,
};
use crate::ai::blocklist::agent_view::{
AgentViewController, AgentViewEntryOrigin, EphemeralMessageModel,
};
Expand All @@ -14,6 +18,53 @@ 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_list_policy_classifies_selected_entry() {
let entry_id = AgentConversationEntryId::Conversation(AIConversationId::new());
assert_eq!(
classify_gui_list_entry(
Some(entry_id),
entry_id,
Some(EntityId::new()),
EntityId::new(),
|| panic!("selected entries should not resolve an open action"),
),
AgentConversationListEntryState::Selected
);
}

#[test]
fn gui_list_policy_classifies_entry_open_elsewhere() {
let entry_id = AgentConversationEntryId::Conversation(AIConversationId::new());
assert_eq!(
classify_gui_list_entry(
None,
entry_id,
Some(EntityId::new()),
EntityId::new(),
|| panic!("entries open elsewhere should not resolve an open action"),
),
AgentConversationListEntryState::OpenElsewhere
);
}

#[test]
fn gui_list_policy_classifies_available_entry() {
let entry_id = AgentConversationEntryId::Conversation(AIConversationId::new());
assert_eq!(
classify_gui_list_entry(None, entry_id, None, EntityId::new(), || true),
AgentConversationListEntryState::Available
);
}

#[test]
fn gui_list_policy_classifies_unavailable_entry() {
let entry_id = AgentConversationEntryId::Conversation(AIConversationId::new());
assert_eq!(
classify_gui_list_entry(None, entry_id, None, EntityId::new(), || false),
AgentConversationListEntryState::Unavailable
);
}

#[test]
fn gui_selection_delegates_to_agent_view() {
Expand Down
13 changes: 13 additions & 0 deletions app/src/ai/blocklist/context_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ use warpui::{App, EntityId, ModelHandle, SingletonEntity};
use super::{BlocklistAIContextModel, PendingAttachment, PendingFile};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{AIAgentContext, ImageContext};
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationListEntryState, AgentConversationListPolicy,
};
use crate::ai::blocklist::agent_view::{AgentViewEntryOrigin, EnterAgentViewError};
use crate::ai::blocklist::conversation_selection::{
ConversationSelection, ConversationSelectionEvent,
Expand Down Expand Up @@ -69,6 +72,16 @@ impl TestConversationSelection {
}
}

impl AgentConversationListPolicy for TestConversationSelection {
fn classify_entry(
&self,
_: &AgentConversationEntry,
_: &warpui::AppContext,
) -> AgentConversationListEntryState {
AgentConversationListEntryState::Unavailable
}
}

impl ConversationSelection for TestConversationSelection {
fn selected_conversation_id(&self, _: &warpui::AppContext) -> Option<AIConversationId> {
self.selected_conversation_id
Expand Down
18 changes: 17 additions & 1 deletion app/src/ai/blocklist/conversation_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ use super::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::agent::conversation::{
AIConversation, AIConversationAutoexecuteMode, AIConversationId,
};
use crate::ai::agent_conversations_model::AgentConversationListPolicy;
#[cfg(any(test, feature = "test-util"))]
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationListEntryState,
};

/// Handle to a terminal surface's conversation-selection implementation.
pub type ConversationSelectionHandle = ModelHandle<Box<dyn ConversationSelection>>;
Expand Down Expand Up @@ -52,7 +57,7 @@ pub enum ConversationSelectionEvent {
/// A selected conversation receives the surface's next query; without one, the next query starts a
/// new conversation. Implementations also describe whether that selection is actively presented
/// and whether it occupies the full surface, without exposing surface-specific presentation state.
pub trait ConversationSelection {
pub trait ConversationSelection: AgentConversationListPolicy {
/// Returns the conversation targeted by the next query.
fn selected_conversation_id(&self, app: &AppContext) -> Option<AIConversationId>;

Expand Down Expand Up @@ -119,6 +124,17 @@ impl Entity for Box<dyn ConversationSelection> {
#[cfg(any(test, feature = "test-util"))]
pub(crate) struct MockConversationSelection;

#[cfg(any(test, feature = "test-util"))]
impl AgentConversationListPolicy for MockConversationSelection {
fn classify_entry(
&self,
_: &AgentConversationEntry,
_: &AppContext,
) -> AgentConversationListEntryState {
AgentConversationListEntryState::Unavailable
}
}

#[cfg(any(test, feature = "test-util"))]
impl ConversationSelection for MockConversationSelection {
fn selected_conversation_id(&self, _: &AppContext) -> Option<AIConversationId> {
Expand Down
3 changes: 3 additions & 0 deletions app/src/terminal/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ use crate::ai::blocklist::agent_view::{
};
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBar;
use crate::ai::blocklist::conversation_selection::ConversationSelectionHandle;
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
use crate::ai::blocklist::handoff::touched_repos::{
pick_handoff_overlap_env, resolve_repo_for_path, TouchedWorkspace,
Expand Down Expand Up @@ -2493,6 +2494,7 @@ impl Input {
ai_context_model: ModelHandle<BlocklistAIContextModel>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
ai_action_model: ModelHandle<BlocklistAIActionModel>,
conversation_selection: ConversationSelectionHandle,
cli_subagent_controller: ModelHandle<CLISubagentController>,
terminal_view_id: EntityId,
current_repo_path: Option<PathBuf>,
Expand Down Expand Up @@ -3492,6 +3494,7 @@ impl Input {
InlineConversationMenuView::new(
suggestions_mode_model.clone(),
agent_view_controller.clone(),
conversation_selection,
&buffer_model,
&inline_terminal_menu_positioner,
active_session.clone(),
Expand Down
45 changes: 18 additions & 27 deletions app/src/terminal/input/conversations/data_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,43 @@ use ordered_float::OrderedFloat;
use warpui::{AppContext, Entity, ModelHandle, SingletonEntity};

use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentManagementFilters,
AgentConversationEntry, AgentConversationListEntryState, AgentManagementFilters,
};
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::conversation_selection::ConversationSelectionHandle;
use crate::search::data_source::{Query, QueryFilter, QueryResult};
use crate::search::mixer::DataSourceRunErrorWrapper;
use crate::search::SyncDataSource;
use crate::terminal::input::conversations::search_item::ConversationSearchItem;
use crate::terminal::input::conversations::AcceptConversation;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::workspace::RestoreConversationLayout;
use crate::AgentConversationsModel;

pub struct ConversationMenuDataSource {
agent_view_controller: ModelHandle<AgentViewController>,
conversation_selection: ConversationSelectionHandle,
active_session: ModelHandle<ActiveSession>,
}

impl ConversationMenuDataSource {
pub fn new(
agent_view_controller: ModelHandle<AgentViewController>,
conversation_selection: ConversationSelectionHandle,
active_session: ModelHandle<ActiveSession>,
) -> Self {
Self {
agent_view_controller,
conversation_selection,
active_session,
}
}

fn entries(&self, app: &AppContext) -> Vec<AgentConversationEntry> {
fn entries(&self, app: &AppContext) -> Vec<(AgentConversationEntry, bool)> {
let policy = self.conversation_selection.as_ref(app);
AgentConversationsModel::as_ref(app)
.get_entries(&AgentManagementFilters::default(), app)
.into_iter()
.filter(|entry: &AgentConversationEntry| {
entry.has_open_action(Some(RestoreConversationLayout::ActivePane), app)
.filter_map(|entry| match policy.classify_entry(&entry, app) {
AgentConversationListEntryState::Available => Some((entry, false)),
AgentConversationListEntryState::OpenElsewhere => Some((entry, true)),
AgentConversationListEntryState::Selected
| AgentConversationListEntryState::Unavailable => None,
})
.collect()
}
Expand All @@ -54,12 +57,6 @@ impl SyncDataSource for ConversationMenuDataSource {
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let conversation_entries = self.entries(app);
let query_text = query.text.trim().to_lowercase();
let active_item_id = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
.map(AgentConversationEntryId::Conversation);

let filter_by_cwd = query
.filters
Expand Down Expand Up @@ -102,24 +99,18 @@ impl SyncDataSource for ConversationMenuDataSource {
// Within each segment, sort to reverse chronological order.
Ok(conversation_entries
.into_iter()
// Don't show the currently open conversation, that's redundant.
.filter(|entry| Some(entry.id) != active_item_id)
.filter(|entry| matches_directory(entry))
.sorted_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated))
.filter(|(entry, _)| matches_directory(entry))
.sorted_by(|(a, _), (b, _)| b.display.last_updated.cmp(&a.display.last_updated))
.take(DEFAULT_RESULT_COUNT)
.map(|conversation_entry| {
QueryResult::from(ConversationSearchItem::new(conversation_entry))
.map(|(entry, is_open_elsewhere)| {
QueryResult::from(ConversationSearchItem::new(entry, is_open_elsewhere))
})
.rev()
.collect())
} else {
let mut search_results = conversation_entries
.into_iter()
.filter_map(|entry| {
if Some(entry.id) == active_item_id {
// Don't show the currently open conversation, that's redundant.
return None;
}
.filter_map(|(entry, is_open_elsewhere)| {
if !matches_directory(&entry) {
return None;
}
Expand All @@ -134,7 +125,7 @@ impl SyncDataSource for ConversationMenuDataSource {
}

Some(QueryResult::from(
ConversationSearchItem::new(entry)
ConversationSearchItem::new(entry, is_open_elsewhere)
.with_name_match_result(Some(match_result.clone()))
.with_score(OrderedFloat(match_result.score as f64)),
))
Expand Down
Loading