From 40e69d7b112ed469a7777d376f3c09ea267e96c9 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Mon, 13 Jul 2026 16:15:01 -0400 Subject: [PATCH 1/5] Add TUI conversation management Co-Authored-By: Oz --- Cargo.lock | 1 + app/src/ai/agent_conversations_model.rs | 74 +++- app/src/ai/agent_conversations_model_tests.rs | 2 + app/src/ai/blocklist/history_model.rs | 45 +++ app/src/ai/blocklist/history_model_tests.rs | 54 +++ app/src/terminal/input/slash_commands/mod.rs | 2 + app/src/tui_export.rs | 12 +- crates/warp_tui/Cargo.toml | 1 + crates/warp_tui/src/conversation_menu.rs | 360 +++++++++++++++++ .../warp_tui/src/conversation_menu_tests.rs | 75 ++++ crates/warp_tui/src/inline_menu.rs | 39 +- crates/warp_tui/src/input/view.rs | 5 + crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/session.rs | 6 +- crates/warp_tui/src/terminal_session_view.rs | 368 +++++++++++++++--- crates/warp_tui/src/ui.rs | 4 +- crates/warp_tui/src/ui_tests.rs | 5 +- specs/CODE-1820/PRODUCT.md | 6 +- specs/CODE-1820/TECH.md | 9 +- specs/CODE-1821/PRODUCT.md | 142 +++++++ specs/CODE-1821/TECH.md | 188 +++++++++ 21 files changed, 1334 insertions(+), 65 deletions(-) create mode 100644 crates/warp_tui/src/conversation_menu.rs create mode 100644 crates/warp_tui/src/conversation_menu_tests.rs create mode 100644 specs/CODE-1821/PRODUCT.md create mode 100644 specs/CODE-1821/TECH.md diff --git a/Cargo.lock b/Cargo.lock index a3e8df57651..e890a97e90c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15911,6 +15911,7 @@ dependencies = [ "dirs 6.0.0", "futures", "futures-lite 1.13.0", + "fuzzy_match", "http_client", "instant", "itertools 0.14.0", diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index fd3338781f3..a3a10a912f9 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -578,6 +578,10 @@ pub struct AgentConversationsModel { active_data_consumers_per_window: HashMap>, /// Whether we have finished the initial task load has_finished_initial_load: bool, + /// Whether the initial cloud conversation metadata request failed. + cloud_metadata_load_failed: bool, + /// Whether a list-open retry for cloud conversation metadata is in flight. + cloud_metadata_retry_in_flight: bool, /// Per-task fetch state for `get_or_async_fetch_task_data`. See [`TaskFetchState`] for /// the meaning of each variant. Tasks that have been successfully fetched live in `tasks` /// and are absent from this map. @@ -633,6 +637,8 @@ impl AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: true, + cloud_metadata_load_failed: false, + cloud_metadata_retry_in_flight: false, task_fetch_state: HashMap::new(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, @@ -672,6 +678,8 @@ impl AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: false, + cloud_metadata_load_failed: false, + cloud_metadata_retry_in_flight: false, task_fetch_state: HashMap::new(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, @@ -692,6 +700,12 @@ impl AgentConversationsModel { !self.has_finished_initial_load } + /// Returns whether cloud conversation metadata is temporarily unavailable. + #[cfg_attr(not(feature = "tui"), allow(dead_code))] + pub(crate) fn cloud_metadata_load_failed(&self) -> bool { + self.cloud_metadata_load_failed + } + fn handle_network_status_changed( &mut self, _: ModelHandle, @@ -941,13 +955,14 @@ impl AgentConversationsModel { }; // Handle conversation metadata result - let mut conversation_metadata = match conversation_metadata_result { - Ok(metadata) => metadata, - Err(e) => { - log::warn!("Failed to fetch conversation metadata: {e:?}"); - vec![] - } - }; + let (mut conversation_metadata, cloud_metadata_loaded) = + match conversation_metadata_result { + Ok(metadata) => (metadata, true), + Err(e) => { + log::warn!("Failed to fetch conversation metadata: {e:?}"); + (vec![], false) + } + }; // Collect all conversation IDs from tasks let task_conversation_ids: HashSet = tasks @@ -991,13 +1006,19 @@ impl AgentConversationsModel { } // Always return success - we handle failures individually above - Ok((tasks, conversation_metadata)) + Ok((tasks, conversation_metadata, cloud_metadata_loaded)) } }, OUT_OF_BAND_REQUEST_RETRY_STRATEGY, |model, result, ctx| { - if let RequestState::RequestSucceeded((tasks, conversation_metadata)) = result { + if let RequestState::RequestSucceeded(( + tasks, + conversation_metadata, + cloud_metadata_loaded, + )) = result + { model.has_finished_initial_load = true; + model.cloud_metadata_load_failed = !cloud_metadata_loaded; // Update tasks if we got any if !tasks.is_empty() { @@ -1025,6 +1046,7 @@ impl AgentConversationsModel { ctx.emit(AgentConversationsModelEvent::ConversationsLoaded); } else if let RequestState::RequestFailed(e) = result { model.has_finished_initial_load = true; + model.cloud_metadata_load_failed = true; model.update_polling_state(ctx); report_error!(e); } @@ -1045,6 +1067,7 @@ impl AgentConversationsModel { .or_default() .insert(view_id); self.update_polling_state(ctx); + self.retry_cloud_conversation_metadata(ctx); // Flush dirty tasks accumulated while no list surface was open. if let Some(dirty_since) = self.dirty_since.take() { @@ -1052,6 +1075,39 @@ impl AgentConversationsModel { } } + /// Retries a failed cloud conversation metadata load while keeping local rows available. + fn retry_cloud_conversation_metadata(&mut self, ctx: &mut ModelContext) { + if !self.cloud_metadata_load_failed || self.cloud_metadata_retry_in_flight { + return; + } + self.cloud_metadata_retry_in_flight = true; + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + ctx.spawn_with_retry_on_error( + move || { + let ai_client = ai_client.clone(); + async move { ai_client.list_ai_conversation_metadata(None).await } + }, + OUT_OF_BAND_REQUEST_RETRY_STRATEGY, + |model, result, ctx| match result { + RequestState::RequestSucceeded(conversation_metadata) => { + model.cloud_metadata_retry_in_flight = false; + model.cloud_metadata_load_failed = false; + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| { + history_model.merge_cloud_conversation_metadata(conversation_metadata); + }); + model.sync_conversations(ctx); + } + RequestState::RequestFailed(error) => { + model.cloud_metadata_retry_in_flight = false; + model.cloud_metadata_load_failed = true; + report_error!(error); + ctx.emit(AgentConversationsModelEvent::ConversationsLoaded); + } + RequestState::RequestFailedRetryPending(_) => {} + }, + ); + } + /// Called when a view that consumes this model's data becomes hidden. /// Uses view_id to make unregistration idempotent. pub fn register_view_closed( diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 4b3362e96dc..23b26d060bc 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -664,6 +664,8 @@ fn create_test_model() -> AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: false, + cloud_metadata_load_failed: false, + cloud_metadata_retry_in_flight: false, task_fetch_state: Default::default(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 9714b9c6cbb..3a640fc2413 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -1132,6 +1132,51 @@ impl BlocklistAIHistoryModel { }); } + /// Attaches an already-loaded conversation to a terminal surface without replacing its state. + pub fn attach_loaded_conversation_to_terminal_surface( + &mut self, + conversation_id: AIConversationId, + terminal_surface_id: EntityId, + ctx: &mut ModelContext, + ) -> bool { + let Some(conversation) = self.conversations_by_id.get(&conversation_id) else { + return false; + }; + let new_status = conversation.status().clone(); + + for cleared_ids in self + .cleared_conversation_ids_for_terminal_surface + .values_mut() + { + cleared_ids.retain(|id| *id != conversation_id); + } + self.cleared_conversation_ids_for_terminal_surface + .retain(|_, ids| !ids.is_empty()); + + let live_ids = self + .live_conversation_ids_for_terminal_surface + .entry(terminal_surface_id) + .or_default(); + if !live_ids.contains(&conversation_id) { + live_ids.push(conversation_id); + } + self.terminal_surface_created_at + .entry(terminal_surface_id) + .or_insert_with(Local::now); + + ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationStatus { + conversation_id, + terminal_surface_id, + update: ConversationStatusUpdate::Restored, + new_status, + }); + ctx.emit(BlocklistAIHistoryEvent::RestoredConversations { + terminal_surface_id, + conversation_ids: vec![conversation_id], + }); + true + } + /// Sets the active conversation ID for a terminal surface and moves the conversation /// from any other terminal surface that currently contains it. /// diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 1c973485407..3f88cf84f27 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -193,6 +193,60 @@ fn persisted_agent_conversation_from_update_event(event: ModelEvent) -> AgentCon } } +#[test] +fn attach_loaded_conversation_preserves_canonical_state() { + App::test((), |mut app| async move { + let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + let old_surface_id = EntityId::new(); + let new_surface_id = EntityId::new(); + let conversation_id = AIConversationId::new(); + let conversation = AIConversation::new_restored( + conversation_id, + vec![warp_multi_agent_api::Task { + id: "root-task".to_owned(), + messages: Vec::new(), + dependencies: None, + description: "Canonical title".to_owned(), + summary: String::new(), + server_data: String::new(), + }], + None, + ) + .unwrap(); + + history.update(&mut app, |history, ctx| { + history.restore_conversations(old_surface_id, vec![conversation], ctx); + history.clear_conversations_for_terminal_surface(old_surface_id, ctx); + assert!(history.attach_loaded_conversation_to_terminal_surface( + conversation_id, + new_surface_id, + ctx, + )); + }); + + history.read(&app, |history, _| { + assert_eq!( + history + .conversation(&conversation_id) + .and_then(|conversation| conversation.title()) + .as_deref(), + Some("Canonical title") + ); + assert_eq!( + history + .all_live_conversations_for_terminal_surface(new_surface_id) + .map(|conversation| conversation.id()) + .collect::>(), + vec![conversation_id] + ); + assert!(!history + .all_cleared_conversations() + .iter() + .any(|(_, conversation)| conversation.id() == conversation_id)); + }); + }); +} + #[test] fn begin_conversation_rename_updates_title_and_cached_metadata() { App::test((), |mut app| async move { diff --git a/app/src/terminal/input/slash_commands/mod.rs b/app/src/terminal/input/slash_commands/mod.rs index ce94b13772a..18c449b369b 100644 --- a/app/src/terminal/input/slash_commands/mod.rs +++ b/app/src/terminal/input/slash_commands/mod.rs @@ -134,6 +134,7 @@ pub enum TuiSlashCommand { New, Compact, Plan, + Conversations, CreateNewProject, ExportToClipboard, ExportToFile, @@ -147,6 +148,7 @@ impl TuiSlashCommand { name if name == commands::NEW.name => Some(Self::New), name if name == commands::COMPACT.name => Some(Self::Compact), name if name == commands::PLAN.name => Some(Self::Plan), + name if name == commands::CONVERSATIONS.name => Some(Self::Conversations), name if name == commands::CREATE_NEW_PROJECT.name => Some(Self::CreateNewProject), name if name == commands::EXPORT_TO_CLIPBOARD.name => Some(Self::ExportToClipboard), name if name == commands::EXPORT_TO_FILE.name => Some(Self::ExportToFile), diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 759fac4c48a..086c798d88e 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -2,6 +2,7 @@ pub use repo_metadata::repositories::RepoDetectionSource; pub use warp_cli::agent::Harness; +use warpui::SingletonEntity as _; pub use crate::ai::agent::api::ServerConversationToken; pub use crate::ai::agent::conversation::{ @@ -21,8 +22,9 @@ pub use crate::ai::agent::{ UserQueryMode, }; pub use crate::ai::agent_conversations_model::{ - AgentConversationEntry, AgentConversationListEntryState, AgentConversationListPolicy, - AgentRunDisplayStatus, + AgentConversationEntry, AgentConversationEntryId, AgentConversationListEntryState, + AgentConversationListPolicy, AgentConversationsModel, AgentConversationsModelEvent, + AgentManagementFilters, AgentRunDisplayStatus, HarnessFilter, OwnerFilter, }; pub use crate::ai::blocklist::agent_view::{ AgentViewController, AgentViewDisplayMode, AgentViewEntryOrigin, EnterAgentViewError, @@ -121,3 +123,9 @@ pub use crate::themes::default_themes::{dark_theme, light_theme}; pub use crate::throttle::throttle; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; + +/// Returns whether cloud conversation metadata is temporarily unavailable. +pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) -> bool { + crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) + .cloud_metadata_load_failed() +} diff --git a/crates/warp_tui/Cargo.toml b/crates/warp_tui/Cargo.toml index 85bf77b99e2..38209b4aef9 100644 --- a/crates/warp_tui/Cargo.toml +++ b/crates/warp_tui/Cargo.toml @@ -43,6 +43,7 @@ command.workspace = true dirs.workspace = true futures.workspace = true futures-lite.workspace = true +fuzzy_match.workspace = true http_client.workspace = true instant.workspace = true itertools.workspace = true diff --git a/crates/warp_tui/src/conversation_menu.rs b/crates/warp_tui/src/conversation_menu.rs new file mode 100644 index 00000000000..96b7f736bb3 --- /dev/null +++ b/crates/warp_tui/src/conversation_menu.rs @@ -0,0 +1,360 @@ +//! Searchable TUI conversation-menu state backed by `AgentConversationsModel`. + +use fuzzy_match::match_indices_case_insensitive; +use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; +use warp::tui_export::{ + agent_conversations_cloud_metadata_load_failed, AgentConversationEntryId, + AgentConversationListEntryState, AgentConversationsModel, AgentConversationsModelEvent, + AgentManagementFilters, ConversationSelectionHandle, Harness, HarnessFilter, +}; +use warp_editor::model::CoreEditorModel; +use warp_search_core::inline_menu::InlineMenuSelection; +use warpui_core::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId}; + +use crate::inline_menu::{ + keep_selected_visible, result_row_capacity, TuiInlineMenuHeader, TuiInlineMenuRow, + TuiInlineMenuRowStyle, TuiInlineMenuSnapshot, TuiInlineMenuStatus, MAX_INLINE_MENU_ROWS, +}; + +const DEFAULT_RESULT_COUNT: usize = 50; +const MAX_SEARCH_RESULTS: usize = 500; +const MINIMUM_FUZZY_SCORE: i64 = 25; +const MAX_VISIBLE_ROWS: usize = result_row_capacity(MAX_INLINE_MENU_ROWS, true, false); + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TuiConversationMenuRow { + id: AgentConversationEntryId, + title: String, +} +#[derive(Debug, Clone)] +struct TuiConversationMenuCandidate { + id: AgentConversationEntryId, + title: String, + last_updated_millis: i64, +} + +#[derive(Debug, Clone, Default)] +enum TuiConversationMenuState { + #[default] + Closed, + Open { + rows: Vec, + selection: InlineMenuSelection, + scroll_offset: usize, + is_loading: bool, + }, +} + +/// Events emitted by the TUI conversation menu. +#[derive(Debug, Clone, Copy)] +pub(crate) enum TuiConversationMenuEvent { + Updated, + CloudMetadataUnavailable, +} + +/// Query, selection, and model-subscription state for `/conversations`. +pub(crate) struct TuiConversationMenuModel { + input_editor: ModelHandle, + conversation_selection: ConversationSelectionHandle, + window_id: WindowId, + state: TuiConversationMenuState, + cloud_warning_shown: bool, +} + +impl TuiConversationMenuModel { + /// Creates a closed conversation menu and subscribes it to input/model changes. + pub(crate) fn new( + input_editor: ModelHandle, + conversation_selection: ConversationSelectionHandle, + window_id: WindowId, + ctx: &mut ModelContext, + ) -> Self { + ctx.subscribe_to_model(&input_editor, |model, _, event, ctx| { + if model.is_open() && matches!(event, CodeEditorModelEvent::ContentChanged { .. }) { + model.refresh_rows(ctx); + } + }); + ctx.subscribe_to_model( + &AgentConversationsModel::handle(ctx), + |model, _, _: &AgentConversationsModelEvent, ctx| { + if model.is_open() { + model.refresh_rows(ctx); + } + }, + ); + Self { + input_editor, + conversation_selection, + window_id, + state: TuiConversationMenuState::Closed, + cloud_warning_shown: false, + } + } + + pub(crate) fn is_open(&self) -> bool { + matches!(self.state, TuiConversationMenuState::Open { .. }) + } + + /// Opens the menu and registers it as an active conversation-list consumer. + pub(crate) fn open(&mut self, ctx: &mut ModelContext) { + if self.is_open() { + return; + } + self.state = TuiConversationMenuState::Open { + rows: Vec::new(), + selection: InlineMenuSelection::default(), + scroll_offset: 0, + is_loading: true, + }; + self.cloud_warning_shown = false; + let window_id = self.window_id; + let model_id = ctx.model_id(); + AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.register_view_open(window_id, model_id, ctx); + }); + self.refresh_rows(ctx); + } + + pub(crate) fn dismiss(&mut self, ctx: &mut ModelContext) { + if !self.is_open() { + return; + } + self.close(ctx); + self.input_editor + .update(ctx, |editor, ctx| editor.clear_buffer(ctx)); + } + + pub(crate) fn select_previous(&mut self, ctx: &mut ModelContext) { + let TuiConversationMenuState::Open { + rows, + selection, + scroll_offset, + .. + } = &mut self.state + else { + return; + }; + if let Some(index) = selection.select_previous(rows.len(), |_| true) { + keep_selected_visible(rows.len(), index, MAX_VISIBLE_ROWS, scroll_offset); + } + ctx.emit(TuiConversationMenuEvent::Updated); + } + + pub(crate) fn select_next(&mut self, ctx: &mut ModelContext) { + let TuiConversationMenuState::Open { + rows, + selection, + scroll_offset, + .. + } = &mut self.state + else { + return; + }; + if let Some(index) = selection.select_next(rows.len(), |_| true) { + keep_selected_visible(rows.len(), index, MAX_VISIBLE_ROWS, scroll_offset); + } + ctx.emit(TuiConversationMenuEvent::Updated); + } + + pub(crate) fn accept_selected( + &mut self, + _ctx: &mut ModelContext, + ) -> Option { + let selected_id = match &self.state { + TuiConversationMenuState::Open { + rows, selection, .. + } => selection + .selected_index() + .and_then(|index| rows.get(index)) + .map(|row| row.id), + TuiConversationMenuState::Closed => None, + }; + selected_id + } + + pub(crate) fn snapshot(&self) -> Option { + let TuiConversationMenuState::Open { + rows, + selection, + scroll_offset, + is_loading, + } = &self.state + else { + return None; + }; + let status = if rows.is_empty() { + Some(if *is_loading { + TuiInlineMenuStatus::Loading("Loading conversations…".to_owned()) + } else { + TuiInlineMenuStatus::Empty("No conversations found".to_owned()) + }) + } else { + None + }; + Some(TuiInlineMenuSnapshot { + header: Some(TuiInlineMenuHeader { + title: Some("Conversations".to_owned()), + tabs: Vec::new(), + }), + rows: rows + .iter() + .map(|row| TuiInlineMenuRow { + title: row.title.clone(), + description: None, + is_selectable: true, + style: TuiInlineMenuRowStyle::Default, + }) + .collect(), + selected_index: selection.selected_index(), + scroll_offset: *scroll_offset, + max_visible_rows: MAX_VISIBLE_ROWS, + status, + }) + } + + fn close(&mut self, ctx: &mut ModelContext) { + self.state = TuiConversationMenuState::Closed; + let window_id = self.window_id; + let model_id = ctx.model_id(); + AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.register_view_closed(window_id, model_id, ctx); + }); + ctx.emit(TuiConversationMenuEvent::Updated); + } + + fn refresh_rows(&mut self, ctx: &mut ModelContext) { + let previous_id = match &self.state { + TuiConversationMenuState::Open { + rows, selection, .. + } => selection + .selected_index() + .and_then(|index| rows.get(index)) + .map(|row| row.id), + TuiConversationMenuState::Closed => return, + }; + let previous_index = match &self.state { + TuiConversationMenuState::Open { selection, .. } => selection.selected_index(), + TuiConversationMenuState::Closed => None, + }; + let conversations_model = AgentConversationsModel::as_ref(ctx); + let is_loading = conversations_model.is_loading(); + let cloud_metadata_load_failed = agent_conversations_cloud_metadata_load_failed(ctx); + let rows = if is_loading { + Vec::new() + } else { + let filters = AgentManagementFilters { + harness: HarnessFilter::Specific(Harness::Oz), + ..Default::default() + }; + let policy = self.conversation_selection.as_ref(ctx); + let candidates = conversations_model + .get_entries(&filters, ctx) + .into_iter() + .filter(|entry| { + policy.classify_entry(entry, ctx) == AgentConversationListEntryState::Available + }) + .map(|entry| TuiConversationMenuCandidate { + id: entry.id, + title: entry.display.title, + last_updated_millis: entry.display.last_updated.timestamp_millis(), + }) + .collect(); + let query = input_text(&self.input_editor, ctx).trim().to_lowercase(); + build_rows(candidates, &query) + }; + + let selection = reconcile_selection(&rows, previous_id, previous_index); + let mut scroll_offset = 0; + if let Some(index) = selection.selected_index() { + keep_selected_visible(rows.len(), index, MAX_VISIBLE_ROWS, &mut scroll_offset); + } + self.state = TuiConversationMenuState::Open { + rows, + selection, + scroll_offset, + is_loading, + }; + if cloud_metadata_load_failed && !self.cloud_warning_shown { + self.cloud_warning_shown = true; + ctx.emit(TuiConversationMenuEvent::CloudMetadataUnavailable); + } + ctx.emit(TuiConversationMenuEvent::Updated); + } +} + +fn build_rows( + candidates: Vec, + query: &str, +) -> Vec { + if query.is_empty() { + let mut rows = candidates + .into_iter() + .take(DEFAULT_RESULT_COUNT) + .map(|candidate| TuiConversationMenuRow { + id: candidate.id, + title: candidate.title, + }) + .collect::>(); + rows.reverse(); + return rows; + } + + let mut matches = candidates + .into_iter() + .filter_map(|candidate| { + let score = match_indices_case_insensitive(&candidate.title, query)?.score; + (score >= MINIMUM_FUZZY_SCORE).then_some(( + score, + candidate.last_updated_millis, + TuiConversationMenuRow { + id: candidate.id, + title: candidate.title, + }, + )) + }) + .collect::>(); + matches.sort_by_key(|(score, last_updated, _)| (*score, *last_updated)); + if matches.len() > MAX_SEARCH_RESULTS { + matches.drain(..matches.len() - MAX_SEARCH_RESULTS); + } + matches.into_iter().map(|(_, _, row)| row).collect() +} + +fn reconcile_selection( + rows: &[TuiConversationMenuRow], + previous_id: Option, + previous_index: Option, +) -> InlineMenuSelection { + let mut selection = InlineMenuSelection::default(); + let index = previous_id + .and_then(|id| rows.iter().position(|row| row.id == id)) + .or_else(|| { + (!rows.is_empty()).then(|| { + previous_index + .unwrap_or(rows.len().saturating_sub(1)) + .min(rows.len().saturating_sub(1)) + }) + }); + if let Some(index) = index { + selection.select(index, rows.len(), |_| true); + } + selection +} + +impl Entity for TuiConversationMenuModel { + type Event = TuiConversationMenuEvent; +} + +fn input_text(editor: &ModelHandle, app: &AppContext) -> String { + let model = editor.as_ref(app); + let buffer = model.content().as_ref(app); + if buffer.is_empty() { + String::new() + } else { + buffer.text().into_string() + } +} + +#[cfg(test)] +#[path = "conversation_menu_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/conversation_menu_tests.rs b/crates/warp_tui/src/conversation_menu_tests.rs new file mode 100644 index 00000000000..9df38228679 --- /dev/null +++ b/crates/warp_tui/src/conversation_menu_tests.rs @@ -0,0 +1,75 @@ +use warp::tui_export::AIConversationId; + +use super::*; + +fn candidate(index: usize, title: impl Into) -> TuiConversationMenuCandidate { + TuiConversationMenuCandidate { + id: AgentConversationEntryId::Conversation(AIConversationId::new()), + title: title.into(), + last_updated_millis: 1_000 - index as i64, + } +} + +#[test] +fn empty_query_caps_recent_rows_and_places_newest_last() { + let candidates = (0..55) + .map(|index| candidate(index, format!("Conversation {index}"))) + .collect(); + + let rows = build_rows(candidates, ""); + + assert_eq!(rows.len(), DEFAULT_RESULT_COUNT); + assert_eq!( + rows.first().map(|row| row.title.as_str()), + Some("Conversation 49") + ); + assert_eq!( + rows.last().map(|row| row.title.as_str()), + Some("Conversation 0") + ); + assert!(!rows.iter().any(|row| row.title == "Conversation 50")); +} + +#[test] +fn fuzzy_query_filters_titles_and_caps_best_results() { + let mut candidates = vec![ + candidate(0, "Deploy the API"), + candidate(1, "Fix unit tests"), + candidate(2, "Deploy the website"), + ]; + candidates.extend( + (0..MAX_SEARCH_RESULTS) + .map(|index| candidate(index + 3, format!("Deploy service {index}"))), + ); + + let rows = build_rows(candidates, "deploy"); + + assert_eq!(rows.len(), MAX_SEARCH_RESULTS); + assert!(rows.iter().all(|row| row.title.contains("Deploy"))); + assert!(!rows.iter().any(|row| row.title == "Fix unit tests")); +} + +#[test] +fn selection_reconciliation_preserves_id_then_uses_nearest_index() { + let rows = vec![ + TuiConversationMenuRow { + id: AgentConversationEntryId::Conversation(AIConversationId::new()), + title: "First".to_owned(), + }, + TuiConversationMenuRow { + id: AgentConversationEntryId::Conversation(AIConversationId::new()), + title: "Second".to_owned(), + }, + TuiConversationMenuRow { + id: AgentConversationEntryId::Conversation(AIConversationId::new()), + title: "Third".to_owned(), + }, + ]; + + let preserved = reconcile_selection(&rows, Some(rows[1].id), Some(0)); + assert_eq!(preserved.selected_index(), Some(1)); + + let missing = AgentConversationEntryId::Conversation(AIConversationId::new()); + let nearest = reconcile_selection(&rows[..2], Some(missing), Some(2)); + assert_eq!(nearest.selected_index(), Some(1)); +} diff --git a/crates/warp_tui/src/inline_menu.rs b/crates/warp_tui/src/inline_menu.rs index 527de01e209..fd41c155881 100644 --- a/crates/warp_tui/src/inline_menu.rs +++ b/crates/warp_tui/src/inline_menu.rs @@ -3,7 +3,7 @@ use std::ops::Range; use std::rc::Rc; use string_offset::CharOffset; -use warp::tui_export::AcceptSlashCommandOrSavedPrompt; +use warp::tui_export::{AcceptSlashCommandOrSavedPrompt, AgentConversationEntryId}; use warpui_core::elements::tui::{ TuiBuffer, TuiConstrainedBox, TuiConstraint, TuiContainer, TuiElement, TuiFlex, TuiLayoutContext, TuiPaintContext, TuiRect, TuiSize, TuiText, @@ -11,6 +11,7 @@ use warpui_core::elements::tui::{ use warpui_core::elements::CrossAxisAlignment; use warpui_core::{AppContext, ModelHandle}; +use crate::conversation_menu::TuiConversationMenuModel; use crate::slash_commands::TuiSlashCommandModel; use crate::tui_builder::TuiUiBuilder; use crate::tui_column_layout::{ @@ -80,6 +81,7 @@ pub(crate) struct TuiInlineMenuSnapshot { #[derive(Debug, Clone)] pub(crate) enum TuiInlineMenuAccepted { SlashCommand(AcceptSlashCommandOrSavedPrompt), + Conversation(AgentConversationEntryId), } /// Type-erased operations shared by TUI inline-menu model handles. @@ -182,6 +184,41 @@ impl TuiInlineMenuHandle for ModelHandle { } } +impl TuiInlineMenuHandle for ModelHandle { + fn is_open(&self, ctx: &AppContext) -> bool { + self.as_ref(ctx).is_open() + } + + fn input_highlight_range(&self, _ctx: &AppContext) -> Option> { + None + } + + fn input_argument_hint_text(&self, _ctx: &AppContext) -> Option<&'static str> { + None + } + + fn select_previous(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.select_previous(ctx)); + } + + fn select_next(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.select_next(ctx)); + } + + fn accept(&self, ctx: &mut AppContext) -> Option { + self.update(ctx, |model, ctx| model.accept_selected(ctx)) + .map(TuiInlineMenuAccepted::Conversation) + } + + fn dismiss(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.dismiss(ctx)); + } + + fn snapshot(&self, ctx: &AppContext) -> Option { + self.as_ref(ctx).snapshot() + } +} + pub(crate) fn render_inline_menu( snapshot: &TuiInlineMenuSnapshot, builder: &TuiUiBuilder, diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index 9c1125ee258..7c9c56654d2 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -465,6 +465,8 @@ pub enum TuiInputViewEvent { Submitted(String), /// The user selected a slash command menu item. AcceptedSlashCommand(AcceptSlashCommandOrSavedPrompt), + /// The user selected a conversation menu item. + AcceptedConversation(warp::tui_export::AgentConversationEntryId), } // ───────────────────────────────────────────────────────────────────────────── @@ -1120,6 +1122,9 @@ impl TuiInputView { TuiInlineMenuAccepted::SlashCommand(action) => { ctx.emit(TuiInputViewEvent::AcceptedSlashCommand(action)); } + TuiInlineMenuAccepted::Conversation(entry_id) => { + ctx.emit(TuiInputViewEvent::AcceptedConversation(entry_id)); + } } } } diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 4c661ae90e0..b8a71ff8db2 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -18,6 +18,7 @@ mod telemetry; mod tui_builder; mod ui; +mod conversation_menu; mod conversation_selection; mod editor_element; mod exit_confirmation; diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index df827957b27..a5088b337df 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -25,7 +25,9 @@ use warpui_core::{AddWindowOptions, AppContext, Entity, ModelHandle, ViewHandle} use crate::resume::TuiExitSummaryHandle; use crate::root_view::RootTuiView; use crate::terminal_background::probe_and_select_theme; -use crate::terminal_session_view::{TuiConversationRestoreOrigin, TuiTerminalSessionView}; +use crate::terminal_session_view::{ + TuiConversationRestoreOrigin, TuiConversationRestoreTarget, TuiTerminalSessionView, +}; use crate::transcript_view::TRANSCRIPT_BLOCK_SPACING; #[derive(Parser)] @@ -204,7 +206,7 @@ fn create_terminal_session_after_login( if let Some(token) = resume_token { surface.update(ctx, |view, ctx| { view.restore_conversation( - token, + TuiConversationRestoreTarget::Server(token), TuiConversationRestoreOrigin::Startup, ctx, ); diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index f4f092a32b6..6bb5c2c9ca6 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -14,8 +14,10 @@ use warp::tui_export::{ prepare_conversation_block_restoration, record_saved_prompt_accepted, record_static_slash_command_accepted, saved_prompt_text_for_id, slash_command_selection_behavior, throttle, AIAgentActionId, AIAgentPtyWriteMode, - AcceptSlashCommandOrSavedPrompt, ActiveSession, ActiveSessionEvent, AgentInteractionMetadata, - AgentViewEntryOrigin, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, + AIConversation, AIConversationId, AcceptSlashCommandOrSavedPrompt, ActiveSession, + ActiveSessionEvent, AgentConversationEntryId, AgentConversationListEntryState, + AgentConversationsModel, AgentInteractionMetadata, AgentViewEntryOrigin, + BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputModel, CLISubagentController, CLISubagentEvent, CancellationReason, ChangelogModel, ChangelogModelEvent, ChangelogRequestType, CloudConversationData, CommandExecutionSource, ConversationFileExport, @@ -40,13 +42,14 @@ use warpui_core::elements::tui::{ use warpui_core::keymap::macros::*; use warpui_core::keymap::FixedBinding; use warpui_core::platform::TerminationMode; -use warpui_core::r#async::Timer; +use warpui_core::r#async::{SpawnedFutureHandle, Timer}; use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; +use crate::conversation_menu::{TuiConversationMenuEvent, TuiConversationMenuModel}; use crate::conversation_selection::TuiConversationSelection; use crate::exit_confirmation::{ExitConfirmation, CTRL_C_EXIT_WINDOW}; use crate::inline_menu::{TuiInlineMenu, MAX_INLINE_MENU_ROWS}; @@ -96,6 +99,13 @@ impl PtyIntentEvent for TuiTerminalSessionEvent { const COMMAND_ALREADY_RUNNING_HINT: &str = "cannot run — command already running"; const NEW_CONVERSATION_COMMAND_RUNNING_HINT: &str = "cannot start new conversation while terminal command is running"; +const SWITCH_COMMAND_RUNNING_HINT: &str = + "Cannot switch conversations while a command is in progress."; +const SWITCH_CONVERSATION_RUNNING_HINT: &str = + "Cannot switch conversations while the current conversation is in progress."; +const SWITCH_LOADING_HINT: &str = "Another conversation is already loading."; +const SWITCH_UNAVAILABLE_HINT: &str = "That conversation is no longer available."; +const LOADING_CONVERSATION_HINT: &str = "Loading conversation…"; /// Footer hint shown while the input is in `!` shell mode. const SHELL_MODE_HINT: &str = "shell mode · esc to exit"; @@ -127,7 +137,6 @@ fn raw_prompt_if_not_blank(input: &str) -> Option<&str> { #[derive(Clone, Copy, Debug)] pub(crate) enum TuiConversationRestoreOrigin { Startup, - #[allow(dead_code)] ConversationList, } @@ -141,11 +150,21 @@ impl TuiConversationRestoreOrigin { } } +#[derive(Clone, Debug)] +pub(crate) enum TuiConversationRestoreTarget { + Local(AIConversationId), + Server(ServerConversationToken), +} + #[derive(Default)] enum ConversationRestoreState { #[default] Idle, - Loading, + Loading { + origin: TuiConversationRestoreOrigin, + request_id: u64, + future: Option, + }, Failed(String), } fn export_file_success_message(export: &ConversationFileExport) -> String { @@ -164,6 +183,8 @@ pub(crate) enum TuiTerminalSessionAction { /// conversation, else clear the input; a second press within /// [`CTRL_C_EXIT_WINDOW`] exits the TUI. Interrupt, + /// Cancel an in-flight conversation restore. + CancelRestore, /// Click on the footer's usage entry: flips the persisted credits⇄cost /// display-mode setting. ToggleUsageDisplay, @@ -174,6 +195,7 @@ pub(crate) struct TuiTerminalSessionView { transcript: ViewHandle, input_view: ViewHandle, inline_menus: Vec, + conversation_menu: ModelHandle, slash_commands_source: ModelHandle, conversation_selection: ConversationSelectionHandle, ai_action_model: ModelHandle, @@ -199,6 +221,7 @@ pub(crate) struct TuiTerminalSessionView { /// shell submission). transient_hint: TransientHint, conversation_restore_state: ConversationRestoreState, + next_restore_request_id: u64, exit_summary: TuiExitSummaryHandle, } @@ -206,12 +229,20 @@ pub(crate) struct TuiTerminalSessionView { /// from `keybindings::init`. Ctrl-c is a fixed (non-remappable) binding, /// mirroring peer agent CLIs that treat it as reserved. pub(crate) fn init(app: &mut AppContext) { - app.register_fixed_bindings([FixedBinding::new( - "ctrl-c", - TuiTerminalSessionAction::Interrupt, - id!(TuiTerminalSessionView::ui_name()), - ) - .with_group(TUI_BINDING_GROUP)]); + app.register_fixed_bindings([ + FixedBinding::new( + "ctrl-c", + TuiTerminalSessionAction::Interrupt, + id!(TuiTerminalSessionView::ui_name()), + ) + .with_group(TUI_BINDING_GROUP), + FixedBinding::new( + "escape", + TuiTerminalSessionAction::CancelRestore, + id!(TuiTerminalSessionView::ui_name()), + ) + .with_group(TUI_BINDING_GROUP), + ]); } impl TuiTerminalSessionView { @@ -347,6 +378,25 @@ impl TuiTerminalSessionView { ) }); ctx.subscribe_to_model(&slash_commands, |_, _, _, ctx| ctx.notify()); + let window_id = ctx.window_id(); + let conversation_menu = ctx.add_model(|ctx| { + TuiConversationMenuModel::new( + input_editor_model.clone(), + conversation_selection.clone(), + window_id, + ctx, + ) + }); + ctx.subscribe_to_model(&conversation_menu, |view, _, event, ctx| match event { + TuiConversationMenuEvent::Updated => ctx.notify(), + TuiConversationMenuEvent::CloudMetadataUnavailable => { + view.show_transient_hint( + "Could not load cloud conversations. Showing local conversations only." + .to_owned(), + ctx, + ); + } + }); // Typing after a ctrl-c press disarms the pending exit confirmation. // The ctrl-c buffer clear leaves the buffer empty, so the window it // arms survives its own clear. @@ -385,7 +435,10 @@ impl TuiTerminalSessionView { }); let input_mode_for_input_view = ai_input_model.clone(); - let inline_menus = vec![TuiInlineMenu::new(slash_commands.clone())]; + let inline_menus = vec![ + TuiInlineMenu::new(slash_commands.clone()), + TuiInlineMenu::new(conversation_menu.clone()), + ]; let inline_menus_for_input = inline_menus.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { TuiInputView::new( @@ -415,6 +468,9 @@ impl TuiTerminalSessionView { TuiInputViewEvent::AcceptedSlashCommand(action) => { view.handle_accepted_slash_command(action, ctx); } + TuiInputViewEvent::AcceptedConversation(entry_id) => { + view.handle_accepted_conversation(*entry_id, ctx); + } }); // The input box border color and the footer's shell-mode hint depend // on the input mode. @@ -597,6 +653,7 @@ impl TuiTerminalSessionView { transcript, input_view, inline_menus, + conversation_menu, slash_commands_source, conversation_selection, ai_action_model: action_model, @@ -612,6 +669,7 @@ impl TuiTerminalSessionView { terminal_model: model, transient_hint: TransientHint::default(), conversation_restore_state: ConversationRestoreState::Idle, + next_restore_request_id: 0, exit_summary, } } @@ -619,49 +677,115 @@ impl TuiTerminalSessionView { /// Restores an Oz conversation into the TUI's sole conversation surface. pub(crate) fn restore_conversation( &mut self, - server_token: ServerConversationToken, + target: TuiConversationRestoreTarget, origin: TuiConversationRestoreOrigin, ctx: &mut ViewContext, ) { - self.conversation_restore_state = ConversationRestoreState::Loading; + if self.is_conversation_restore_loading() { + return; + } + self.next_restore_request_id = self.next_restore_request_id.wrapping_add(1); + let request_id = self.next_restore_request_id; + self.conversation_restore_state = ConversationRestoreState::Loading { + origin, + request_id, + future: None, + }; ctx.notify(); - - let requested_token = server_token.clone(); - let future = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - history.load_conversation_by_server_token(&server_token, ctx) - }); - ctx.spawn(future, move |view, result, ctx| match result { - Some(CloudConversationData::Oz(conversation)) => { - if conversation.server_conversation_token() != Some(&requested_token) { + let target_for_load = target.clone(); + let (future, resident_conversation_id) = + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let resident_conversation_id = match &target_for_load { + TuiConversationRestoreTarget::Local(conversation_id) => history + .conversation(conversation_id) + .map(|conversation| conversation.id()), + TuiConversationRestoreTarget::Server(server_token) => history + .find_conversation_id_by_server_token(server_token) + .filter(|conversation_id| history.conversation(conversation_id).is_some()), + }; + let future = match &target_for_load { + TuiConversationRestoreTarget::Local(conversation_id) => { + history.load_conversation_data(*conversation_id, ctx) + } + TuiConversationRestoreTarget::Server(server_token) => { + history.load_conversation_by_server_token(server_token, ctx) + } + }; + (future, resident_conversation_id) + }); + let future_handle = ctx.spawn(future, move |view, result, ctx| { + if !view.is_current_restore_request(request_id) { + return; + } + match result { + Some(CloudConversationData::Oz(conversation)) => { + let matches_target = match &target { + TuiConversationRestoreTarget::Local(conversation_id) => { + conversation.id() == *conversation_id + } + TuiConversationRestoreTarget::Server(server_token) => { + conversation.server_conversation_token() == Some(server_token) + } + }; + if !matches_target { + view.fail_conversation_restore( + request_id, + "The restored conversation did not match the requested conversation." + .to_owned(), + ctx, + ); + return; + } + let is_resident = resident_conversation_id == Some(conversation.id()); + view.finish_conversation_restore( + *conversation, + is_resident, + origin, + request_id, + ctx, + ); + } + Some(CloudConversationData::CLIAgent(_)) => { view.fail_conversation_restore( - "The restored conversation did not match the requested token.".to_owned(), + request_id, + "The Warp TUI only supports Oz/Warp conversations.".to_owned(), + ctx, + ); + } + None => { + view.fail_conversation_restore( + request_id, + "The conversation could not be loaded.".to_owned(), ctx, ); - return; } - view.finish_conversation_restore(*conversation, origin, ctx); - } - Some(CloudConversationData::CLIAgent(_)) => { - view.fail_conversation_restore( - "The Warp TUI only supports Oz/Warp conversations.".to_owned(), - ctx, - ); - } - None => { - view.fail_conversation_restore( - "The conversation could not be loaded.".to_owned(), - ctx, - ); } }); + match &mut self.conversation_restore_state { + ConversationRestoreState::Loading { + request_id: active_request_id, + future, + .. + } if *active_request_id == request_id => { + *future = Some(future_handle); + } + ConversationRestoreState::Idle + | ConversationRestoreState::Failed(_) + | ConversationRestoreState::Loading { .. } => future_handle.abort(), + } } fn finish_conversation_restore( &mut self, - conversation: warp::tui_export::AIConversation, + conversation: AIConversation, + is_resident: bool, origin: TuiConversationRestoreOrigin, + request_id: u64, ctx: &mut ViewContext, ) { + if !self.is_current_restore_request(request_id) { + return; + } let previous_conversation_id = self .conversation_selection .as_ref(ctx) @@ -691,7 +815,15 @@ impl TuiTerminalSessionView { actions.restore_action_results_from_exchanges(restoration_plan.exchanges().collect()); }); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - history.restore_conversations(self.terminal_surface_id, vec![conversation], ctx); + if !is_resident + || !history.attach_loaded_conversation_to_terminal_surface( + conversation_id, + self.terminal_surface_id, + ctx, + ) + { + history.restore_conversations(self.terminal_surface_id, vec![conversation], ctx); + } }); self.transcript.update(ctx, |transcript, ctx| { transcript.restore_conversation(conversation_id, restoration_plan, ctx); @@ -712,8 +844,64 @@ impl TuiTerminalSessionView { ctx.notify(); } - fn fail_conversation_restore(&mut self, message: String, ctx: &mut ViewContext) { - self.conversation_restore_state = ConversationRestoreState::Failed(message); + fn is_current_restore_request(&self, request_id: u64) -> bool { + matches!( + &self.conversation_restore_state, + ConversationRestoreState::Loading { + request_id: active_request_id, + .. + } if *active_request_id == request_id + ) + } + + fn is_conversation_restore_loading(&self) -> bool { + matches!( + &self.conversation_restore_state, + ConversationRestoreState::Loading { .. } + ) + } + + fn cancel_conversation_restore(&mut self, ctx: &mut ViewContext) -> bool { + let state = std::mem::take(&mut self.conversation_restore_state); + let ConversationRestoreState::Loading { future, .. } = state else { + self.conversation_restore_state = state; + return false; + }; + if let Some(future) = future { + future.abort(); + } + self.next_restore_request_id = self.next_restore_request_id.wrapping_add(1); + ctx.focus(&self.input_view); + ctx.notify(); + true + } + + fn fail_conversation_restore( + &mut self, + request_id: u64, + message: String, + ctx: &mut ViewContext, + ) { + let origin = match &self.conversation_restore_state { + ConversationRestoreState::Loading { + origin, + request_id: active_request_id, + .. + } if *active_request_id == request_id => *origin, + ConversationRestoreState::Idle + | ConversationRestoreState::Failed(_) + | ConversationRestoreState::Loading { .. } => return, + }; + match origin { + TuiConversationRestoreOrigin::Startup => { + self.conversation_restore_state = ConversationRestoreState::Failed(message); + } + TuiConversationRestoreOrigin::ConversationList => { + self.conversation_restore_state = ConversationRestoreState::Idle; + self.show_transient_hint(message, ctx); + ctx.focus(&self.input_view); + } + } ctx.notify(); } @@ -782,9 +970,12 @@ impl TuiTerminalSessionView { /// conversation if there is one, else clear the input — and the exit /// confirmation is (re-)armed, surfacing [`CTRL_C_EXIT_HINT`] in the footer. fn handle_interrupt(&mut self, ctx: &mut ViewContext) { - if !matches!( - self.conversation_restore_state, - ConversationRestoreState::Idle + if self.cancel_conversation_restore(ctx) { + return; + } + if matches!( + &self.conversation_restore_state, + ConversationRestoreState::Failed(_) ) { ctx.terminate_app(TerminationMode::ForceTerminate, None); return; @@ -851,6 +1042,14 @@ impl TuiTerminalSessionView { // replaces the other hints in place. let hint = if self.exit_confirmation.is_armed() { Some((CTRL_C_EXIT_HINT.to_owned(), muted)) + } else if matches!( + &self.conversation_restore_state, + ConversationRestoreState::Loading { + origin: TuiConversationRestoreOrigin::ConversationList, + .. + } + ) { + Some((LOADING_CONVERSATION_HINT.to_owned(), muted)) } else if let Some((transient, tone)) = self.transient_hint.current() { let style = match tone { TransientHintTone::Muted => muted, @@ -1130,6 +1329,9 @@ impl TuiTerminalSessionView { } fn handle_submitted_input(&mut self, input: &str, ctx: &mut ViewContext) { + if self.is_conversation_restore_loading() { + return; + } match self .slash_commands_source .as_ref(ctx) @@ -1209,6 +1411,66 @@ impl TuiTerminalSessionView { ctx.notify(); } + fn handle_accepted_conversation( + &mut self, + entry_id: AgentConversationEntryId, + ctx: &mut ViewContext, + ) { + if self.is_conversation_restore_loading() { + self.show_transient_hint(SWITCH_LOADING_HINT.to_owned(), ctx); + return; + } + if !self + .ai_context_model + .as_ref(ctx) + .can_start_new_conversation() + { + self.show_transient_hint(SWITCH_COMMAND_RUNNING_HINT.to_owned(), ctx); + return; + } + let current_conversation_is_busy = self + .conversation_selection + .as_ref(ctx) + .selected_conversation(ctx) + .is_some_and(|conversation| { + !conversation.is_empty() && !conversation.status().is_done() + }); + if current_conversation_is_busy { + self.show_transient_hint(SWITCH_CONVERSATION_RUNNING_HINT.to_owned(), ctx); + return; + } + + let Some(entry) = AgentConversationsModel::as_ref(ctx).get_entry_by_id(&entry_id, ctx) + else { + self.show_transient_hint(SWITCH_UNAVAILABLE_HINT.to_owned(), ctx); + return; + }; + if self + .conversation_selection + .as_ref(ctx) + .classify_entry(&entry, ctx) + != AgentConversationListEntryState::Available + { + self.show_transient_hint(SWITCH_UNAVAILABLE_HINT.to_owned(), ctx); + return; + } + let target = match ( + entry.identity.local_conversation_id, + entry.identity.server_conversation_token, + ) { + (Some(conversation_id), _) => TuiConversationRestoreTarget::Local(conversation_id), + (None, Some(server_token)) => TuiConversationRestoreTarget::Server(server_token), + (None, None) => { + self.show_transient_hint(SWITCH_UNAVAILABLE_HINT.to_owned(), ctx); + return; + } + }; + + self.conversation_menu + .update(ctx, |menu, ctx| menu.dismiss(ctx)); + self.restore_conversation(target, TuiConversationRestoreOrigin::ConversationList, ctx); + } + fn select_tui_slash_command(&mut self, command: &StaticCommand, ctx: &mut ViewContext) { match slash_command_selection_behavior(command) { SlashCommandSelectionBehavior::InsertCommandText(text) => { @@ -1263,6 +1525,12 @@ impl TuiTerminalSessionView { self.input_view.update(ctx, |input, ctx| input.clear(ctx)); record_static_slash_command_accepted(command.name, true, ctx); } + TuiSlashCommand::Conversations => { + self.input_view.update(ctx, |input, ctx| input.clear(ctx)); + self.conversation_menu + .update(ctx, |menu, ctx| menu.open(ctx)); + record_static_slash_command_accepted(command.name, true, ctx); + } TuiSlashCommand::CreateNewProject => { let Some(query) = argument .map(|argument| argument.trim()) @@ -1434,7 +1702,14 @@ impl TuiView for TuiTerminalSessionView { fn render(&self, ctx: &AppContext) -> Box { match &self.conversation_restore_state { - ConversationRestoreState::Loading => return conversation_restoring(ctx), + ConversationRestoreState::Loading { + origin: TuiConversationRestoreOrigin::Startup, + .. + } => return conversation_restoring(ctx), + ConversationRestoreState::Loading { + origin: TuiConversationRestoreOrigin::ConversationList, + .. + } => {} ConversationRestoreState::Failed(message) => { return conversation_restore_failed(message); } @@ -1551,6 +1826,9 @@ impl TypedActionView for TuiTerminalSessionView { fn handle_action(&mut self, action: &TuiTerminalSessionAction, ctx: &mut ViewContext) { match action { TuiTerminalSessionAction::Interrupt => self.handle_interrupt(ctx), + TuiTerminalSessionAction::CancelRestore => { + self.cancel_conversation_restore(ctx); + } TuiTerminalSessionAction::ToggleUsageDisplay => self.toggle_usage_display(ctx), } } diff --git a/crates/warp_tui/src/ui.rs b/crates/warp_tui/src/ui.rs index 9dcc9d7a7df..7546a97db01 100644 --- a/crates/warp_tui/src/ui.rs +++ b/crates/warp_tui/src/ui.rs @@ -57,6 +57,7 @@ pub(crate) fn compact_footer_path(path: &str) -> String { /// Placeholder shown while a requested conversation is restored. pub(crate) fn conversation_restoring(app: &AppContext) -> Box { let muted = TuiUiBuilder::from_app(app).muted_text_style(); + let hint = "Esc or Ctrl-C to cancel and start a new session"; centered_in_viewport( TuiConstrainedBox::new( TuiFlex::column() @@ -70,10 +71,11 @@ pub(crate) fn conversation_restoring(app: &AppContext) -> Box { .truncate() .finish(), ) + .child(TuiText::new(hint).with_style(muted).truncate().finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(), ) - .with_max_cols("Loading session...".len() as u16) + .with_max_cols(hint.len() as u16) .finish(), ) } diff --git a/crates/warp_tui/src/ui_tests.rs b/crates/warp_tui/src/ui_tests.rs index c3b2549dd65..d2f010b6116 100644 --- a/crates/warp_tui/src/ui_tests.rs +++ b/crates/warp_tui/src/ui_tests.rs @@ -29,12 +29,15 @@ fn conversation_loader_is_centered_and_animated() { app.read(|app_ctx| { let element = conversation_restoring(app_ctx); let mut presenter = TuiPresenter::new(); - let frame = presenter.present_element(element, TuiRect::new(0, 0, 40, 7), app_ctx); + let frame = presenter.present_element(element, TuiRect::new(0, 0, 60, 7), app_ctx); let lines = frame.buffer.to_lines(); let label = lines .iter() .find(|line| line.contains("Loading session...")) .expect("loading label should render"); + assert!(lines + .iter() + .any(|line| { line.contains("Esc or Ctrl-C to cancel and start a new session") })); assert!( label.find("Loading session...").is_some_and(|x| x > 0), diff --git a/specs/CODE-1820/PRODUCT.md b/specs/CODE-1820/PRODUCT.md index d0aac2309a2..fd637584485 100644 --- a/specs/CODE-1820/PRODUCT.md +++ b/specs/CODE-1820/PRODUCT.md @@ -82,7 +82,11 @@ Figma: none provided. 23. A non-Oz token produces an explicit message that the Warp TUI only supports Oz/Warp conversations. -24. Exiting while restoration is loading cancels the user-visible restore flow and exits normally. It does not print a resume hint for a conversation that was never successfully selected. +24. While startup restoration is loading, Escape or Ctrl-C cancels the restore and continues into the provisional new TUI session as though `--resume` had not been supplied. + - The loading screen shows `Esc or Ctrl-C to cancel and start a new session`. + - The provisional conversation becomes interactive without requiring a restart. + - A late loader result is ignored and cannot replace the new session. + - The cancelled target does not become selected and does not produce an exit resume hint. ### Exit resume hint diff --git a/specs/CODE-1820/TECH.md b/specs/CODE-1820/TECH.md index 1f184887b98..48b9b262352 100644 --- a/specs/CODE-1820/TECH.md +++ b/specs/CODE-1820/TECH.md @@ -100,6 +100,8 @@ The server token must be installed on the `AIConversation` before `restore_conve For startup, replacement operates on the provisional eager conversation and an otherwise empty transcript. For future inline selection, load and validate the requested conversation before clearing the current surface. If loading fails, retain the old transcript and selection. Once loading succeeds, remove the old conversation's agent rich content, conversation-derived command blocks, and action state before applying the new plan. `clear_conversations_for_terminal_surface` still emits a cleanup event for other subscribers; selection and transcript handlers must compare their current content with the IDs carried by that event because delivery may occur after the restored conversation is installed. +Escape or Ctrl-C while startup restoration is loading aborts the loader, invalidates its request generation, clears the loading state, and reveals the existing eager provisional conversation as a normal new TUI session. The loading screen includes `Esc or Ctrl-C to cancel and start a new session`. A cancelled request cannot later replace the session, and the requested token never reaches the exit-summary handle. + ### 3. Extract shared conversation block restoration preparation The GUI and TUI share `AIConversation`, `TerminalModel`, and `BlockList`; only their final rich-content view types differ. `app/src/terminal/conversation_restoration.rs` contains the model/blocklist work extracted from `TerminalView::restore_conversation_after_view_creation`. @@ -165,7 +167,7 @@ Registration must precede `TuiAIBlock` construction because `AIBlockModelImpl` r - `Loading` - `Failed(String)` -Startup resume renders loading instead of the zero state until completion. A startup failure renders an error with the input disabled until the user exits; it must not silently enter new-conversation mode. +Startup resume renders loading instead of the zero state until completion and includes the cancellation hint. A startup failure renders an error with the input disabled until the user exits; it must not silently enter new-conversation mode. User-initiated cancellation is distinct from failure: it aborts the request and reveals the eager provisional new-conversation state. Future inline selection may display loading while retaining the current transcript. On failure, show a transient or inline error and return to the prior selected conversation. Keep this distinction controlled by `TuiConversationRestoreOrigin`, while all loader and materialization behavior remains shared. @@ -217,7 +219,7 @@ Use existing `App::test` fixtures under `crates/warp_tui` to verify: - Transcript clear handling removes only agent blocks whose conversation IDs appear in the event payload. - The next submitted prompt carries the restored server token. (PRODUCT 16) - Inline-style replacement retains the old transcript on load failure and removes stale blocks/action state on success. (PRODUCT 19-20) -- Loading cancellation and error states do not start a new conversation. (PRODUCT 21-24) +- Startup loading cancellation reveals the existing eager provisional conversation, does not create a second replacement conversation, and ignores late loader completion. Error states remain blocking. (PRODUCT 21-24) ### CLI and exit tests @@ -225,7 +227,7 @@ Use existing `App::test` fixtures under `crates/warp_tui` to verify: - No `--resume` preserves existing startup. (PRODUCT 1) - A malformed or missing token value fails clearly. (PRODUCT 2-4) - Successful exit prints the selected token after TUI teardown. (PRODUCT 25-27) -- Empty selection, no selection, no selected token, worker mode, and error termination print no hint. (PRODUCT 28-31) +- Empty selection, no selection, no selected token, cancelled startup restoration, worker mode, and error termination print no hint. (PRODUCT 28-31) ### End-to-end verification @@ -239,6 +241,7 @@ Run `cargo nextest run -p warp_tui`, the focused `warp` restoration/history test - **Token patched too late:** inserting a conversation before its token is present can rebuild a stale reverse index. Verify or repair the token before `restore_conversations`, never after. - **Partial surface replacement:** clearing before a cloud load succeeds can destroy a usable transcript. Load and validate first, then replace the surface as one foreground-thread operation. - **Delayed provisional cleanup:** the provisional history clear event may be delivered after the restored conversation is selected and its blocks are inserted. Every clear subscriber must scope cleanup to the IDs carried by the event. +- **Late completion after cancellation:** retain an abort handle and request generation for startup restoration; cancellation invalidates both before returning the provisional session to interactive state. - **Stale action results:** reusing an action model across conversation replacements can leak historical tool status. Clear or reinitialize per-conversation restoration state as part of replacement and test action-ID isolation. - **Hint printed inside the alternate screen:** only persist the exit summary during the run; print after `run_tui` returns. diff --git a/specs/CODE-1821/PRODUCT.md b/specs/CODE-1821/PRODUCT.md new file mode 100644 index 00000000000..073b5c6df74 --- /dev/null +++ b/specs/CODE-1821/PRODUCT.md @@ -0,0 +1,142 @@ +# PRODUCT: Warp TUI Conversation Management (CODE-1821) + +Linear: [CODE-1821 — Conversation management](https://linear.app/warpdotdev/issue/CODE-1821/conversation-management) + +## Summary + +The Warp TUI provides a searchable inline `/conversations` menu for switching its single conversation surface to another user-owned Oz conversation. The menu combines local and cloud history, and selection safely loads and validates the requested conversation before replacing the current transcript. + +## Figma + +Figma: none provided. The existing TUI inline-menu styling is the visual reference. + +## Goals + +- Let users discover and continue their local and cloud Oz conversations without leaving the TUI. +- Preserve the TUI's one-session, one-visible-conversation model. +- Match the GUI inline conversation menu's core aggregation, deduplication, recency, and search semantics where they apply to the TUI. +- Keep failed, cancelled, or blocked switches from damaging the current conversation. + +## Non-goals + +- Displaying non-Oz or third-party CLI-agent conversations. +- Attaching to or following a cloud conversation that is still running. +- Supporting multiple simultaneous TUI conversation surfaces, panes, tabs, or windows. +- Adding conversation deletion, renaming, status display, timestamps, management actions, or a current-directory tab. +- Preserving per-conversation viewport position when switching away and back. + +## Behavior + +### Opening and browsing + +1. The TUI supports the `/conversations` slash command. Selecting or submitting the command clears the slash-command text and opens the inline conversation menu. + +2. The menu uses the existing TUI inline-menu visual treatment and keyboard behavior. It renders above the input, uses the active theme, supports up/down navigation, accepts the selected row with Enter, and closes with Escape. + +3. The menu may be opened and browsed while a terminal command or the current conversation is in progress. Opening the menu never cancels work and never changes the selected conversation. + +4. Until the initial local-and-cloud conversation aggregation finishes, the menu shows its loading state instead of a partial result set. + +5. If cloud conversation metadata cannot be loaded, the loading state ends, locally available conversations remain searchable, and the transient message area shows: + `Could not load cloud conversations. Showing local conversations only.` + +6. A later successful refresh merges cloud conversations into the open menu without requiring it to be closed and reopened. + +### Conversation universe + +7. The menu contains user-owned Oz conversations that can be restored from at least one of: + - Conversation data already loaded in the current process. + - Locally persisted conversation data. + - Cloud conversation metadata with a server conversation token. + +8. Local, cloud, ambient-task-backed, live-in-memory, cleared-from-the-current-surface, and persisted records referring to the same underlying conversation appear as one entry. + +9. The currently selected conversation is omitted. + +10. The following entries are omitted: + - Empty conversations that are not currently selected. + - Child-agent conversations. + - Internal, passive-only, shared-session-viewer, and transcript-viewer conversations. + - Non-Oz conversations. + - Entries without a local conversation identity or server conversation token that the TUI can restore. + +11. A target conversation that is queued, pending, claimed, blocked, or actively running is unavailable and omitted. It becomes eligible when its status changes to a safely restorable terminal state. + +12. Each row displays only the conversation title. The TUI does not show status, timestamps, open-pane indicators, working directory, harness, artifacts, or other management metadata. + +### Ordering, search, and live updates + +13. With an empty query, the menu shows at most the 50 most recently updated eligible conversations. + +14. Typing while the conversation menu is open updates a menu-only fuzzy title query. The query is not submitted as a prompt. + +15. Search uses the same case-insensitive fuzzy title matching and score threshold as the GUI inline conversation menu. Search returns at most 500 results. + +16. Clearing the query restores the 50-entry recent-conversation view. + +17. When no eligible conversations or search matches exist, the menu shows an explicit empty state rather than an empty bordered panel. + +18. While the menu is open, local-history and cloud/RTC updates refresh the current query. + +19. Refreshes preserve selection by the entry's stable identity. If the selected entry disappears, the nearest remaining selectable row becomes selected. + +20. A title or status update may update, add, or remove an entry. Rows reorder only when the conversation's last-updated value changes or when the search score changes. + +### Selecting a conversation + +21. Pressing Enter revalidates the current TUI state and the selected target. Validation at menu-open time is not sufficient. + +22. If a foreground terminal command is running, the switch is rejected, the menu remains open, and the transient message area shows: + `Cannot switch conversations while a command is in progress.` + +23. If the current conversation is in progress or blocked, the switch is rejected, the menu remains open, and the transient message area shows: + `Cannot switch conversations while the current conversation is in progress.` + +24. If another conversation restoration is already loading, the switch is rejected and the transient message area shows: + `Another conversation is already loading.` + +25. Rejected selection never cancels the command or conversation, clears the transcript, changes selection, or starts a load. + +26. An accepted row closes the menu, clears its search query, and enters the same conversation-restoration loading state used by startup `--resume`. + +### Loading and replacement + +27. List-originated loading keeps the current transcript visible but disables prompt and command submission until loading succeeds, fails, or is cancelled. + +28. A local restoration target loads from memory or local persistence and works offline. + +29. A server-token restoration target uses the existing local-first, server-fallback behavior. + +30. Local and server restoration produce the same visible transcript and continuation behavior. + +31. Loading and validation complete before any part of the current transcript, selection, action state, or command history is removed. + +32. A successful load atomically replaces the sole TUI conversation surface: + - The old conversation's visible transcript and conversation-derived command blocks are removed. + - The selected conversation's visible prompts, agent responses, supported reasoning, tool calls and recorded results, and conversation-derived command blocks are restored in their original order. + - No restored command or tool action is executed. + - The restored conversation becomes the target of the next prompt. + - The viewport moves to the newest content and input regains focus. + +33. Switching away does not delete the old conversation. Once eligible, it appears in the menu and can be selected again. + +34. A previously loaded target is restored from its current canonical in-memory state; switching must not replace newer conversation state with an older snapshot. + +35. Only one list-originated restoration may run at a time. Late completion from a cancelled or superseded request must not replace the visible conversation. + +36. If loading or validation fails, the current transcript and selection remain unchanged, submission becomes available again, and the transient message area shows a concise error. + +37. A non-Oz result is treated as unsupported and does not replace the current conversation. + +### Cancellation + +38. While a list-originated restoration is loading, Escape or Ctrl-C cancels the request and returns to the previous conversation without showing an error. + +39. While startup `--resume` restoration is loading, Escape or Ctrl-C cancels the request and continues into the provisional new TUI session as though `--resume` had not been supplied. + +40. The startup loading screen shows a visible hint: + `Esc or Ctrl-C to cancel and start a new session` + +41. Cancelling startup restoration does not print or retain a resume hint for the requested conversation. + +42. After either cancellation path, a late loader result is ignored and cannot replace the active conversation. diff --git a/specs/CODE-1821/TECH.md b/specs/CODE-1821/TECH.md new file mode 100644 index 00000000000..3ab769f31fe --- /dev/null +++ b/specs/CODE-1821/TECH.md @@ -0,0 +1,188 @@ +# TECH: Warp TUI Conversation Management (CODE-1821) + +Implements [`PRODUCT.md`](./PRODUCT.md) for [CODE-1821](https://linear.app/warpdotdev/issue/CODE-1821/conversation-management). + +## Context + +This change builds on two parent branches whose APIs are already established: + +- [`crates/warp_tui/src/inline_menu.rs @ 067fd301`](https://github.com/warpdotdev/warp/blob/067fd301/crates/warp_tui/src/inline_menu.rs) and [`crates/warp_tui/src/input/view.rs @ 067fd301`](https://github.com/warpdotdev/warp/blob/067fd301/crates/warp_tui/src/input/view.rs) route navigation, acceptance, dismissal, and rendering through an ordered collection of TUI inline menus. +- [`specs/frontend-neutral-conversation-list-policy/TECH.md`](../frontend-neutral-conversation-list-policy/TECH.md) documents the frontend-neutral list policy introduced by the next parent branch. [`AgentConversationsModel @ 2588808d`](https://github.com/warpdotdev/warp/blob/2588808d/app/src/ai/agent_conversations_model.rs) supplies normalized entries, and the TUI’s `ConversationSelection` classifies them relative to its sole conversation surface. + +This spec does not repeat those parent-branch designs. This branch owns the `/conversations` menu, its loading/search/update lifecycle, acceptance-time validation, and safe replacement of the TUI’s selected conversation. + +The existing restoration path from CODE-1820 provides the starting point: + +- [`crates/warp_tui/src/terminal_session_view.rs (589-698) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/crates/warp_tui/src/terminal_session_view.rs#L589-L698) loads a conversation by server token and replaces the provisional TUI conversation after loading succeeds. +- [`app/src/ai/blocklist/history_model/conversation_loader.rs (233-366) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/ai/blocklist/history_model/conversation_loader.rs#L233-L366) loads conversations from memory, local persistence, or the server. +- [`app/src/ai/blocklist/history_model.rs (1058-1169) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/ai/blocklist/history_model.rs#L1058-L1169) registers restored conversations and owns terminal-surface associations. +- [`app/src/terminal/conversation_restoration.rs (20-97) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/app/src/terminal/conversation_restoration.rs#L20-L97) builds the frontend-neutral command/exchange restoration plan consumed by the TUI. +- [`crates/warp_tui/src/transcript_view.rs (84-307) @ 99261042`](https://github.com/warpdotdev/warp/blob/992610422e4e441ae1e4d29de17aebbc5e0a1b23/crates/warp_tui/src/transcript_view.rs#L84-L307) materializes restored agent blocks and removes conversation-scoped transcript content. + +## Proposed changes + +### Add the conversation menu model + +Add `TuiConversationMenuModel` in `crates/warp_tui/src/conversation_menu.rs`. It owns: + +- Explicit open and closed state. +- Title-only rows with stable `AgentConversationEntryId` identity. +- `InlineMenuSelection`, scroll offset, loading state, and selection reconciliation. +- A menu-only query sourced from the shared input editor. +- Loading and empty snapshots using the existing TUI inline-menu presentation. + +Opening the menu registers its model ID with `AgentConversationsModel`; closing unregisters it. This activates existing polling and deferred RTC refresh behavior only while the list is visible. + +The model subscribes to input changes and `AgentConversationsModelEvent`. Every refresh reruns the current query, preserves selection by stable entry ID, and falls back to the nearest valid index when the selected row disappears. + +### Query and order eligible entries + +Request personal Oz entries from `AgentConversationsModel::get_entries` and classify each through the TUI’s existing conversation-selection policy. + +With an empty query: + +- Keep the model’s reverse-chronological ordering. +- Take the 50 most recent eligible entries. +- Reverse those rows for bottom-selected inline-menu presentation. + +With a non-empty query: + +- Use `fuzzy_match::match_indices_case_insensitive`. +- Drop scores below 25. +- Order by score and last-updated time so the best result remains nearest the default bottom selection. +- Retain at most 500 results. + +The currently selected conversation and unavailable entries are omitted by their policy state. Rows render only the conversation title. + +### Surface partial cloud failure + +Extend the initial `AgentConversationsModel` load result to distinguish successful cloud metadata loading from a request that returned usable local/task data without cloud metadata. + +Track: + +- Whether cloud metadata is temporarily unavailable. +- Whether a list-open retry is already running. + +When a list opens after a cloud metadata failure, retry the metadata request without hiding local results. A successful retry merges metadata into `BlocklistAIHistoryModel` and resynchronizes entries. A failed retry leaves local entries available and emits the event used by the TUI to show the PRODUCT.md warning once per menu opening. + +### Wire `/conversations` + +Add the existing plural command to the TUI slash-command allowlist. Selecting or submitting it: + +1. Clears the slash-command input. +2. Opens `TuiConversationMenuModel`. +3. Leaves focus in the input editor so typing updates the menu query. +4. Records normal static slash-command telemetry. + +Extend the existing inline-menu router with a conversation-menu variant and an accepted conversation entry ID. Escape dismisses the active conversation menu and clears its query. Enter sends the stable entry ID to `TuiTerminalSessionView`. + +### Revalidate acceptance + +Menu contents are advisory; acceptance rechecks all mutable state before loading: + +1. Reject when another restore is already loading. +2. Reject when the foreground terminal cannot start a new conversation. +3. Reject when the selected conversation is non-empty and not done. +4. Re-fetch the normalized entry by stable ID. +5. Reclassify it through the TUI conversation-selection policy. +6. Resolve either a local conversation ID or server token. + +Rejected acceptance keeps the menu and current conversation intact and shows the PRODUCT.md message in the transient footer slot. Accepted selection dismisses the menu before starting restoration. + +### Generalize restoration identity + +Replace the server-token-only TUI restoration input with: + +- `Local(AIConversationId)` for loaded or persisted local conversations. +- `Server(ServerConversationToken)` for metadata-only conversations and startup `--resume`. + +Both paths return the same `CloudConversationData` result. The completion callback validates that the returned conversation matches the requested local ID or server token before mutating the visible surface. + +Determine whether the target was already resident before loading. For a resident target, attach its canonical in-memory conversation to the TUI surface instead of reinserting the cloned loader result. `BlocklistAIHistoryModel::attach_loaded_conversation_to_terminal_surface` removes the ID from cleared collections, marks it live for the new surface, and emits the same restoration events without replacing `conversations_by_id`. + +Newly loaded conversations continue through `restore_conversations`. + +### Replace the visible conversation after loading + +No current state is removed until loading and target validation succeed. Successful replacement remains synchronous on the foreground callback: + +1. Clear old TUI agent-block views. +2. Remove command blocks associated with the previous conversation. +3. Clear restored historical action results. +4. Clear the terminal surface’s previous history association. +5. Build the shared restoration plan. +6. Restore action results required by historical blocks. +7. Register or attach the target conversation. +8. Materialize restored TUI blocks. +9. Mark the target active and selected. +10. Refresh the exit summary, focus input, and repaint. + +This preserves the existing CODE-1820 transcript ordering and continuation behavior while allowing repeated switching without overwriting newer canonical in-memory state. + +### Make restoration single-flight and cancelable + +Expand `ConversationRestoreState::Loading` with: + +- The restore origin. +- A monotonically increasing request ID. +- The spawned loader handle. + +Every completion verifies that its request ID is still current. Cancellation aborts the loader, invalidates the generation, returns to `Idle`, and ignores late completion. + +Presentation depends on origin: + +- Startup restore keeps the full loading screen and adds the CODE-1820 cancellation hint. +- Conversation-list restore keeps the current transcript visible, shows a loading footer hint, and suppresses prompt submission. + +Escape and Ctrl-C cancel either origin. Startup cancellation reveals the provisional empty conversation; list cancellation returns to the previous selected conversation. Startup failure retains the blocking error screen, while list failure returns to the old conversation with a transient error. + +## End-to-end flow + +```mermaid +sequenceDiagram + participant User + participant Menu as TUI conversation menu + participant Model as AgentConversationsModel + participant Session as TuiTerminalSessionView + participant History as BlocklistAIHistoryModel + participant Transcript as TuiTranscriptView + + User->>Menu: /conversations + Menu->>Model: query classified Oz entries + Model-->>Menu: eligible normalized rows + User->>Menu: search and accept + Menu->>Session: stable entry ID + Session->>Model: re-fetch and reclassify + Session->>History: load local ID or server token + alt rejected, failed, or cancelled + Session-->>User: retain current conversation + else valid Oz conversation + Session->>Transcript: clear previous visible content + Session->>History: attach or register target + Session->>Transcript: materialize restoration plan + Session-->>User: focus restored conversation + end +``` + +## Testing and validation + +- Conversation-menu unit tests cover the 50-row recent cap, fuzzy threshold and 500-result cap, stable-ID selection preservation, and nearest-index fallback. +- TUI policy tests from the parent branch cover selected, terminal-state, active, unsupported-harness, and missing-identity classifications. +- History-model tests verify that attaching a resident conversation preserves canonical state and removes stale cleared membership. +- Input/menu tests continue to verify navigation, acceptance, and dismissal routing through the active menu. +- UI tests verify the startup restoration cancellation hint. +- Focused session tests should cover busy rejection, load failure rollback, successful local/server replacement, list cancellation, startup cancellation, and ignored late completion. +- Run `cargo nextest run -p warp_tui` and focused history/model tests. +- Run `./script/format` and the repository clippy invocation before submission. + +## Risks and mitigations + +- **Partial destructive replacement:** Loading and identity validation finish before any transcript, history, command, or action state is removed. +- **Stale async completion:** Abort handles and request generations prevent cancelled or superseded loads from replacing the visible conversation. +- **Resident-state overwrite:** Already-loaded targets are attached by ID instead of reinserting loader clones. +- **Menu churn:** Refreshes preserve stable entry identity and clamp selection and scroll state. +- **Hidden cloud failure:** Local rows remain available while the explicit warning and list-open retry expose incomplete cloud metadata. + +## Parallelization + +Parallel implementation is not useful. The menu lifecycle, accepted-entry routing, restore state machine, and terminal-surface replacement overlap in `terminal_session_view.rs` and depend sequentially on the same model and history APIs. \ No newline at end of file From 6c415e9caf9d2b9927b998f6238e90c4906a92d8 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Mon, 13 Jul 2026 17:45:23 -0400 Subject: [PATCH 2/5] extract out shared searcher --- Cargo.lock | 1 - app/src/ai/agent_conversations_model.rs | 61 ++++++--- app/src/ai/agent_conversations_model/query.rs | 49 +++++++ app/src/ai/agent_conversations_model_tests.rs | 120 +++++++++++++++++- .../input/conversations/data_source.rs | 80 +++++------- app/src/tui_export.rs | 13 +- crates/warp_tui/Cargo.toml | 1 - crates/warp_tui/src/conversation_menu.rs | 75 ++--------- .../warp_tui/src/conversation_menu_tests.rs | 47 ------- crates/warp_tui/src/terminal_session_view.rs | 2 + crates/warp_tui/src/ui.rs | 1 + specs/CODE-1821/TECH.md | 13 +- 12 files changed, 263 insertions(+), 200 deletions(-) create mode 100644 app/src/ai/agent_conversations_model/query.rs diff --git a/Cargo.lock b/Cargo.lock index e890a97e90c..a3e8df57651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15911,7 +15911,6 @@ dependencies = [ "dirs 6.0.0", "futures", "futures-lite 1.13.0", - "fuzzy_match", "http_client", "instant", "itertools 0.14.0", diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index a3a10a912f9..bb56562557e 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -1,5 +1,6 @@ #[allow(dead_code)] pub mod entry; +mod query; use std::collections::{HashMap, HashSet}; use std::time::Duration; @@ -11,8 +12,10 @@ pub use entry::{ AgentConversationProvenance, }; use futures::stream::AbortHandle; +use fuzzy_match::FuzzyMatchResult; use instant::Instant; use itertools::Itertools; +pub use query::query_conversation_entries; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use warp_cli::agent::Harness; use warp_core::execution_mode::AppExecutionMode; @@ -122,6 +125,15 @@ enum TaskFetchState { TransientlyFailed { at: Instant, error: TaskFetchError }, } +/// Availability and retry state for cloud conversation metadata. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum CloudMetadataLoadState { + #[default] + Available, + Failed, + Retrying, +} + /// Tracks the cooldown window for RTC-triggered task-list refreshes. Pending events keep /// the earliest timestamp in the burst because `updated_after` is a lower bound; using the /// latest timestamp could skip tasks that changed earlier in the same window. @@ -302,6 +314,12 @@ pub trait AgentConversationListPolicy: 'static { ) -> AgentConversationListEntryState; } +/// A normalized conversation entry paired with optional title-match metadata. +pub struct AgentConversationQueryResult { + pub entry: AgentConversationEntry, + pub title_match: Option, +} + impl AgentManagementFilters { pub fn reset_all_but_owner(&mut self) { self.status = StatusFilter::default(); @@ -578,10 +596,8 @@ pub struct AgentConversationsModel { active_data_consumers_per_window: HashMap>, /// Whether we have finished the initial task load has_finished_initial_load: bool, - /// Whether the initial cloud conversation metadata request failed. - cloud_metadata_load_failed: bool, - /// Whether a list-open retry for cloud conversation metadata is in flight. - cloud_metadata_retry_in_flight: bool, + /// Availability and retry state for cloud conversation metadata. + cloud_metadata_load_state: CloudMetadataLoadState, /// Per-task fetch state for `get_or_async_fetch_task_data`. See [`TaskFetchState`] for /// the meaning of each variant. Tasks that have been successfully fetched live in `tasks` /// and are absent from this map. @@ -637,8 +653,7 @@ impl AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: true, - cloud_metadata_load_failed: false, - cloud_metadata_retry_in_flight: false, + cloud_metadata_load_state: CloudMetadataLoadState::Available, task_fetch_state: HashMap::new(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, @@ -678,8 +693,7 @@ impl AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: false, - cloud_metadata_load_failed: false, - cloud_metadata_retry_in_flight: false, + cloud_metadata_load_state: CloudMetadataLoadState::Available, task_fetch_state: HashMap::new(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, @@ -700,10 +714,13 @@ impl AgentConversationsModel { !self.has_finished_initial_load } - /// Returns whether cloud conversation metadata is temporarily unavailable. + /// Returns whether cloud conversation metadata is failed or retrying. #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(crate) fn cloud_metadata_load_failed(&self) -> bool { - self.cloud_metadata_load_failed + pub(crate) fn cloud_metadata_unavailable(&self) -> bool { + matches!( + self.cloud_metadata_load_state, + CloudMetadataLoadState::Failed | CloudMetadataLoadState::Retrying + ) } fn handle_network_status_changed( @@ -1018,7 +1035,11 @@ impl AgentConversationsModel { )) = result { model.has_finished_initial_load = true; - model.cloud_metadata_load_failed = !cloud_metadata_loaded; + model.cloud_metadata_load_state = if cloud_metadata_loaded { + CloudMetadataLoadState::Available + } else { + CloudMetadataLoadState::Failed + }; // Update tasks if we got any if !tasks.is_empty() { @@ -1046,7 +1067,7 @@ impl AgentConversationsModel { ctx.emit(AgentConversationsModelEvent::ConversationsLoaded); } else if let RequestState::RequestFailed(e) = result { model.has_finished_initial_load = true; - model.cloud_metadata_load_failed = true; + model.cloud_metadata_load_state = CloudMetadataLoadState::Failed; model.update_polling_state(ctx); report_error!(e); } @@ -1077,10 +1098,12 @@ impl AgentConversationsModel { /// Retries a failed cloud conversation metadata load while keeping local rows available. fn retry_cloud_conversation_metadata(&mut self, ctx: &mut ModelContext) { - if !self.cloud_metadata_load_failed || self.cloud_metadata_retry_in_flight { - return; + match self.cloud_metadata_load_state { + CloudMetadataLoadState::Available | CloudMetadataLoadState::Retrying => return, + CloudMetadataLoadState::Failed => { + self.cloud_metadata_load_state = CloudMetadataLoadState::Retrying; + } } - self.cloud_metadata_retry_in_flight = true; let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); ctx.spawn_with_retry_on_error( move || { @@ -1090,16 +1113,14 @@ impl AgentConversationsModel { OUT_OF_BAND_REQUEST_RETRY_STRATEGY, |model, result, ctx| match result { RequestState::RequestSucceeded(conversation_metadata) => { - model.cloud_metadata_retry_in_flight = false; - model.cloud_metadata_load_failed = false; + model.cloud_metadata_load_state = CloudMetadataLoadState::Available; BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| { history_model.merge_cloud_conversation_metadata(conversation_metadata); }); model.sync_conversations(ctx); } RequestState::RequestFailed(error) => { - model.cloud_metadata_retry_in_flight = false; - model.cloud_metadata_load_failed = true; + model.cloud_metadata_load_state = CloudMetadataLoadState::Failed; report_error!(error); ctx.emit(AgentConversationsModelEvent::ConversationsLoaded); } diff --git a/app/src/ai/agent_conversations_model/query.rs b/app/src/ai/agent_conversations_model/query.rs new file mode 100644 index 00000000000..e31394ec659 --- /dev/null +++ b/app/src/ai/agent_conversations_model/query.rs @@ -0,0 +1,49 @@ +use fuzzy_match::match_indices_case_insensitive; + +use super::{AgentConversationEntry, AgentConversationQueryResult}; + +pub(super) const DEFAULT_RESULT_COUNT: usize = 50; +pub(super) const MAX_SEARCH_RESULTS: usize = 500; +const MINIMUM_FUZZY_SCORE: i64 = 25; + +/// Applies the shared conversation-menu recency and fuzzy-ranking policy. +pub fn query_conversation_entries( + mut entries: Vec, + query: &str, +) -> Vec { + let query = query.trim().to_lowercase(); + if query.is_empty() { + entries.sort_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated)); + entries.truncate(DEFAULT_RESULT_COUNT); + entries.reverse(); + return entries + .into_iter() + .map(|entry| AgentConversationQueryResult { + entry, + title_match: None, + }) + .collect(); + } + + let mut matches = entries + .into_iter() + .filter_map(|entry| { + let title_match = match_indices_case_insensitive(&entry.display.title, &query)?; + (title_match.score >= MINIMUM_FUZZY_SCORE).then_some(AgentConversationQueryResult { + entry, + title_match: Some(title_match), + }) + }) + .collect::>(); + matches.sort_by_key(|result| { + let score = result + .title_match + .as_ref() + .map_or(i64::MIN, |title_match| title_match.score); + (score, result.entry.display.last_updated.timestamp_millis()) + }); + if matches.len() > MAX_SEARCH_RESULTS { + matches.drain(..matches.len() - MAX_SEARCH_RESULTS); + } + matches +} diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 23b26d060bc..85f768e4cf0 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -13,10 +13,12 @@ use warpui::{App, EntityId, ModelHandle, SingletonEntity}; use super::entry::{ AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationProvenance, }; +use super::query::{DEFAULT_RESULT_COUNT, MAX_SEARCH_RESULTS}; use super::{ - record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel, - AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, - ConversationMetadata, ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, + query_conversation_entries, record_earliest_rtc_task_refresh_timestamp, + AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, + AgentRunDisplayStatus, ArtifactFilter, CloudMetadataLoadState, ConversationMetadata, + ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, RtcTaskRefreshThrottleState, StatusFilter, TaskFetchError, TaskFetchState, MAX_PERSONAL_TASKS, MAX_TEAM_TASKS, }; @@ -664,14 +666,122 @@ fn create_test_model() -> AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: false, - cloud_metadata_load_failed: false, - cloud_metadata_retry_in_flight: false, + cloud_metadata_load_state: CloudMetadataLoadState::Available, task_fetch_state: Default::default(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, } } +#[test] +fn cloud_metadata_is_unavailable_while_failed_or_retrying() { + let mut model = create_test_model(); + assert!(!model.cloud_metadata_unavailable()); + + model.cloud_metadata_load_state = CloudMetadataLoadState::Failed; + assert!(model.cloud_metadata_unavailable()); + + model.cloud_metadata_load_state = CloudMetadataLoadState::Retrying; + assert!(model.cloud_metadata_unavailable()); +} + +#[test] +fn conversation_query_caps_recent_entries_and_places_newest_last() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + let now = Utc::now(); + let mut model = create_test_model(); + for index in 0..55 { + let task_id = make_uuid(9000 + index); + let mut task = + create_test_task(&task_id, "user-a", now - Duration::seconds(index as i64)); + task.title = format!("Conversation {index}"); + model.tasks.insert(task.task_id, task); + } + + app.update(|ctx| { + let entries = model.get_entries(&all_owner_filters(), ctx); + let results = query_conversation_entries(entries, ""); + + assert_eq!(results.len(), DEFAULT_RESULT_COUNT); + assert_eq!( + results + .first() + .map(|result| result.entry.display.title.as_str()), + Some("Conversation 49") + ); + assert_eq!( + results + .last() + .map(|result| result.entry.display.title.as_str()), + Some("Conversation 0") + ); + assert!(!results + .iter() + .any(|result| result.entry.display.title == "Conversation 50")); + }); + }); +} + +#[test] +fn conversation_query_filters_titles_and_caps_best_fuzzy_results() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + let now = Utc::now(); + let mut model = create_test_model(); + for index in 0..=MAX_SEARCH_RESULTS + 2 { + let task_id = make_uuid(9100 + index); + let mut task = + create_test_task(&task_id, "user-a", now - Duration::seconds(index as i64)); + task.title = if index == 1 { + "Fix unit tests".to_owned() + } else { + format!("Deploy service {index}") + }; + model.tasks.insert(task.task_id, task); + } + + app.update(|ctx| { + let entries = model.get_entries(&all_owner_filters(), ctx); + let results = query_conversation_entries(entries, "deploy"); + + assert_eq!(results.len(), MAX_SEARCH_RESULTS); + assert!(results + .iter() + .all(|result| result.entry.display.title.contains("Deploy"))); + assert!(results.windows(2).all(|window| { + window[0].title_match.as_ref().unwrap().score + <= window[1].title_match.as_ref().unwrap().score + })); + }); + }); +} + +#[test] +fn conversation_query_orders_equal_fuzzy_scores_by_recency() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + let now = Utc::now(); + let mut model = create_test_model(); + for index in [0, 2, 1] { + let task_id = make_uuid(9700 + index); + let mut task = + create_test_task(&task_id, "user-a", now - Duration::seconds(index as i64)); + task.title = "Deploy service".to_owned(); + model.tasks.insert(task.task_id, task); + } + + app.update(|ctx| { + let entries = model.get_entries(&all_owner_filters(), ctx); + let results = query_conversation_entries(entries, "deploy"); + + assert!(results.windows(2).all(|window| { + window[0].entry.display.last_updated <= window[1].entry.display.last_updated + })); + }); + }); +} + #[test] fn rtc_task_refresh_pending_timestamp_records_first_timestamp() { let timestamp = Utc::now(); diff --git a/app/src/terminal/input/conversations/data_source.rs b/app/src/terminal/input/conversations/data_source.rs index b487ef7ed76..6c7f912cb66 100644 --- a/app/src/terminal/input/conversations/data_source.rs +++ b/app/src/terminal/input/conversations/data_source.rs @@ -1,11 +1,12 @@ //! Data source for the inline conversation menu. +use std::collections::HashSet; -use itertools::Itertools; use ordered_float::OrderedFloat; use warpui::{AppContext, Entity, ModelHandle, SingletonEntity}; use crate::ai::agent_conversations_model::{ - AgentConversationEntry, AgentConversationListEntryState, AgentManagementFilters, + query_conversation_entries, AgentConversationEntry, AgentConversationListEntryState, + AgentManagementFilters, }; use crate::ai::blocklist::conversation_selection::ConversationSelectionHandle; use crate::search::data_source::{Query, QueryFilter, QueryResult}; @@ -56,7 +57,6 @@ impl SyncDataSource for ConversationMenuDataSource { app: &AppContext, ) -> Result>, DataSourceRunErrorWrapper> { let conversation_entries = self.entries(app); - let query_text = query.text.trim().to_lowercase(); let filter_by_cwd = query .filters @@ -91,54 +91,32 @@ impl SyncDataSource for ConversationMenuDataSource { }) }; - if query_text.is_empty() { - // By default, show 50 most recent conversations in the list. - const DEFAULT_RESULT_COUNT: usize = 50; - - // In the zero state, sort conversations in the active pane above all other conversations. - // Within each segment, sort to reverse chronological order. - Ok(conversation_entries - .into_iter() - .filter(|(entry, _)| matches_directory(entry)) - .sorted_by(|(a, _), (b, _)| b.display.last_updated.cmp(&a.display.last_updated)) - .take(DEFAULT_RESULT_COUNT) - .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, is_open_elsewhere)| { - if !matches_directory(&entry) { - return None; - } - let match_result = fuzzy_match::match_indices_case_insensitive( - &entry.display.title, - &query_text, - )?; - - // 25 is arbitrary. - if match_result.score < 25 { - return None; - } - - Some(QueryResult::from( - ConversationSearchItem::new(entry, is_open_elsewhere) - .with_name_match_result(Some(match_result.clone())) - .with_score(OrderedFloat(match_result.score as f64)), - )) - }) - .sorted_by(|a, b| b.score().cmp(&a.score())) - .collect_vec(); - - // This is basically here so the app doesn't choke. - const MAX_SEARCH_RESULTS: usize = 500; - - search_results.truncate(MAX_SEARCH_RESULTS); - Ok(search_results) - } + let mut open_elsewhere_ids = HashSet::new(); + let entries = conversation_entries + .into_iter() + .filter_map(|(entry, is_open_elsewhere)| { + if !matches_directory(&entry) { + return None; + } + if is_open_elsewhere { + open_elsewhere_ids.insert(entry.id); + } + Some(entry) + }) + .collect(); + Ok(query_conversation_entries(entries, &query.text) + .into_iter() + .map(|result| { + let is_open_elsewhere = open_elsewhere_ids.contains(&result.entry.id); + let mut item = ConversationSearchItem::new(result.entry, is_open_elsewhere); + if let Some(title_match) = result.title_match { + item = item + .with_score(OrderedFloat(title_match.score as f64)) + .with_name_match_result(Some(title_match)); + } + QueryResult::from(item) + }) + .collect()) } } diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 086c798d88e..4c01b7cd158 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -22,9 +22,10 @@ pub use crate::ai::agent::{ UserQueryMode, }; pub use crate::ai::agent_conversations_model::{ - AgentConversationEntry, AgentConversationEntryId, AgentConversationListEntryState, - AgentConversationListPolicy, AgentConversationsModel, AgentConversationsModelEvent, - AgentManagementFilters, AgentRunDisplayStatus, HarnessFilter, OwnerFilter, + query_conversation_entries, AgentConversationEntry, AgentConversationEntryId, + AgentConversationListEntryState, AgentConversationListPolicy, AgentConversationsModel, + AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, HarnessFilter, + OwnerFilter, }; pub use crate::ai::blocklist::agent_view::{ AgentViewController, AgentViewDisplayMode, AgentViewEntryOrigin, EnterAgentViewError, @@ -124,8 +125,8 @@ pub use crate::throttle::throttle; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; -/// Returns whether cloud conversation metadata is temporarily unavailable. -pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) -> bool { +/// Returns whether cloud conversation metadata is failed or retrying. +pub fn agent_conversations_cloud_metadata_unavailable(app: &warpui::AppContext) -> bool { crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) - .cloud_metadata_load_failed() + .cloud_metadata_unavailable() } diff --git a/crates/warp_tui/Cargo.toml b/crates/warp_tui/Cargo.toml index 38209b4aef9..85bf77b99e2 100644 --- a/crates/warp_tui/Cargo.toml +++ b/crates/warp_tui/Cargo.toml @@ -43,7 +43,6 @@ command.workspace = true dirs.workspace = true futures.workspace = true futures-lite.workspace = true -fuzzy_match.workspace = true http_client.workspace = true instant.workspace = true itertools.workspace = true diff --git a/crates/warp_tui/src/conversation_menu.rs b/crates/warp_tui/src/conversation_menu.rs index 96b7f736bb3..1f65326f335 100644 --- a/crates/warp_tui/src/conversation_menu.rs +++ b/crates/warp_tui/src/conversation_menu.rs @@ -1,11 +1,11 @@ //! Searchable TUI conversation-menu state backed by `AgentConversationsModel`. -use fuzzy_match::match_indices_case_insensitive; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; use warp::tui_export::{ - agent_conversations_cloud_metadata_load_failed, AgentConversationEntryId, - AgentConversationListEntryState, AgentConversationsModel, AgentConversationsModelEvent, - AgentManagementFilters, ConversationSelectionHandle, Harness, HarnessFilter, + agent_conversations_cloud_metadata_unavailable, query_conversation_entries, + AgentConversationEntryId, AgentConversationListEntryState, AgentConversationsModel, + AgentConversationsModelEvent, AgentManagementFilters, ConversationSelectionHandle, Harness, + HarnessFilter, }; use warp_editor::model::CoreEditorModel; use warp_search_core::inline_menu::InlineMenuSelection; @@ -16,9 +16,6 @@ use crate::inline_menu::{ TuiInlineMenuRowStyle, TuiInlineMenuSnapshot, TuiInlineMenuStatus, MAX_INLINE_MENU_ROWS, }; -const DEFAULT_RESULT_COUNT: usize = 50; -const MAX_SEARCH_RESULTS: usize = 500; -const MINIMUM_FUZZY_SCORE: i64 = 25; const MAX_VISIBLE_ROWS: usize = result_row_capacity(MAX_INLINE_MENU_ROWS, true, false); #[derive(Debug, Clone, PartialEq, Eq)] @@ -26,12 +23,6 @@ struct TuiConversationMenuRow { id: AgentConversationEntryId, title: String, } -#[derive(Debug, Clone)] -struct TuiConversationMenuCandidate { - id: AgentConversationEntryId, - title: String, - last_updated_millis: i64, -} #[derive(Debug, Clone, Default)] enum TuiConversationMenuState { @@ -238,7 +229,7 @@ impl TuiConversationMenuModel { }; let conversations_model = AgentConversationsModel::as_ref(ctx); let is_loading = conversations_model.is_loading(); - let cloud_metadata_load_failed = agent_conversations_cloud_metadata_load_failed(ctx); + let cloud_metadata_unavailable = agent_conversations_cloud_metadata_unavailable(ctx); let rows = if is_loading { Vec::new() } else { @@ -247,20 +238,20 @@ impl TuiConversationMenuModel { ..Default::default() }; let policy = self.conversation_selection.as_ref(ctx); - let candidates = conversations_model + let entries = conversations_model .get_entries(&filters, ctx) .into_iter() .filter(|entry| { policy.classify_entry(entry, ctx) == AgentConversationListEntryState::Available }) - .map(|entry| TuiConversationMenuCandidate { - id: entry.id, - title: entry.display.title, - last_updated_millis: entry.display.last_updated.timestamp_millis(), - }) .collect(); - let query = input_text(&self.input_editor, ctx).trim().to_lowercase(); - build_rows(candidates, &query) + query_conversation_entries(entries, &input_text(&self.input_editor, ctx)) + .into_iter() + .map(|result| TuiConversationMenuRow { + id: result.entry.id, + title: result.entry.display.title, + }) + .collect() }; let selection = reconcile_selection(&rows, previous_id, previous_index); @@ -274,7 +265,7 @@ impl TuiConversationMenuModel { scroll_offset, is_loading, }; - if cloud_metadata_load_failed && !self.cloud_warning_shown { + if cloud_metadata_unavailable && !self.cloud_warning_shown { self.cloud_warning_shown = true; ctx.emit(TuiConversationMenuEvent::CloudMetadataUnavailable); } @@ -282,44 +273,6 @@ impl TuiConversationMenuModel { } } -fn build_rows( - candidates: Vec, - query: &str, -) -> Vec { - if query.is_empty() { - let mut rows = candidates - .into_iter() - .take(DEFAULT_RESULT_COUNT) - .map(|candidate| TuiConversationMenuRow { - id: candidate.id, - title: candidate.title, - }) - .collect::>(); - rows.reverse(); - return rows; - } - - let mut matches = candidates - .into_iter() - .filter_map(|candidate| { - let score = match_indices_case_insensitive(&candidate.title, query)?.score; - (score >= MINIMUM_FUZZY_SCORE).then_some(( - score, - candidate.last_updated_millis, - TuiConversationMenuRow { - id: candidate.id, - title: candidate.title, - }, - )) - }) - .collect::>(); - matches.sort_by_key(|(score, last_updated, _)| (*score, *last_updated)); - if matches.len() > MAX_SEARCH_RESULTS { - matches.drain(..matches.len() - MAX_SEARCH_RESULTS); - } - matches.into_iter().map(|(_, _, row)| row).collect() -} - fn reconcile_selection( rows: &[TuiConversationMenuRow], previous_id: Option, diff --git a/crates/warp_tui/src/conversation_menu_tests.rs b/crates/warp_tui/src/conversation_menu_tests.rs index 9df38228679..5e938b5b3e2 100644 --- a/crates/warp_tui/src/conversation_menu_tests.rs +++ b/crates/warp_tui/src/conversation_menu_tests.rs @@ -2,53 +2,6 @@ use warp::tui_export::AIConversationId; use super::*; -fn candidate(index: usize, title: impl Into) -> TuiConversationMenuCandidate { - TuiConversationMenuCandidate { - id: AgentConversationEntryId::Conversation(AIConversationId::new()), - title: title.into(), - last_updated_millis: 1_000 - index as i64, - } -} - -#[test] -fn empty_query_caps_recent_rows_and_places_newest_last() { - let candidates = (0..55) - .map(|index| candidate(index, format!("Conversation {index}"))) - .collect(); - - let rows = build_rows(candidates, ""); - - assert_eq!(rows.len(), DEFAULT_RESULT_COUNT); - assert_eq!( - rows.first().map(|row| row.title.as_str()), - Some("Conversation 49") - ); - assert_eq!( - rows.last().map(|row| row.title.as_str()), - Some("Conversation 0") - ); - assert!(!rows.iter().any(|row| row.title == "Conversation 50")); -} - -#[test] -fn fuzzy_query_filters_titles_and_caps_best_results() { - let mut candidates = vec![ - candidate(0, "Deploy the API"), - candidate(1, "Fix unit tests"), - candidate(2, "Deploy the website"), - ]; - candidates.extend( - (0..MAX_SEARCH_RESULTS) - .map(|index| candidate(index + 3, format!("Deploy service {index}"))), - ); - - let rows = build_rows(candidates, "deploy"); - - assert_eq!(rows.len(), MAX_SEARCH_RESULTS); - assert!(rows.iter().all(|row| row.title.contains("Deploy"))); - assert!(!rows.iter().any(|row| row.title == "Fix unit tests")); -} - #[test] fn selection_reconciliation_preserves_id_then_uses_nearest_index() { let rows = vec![ diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 6bb5c2c9ca6..d997b9affde 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -691,6 +691,7 @@ impl TuiTerminalSessionView { request_id, future: None, }; + ctx.notify(); let target_for_load = target.clone(); let (future, resident_conversation_id) = @@ -713,6 +714,7 @@ impl TuiTerminalSessionView { }; (future, resident_conversation_id) }); + let future_handle = ctx.spawn(future, move |view, result, ctx| { if !view.is_current_restore_request(request_id) { return; diff --git a/crates/warp_tui/src/ui.rs b/crates/warp_tui/src/ui.rs index 7546a97db01..18ec40f606e 100644 --- a/crates/warp_tui/src/ui.rs +++ b/crates/warp_tui/src/ui.rs @@ -58,6 +58,7 @@ pub(crate) fn compact_footer_path(path: &str) -> String { pub(crate) fn conversation_restoring(app: &AppContext) -> Box { let muted = TuiUiBuilder::from_app(app).muted_text_style(); let hint = "Esc or Ctrl-C to cancel and start a new session"; + centered_in_viewport( TuiConstrainedBox::new( TuiFlex::column() diff --git a/specs/CODE-1821/TECH.md b/specs/CODE-1821/TECH.md index 3ab769f31fe..9c3b4171227 100644 --- a/specs/CODE-1821/TECH.md +++ b/specs/CODE-1821/TECH.md @@ -7,7 +7,7 @@ Implements [`PRODUCT.md`](./PRODUCT.md) for [CODE-1821](https://linear.app/warpd This change builds on two parent branches whose APIs are already established: - [`crates/warp_tui/src/inline_menu.rs @ 067fd301`](https://github.com/warpdotdev/warp/blob/067fd301/crates/warp_tui/src/inline_menu.rs) and [`crates/warp_tui/src/input/view.rs @ 067fd301`](https://github.com/warpdotdev/warp/blob/067fd301/crates/warp_tui/src/input/view.rs) route navigation, acceptance, dismissal, and rendering through an ordered collection of TUI inline menus. -- [`specs/frontend-neutral-conversation-list-policy/TECH.md`](../frontend-neutral-conversation-list-policy/TECH.md) documents the frontend-neutral list policy introduced by the next parent branch. [`AgentConversationsModel @ 2588808d`](https://github.com/warpdotdev/warp/blob/2588808d/app/src/ai/agent_conversations_model.rs) supplies normalized entries, and the TUI’s `ConversationSelection` classifies them relative to its sole conversation surface. +- [`specs/frontend-neutral-conversation-list-policy/TECH.md`](../frontend-neutral-conversation-list-policy/TECH.md) documents the frontend-neutral list policy introduced by the next parent branch. [`AgentConversationsModel @ d55907a2`](https://github.com/warpdotdev/warp/blob/d55907a2/app/src/ai/agent_conversations_model.rs) supplies normalized entries, and the TUI’s `ConversationSelection` classifies them relative to its sole conversation surface. This spec does not repeat those parent-branch designs. This branch owns the `/conversations` menu, its loading/search/update lifecycle, acceptance-time validation, and safe replacement of the TUI’s selected conversation. @@ -37,7 +37,7 @@ The model subscribes to input changes and `AgentConversationsModelEvent`. Every ### Query and order eligible entries -Request personal Oz entries from `AgentConversationsModel::get_entries` and classify each through the TUI’s existing conversation-selection policy. +Request personal Oz entries from `AgentConversationsModel::get_entries` and classify each through the TUI’s existing conversation-selection policy. Pass eligible entries to the same pure `query_conversation_entries` helper used by the GUI inline menu. The helper owns the 50-entry recent cap, case-insensitive fuzzy matching, score threshold, score/recency ordering, and 500-result search cap. GUI-only directory filtering and frontend-specific row construction remain outside the helper. With an empty query: @@ -58,12 +58,9 @@ The currently selected conversation and unavailable entries are omitted by their Extend the initial `AgentConversationsModel` load result to distinguish successful cloud metadata loading from a request that returned usable local/task data without cloud metadata. -Track: +Track cloud metadata with one exhaustive `CloudMetadataLoadState`: `Available`, `Failed`, or `Retrying`. Initial-load results transition atomically to `Available` or `Failed`; only `Failed` starts a retry, and retry completion returns to `Available` or `Failed`. -- Whether cloud metadata is temporarily unavailable. -- Whether a list-open retry is already running. - -When a list opens after a cloud metadata failure, retry the metadata request without hiding local results. A successful retry merges metadata into `BlocklistAIHistoryModel` and resynchronizes entries. A failed retry leaves local entries available and emits the event used by the TUI to show the PRODUCT.md warning once per menu opening. +When a list opens after a cloud metadata failure, retry the metadata request without hiding local results. A successful retry merges metadata into `BlocklistAIHistoryModel` and resynchronizes entries. Failed and retrying states both leave local entries available and cause the TUI to show the PRODUCT.md warning once per menu opening. ### Wire `/conversations` @@ -166,7 +163,7 @@ sequenceDiagram ## Testing and validation -- Conversation-menu unit tests cover the 50-row recent cap, fuzzy threshold and 500-result cap, stable-ID selection preservation, and nearest-index fallback. +- Shared model tests cover the 50-row recent cap, fuzzy threshold, score/recency ordering, and 500-result cap; the TUI menu tests only its stable-ID selection preservation and nearest-index fallback. - TUI policy tests from the parent branch cover selected, terminal-state, active, unsupported-harness, and missing-identity classifications. - History-model tests verify that attaching a resident conversation preserves canonical state and removes stale cleared membership. - Input/menu tests continue to verify navigation, acceptance, and dismissal routing through the active menu. From a26722c08c2a7d814b2fe8f2422053e77cec6250 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Mon, 13 Jul 2026 17:59:56 -0400 Subject: [PATCH 3/5] address comments --- app/src/ai/agent_conversations_model.rs | 66 ++------ app/src/ai/agent_conversations_model_tests.rs | 17 +- app/src/ai/blocklist/history_model.rs | 45 ------ app/src/ai/blocklist/history_model_tests.rs | 54 ------- app/src/tui_export.rs | 6 +- crates/warp_tui/src/conversation_menu.rs | 23 ++- crates/warp_tui/src/terminal_session_view.rs | 149 +++++++++--------- specs/CODE-1821/PRODUCT.md | 4 +- specs/CODE-1821/TECH.md | 22 ++- 9 files changed, 126 insertions(+), 260 deletions(-) diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index bb56562557e..1308b8c9744 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -125,13 +125,12 @@ enum TaskFetchState { TransientlyFailed { at: Instant, error: TaskFetchError }, } -/// Availability and retry state for cloud conversation metadata. +/// Availability state for cloud conversation metadata. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum CloudMetadataLoadState { +enum CloudConversationMetadataLoadState { #[default] Available, Failed, - Retrying, } /// Tracks the cooldown window for RTC-triggered task-list refreshes. Pending events keep @@ -596,8 +595,8 @@ pub struct AgentConversationsModel { active_data_consumers_per_window: HashMap>, /// Whether we have finished the initial task load has_finished_initial_load: bool, - /// Availability and retry state for cloud conversation metadata. - cloud_metadata_load_state: CloudMetadataLoadState, + /// Availability state for cloud conversation metadata. + cloud_conversation_metadata_load_state: CloudConversationMetadataLoadState, /// Per-task fetch state for `get_or_async_fetch_task_data`. See [`TaskFetchState`] for /// the meaning of each variant. Tasks that have been successfully fetched live in `tasks` /// and are absent from this map. @@ -653,7 +652,8 @@ impl AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: true, - cloud_metadata_load_state: CloudMetadataLoadState::Available, + cloud_conversation_metadata_load_state: + CloudConversationMetadataLoadState::Available, task_fetch_state: HashMap::new(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, @@ -693,7 +693,7 @@ impl AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: false, - cloud_metadata_load_state: CloudMetadataLoadState::Available, + cloud_conversation_metadata_load_state: CloudConversationMetadataLoadState::Available, task_fetch_state: HashMap::new(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, @@ -714,13 +714,10 @@ impl AgentConversationsModel { !self.has_finished_initial_load } - /// Returns whether cloud conversation metadata is failed or retrying. + /// Returns whether cloud conversation metadata failed to load. #[cfg_attr(not(feature = "tui"), allow(dead_code))] - pub(crate) fn cloud_metadata_unavailable(&self) -> bool { - matches!( - self.cloud_metadata_load_state, - CloudMetadataLoadState::Failed | CloudMetadataLoadState::Retrying - ) + pub(crate) fn cloud_conversation_metadata_load_failed(&self) -> bool { + self.cloud_conversation_metadata_load_state == CloudConversationMetadataLoadState::Failed } fn handle_network_status_changed( @@ -1035,10 +1032,10 @@ impl AgentConversationsModel { )) = result { model.has_finished_initial_load = true; - model.cloud_metadata_load_state = if cloud_metadata_loaded { - CloudMetadataLoadState::Available + model.cloud_conversation_metadata_load_state = if cloud_metadata_loaded { + CloudConversationMetadataLoadState::Available } else { - CloudMetadataLoadState::Failed + CloudConversationMetadataLoadState::Failed }; // Update tasks if we got any @@ -1067,7 +1064,8 @@ impl AgentConversationsModel { ctx.emit(AgentConversationsModelEvent::ConversationsLoaded); } else if let RequestState::RequestFailed(e) = result { model.has_finished_initial_load = true; - model.cloud_metadata_load_state = CloudMetadataLoadState::Failed; + model.cloud_conversation_metadata_load_state = + CloudConversationMetadataLoadState::Failed; model.update_polling_state(ctx); report_error!(e); } @@ -1088,7 +1086,6 @@ impl AgentConversationsModel { .or_default() .insert(view_id); self.update_polling_state(ctx); - self.retry_cloud_conversation_metadata(ctx); // Flush dirty tasks accumulated while no list surface was open. if let Some(dirty_since) = self.dirty_since.take() { @@ -1096,39 +1093,6 @@ impl AgentConversationsModel { } } - /// Retries a failed cloud conversation metadata load while keeping local rows available. - fn retry_cloud_conversation_metadata(&mut self, ctx: &mut ModelContext) { - match self.cloud_metadata_load_state { - CloudMetadataLoadState::Available | CloudMetadataLoadState::Retrying => return, - CloudMetadataLoadState::Failed => { - self.cloud_metadata_load_state = CloudMetadataLoadState::Retrying; - } - } - let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); - ctx.spawn_with_retry_on_error( - move || { - let ai_client = ai_client.clone(); - async move { ai_client.list_ai_conversation_metadata(None).await } - }, - OUT_OF_BAND_REQUEST_RETRY_STRATEGY, - |model, result, ctx| match result { - RequestState::RequestSucceeded(conversation_metadata) => { - model.cloud_metadata_load_state = CloudMetadataLoadState::Available; - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| { - history_model.merge_cloud_conversation_metadata(conversation_metadata); - }); - model.sync_conversations(ctx); - } - RequestState::RequestFailed(error) => { - model.cloud_metadata_load_state = CloudMetadataLoadState::Failed; - report_error!(error); - ctx.emit(AgentConversationsModelEvent::ConversationsLoaded); - } - RequestState::RequestFailedRetryPending(_) => {} - }, - ); - } - /// Called when a view that consumes this model's data becomes hidden. /// Uses view_id to make unregistration idempotent. pub fn register_view_closed( diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 85f768e4cf0..0995c311f5c 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -17,8 +17,8 @@ use super::query::{DEFAULT_RESULT_COUNT, MAX_SEARCH_RESULTS}; use super::{ query_conversation_entries, record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, - AgentRunDisplayStatus, ArtifactFilter, CloudMetadataLoadState, ConversationMetadata, - ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, + AgentRunDisplayStatus, ArtifactFilter, CloudConversationMetadataLoadState, + ConversationMetadata, ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, RtcTaskRefreshThrottleState, StatusFilter, TaskFetchError, TaskFetchState, MAX_PERSONAL_TASKS, MAX_TEAM_TASKS, }; @@ -666,7 +666,7 @@ fn create_test_model() -> AgentConversationsModel { next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: false, - cloud_metadata_load_state: CloudMetadataLoadState::Available, + cloud_conversation_metadata_load_state: CloudConversationMetadataLoadState::Available, task_fetch_state: Default::default(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, @@ -674,15 +674,12 @@ fn create_test_model() -> AgentConversationsModel { } #[test] -fn cloud_metadata_is_unavailable_while_failed_or_retrying() { +fn cloud_conversation_metadata_reports_failed_load() { let mut model = create_test_model(); - assert!(!model.cloud_metadata_unavailable()); + assert!(!model.cloud_conversation_metadata_load_failed()); - model.cloud_metadata_load_state = CloudMetadataLoadState::Failed; - assert!(model.cloud_metadata_unavailable()); - - model.cloud_metadata_load_state = CloudMetadataLoadState::Retrying; - assert!(model.cloud_metadata_unavailable()); + model.cloud_conversation_metadata_load_state = CloudConversationMetadataLoadState::Failed; + assert!(model.cloud_conversation_metadata_load_failed()); } #[test] diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 3a640fc2413..9714b9c6cbb 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -1132,51 +1132,6 @@ impl BlocklistAIHistoryModel { }); } - /// Attaches an already-loaded conversation to a terminal surface without replacing its state. - pub fn attach_loaded_conversation_to_terminal_surface( - &mut self, - conversation_id: AIConversationId, - terminal_surface_id: EntityId, - ctx: &mut ModelContext, - ) -> bool { - let Some(conversation) = self.conversations_by_id.get(&conversation_id) else { - return false; - }; - let new_status = conversation.status().clone(); - - for cleared_ids in self - .cleared_conversation_ids_for_terminal_surface - .values_mut() - { - cleared_ids.retain(|id| *id != conversation_id); - } - self.cleared_conversation_ids_for_terminal_surface - .retain(|_, ids| !ids.is_empty()); - - let live_ids = self - .live_conversation_ids_for_terminal_surface - .entry(terminal_surface_id) - .or_default(); - if !live_ids.contains(&conversation_id) { - live_ids.push(conversation_id); - } - self.terminal_surface_created_at - .entry(terminal_surface_id) - .or_insert_with(Local::now); - - ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationStatus { - conversation_id, - terminal_surface_id, - update: ConversationStatusUpdate::Restored, - new_status, - }); - ctx.emit(BlocklistAIHistoryEvent::RestoredConversations { - terminal_surface_id, - conversation_ids: vec![conversation_id], - }); - true - } - /// Sets the active conversation ID for a terminal surface and moves the conversation /// from any other terminal surface that currently contains it. /// diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 3f88cf84f27..1c973485407 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -193,60 +193,6 @@ fn persisted_agent_conversation_from_update_event(event: ModelEvent) -> AgentCon } } -#[test] -fn attach_loaded_conversation_preserves_canonical_state() { - App::test((), |mut app| async move { - let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); - let old_surface_id = EntityId::new(); - let new_surface_id = EntityId::new(); - let conversation_id = AIConversationId::new(); - let conversation = AIConversation::new_restored( - conversation_id, - vec![warp_multi_agent_api::Task { - id: "root-task".to_owned(), - messages: Vec::new(), - dependencies: None, - description: "Canonical title".to_owned(), - summary: String::new(), - server_data: String::new(), - }], - None, - ) - .unwrap(); - - history.update(&mut app, |history, ctx| { - history.restore_conversations(old_surface_id, vec![conversation], ctx); - history.clear_conversations_for_terminal_surface(old_surface_id, ctx); - assert!(history.attach_loaded_conversation_to_terminal_surface( - conversation_id, - new_surface_id, - ctx, - )); - }); - - history.read(&app, |history, _| { - assert_eq!( - history - .conversation(&conversation_id) - .and_then(|conversation| conversation.title()) - .as_deref(), - Some("Canonical title") - ); - assert_eq!( - history - .all_live_conversations_for_terminal_surface(new_surface_id) - .map(|conversation| conversation.id()) - .collect::>(), - vec![conversation_id] - ); - assert!(!history - .all_cleared_conversations() - .iter() - .any(|(_, conversation)| conversation.id() == conversation_id)); - }); - }); -} - #[test] fn begin_conversation_rename_updates_title_and_cached_metadata() { App::test((), |mut app| async move { diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 4c01b7cd158..1a25ee4d3b7 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -125,8 +125,8 @@ pub use crate::throttle::throttle; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; -/// Returns whether cloud conversation metadata is failed or retrying. -pub fn agent_conversations_cloud_metadata_unavailable(app: &warpui::AppContext) -> bool { +/// Returns whether cloud conversation metadata failed to load. +pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext) -> bool { crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app) - .cloud_metadata_unavailable() + .cloud_conversation_metadata_load_failed() } diff --git a/crates/warp_tui/src/conversation_menu.rs b/crates/warp_tui/src/conversation_menu.rs index 1f65326f335..398e8782e1e 100644 --- a/crates/warp_tui/src/conversation_menu.rs +++ b/crates/warp_tui/src/conversation_menu.rs @@ -1,8 +1,13 @@ -//! Searchable TUI conversation-menu state backed by `AgentConversationsModel`. +//! Searchable `/conversations` menu state for the TUI. +//! +//! The model reads normalized entries from `AgentConversationsModel`, applies the TUI +//! selection policy and shared query ranking, and preserves stable selection and scrolling +//! across live model updates. It owns menu lifecycle and presentation only; the terminal +//! session revalidates an accepted entry before restoring it. use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; use warp::tui_export::{ - agent_conversations_cloud_metadata_unavailable, query_conversation_entries, + agent_conversations_cloud_metadata_load_failed, query_conversation_entries, AgentConversationEntryId, AgentConversationListEntryState, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ConversationSelectionHandle, Harness, HarnessFilter, @@ -82,6 +87,7 @@ impl TuiConversationMenuModel { } } + /// Returns whether the conversation menu is currently open. pub(crate) fn is_open(&self) -> bool { matches!(self.state, TuiConversationMenuState::Open { .. }) } @@ -106,6 +112,7 @@ impl TuiConversationMenuModel { self.refresh_rows(ctx); } + /// Closes the menu and clears its query buffer. pub(crate) fn dismiss(&mut self, ctx: &mut ModelContext) { if !self.is_open() { return; @@ -115,6 +122,7 @@ impl TuiConversationMenuModel { .update(ctx, |editor, ctx| editor.clear_buffer(ctx)); } + /// Moves selection to the previous row and keeps it visible. pub(crate) fn select_previous(&mut self, ctx: &mut ModelContext) { let TuiConversationMenuState::Open { rows, @@ -131,6 +139,7 @@ impl TuiConversationMenuModel { ctx.emit(TuiConversationMenuEvent::Updated); } + /// Moves selection to the next row and keeps it visible. pub(crate) fn select_next(&mut self, ctx: &mut ModelContext) { let TuiConversationMenuState::Open { rows, @@ -147,6 +156,7 @@ impl TuiConversationMenuModel { ctx.emit(TuiConversationMenuEvent::Updated); } + /// Returns the stable ID of the selected row without closing the menu. pub(crate) fn accept_selected( &mut self, _ctx: &mut ModelContext, @@ -163,6 +173,7 @@ impl TuiConversationMenuModel { selected_id } + /// Returns the render snapshot for the open menu. pub(crate) fn snapshot(&self) -> Option { let TuiConversationMenuState::Open { rows, @@ -203,6 +214,7 @@ impl TuiConversationMenuModel { }) } + /// Closes the menu and unregisters its conversation-list consumer. fn close(&mut self, ctx: &mut ModelContext) { self.state = TuiConversationMenuState::Closed; let window_id = self.window_id; @@ -213,6 +225,7 @@ impl TuiConversationMenuModel { ctx.emit(TuiConversationMenuEvent::Updated); } + /// Rebuilds rows from the current query while preserving stable selection. fn refresh_rows(&mut self, ctx: &mut ModelContext) { let previous_id = match &self.state { TuiConversationMenuState::Open { @@ -229,7 +242,7 @@ impl TuiConversationMenuModel { }; let conversations_model = AgentConversationsModel::as_ref(ctx); let is_loading = conversations_model.is_loading(); - let cloud_metadata_unavailable = agent_conversations_cloud_metadata_unavailable(ctx); + let cloud_metadata_load_failed = agent_conversations_cloud_metadata_load_failed(ctx); let rows = if is_loading { Vec::new() } else { @@ -265,7 +278,7 @@ impl TuiConversationMenuModel { scroll_offset, is_loading, }; - if cloud_metadata_unavailable && !self.cloud_warning_shown { + if cloud_metadata_load_failed && !self.cloud_warning_shown { self.cloud_warning_shown = true; ctx.emit(TuiConversationMenuEvent::CloudMetadataUnavailable); } @@ -273,6 +286,7 @@ impl TuiConversationMenuModel { } } +/// Preserves selection by ID, falling back to the nearest valid index. fn reconcile_selection( rows: &[TuiConversationMenuRow], previous_id: Option, @@ -298,6 +312,7 @@ impl Entity for TuiConversationMenuModel { type Event = TuiConversationMenuEvent; } +/// Returns the input editor's current plain text. fn input_text(editor: &ModelHandle, app: &AppContext) -> String { let model = editor.as_ref(app); let buffer = model.content().as_ref(app); diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index d997b9affde..7344ef38b99 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -693,75 +693,18 @@ impl TuiTerminalSessionView { }; ctx.notify(); - let target_for_load = target.clone(); - let (future, resident_conversation_id) = - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - let resident_conversation_id = match &target_for_load { - TuiConversationRestoreTarget::Local(conversation_id) => history - .conversation(conversation_id) - .map(|conversation| conversation.id()), - TuiConversationRestoreTarget::Server(server_token) => history - .find_conversation_id_by_server_token(server_token) - .filter(|conversation_id| history.conversation(conversation_id).is_some()), - }; - let future = match &target_for_load { - TuiConversationRestoreTarget::Local(conversation_id) => { - history.load_conversation_data(*conversation_id, ctx) - } - TuiConversationRestoreTarget::Server(server_token) => { - history.load_conversation_by_server_token(server_token, ctx) - } - }; - (future, resident_conversation_id) + let future = + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| match &target { + TuiConversationRestoreTarget::Local(conversation_id) => { + history.load_conversation_data(*conversation_id, ctx) + } + TuiConversationRestoreTarget::Server(server_token) => { + history.load_conversation_by_server_token(server_token, ctx) + } }); let future_handle = ctx.spawn(future, move |view, result, ctx| { - if !view.is_current_restore_request(request_id) { - return; - } - match result { - Some(CloudConversationData::Oz(conversation)) => { - let matches_target = match &target { - TuiConversationRestoreTarget::Local(conversation_id) => { - conversation.id() == *conversation_id - } - TuiConversationRestoreTarget::Server(server_token) => { - conversation.server_conversation_token() == Some(server_token) - } - }; - if !matches_target { - view.fail_conversation_restore( - request_id, - "The restored conversation did not match the requested conversation." - .to_owned(), - ctx, - ); - return; - } - let is_resident = resident_conversation_id == Some(conversation.id()); - view.finish_conversation_restore( - *conversation, - is_resident, - origin, - request_id, - ctx, - ); - } - Some(CloudConversationData::CLIAgent(_)) => { - view.fail_conversation_restore( - request_id, - "The Warp TUI only supports Oz/Warp conversations.".to_owned(), - ctx, - ); - } - None => { - view.fail_conversation_restore( - request_id, - "The conversation could not be loaded.".to_owned(), - ctx, - ); - } - } + view.handle_conversation_restore_result(target, origin, request_id, result, ctx); }); match &mut self.conversation_restore_state { ConversationRestoreState::Loading { @@ -777,17 +720,66 @@ impl TuiTerminalSessionView { } } - fn finish_conversation_restore( + /// Validates a completed load before starting synchronous surface replacement. + fn handle_conversation_restore_result( &mut self, - conversation: AIConversation, - is_resident: bool, + target: TuiConversationRestoreTarget, origin: TuiConversationRestoreOrigin, request_id: u64, + result: Option, ctx: &mut ViewContext, ) { if !self.is_current_restore_request(request_id) { return; } + + let conversation = match result { + Some(CloudConversationData::Oz(conversation)) => conversation, + Some(CloudConversationData::CLIAgent(_)) => { + self.fail_conversation_restore( + request_id, + "The Warp TUI only supports Oz/Warp conversations.".to_owned(), + ctx, + ); + return; + } + None => { + self.fail_conversation_restore( + request_id, + "The conversation could not be loaded.".to_owned(), + ctx, + ); + return; + } + }; + + let matches_target = match &target { + TuiConversationRestoreTarget::Local(conversation_id) => { + conversation.id() == *conversation_id + } + TuiConversationRestoreTarget::Server(server_token) => { + conversation.server_conversation_token() == Some(server_token) + } + }; + if !matches_target { + self.fail_conversation_restore( + request_id, + "The restored conversation did not match the requested conversation.".to_owned(), + ctx, + ); + return; + } + + self.replace_conversation_surface(*conversation, origin, ctx); + } + + /// Replaces the visible conversation and completes the restore state transition. + fn replace_conversation_surface( + &mut self, + conversation: AIConversation, + origin: TuiConversationRestoreOrigin, + ctx: &mut ViewContext, + ) { let previous_conversation_id = self .conversation_selection .as_ref(ctx) @@ -796,13 +788,16 @@ impl TuiTerminalSessionView { self.transcript.update(ctx, |transcript, ctx| { transcript.clear_for_replacement(ctx); }); + self.terminal_model .lock() .block_list_mut() .remove_command_blocks_for_conversation(previous_conversation_id); + self.ai_action_model.update(ctx, |actions, _| { actions.clear_restored_action_results(); }); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.clear_conversations_for_terminal_surface(self.terminal_surface_id, ctx); }); @@ -813,26 +808,23 @@ impl TuiTerminalSessionView { let mut terminal_model = self.terminal_model.lock(); prepare_conversation_block_restoration(&conversation, &mut terminal_model) }; + self.ai_action_model.update(ctx, |actions, _| { actions.restore_action_results_from_exchanges(restoration_plan.exchanges().collect()); }); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - if !is_resident - || !history.attach_loaded_conversation_to_terminal_surface( - conversation_id, - self.terminal_surface_id, - ctx, - ) - { - history.restore_conversations(self.terminal_surface_id, vec![conversation], ctx); - } + history.restore_conversations(self.terminal_surface_id, vec![conversation], ctx); }); + self.transcript.update(ctx, |transcript, ctx| { transcript.restore_conversation(conversation_id, restoration_plan, ctx); }); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.set_active_conversation_id(conversation_id, self.terminal_surface_id, ctx); }); + self.conversation_selection.update(ctx, |selection, ctx| { selection.select_existing_conversation( conversation_id, @@ -840,6 +832,7 @@ impl TuiTerminalSessionView { ctx, ); }); + self.conversation_restore_state = ConversationRestoreState::Idle; self.refresh_exit_summary(ctx); ctx.focus(&self.input_view); diff --git a/specs/CODE-1821/PRODUCT.md b/specs/CODE-1821/PRODUCT.md index 073b5c6df74..5d7cfc1f73d 100644 --- a/specs/CODE-1821/PRODUCT.md +++ b/specs/CODE-1821/PRODUCT.md @@ -40,7 +40,7 @@ Figma: none provided. The existing TUI inline-menu styling is the visual referen 5. If cloud conversation metadata cannot be loaded, the loading state ends, locally available conversations remain searchable, and the transient message area shows: `Could not load cloud conversations. Showing local conversations only.` -6. A later successful refresh merges cloud conversations into the open menu without requiring it to be closed and reopened. +6. Opening or reopening the menu after a cloud metadata failure continues to show local conversations and does not start a separate cloud refresh. ### Conversation universe @@ -120,7 +120,7 @@ Figma: none provided. The existing TUI inline-menu styling is the visual referen 33. Switching away does not delete the old conversation. Once eligible, it appears in the menu and can be selected again. -34. A previously loaded target is restored from its current canonical in-memory state; switching must not replace newer conversation state with an older snapshot. +34. Previously loaded and newly loaded targets use the same restoration behavior as the GUI. 35. Only one list-originated restoration may run at a time. Late completion from a cancelled or superseded request must not replace the visible conversation. diff --git a/specs/CODE-1821/TECH.md b/specs/CODE-1821/TECH.md index 9c3b4171227..18e428c8d74 100644 --- a/specs/CODE-1821/TECH.md +++ b/specs/CODE-1821/TECH.md @@ -6,8 +6,8 @@ Implements [`PRODUCT.md`](./PRODUCT.md) for [CODE-1821](https://linear.app/warpd This change builds on two parent branches whose APIs are already established: -- [`crates/warp_tui/src/inline_menu.rs @ 067fd301`](https://github.com/warpdotdev/warp/blob/067fd301/crates/warp_tui/src/inline_menu.rs) and [`crates/warp_tui/src/input/view.rs @ 067fd301`](https://github.com/warpdotdev/warp/blob/067fd301/crates/warp_tui/src/input/view.rs) route navigation, acceptance, dismissal, and rendering through an ordered collection of TUI inline menus. -- [`specs/frontend-neutral-conversation-list-policy/TECH.md`](../frontend-neutral-conversation-list-policy/TECH.md) documents the frontend-neutral list policy introduced by the next parent branch. [`AgentConversationsModel @ d55907a2`](https://github.com/warpdotdev/warp/blob/d55907a2/app/src/ai/agent_conversations_model.rs) supplies normalized entries, and the TUI’s `ConversationSelection` classifies them relative to its sole conversation surface. +- [`crates/warp_tui/src/inline_menu.rs @ 053d02e1`](https://github.com/warpdotdev/warp/blob/053d02e1/crates/warp_tui/src/inline_menu.rs) and [`crates/warp_tui/src/input/view.rs @ 053d02e1`](https://github.com/warpdotdev/warp/blob/053d02e1/crates/warp_tui/src/input/view.rs) route navigation, acceptance, dismissal, and rendering through an ordered collection of type-erased TUI inline-menu handles. +- [`specs/frontend-neutral-conversation-list-policy/TECH.md`](../frontend-neutral-conversation-list-policy/TECH.md) documents the frontend-neutral list policy introduced by the next parent branch. [`AgentConversationsModel @ 0f9eecda`](https://github.com/warpdotdev/warp/blob/0f9eecda/app/src/ai/agent_conversations_model.rs) supplies normalized entries, and the TUI’s `ConversationSelection` classifies them relative to its sole conversation surface. This spec does not repeat those parent-branch designs. This branch owns the `/conversations` menu, its loading/search/update lifecycle, acceptance-time validation, and safe replacement of the TUI’s selected conversation. @@ -58,9 +58,9 @@ The currently selected conversation and unavailable entries are omitted by their Extend the initial `AgentConversationsModel` load result to distinguish successful cloud metadata loading from a request that returned usable local/task data without cloud metadata. -Track cloud metadata with one exhaustive `CloudMetadataLoadState`: `Available`, `Failed`, or `Retrying`. Initial-load results transition atomically to `Available` or `Failed`; only `Failed` starts a retry, and retry completion returns to `Available` or `Failed`. +Track cloud metadata with one exhaustive `CloudConversationMetadataLoadState`: `Available` or `Failed`. The shared initial request already uses `OUT_OF_BAND_REQUEST_RETRY_STRATEGY`; opening a GUI or TUI list does not add another retry lifecycle. -When a list opens after a cloud metadata failure, retry the metadata request without hiding local results. A successful retry merges metadata into `BlocklistAIHistoryModel` and resynchronizes entries. Failed and retrying states both leave local entries available and cause the TUI to show the PRODUCT.md warning once per menu opening. +When the shared request exhausts its retries, local entries remain available and the TUI shows the PRODUCT.md warning once per menu opening. ### Wire `/conversations` @@ -95,9 +95,7 @@ Replace the server-token-only TUI restoration input with: Both paths return the same `CloudConversationData` result. The completion callback validates that the returned conversation matches the requested local ID or server token before mutating the visible surface. -Determine whether the target was already resident before loading. For a resident target, attach its canonical in-memory conversation to the TUI surface instead of reinserting the cloned loader result. `BlocklistAIHistoryModel::attach_loaded_conversation_to_terminal_surface` removes the ID from cleared collections, marks it live for the new surface, and emits the same restoration events without replacing `conversations_by_id`. - -Newly loaded conversations continue through `restore_conversations`. +Both local and server targets use the existing GUI restoration path: load an owned conversation value and register it on the destination surface through `BlocklistAIHistoryModel::restore_conversations`. ### Replace the visible conversation after loading @@ -109,12 +107,12 @@ No current state is removed until loading and target validation succeed. Success 4. Clear the terminal surface’s previous history association. 5. Build the shared restoration plan. 6. Restore action results required by historical blocks. -7. Register or attach the target conversation. +7. Register the target conversation. 8. Materialize restored TUI blocks. 9. Mark the target active and selected. 10. Refresh the exit summary, focus input, and repaint. -This preserves the existing CODE-1820 transcript ordering and continuation behavior while allowing repeated switching without overwriting newer canonical in-memory state. +This preserves the existing CODE-1820 transcript ordering and continuation behavior while matching the GUI restoration path for repeated switching. ### Make restoration single-flight and cancelable @@ -155,7 +153,7 @@ sequenceDiagram Session-->>User: retain current conversation else valid Oz conversation Session->>Transcript: clear previous visible content - Session->>History: attach or register target + Session->>History: register target Session->>Transcript: materialize restoration plan Session-->>User: focus restored conversation end @@ -165,7 +163,6 @@ sequenceDiagram - Shared model tests cover the 50-row recent cap, fuzzy threshold, score/recency ordering, and 500-result cap; the TUI menu tests only its stable-ID selection preservation and nearest-index fallback. - TUI policy tests from the parent branch cover selected, terminal-state, active, unsupported-harness, and missing-identity classifications. -- History-model tests verify that attaching a resident conversation preserves canonical state and removes stale cleared membership. - Input/menu tests continue to verify navigation, acceptance, and dismissal routing through the active menu. - UI tests verify the startup restoration cancellation hint. - Focused session tests should cover busy rejection, load failure rollback, successful local/server replacement, list cancellation, startup cancellation, and ignored late completion. @@ -176,9 +173,8 @@ sequenceDiagram - **Partial destructive replacement:** Loading and identity validation finish before any transcript, history, command, or action state is removed. - **Stale async completion:** Abort handles and request generations prevent cancelled or superseded loads from replacing the visible conversation. -- **Resident-state overwrite:** Already-loaded targets are attached by ID instead of reinserting loader clones. - **Menu churn:** Refreshes preserve stable entry identity and clamp selection and scroll state. -- **Hidden cloud failure:** Local rows remain available while the explicit warning and list-open retry expose incomplete cloud metadata. +- **Hidden cloud failure:** Local rows remain available and the explicit warning exposes incomplete cloud metadata after shared retries are exhausted. ## Parallelization From 524d7f8cc153245beb80dc408447fc0e4c46e7d8 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 09:34:15 -0400 Subject: [PATCH 4/5] hide empty thinking blocks (following GUI prior art) --- crates/warp_tui/src/agent_block.rs | 38 ++++++++++++++---------- crates/warp_tui/src/agent_block_tests.rs | 28 +++++++++++++++++ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index 786afd4373e..07b8a862ce5 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -471,22 +471,28 @@ impl TuiAIBlock { text, finished_duration, } => { - sections.push(TuiAIBlockSection::Thinking { - message_id: message.id.clone(), - finished_duration: *finished_duration, - body: text - .sections - .iter() - .filter_map(|section| match section { - AIAgentTextSection::PlainText { text } => Some(text.text()), - // The TUI can't render these section kinds yet. - AIAgentTextSection::Code { .. } - | AIAgentTextSection::Table { .. } - | AIAgentTextSection::Image { .. } - | AIAgentTextSection::MermaidDiagram { .. } => None, - }) - .join("\n"), - }); + let body = text + .sections + .iter() + .filter_map(|section| match section { + AIAgentTextSection::PlainText { text } => Some(text.text()), + // The TUI can't render these section kinds yet. + AIAgentTextSection::Code { .. } + | AIAgentTextSection::Table { .. } + | AIAgentTextSection::Image { .. } + | AIAgentTextSection::MermaidDiagram { .. } => None, + }) + .join("\n"); + // Some providers intentionally emit duration/signature-only reasoning + // records for conversation continuity when no user-visible summary exists; + // omit them because they have no content to render. + if !body.is_empty() { + sections.push(TuiAIBlockSection::Thinking { + message_id: message.id.clone(), + finished_duration: *finished_duration, + body, + }); + } } AIAgentOutputMessageType::Summarization { text, diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index b5ba66efd1a..e9e0321f835 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -535,6 +535,34 @@ fn finished_reasoning_renders_collapsed_thought_for_header() { }); } +/// Duration-only reasoning records do not create empty thinking sections. +#[test] +fn empty_finished_reasoning_is_omitted() { + App::test((), |mut app| async move { + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![ + plain_text_message("m1", "before"), + reasoning_message("r1", Some(Duration::from_secs(15)), ""), + plain_text_message("m2", "after"), + ]), + }, + ); + app.read(|app_ctx| { + let block = block.as_ref(app_ctx); + assert_eq!( + block.sections(app_ctx), + vec![ + TuiAIBlockSection::PlainText("before".to_owned()), + TuiAIBlockSection::PlainText("after".to_owned()), + ] + ); + }); + }); +} + #[test] fn manual_expand_override_shows_finished_reasoning_body() { App::test((), |mut app| async move { From f4bdf4220a6c8cf011d41bce95ac055f51b5ce6f Mon Sep 17 00:00:00 2001 From: harryalbert Date: Tue, 14 Jul 2026 09:40:49 -0400 Subject: [PATCH 5/5] update oz usage --- specs/CODE-1821/TECH.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/CODE-1821/TECH.md b/specs/CODE-1821/TECH.md index 18e428c8d74..29cf9b9a9e0 100644 --- a/specs/CODE-1821/TECH.md +++ b/specs/CODE-1821/TECH.md @@ -37,7 +37,7 @@ The model subscribes to input changes and `AgentConversationsModelEvent`. Every ### Query and order eligible entries -Request personal Oz entries from `AgentConversationsModel::get_entries` and classify each through the TUI’s existing conversation-selection policy. Pass eligible entries to the same pure `query_conversation_entries` helper used by the GUI inline menu. The helper owns the 50-entry recent cap, case-insensitive fuzzy matching, score threshold, score/recency ordering, and 500-result search cap. GUI-only directory filtering and frontend-specific row construction remain outside the helper. +Request personal Warp Agent entries from `AgentConversationsModel::get_entries` and classify each through the TUI’s existing conversation-selection policy. Pass eligible entries to the same pure `query_conversation_entries` helper used by the GUI inline menu. The helper owns the 50-entry recent cap, case-insensitive fuzzy matching, score threshold, score/recency ordering, and 500-result search cap. GUI-only directory filtering and frontend-specific row construction remain outside the helper. With an empty query: @@ -143,7 +143,7 @@ sequenceDiagram participant Transcript as TuiTranscriptView User->>Menu: /conversations - Menu->>Model: query classified Oz entries + Menu->>Model: query classified Warp Agent entries Model-->>Menu: eligible normalized rows User->>Menu: search and accept Menu->>Session: stable entry ID @@ -151,7 +151,7 @@ sequenceDiagram Session->>History: load local ID or server token alt rejected, failed, or cancelled Session-->>User: retain current conversation - else valid Oz conversation + else valid Warp Agent conversation Session->>Transcript: clear previous visible content Session->>History: register target Session->>Transcript: materialize restoration plan