From 6ae2f709b6f3084c9ae2c4da233f5e78fb0e5111 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Mon, 13 Jul 2026 16:12:26 -0400 Subject: [PATCH 1/3] Add frontend-neutral conversation list policy Co-Authored-By: Oz --- app/src/ai/active_agent_views_model.rs | 6 -- app/src/ai/agent_conversations_model.rs | 19 +++++ .../agent_view/conversation_selection.rs | 34 ++++++++ app/src/ai/blocklist/context_model_tests.rs | 13 ++++ .../ai/blocklist/conversation_selection.rs | 18 ++++- app/src/terminal/input.rs | 3 + .../input/conversations/data_source.rs | 45 +++++------ app/src/terminal/input/conversations/mod.rs | 14 ++-- .../input/conversations/search_item.rs | 20 ++--- app/src/terminal/input/conversations/view.rs | 7 +- app/src/terminal/view.rs | 1 + app/src/tui_export.rs | 5 ++ crates/warp_tui/src/conversation_selection.rs | 61 ++++++++++++++- .../src/conversation_selection_tests.rs | 62 ++++++++++++++- .../TECH.md | 77 +++++++++++++++++++ 15 files changed, 321 insertions(+), 64 deletions(-) create mode 100644 specs/frontend-neutral-conversation-list-policy/TECH.md diff --git a/app/src/ai/active_agent_views_model.rs b/app/src/ai/active_agent_views_model.rs index fd66fb90581..fcf5c455598 100644 --- a/app/src/ai/active_agent_views_model.rs +++ b/app/src/ai/active_agent_views_model.rs @@ -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 { - 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 { self.last_focused_terminal_state diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index bc1ea979d9a..c776f9b3ba7 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -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 { + /// Returns how this frontend can present and navigate to `entry`. + fn classify_entry( + &self, + entry: &AgentConversationEntry, + app: &AppContext, + ) -> AgentConversationListEntryState; +} + impl AgentManagementFilters { pub fn reset_all_but_owner(&mut self) { self.status = StatusFilter::default(); diff --git a/app/src/ai/blocklist/agent_view/conversation_selection.rs b/app/src/ai/blocklist/agent_view/conversation_selection.rs index ba6ccd98d96..b2c721e4385 100644 --- a/app/src/ai/blocklist/agent_view/conversation_selection.rs +++ b/app/src/ai/blocklist/agent_view/conversation_selection.rs @@ -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 { @@ -61,6 +67,34 @@ impl AgentViewConversationSelection { } } +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); + if selected_entry_id == Some(entry.id) { + return AgentConversationListEntryState::Selected; + } + + if ActiveAgentViewsModel::as_ref(app) + .get_terminal_view_id_for_entry(entry, app) + .is_some_and(|terminal_view_id| terminal_view_id != self.terminal_surface_id) + { + return AgentConversationListEntryState::OpenElsewhere; + } + + if entry.has_open_action(Some(RestoreConversationLayout::ActivePane), app) { + AgentConversationListEntryState::Available + } else { + AgentConversationListEntryState::Unavailable + } + } +} + impl ConversationSelection for AgentViewConversationSelection { fn selected_conversation_id(&self, app: &AppContext) -> Option { self.agent_view_controller diff --git a/app/src/ai/blocklist/context_model_tests.rs b/app/src/ai/blocklist/context_model_tests.rs index 77157877b1f..b0effd334cd 100644 --- a/app/src/ai/blocklist/context_model_tests.rs +++ b/app/src/ai/blocklist/context_model_tests.rs @@ -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, @@ -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 { self.selected_conversation_id diff --git a/app/src/ai/blocklist/conversation_selection.rs b/app/src/ai/blocklist/conversation_selection.rs index 42575b663d8..80fced01945 100644 --- a/app/src/ai/blocklist/conversation_selection.rs +++ b/app/src/ai/blocklist/conversation_selection.rs @@ -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>; @@ -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; @@ -119,6 +124,17 @@ impl Entity for Box { #[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 { diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index bfb16763864..46e4e596dee 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -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, @@ -2493,6 +2494,7 @@ impl Input { ai_context_model: ModelHandle, ai_input_model: ModelHandle, ai_action_model: ModelHandle, + conversation_selection: ConversationSelectionHandle, cli_subagent_controller: ModelHandle, terminal_view_id: EntityId, current_repo_path: Option, @@ -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(), diff --git a/app/src/terminal/input/conversations/data_source.rs b/app/src/terminal/input/conversations/data_source.rs index 975be570f8b..b487ef7ed76 100644 --- a/app/src/terminal/input/conversations/data_source.rs +++ b/app/src/terminal/input/conversations/data_source.rs @@ -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, + conversation_selection: ConversationSelectionHandle, active_session: ModelHandle, } impl ConversationMenuDataSource { pub fn new( - agent_view_controller: ModelHandle, + conversation_selection: ConversationSelectionHandle, active_session: ModelHandle, ) -> Self { Self { - agent_view_controller, + conversation_selection, active_session, } } - fn entries(&self, app: &AppContext) -> Vec { + 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() } @@ -54,12 +57,6 @@ impl SyncDataSource for ConversationMenuDataSource { ) -> Result>, 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 @@ -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; } @@ -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)), )) diff --git a/app/src/terminal/input/conversations/mod.rs b/app/src/terminal/input/conversations/mod.rs index eebfc3e1de8..a399c265b02 100644 --- a/app/src/terminal/input/conversations/mod.rs +++ b/app/src/terminal/input/conversations/mod.rs @@ -10,7 +10,6 @@ use warp_core::ui::appearance::Appearance; use warpui::keymap::Keystroke; use warpui::SingletonEntity; -use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId}; use crate::ai::agent_conversations_model::AgentConversationEntryId; use crate::terminal::input::inline_menu::{ default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction, @@ -31,6 +30,7 @@ pub enum InlineConversationMenuTab { #[derive(Clone, Debug)] pub struct AcceptConversation { pub item_id: AgentConversationEntryId, + pub is_open_elsewhere: bool, } impl InlineMenuAction for AcceptConversation { @@ -45,17 +45,14 @@ impl InlineMenuAction for AcceptConversation { let mut items = Vec::new(); if let Some(item) = inline_menu_model.selected_item() { - let active_ids = - ActiveAgentViewsModel::as_ref(app).get_all_active_conversation_ids(app); - let is_active = active_ids.contains(&ConversationOrTaskId::from(item.item_id)); - - let text = if is_active { + let text = if item.is_open_elsewhere { " go to conversation" } else { " continue in this pane" }; let item_id = item.item_id; + let is_open_elsewhere = item.is_open_elsewhere; items.push(MessageItem::clickable( vec![ MessageItem::keystroke(Keystroke { @@ -66,7 +63,10 @@ impl InlineMenuAction for AcceptConversation { ], move |ctx| { ctx.dispatch_typed_action(InlineMenuRowAction::Accept { - item: AcceptConversation { item_id }, + item: AcceptConversation { + item_id, + is_open_elsewhere, + }, cmd_or_ctrl_enter: false, }); }, diff --git a/app/src/terminal/input/conversations/search_item.rs b/app/src/terminal/input/conversations/search_item.rs index bbc92018f4a..43b92998620 100644 --- a/app/src/terminal/input/conversations/search_item.rs +++ b/app/src/terminal/input/conversations/search_item.rs @@ -9,7 +9,6 @@ use warpui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAx use warpui::text_layout::ClipConfig; use warpui::{AppContext, Element, SingletonEntity}; -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent_conversations_model::AgentConversationEntry; use crate::ai::conversation_status_ui::render_status_element; use crate::appearance::Appearance; @@ -24,14 +23,16 @@ pub(super) struct ConversationSearchItem { entry: AgentConversationEntry, name_match_result: Option, score: OrderedFloat, + is_open_elsewhere: bool, } impl ConversationSearchItem { - pub fn new(entry: AgentConversationEntry) -> Self { + pub fn new(entry: AgentConversationEntry, is_open_elsewhere: bool) -> Self { Self { entry, name_match_result: None, score: OrderedFloat(f64::MIN), + is_open_elsewhere, } } @@ -76,19 +77,9 @@ impl SearchItem for ConversationSearchItem { let primary_text_color = inline_styles::primary_text_color(theme, background_color.into()); let secondary_text_color = theme.disabled_text_color(background_color.into()); - let active_agent_views = ActiveAgentViewsModel::as_ref(app); - let open_terminal_view_id = - active_agent_views.get_terminal_view_id_for_entry(&self.entry, app); - let focused_terminal_view_id = app - .windows() - .active_window() - .and_then(|window_id| active_agent_views.get_focused_terminal_view_id(window_id)); - let secondary_suffix = " open in different pane"; let title = &self.entry.display.title; - let should_show_suffix = open_terminal_view_id - .is_some_and(|terminal_view_id| Some(terminal_view_id) != focused_terminal_view_id); - let full_text = if should_show_suffix { + let full_text = if self.is_open_elsewhere { format!("{title}{secondary_suffix}") } else { title.clone() @@ -107,7 +98,7 @@ impl SearchItem for ConversationSearchItem { } } - if should_show_suffix { + if self.is_open_elsewhere { let secondary_range = title.len()..(title.len() + secondary_suffix.len()); name_text = name_text.with_single_highlight( Highlight::new() @@ -164,6 +155,7 @@ impl SearchItem for ConversationSearchItem { fn accept_result(&self) -> Self::Action { AcceptConversation { item_id: self.entry.id, + is_open_elsewhere: self.is_open_elsewhere, } } diff --git a/app/src/terminal/input/conversations/view.rs b/app/src/terminal/input/conversations/view.rs index 97b89195165..5f8f8eabad9 100644 --- a/app/src/terminal/input/conversations/view.rs +++ b/app/src/terminal/input/conversations/view.rs @@ -9,6 +9,7 @@ use warpui::{Element, Entity, ModelHandle, SingletonEntity, View, ViewContext, V use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent_conversations_model::AgentConversationEntryId; use crate::ai::blocklist::agent_view::AgentViewController; +use crate::ai::blocklist::conversation_selection::ConversationSelectionHandle; use crate::features::FeatureFlag; use crate::search::data_source::{Query, QueryFilter}; use crate::search::mixer::SearchMixer; @@ -60,14 +61,14 @@ impl InlineConversationMenuView { pub fn new( input_suggestions_model: ModelHandle, agent_view_controller: ModelHandle, + conversation_selection: ConversationSelectionHandle, input_buffer_model: &ModelHandle, positioner: &ModelHandle, active_session: ModelHandle, ctx: &mut ViewContext, ) -> Self { - let data_source = ctx.add_model(|_| { - ConversationMenuDataSource::new(agent_view_controller.clone(), active_session) - }); + let data_source = ctx + .add_model(|_| ConversationMenuDataSource::new(conversation_selection, active_session)); let tab_configs = TAB_CONFIGS.clone(); let initial_filters = tab_configs diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index a8046e1a0ad..c6460bddfa1 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -3774,6 +3774,7 @@ impl TerminalView { ai_context_model.clone(), ai_input_model.clone(), ai_action_model.clone(), + conversation_selection.clone(), cli_subagent_controller.clone(), terminal_view_id, None, // current_repo_path - will be set when CWD is determined diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index e54975f40a8..759fac4c48a 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -1,6 +1,7 @@ //! Public app APIs used by the `warp_tui` frontend. pub use repo_metadata::repositories::RepoDetectionSource; +pub use warp_cli::agent::Harness; pub use crate::ai::agent::api::ServerConversationToken; pub use crate::ai::agent::conversation::{ @@ -19,6 +20,10 @@ pub use crate::ai::agent::{ StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, TodoOperation, UserQueryMode, }; +pub use crate::ai::agent_conversations_model::{ + AgentConversationEntry, AgentConversationListEntryState, AgentConversationListPolicy, + AgentRunDisplayStatus, +}; pub use crate::ai::blocklist::agent_view::{ AgentViewController, AgentViewDisplayMode, AgentViewEntryOrigin, EnterAgentViewError, EphemeralMessageModel, diff --git a/crates/warp_tui/src/conversation_selection.rs b/crates/warp_tui/src/conversation_selection.rs index d74ff06ad42..e774e2604bf 100644 --- a/crates/warp_tui/src/conversation_selection.rs +++ b/crates/warp_tui/src/conversation_selection.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use parking_lot::FairMutex; use warp::tui_export::{ - AIConversationAutoexecuteMode, AIConversationId, AgentViewEntryOrigin, BlocklistAIHistoryEvent, - BlocklistAIHistoryModel, ConversationSelection, ConversationSelectionEvent, - EnterAgentViewError, PendingQueryState, TerminalModel, + AIConversationAutoexecuteMode, AIConversationId, AgentConversationEntry, + AgentConversationListEntryState, AgentConversationListPolicy, AgentRunDisplayStatus, + AgentViewEntryOrigin, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationSelection, + ConversationSelectionEvent, EnterAgentViewError, Harness, PendingQueryState, TerminalModel, }; use warpui::{AppContext, EntityId, ModelContext, SingletonEntity}; @@ -140,6 +141,60 @@ impl TuiConversationSelection { } } +impl AgentConversationListPolicy for TuiConversationSelection { + fn classify_entry( + &self, + entry: &AgentConversationEntry, + _: &AppContext, + ) -> AgentConversationListEntryState { + classify_conversation_list_entry( + self.selected_id(), + entry.identity.local_conversation_id, + entry.identity.server_conversation_token.is_some(), + entry.display.harness, + &entry.display.status, + ) + } +} + +fn classify_conversation_list_entry( + selected_id: Option, + local_conversation_id: Option, + has_server_token: bool, + harness: Option, + status: &AgentRunDisplayStatus, +) -> AgentConversationListEntryState { + if selected_id.is_some_and(|selected_id| local_conversation_id == Some(selected_id)) { + return AgentConversationListEntryState::Selected; + } + if harness != Some(Harness::Oz) { + return AgentConversationListEntryState::Unavailable; + } + + let has_terminal_status = match status { + AgentRunDisplayStatus::TaskQueued + | AgentRunDisplayStatus::TaskPending + | AgentRunDisplayStatus::TaskClaimed + | AgentRunDisplayStatus::TaskInProgress + | AgentRunDisplayStatus::TaskBlocked { .. } + | AgentRunDisplayStatus::ConversationInProgress + | AgentRunDisplayStatus::ConversationBlocked { .. } => false, + AgentRunDisplayStatus::TaskSucceeded + | AgentRunDisplayStatus::TaskFailed + | AgentRunDisplayStatus::TaskError + | AgentRunDisplayStatus::TaskCancelled + | AgentRunDisplayStatus::TaskUnknown + | AgentRunDisplayStatus::ConversationSucceeded + | AgentRunDisplayStatus::ConversationError + | AgentRunDisplayStatus::ConversationCancelled => true, + }; + if has_terminal_status && (local_conversation_id.is_some() || has_server_token) { + AgentConversationListEntryState::Available + } else { + AgentConversationListEntryState::Unavailable + } +} + impl ConversationSelection for TuiConversationSelection { fn selected_conversation_id(&self, _: &AppContext) -> Option { self.selected_id() diff --git a/crates/warp_tui/src/conversation_selection_tests.rs b/crates/warp_tui/src/conversation_selection_tests.rs index 82ae9499afe..adc7ef6f1c8 100644 --- a/crates/warp_tui/src/conversation_selection_tests.rs +++ b/crates/warp_tui/src/conversation_selection_tests.rs @@ -2,13 +2,69 @@ use std::sync::Arc; use parking_lot::FairMutex; use warp::tui_export::{ - AIConversationId, AgentViewEntryOrigin, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, - ConversationSelection, ConversationSelectionHandle, TerminalModel, TranscriptScope, + AIConversationId, AgentConversationListEntryState, AgentRunDisplayStatus, AgentViewEntryOrigin, + BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationSelection, + ConversationSelectionHandle, Harness, TerminalModel, TranscriptScope, }; use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::{App, EntityId, ModelHandle}; -use super::TuiConversationSelection; +use super::{classify_conversation_list_entry, TuiConversationSelection}; + +#[test] +fn tui_list_policy_classifies_selected_terminal_and_unavailable_entries() { + let selected_id = AIConversationId::new(); + assert_eq!( + classify_conversation_list_entry( + Some(selected_id), + Some(selected_id), + true, + Some(Harness::Oz), + &AgentRunDisplayStatus::ConversationSucceeded, + ), + AgentConversationListEntryState::Selected + ); + assert_eq!( + classify_conversation_list_entry( + None, + Some(AIConversationId::new()), + false, + Some(Harness::Oz), + &AgentRunDisplayStatus::ConversationCancelled, + ), + AgentConversationListEntryState::Available + ); + assert_eq!( + classify_conversation_list_entry( + None, + None, + true, + Some(Harness::Oz), + &AgentRunDisplayStatus::TaskInProgress, + ), + AgentConversationListEntryState::Unavailable + ); + assert_eq!( + classify_conversation_list_entry( + None, + None, + true, + Some(Harness::Claude), + &AgentRunDisplayStatus::TaskSucceeded, + ), + AgentConversationListEntryState::Unavailable + ); + assert_eq!( + classify_conversation_list_entry( + None, + None, + false, + Some(Harness::Oz), + &AgentRunDisplayStatus::TaskSucceeded, + ), + AgentConversationListEntryState::Unavailable + ); +} /// Creates a terminal model configured for the TUI's unfiltered transcript. fn tui_terminal_model() -> Arc> { diff --git a/specs/frontend-neutral-conversation-list-policy/TECH.md b/specs/frontend-neutral-conversation-list-policy/TECH.md new file mode 100644 index 00000000000..ac776a6d2a8 --- /dev/null +++ b/specs/frontend-neutral-conversation-list-policy/TECH.md @@ -0,0 +1,77 @@ +# TECH: Frontend-neutral conversation-list policy + +## Context + +`AgentConversationsModel` is the process-wide source for normalized local conversations and ambient runs. Its entries combine stable identity, display metadata, backing sources, and capabilities, but navigation availability is currently resolved through GUI-only workspace actions. + +The relevant pre-migration paths are: + +- [`app/src/ai/agent_conversations_model.rs (1207-1424) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/ai/agent_conversations_model.rs#L1207-L1424) aggregates normalized entries and resolves them into GUI `WorkspaceAction` values. +- [`app/src/ai/agent_conversations_model/entry.rs (30-166) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/ai/agent_conversations_model/entry.rs#L30-L166) defines stable entry identity, normalized display data, backing data, and capabilities. +- [`app/src/terminal/input/conversations/data_source.rs (19-150) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/terminal/input/conversations/data_source.rs#L19-L150) independently excludes the selected conversation and determines which normalized entries the GUI inline menu can open. +- [`app/src/terminal/input/conversations/search_item.rs (20-165) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/terminal/input/conversations/search_item.rs#L20-L165) independently compares open and focused terminal views to render “open in different pane.” +- [`app/src/ai/blocklist/conversation_selection.rs (12-112) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/ai/blocklist/conversation_selection.rs#L12-L112) provides the per-terminal-surface abstraction for the conversation targeted by the next query. + +Selection and navigation state are relative to the frontend surface presenting a list. A normalized entry can be selected in one surface, open elsewhere in another, available through one frontend’s navigation model, or unsupported by another frontend. Encoding those states in `AgentConversationEntry` would make process-wide data depend on one presentation context, while exposing `WorkspaceAction` requires every frontend to understand GUI workspace semantics. + +## Proposed changes + +### Add frontend-relative list policy + +Define these types next to `AgentConversationEntry`: + +- `AgentConversationListEntryState` with `Selected`, `OpenElsewhere`, `Available`, and `Unavailable`. +- `AgentConversationListPolicy`, which classifies one normalized entry using the current `AppContext`. + +Keep `AgentConversationsModel::get_entries` as the normalized management API. List consumers classify each returned entry through their policy while applying presentation-specific filtering. Aggregation, deduplication, ownership filtering, and normalized identity remain centralized in the model; transient frontend state is not stored in a second entry type. + +### Make conversation selection own list policy + +Extend `ConversationSelection` with `AgentConversationListPolicy`. Both abstractions answer questions relative to the same terminal surface: + +- Which conversation receives the next query. +- Whether a normalized entry is that selected conversation. +- Whether the surface can navigate to the entry. +- Whether navigation should focus another surface instead of restoring locally. + +Keeping the policy on the existing selection handle avoids a second per-surface model and lets list consumers use the same dynamic frontend implementation that controls query selection. + +Test selection implementations classify entries as unavailable unless the test supplies a policy explicitly. This keeps mocks conservative and prevents a test-only selection from accidentally advertising unsupported navigation. + +### Preserve GUI behavior through the policy + +`AgentViewConversationSelection` implements the GUI policy in this order: + +1. Return `Selected` when the entry’s local conversation identity matches the controller’s selected conversation. +2. Return `OpenElsewhere` when `ActiveAgentViewsModel` maps the entry to a terminal surface other than the selection’s own surface. +3. Return `Available` when the existing active-pane open-action resolution succeeds. +4. Otherwise return `Unavailable`. + +`ConversationMenuDataSource` stores `ConversationSelectionHandle`, requests normalized entries, classifies each entry, and retains only `Available` and `OpenElsewhere`. It preserves the existing current-directory filter, recency ordering, default result cap, fuzzy matching, and search result cap. + +`ConversationSearchItem` receives `is_open_elsewhere` from the classified result rather than consulting global focus state while rendering. The accepted action carries the same presentation state for its footer copy, while actual navigation continues to re-resolve the stable entry ID through `AgentConversationsModel`. + +The menu constructor receives the terminal surface’s existing selection handle. The now-redundant focused-terminal getter is removed from `ActiveAgentViewsModel`. + +### Implement the policy for the headless frontend + +The headless conversation selection implements the same trait without importing GUI workspace actions. It classifies its selected local conversation as `Selected`, safely restorable terminal-state Oz entries with local or server identity as `Available`, and unsupported entries as `Unavailable`. It never returns `OpenElsewhere` because the frontend currently owns one conversation surface. + +Only the entry, state, policy, display status, and harness types needed for that implementation are exported through `app/src/tui_export.rs`. + +## Testing and validation + +- Add focused GUI policy coverage for selected, open-elsewhere, available, and unavailable entries. +- Retain the GUI inline menu’s filtering, ordering, fuzzy search, open-elsewhere suffix, and acceptance behavior. +- Add headless policy tests covering selected entries, supported terminal-state entries, active entries, unsupported harnesses, and entries without restoration identity. +- Run formatting and linting for both the main application and `warp_tui`. + +## Risks and mitigations + +- **GUI behavior drift:** The GUI policy delegates availability to the existing active-pane open-action resolution and migrates only the inline conversation menu. +- **Stale presentation state:** Accepted actions retain stable entry identity, and navigation re-resolves that identity instead of trusting the classification captured by the search row. +- **Frontend coupling:** Shared entry aggregation does not import frontend actions; policy implementations remain alongside each frontend’s conversation selection. + +## Parallelization + +Parallel implementation is not useful because the trait definition, every `ConversationSelection` implementation, the GUI data source, and exported types must compile together. The migration is one dependency chain and should land as one branch. From 72e25abbb9fc0b3d0e4556c1deeff27ca785e66c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Mon, 13 Jul 2026 17:12:47 -0400 Subject: [PATCH 2/3] comment --- app/src/ai/agent_conversations_model.rs | 2 +- app/src/ai/blocklist/agent_view/conversation_selection.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index c776f9b3ba7..fd3338781f3 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -294,7 +294,7 @@ pub enum AgentConversationListEntryState { /// Per-frontend policy for classifying normalized conversation-list entries. pub trait AgentConversationListPolicy: 'static { - /// Returns how this frontend can present and navigate to `entry`. + /// Classifies `entry` as selected, open elsewhere, available, or unavailable. fn classify_entry( &self, entry: &AgentConversationEntry, diff --git a/app/src/ai/blocklist/agent_view/conversation_selection.rs b/app/src/ai/blocklist/agent_view/conversation_selection.rs index b2c721e4385..abcbc2307d1 100644 --- a/app/src/ai/blocklist/agent_view/conversation_selection.rs +++ b/app/src/ai/blocklist/agent_view/conversation_selection.rs @@ -67,6 +67,7 @@ impl AgentViewConversationSelection { } } +/// Classifies entries relative to this GUI Agent View terminal surface. impl AgentConversationListPolicy for AgentViewConversationSelection { fn classify_entry( &self, From 9d722fe374356ca582104de141e454e035579a1d Mon Sep 17 00:00:00 2001 From: harryalbert Date: Mon, 13 Jul 2026 17:23:36 -0400 Subject: [PATCH 3/3] update tests --- .../agent_view/conversation_selection.rs | 46 ++++++++++------ .../conversation_selection_tests.rs | 53 ++++++++++++++++++- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/app/src/ai/blocklist/agent_view/conversation_selection.rs b/app/src/ai/blocklist/agent_view/conversation_selection.rs index abcbc2307d1..140f9d30081 100644 --- a/app/src/ai/blocklist/agent_view/conversation_selection.rs +++ b/app/src/ai/blocklist/agent_view/conversation_selection.rs @@ -67,6 +67,27 @@ impl AgentViewConversationSelection { } } +/// Applies GUI list-state precedence without consulting frontend models. +fn classify_gui_list_entry( + selected_entry_id: Option, + entry_id: AgentConversationEntryId, + open_terminal_view_id: Option, + 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( @@ -77,22 +98,15 @@ impl AgentConversationListPolicy for AgentViewConversationSelection { let selected_entry_id = self .selected_conversation_id(app) .map(AgentConversationEntryId::Conversation); - if selected_entry_id == Some(entry.id) { - return AgentConversationListEntryState::Selected; - } - - if ActiveAgentViewsModel::as_ref(app) - .get_terminal_view_id_for_entry(entry, app) - .is_some_and(|terminal_view_id| terminal_view_id != self.terminal_surface_id) - { - return AgentConversationListEntryState::OpenElsewhere; - } - - if entry.has_open_action(Some(RestoreConversationLayout::ActivePane), app) { - AgentConversationListEntryState::Available - } else { - AgentConversationListEntryState::Unavailable - } + 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), + ) } } diff --git a/app/src/ai/blocklist/agent_view/conversation_selection_tests.rs b/app/src/ai/blocklist/agent_view/conversation_selection_tests.rs index b50ec16614a..8ce3b94da5c 100644 --- a/app/src/ai/blocklist/agent_view/conversation_selection_tests.rs +++ b/app/src/ai/blocklist/agent_view/conversation_selection_tests.rs @@ -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, }; @@ -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() {