diff --git a/Cargo.lock b/Cargo.lock index cb2f8cfdea..d32179814a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6330,7 +6330,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -10011,7 +10011,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", diff --git a/app/src/ai/blocklist/block/status_bar.rs b/app/src/ai/blocklist/block/status_bar.rs index 0743287242..3e3205eedb 100644 --- a/app/src/ai/blocklist/block/status_bar.rs +++ b/app/src/ai/blocklist/block/status_bar.rs @@ -46,7 +46,7 @@ use crate::ai::blocklist::summarization_cancel_dialog::{ use crate::ai::blocklist::{ ai_brand_color, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent, BlocklistAIInputEvent, - BlocklistAIInputModel, ResponseStreamId, + BlocklistAIInputModel, QueuedQueryEvent, QueuedQueryModel, ResponseStreamId, }; use crate::ai::llms::LLMPreferences; use crate::ai::AgentTip; @@ -211,11 +211,12 @@ impl BlocklistAIStatusBar { } }); ctx.subscribe_to_model(&context_model, |_, _, event, ctx| { - if matches!( - event, - BlocklistAIContextEvent::PendingQueryStateUpdated - | BlocklistAIContextEvent::QueueNextPromptToggled - ) { + if matches!(event, BlocklistAIContextEvent::PendingQueryStateUpdated) { + ctx.notify(); + } + }); + ctx.subscribe_to_model(&QueuedQueryModel::handle(ctx), |_, _, event, ctx| { + if matches!(event, QueuedQueryEvent::QueueNextPromptToggled { .. }) { ctx.notify(); } }); @@ -835,10 +836,8 @@ impl BlocklistAIStatusBar { ButtonProps { button_handle: &self.state_handles.queue_next_prompt_button, keystroke: self.queue_next_prompt_keystroke.as_ref(), - is_active: self - .context_model - .as_ref(app) - .is_queue_next_prompt_enabled(), + is_active: QueuedQueryModel::as_ref(app) + .is_queue_next_prompt_enabled(conversation.id()), }, ), stop_button: Some(ButtonProps { diff --git a/app/src/ai/blocklist/context_model.rs b/app/src/ai/blocklist/context_model.rs index a894fbc254..5c2281515e 100644 --- a/app/src/ai/blocklist/context_model.rs +++ b/app/src/ai/blocklist/context_model.rs @@ -133,11 +133,6 @@ pub struct BlocklistAIContextModel { /// When `AgentViewBlockContext` is enabled, completed user commands are tracked here /// and automatically included as context with the next user query. auto_attached_agent_view_user_block_ids: Vec, - - /// When true, submitting a prompt while the agent is responding will queue it - /// instead of sending it immediately. - /// Persists across exchanges in the same conversation (like fast-forward). - queue_next_prompt_enabled: bool, } pub fn block_context_from_terminal_model( @@ -291,7 +286,6 @@ impl BlocklistAIContextModel { pending_inline_diff_hunk_attachments: Default::default(), pending_document_id: None, auto_attached_agent_view_user_block_ids: Vec::new(), - queue_next_prompt_enabled: false, } } @@ -319,7 +313,6 @@ impl BlocklistAIContextModel { pending_inline_diff_hunk_attachments: Default::default(), pending_document_id: None, auto_attached_agent_view_user_block_ids: Vec::new(), - queue_next_prompt_enabled: false, } } @@ -851,15 +844,6 @@ impl BlocklistAIContextModel { } } - pub fn is_queue_next_prompt_enabled(&self) -> bool { - self.queue_next_prompt_enabled - } - - pub fn toggle_queue_next_prompt(&mut self, ctx: &mut ModelContext) { - self.queue_next_prompt_enabled = !self.queue_next_prompt_enabled; - ctx.emit(BlocklistAIContextEvent::QueueNextPromptToggled); - } - pub fn toggle_pending_query_autoexecute(&mut self, ctx: &mut ModelContext) { // When AgentView is enabled, the autoexecution toggle should apply to the active agent view // conversation -- even when starting a new conversation, the agent view always has a conversation @@ -1016,7 +1000,6 @@ pub enum BlocklistAIContextEvent { }, /// Emitted whenever the value changes. PendingQueryStateUpdated, - QueueNextPromptToggled, } impl Entity for BlocklistAIContextModel { diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 6357f5442e..4586a8a4a9 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -1692,28 +1692,32 @@ impl BlocklistAIHistoryModel { let active_conversation_id = self .active_conversation_for_terminal_view .remove(&terminal_view_id); - if let Some(cleared_conversation_ids) = self + let mut cleared_conversation_ids: Vec = Vec::new(); + if let Some(ids) = self .live_conversation_ids_for_terminal_view .remove(&terminal_view_id) { + cleared_conversation_ids.extend(ids.iter().copied()); self.cleared_conversation_ids_for_terminal_view .entry(terminal_view_id) - .and_modify(|existing| existing.extend(cleared_conversation_ids.clone())) - .or_insert(cleared_conversation_ids); + .and_modify(|existing| existing.extend(ids.clone())) + .or_insert(ids); } - let cleared_conversation_ids = self + if let Some(ids) = self .live_conversation_ids_for_terminal_view - .remove(&terminal_view_id); - if let Some(cleared_conversation_ids) = cleared_conversation_ids { + .remove(&terminal_view_id) + { + cleared_conversation_ids.extend(ids.iter().copied()); self.cleared_conversation_ids_for_terminal_view .entry(terminal_view_id) - .and_modify(|existing| existing.extend(cleared_conversation_ids.clone())) - .or_insert(cleared_conversation_ids); + .and_modify(|existing| existing.extend(ids.clone())) + .or_insert(ids); } ctx.emit( BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { terminal_view_id, active_conversation_id, + cleared_conversation_ids, }, ); } @@ -2432,6 +2436,9 @@ pub enum BlocklistAIHistoryEvent { ClearedConversationsInTerminalView { terminal_view_id: EntityId, active_conversation_id: Option, + /// All conversation ids that were live in `terminal_view_id` before the clear. + /// Subscribers (e.g. `QueuedQueryModel`) use this to drop per-conversation state. + cleared_conversation_ids: Vec, }, UpdatedTodoList { diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 81d39c9bba..968dd77ae2 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod orchestration_event_streamer; pub(crate) mod orchestration_events; pub(crate) mod orchestration_topology; mod passive_suggestions; +pub(crate) mod queued_query; pub(super) use controller::RequestInput; pub mod history_model; pub mod inline_action; @@ -69,6 +70,10 @@ pub use permissions::{BlocklistAIPermissions, CommandExecutionPermissionAllowedR #[cfg_attr(target_family = "wasm", allow(unused))] pub(crate) use persistence::PersistedAIInputType; pub(crate) use persistence::{PersistedAIInput, SerializedBlockListItem}; +pub(crate) use queued_query::{ + AutofireAction, QueuedQuery, QueuedQueryEvent, QueuedQueryId, QueuedQueryModel, + QueuedQueryOrigin, +}; pub use suggestion_chip_view::*; pub use view_util::error_color; pub(crate) use view_util::{ diff --git a/app/src/ai/blocklist/queued_query.rs b/app/src/ai/blocklist/queued_query.rs index c40d432601..a82220f090 100644 --- a/app/src/ai/blocklist/queued_query.rs +++ b/app/src/ai/blocklist/queued_query.rs @@ -1,28 +1,57 @@ -use std::collections::VecDeque; +use std::collections::HashMap; -use warpui::{Entity, ModelContext}; +use uuid::Uuid; +use warpui::{Entity, ModelContext, SingletonEntity}; -#[derive(Clone, Copy, Debug)] +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; + +/// A globally unique identifier for a single queued prompt row. +/// Used by the queue panel to address rows across reorder, edit, and delete. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct QueuedQueryId(Uuid); + +impl QueuedQueryId { + fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +/// Where a queued prompt came from. +/// The origin is informational for telemetry; FIFO ordering and firing semantics are uniform. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QueuedQueryOrigin { + /// Filed while the initial Cloud Mode prompt waits to be handed off. InitialCloudMode, + /// Filed via the `/queue ` slash command. + QueueSlashCommand, + /// Filed via the auto-queue toggle in the warping indicator. + AutoQueueToggle, } +/// A single queued prompt. #[derive(Debug, Clone)] pub struct QueuedQuery { - query: String, + id: QueuedQueryId, + text: String, origin: QueuedQueryOrigin, } impl QueuedQuery { - pub fn initial_cloud_mode(query: String) -> Self { + pub fn new(text: String, origin: QueuedQueryOrigin) -> Self { Self { - query, - origin: QueuedQueryOrigin::InitialCloudMode, + id: QueuedQueryId::new(), + text, + origin, } } - pub fn query(&self) -> &String { - &self.query + pub fn id(&self) -> QueuedQueryId { + self.id + } + + pub fn text(&self) -> &str { + &self.text } pub fn origin(&self) -> QueuedQueryOrigin { @@ -30,38 +59,363 @@ impl QueuedQuery { } } +/// What the auto-fire drain should do with a popped row. +#[derive(Debug)] +pub enum AutofireAction { + /// Submit this prompt as a normal queued user query. + Submit { text: String }, + /// The popped row was in edit mode at the time of pop. + /// The caller places `text` (the row's last committed text) in the input box. + PopFromEditMode { text: String }, +} + +/// Per-conversation queue / edit / toggle state. +/// Lives inside [`QueuedQueryModel::queues`]; a missing key means empty queue, no edit in +/// progress, and toggle off. +#[derive(Default)] +struct ConversationQueueState { + queue: Vec, + editing: Option, + queue_next_prompt_enabled: bool, +} + +/// App-wide singleton owning the queued prompts and auto-queue toggle for every conversation, +/// indexed by [`AIConversationId`]. Queues outlive the agent-view session that originated them; +/// cleanup is driven by [`BlocklistAIHistoryModel`] lifecycle events that this model subscribes +/// to in [`QueuedQueryModel::new`]. pub struct QueuedQueryModel { - queries: VecDeque, + queues: HashMap, +} + +/// Events emitted by [`QueuedQueryModel`]. Every variant carries the `conversation_id` it applies +/// to so subscribers can filter to the conversation they care about. +#[derive(Debug, Clone)] +pub enum QueuedQueryEvent { + Appended { + conversation_id: AIConversationId, + query_id: QueuedQueryId, + }, + Removed { + conversation_id: AIConversationId, + query_id: QueuedQueryId, + }, + Reordered { + conversation_id: AIConversationId, + }, + EditEntered { + conversation_id: AIConversationId, + query_id: QueuedQueryId, + }, + EditCommitted { + conversation_id: AIConversationId, + #[allow(dead_code)] + query_id: QueuedQueryId, + }, + EditCancelled { + conversation_id: AIConversationId, + #[allow(dead_code)] + query_id: QueuedQueryId, + }, + Cleared { + conversation_id: AIConversationId, + }, + QueueNextPromptToggled { + conversation_id: AIConversationId, + }, } +impl Entity for QueuedQueryModel { + type Event = QueuedQueryEvent; +} + +impl SingletonEntity for QueuedQueryModel {} + impl QueuedQueryModel { - pub fn new() -> Self { + pub fn new(ctx: &mut ModelContext) -> Self { + // Drop queue/toggle state for any conversation that is removed, deleted, or cleared + // from its owning terminal view. Agent-view exit is intentionally NOT subscribed to: + // conversations (cloud agents in particular) outlive their visible session. + let history_handle = BlocklistAIHistoryModel::handle(ctx); + ctx.subscribe_to_model(&history_handle, |this, event, ctx| { + this.handle_history_event(event, ctx); + }); + Self { - queries: VecDeque::new(), + queues: HashMap::new(), } } - pub fn queue_query(&mut self, query: QueuedQuery, ctx: &mut ModelContext) { - self.queries.push_back(query); - ctx.emit(QueuedQueryEvent::QueuedQuery); + fn handle_history_event( + &mut self, + event: &BlocklistAIHistoryEvent, + ctx: &mut ModelContext, + ) { + match event { + BlocklistAIHistoryEvent::RemoveConversation { + conversation_id, .. + } + | BlocklistAIHistoryEvent::DeletedConversation { + conversation_id, .. + } => { + self.drop_conversation(*conversation_id, ctx); + } + BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { + cleared_conversation_ids, + .. + } => { + for conversation_id in cleared_conversation_ids.clone() { + self.drop_conversation(conversation_id, ctx); + } + } + _ => {} + } } - pub fn pop_query(&mut self, ctx: &mut ModelContext) -> Option { - let query = self.queries.pop_front(); - ctx.emit(QueuedQueryEvent::UnqueuedQuery); - query + fn drop_conversation( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + if self.queues.remove(&conversation_id).is_some() { + ctx.emit(QueuedQueryEvent::Cleared { conversation_id }); + } } - pub fn has_queries(&self) -> bool { - !self.queries.is_empty() + /// Returns the queue for `conversation_id`. Returns an empty slice when no entry exists. + pub fn queue(&self, conversation_id: AIConversationId) -> &[QueuedQuery] { + self.queues + .get(&conversation_id) + .map(|state| state.queue.as_slice()) + .unwrap_or(&[]) } -} -pub enum QueuedQueryEvent { - QueuedQuery, - UnqueuedQuery, -} + /// Returns true when `conversation_id` has at least one queued prompt. + pub fn has_queue(&self, conversation_id: AIConversationId) -> bool { + self.queues + .get(&conversation_id) + .is_some_and(|state| !state.queue.is_empty()) + } -impl Entity for QueuedQueryModel { - type Event = QueuedQueryEvent; + /// Returns the row currently in edit mode for `conversation_id`, if any. + pub fn editing_row(&self, conversation_id: AIConversationId) -> Option { + self.queues + .get(&conversation_id) + .and_then(|state| state.editing) + } + + /// Returns true when the head row of `conversation_id`'s queue is currently being edited. + pub fn first_row_is_in_edit_mode(&self, conversation_id: AIConversationId) -> bool { + let Some(state) = self.queues.get(&conversation_id) else { + return false; + }; + let Some(editing_id) = state.editing else { + return false; + }; + state.queue.first().is_some_and(|q| q.id == editing_id) + } + + /// Returns the per-conversation auto-queue toggle state. Defaults to false for conversations + /// that have never been touched. + pub fn is_queue_next_prompt_enabled(&self, conversation_id: AIConversationId) -> bool { + self.queues + .get(&conversation_id) + .is_some_and(|state| state.queue_next_prompt_enabled) + } + + /// Toggles the per-conversation auto-queue state. + pub fn toggle_queue_next_prompt( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + let state = self.queues.entry(conversation_id).or_default(); + state.queue_next_prompt_enabled = !state.queue_next_prompt_enabled; + ctx.emit(QueuedQueryEvent::QueueNextPromptToggled { conversation_id }); + } + + /// Appends `query` to the tail of `conversation_id`'s queue. + pub fn append( + &mut self, + conversation_id: AIConversationId, + query: QueuedQuery, + ctx: &mut ModelContext, + ) -> QueuedQueryId { + let query_id = query.id; + let state = self.queues.entry(conversation_id).or_default(); + state.queue.push(query); + ctx.emit(QueuedQueryEvent::Appended { + conversation_id, + query_id, + }); + query_id + } + + /// Pops the first row in `conversation_id`'s queue and returns it. Used by the error/cancel + /// drain path where the caller restores the popped text to the input editor. + pub fn pop_front( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) -> Option { + let state = self.queues.get_mut(&conversation_id)?; + if state.queue.is_empty() { + return None; + } + let popped = state.queue.remove(0); + if state.editing == Some(popped.id) { + state.editing = None; + } + ctx.emit(QueuedQueryEvent::Removed { + conversation_id, + query_id: popped.id, + }); + Some(popped) + } + + /// Auto-fire drain entry point for `conversation_id`. Pops the first row and tells the caller + /// whether to submit it normally or treat it as a popped edit-mode row (per the spec, the + /// row's last-committed text is restored to the input box). + pub fn pop_for_autofire( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) -> Option { + let state = self.queues.get_mut(&conversation_id)?; + let first = state.queue.first()?; + let first_in_edit_mode = state.editing == Some(first.id); + let popped = state.queue.remove(0); + if first_in_edit_mode { + state.editing = None; + } + ctx.emit(QueuedQueryEvent::Removed { + conversation_id, + query_id: popped.id, + }); + + Some(if first_in_edit_mode { + AutofireAction::PopFromEditMode { text: popped.text } + } else { + AutofireAction::Submit { text: popped.text } + }) + } + + /// Removes a specific row by id within `conversation_id`'s queue, if present. + pub fn remove_by_id( + &mut self, + conversation_id: AIConversationId, + query_id: QueuedQueryId, + ctx: &mut ModelContext, + ) -> Option { + let state = self.queues.get_mut(&conversation_id)?; + let idx = state.queue.iter().position(|q| q.id == query_id)?; + let removed = state.queue.remove(idx); + if state.editing == Some(query_id) { + state.editing = None; + } + ctx.emit(QueuedQueryEvent::Removed { + conversation_id, + query_id, + }); + Some(removed) + } + + /// Moves the row identified by `source_id` to position `target_index` within + /// `conversation_id`'s queue. `target_index` is interpreted as the index in the post-removal + /// list and is clamped to the queue length. + pub fn reorder( + &mut self, + conversation_id: AIConversationId, + source_id: QueuedQueryId, + target_index: usize, + ctx: &mut ModelContext, + ) { + let Some(state) = self.queues.get_mut(&conversation_id) else { + return; + }; + let Some(source_idx) = state.queue.iter().position(|q| q.id == source_id) else { + return; + }; + let row = state.queue.remove(source_idx); + let clamped = target_index.min(state.queue.len()); + state.queue.insert(clamped, row); + ctx.emit(QueuedQueryEvent::Reordered { conversation_id }); + } + + /// Enters edit mode for `query_id` in `conversation_id`'s queue. If another row was being + /// edited, that edit is cancelled (its text is unchanged, per the spec). + pub fn enter_edit_mode( + &mut self, + conversation_id: AIConversationId, + query_id: QueuedQueryId, + ctx: &mut ModelContext, + ) { + let Some(state) = self.queues.get_mut(&conversation_id) else { + return; + }; + if !state.queue.iter().any(|q| q.id == query_id) { + return; + } + let prev_edit = state.editing.replace(query_id); + if let Some(prev) = prev_edit { + if prev != query_id { + ctx.emit(QueuedQueryEvent::EditCancelled { + conversation_id, + query_id: prev, + }); + } + } + ctx.emit(QueuedQueryEvent::EditEntered { + conversation_id, + query_id, + }); + } + + /// Commits the in-progress edit in `conversation_id` by replacing the row's text with + /// `new_text` and clearing edit state. An empty `new_text` cancels the edit and leaves the + /// original row text untouched. + pub fn commit_edit( + &mut self, + conversation_id: AIConversationId, + new_text: String, + ctx: &mut ModelContext, + ) { + let Some(state) = self.queues.get_mut(&conversation_id) else { + return; + }; + let Some(query_id) = state.editing.take() else { + return; + }; + if new_text.is_empty() { + ctx.emit(QueuedQueryEvent::EditCancelled { + conversation_id, + query_id, + }); + return; + } + if let Some(row) = state.queue.iter_mut().find(|q| q.id == query_id) { + row.text = new_text; + } + ctx.emit(QueuedQueryEvent::EditCommitted { + conversation_id, + query_id, + }); + } + + /// Cancels the in-progress edit in `conversation_id` without modifying the row's text. + pub fn cancel_edit(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext) { + let Some(state) = self.queues.get_mut(&conversation_id) else { + return; + }; + let Some(query_id) = state.editing.take() else { + return; + }; + ctx.emit(QueuedQueryEvent::EditCancelled { + conversation_id, + query_id, + }); + } } + +#[cfg(test)] +#[path = "queued_query_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/queued_query_tests.rs b/app/src/ai/blocklist/queued_query_tests.rs new file mode 100644 index 0000000000..474e4d1113 --- /dev/null +++ b/app/src/ai/blocklist/queued_query_tests.rs @@ -0,0 +1,464 @@ +//! Unit tests for [`super::QueuedQueryModel`]. +//! +//! Covers FIFO ordering, append from each origin, edit semantics, reorder semantics, the +//! per-conversation auto-queue toggle, and history-driven cleanup. +use std::cell::RefCell; +use std::rc::Rc; + +use warpui::{App, SingletonEntity}; + +use super::{ + AutofireAction, QueuedQuery, QueuedQueryEvent, QueuedQueryId, QueuedQueryModel, + QueuedQueryOrigin, +}; +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::blocklist::BlocklistAIHistoryModel; +use crate::test_util::settings::initialize_history_persistence_for_tests; + +/// Helper to drive the singleton `QueuedQueryModel` (plus its required `BlocklistAIHistoryModel` +/// singleton) inside a test app and capture emitted events. +fn with_model(test: F) +where + F: FnOnce(App, warpui::ModelHandle, Rc>>) + + 'static, +{ + App::test((), |mut app| async move { + // Initializes settings (incl. `PrivatePreferences`) and registers + // `GlobalResourceHandlesProvider`. The provider is required because + // `BlocklistAIHistoryModel::delete_conversation` reads the global + // model-event sender to enqueue a sqlite delete. + initialize_history_persistence_for_tests(&mut app); + app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + let model = app.add_singleton_model(QueuedQueryModel::new); + let events: Rc>> = Rc::new(RefCell::new(Vec::new())); + let events_clone = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_model(&model, move |_, event: &QueuedQueryEvent, _| { + events_clone.borrow_mut().push(event.clone()); + }); + }); + test(app, model, events); + }); +} + +fn user_query(text: &str) -> QueuedQuery { + QueuedQuery::new(text.to_owned(), QueuedQueryOrigin::QueueSlashCommand) +} + +fn append_user( + model: &warpui::ModelHandle, + app: &mut App, + conversation_id: AIConversationId, + text: &str, +) -> QueuedQueryId { + model.update(app, |model, ctx| { + model.append(conversation_id, user_query(text), ctx) + }) +} + +#[test] +fn append_preserves_fifo_order() { + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + let id_b = append_user(&model, &mut app, conv, "second"); + let id_c = append_user(&model, &mut app, conv, "third"); + + model.read(&app, |model, _| { + let queue = model.queue(conv); + assert_eq!(queue.len(), 3); + assert_eq!(queue[0].id(), id_a); + assert_eq!(queue[0].text(), "first"); + assert_eq!(queue[1].id(), id_b); + assert_eq!(queue[1].text(), "second"); + assert_eq!(queue[2].id(), id_c); + assert_eq!(queue[2].text(), "third"); + }); + }); +} + +#[test] +fn append_from_each_user_origin_lands_in_the_queue() { + // /queue and the auto-queue toggle both land in the queue. + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let origins = [ + QueuedQueryOrigin::QueueSlashCommand, + QueuedQueryOrigin::AutoQueueToggle, + ]; + for (i, origin) in origins.iter().enumerate() { + let text = format!("p{i}"); + model.update(&mut app, |m, ctx| { + m.append(conv, QueuedQuery::new(text, *origin), ctx) + }); + } + model.read(&app, |model, _| { + let queue = model.queue(conv); + assert_eq!(queue.len(), 2); + for (i, origin) in origins.iter().enumerate() { + assert_eq!(queue[i].origin(), *origin); + } + }); + }); +} + +#[test] +fn queue_next_prompt_toggle_defaults_false_and_emits_event() { + with_model(|mut app, model, events| { + let conv = AIConversationId::new(); + model.read(&app, |model, _| { + assert!(!model.is_queue_next_prompt_enabled(conv)); + }); + + model.update(&mut app, |model, ctx| { + model.toggle_queue_next_prompt(conv, ctx); + }); + + model.read(&app, |model, _| { + assert!(model.is_queue_next_prompt_enabled(conv)); + }); + + let evts = events.borrow(); + assert!(matches!( + evts.as_slice(), + [QueuedQueryEvent::QueueNextPromptToggled { conversation_id }] if *conversation_id == conv + )); + }); +} + +#[test] +fn toggle_state_is_isolated_per_conversation() { + // Toggling for conversation A must not affect conversation B's toggle state. + with_model(|mut app, model, _events| { + let conv_a = AIConversationId::new(); + let conv_b = AIConversationId::new(); + + model.update(&mut app, |m, ctx| m.toggle_queue_next_prompt(conv_a, ctx)); + model.read(&app, |m, _| { + assert!(m.is_queue_next_prompt_enabled(conv_a)); + assert!(!m.is_queue_next_prompt_enabled(conv_b)); + }); + }); +} + +#[test] +fn append_state_is_isolated_per_conversation() { + // Appending to one conversation's queue must not show up in another's. + with_model(|mut app, model, _events| { + let conv_a = AIConversationId::new(); + let conv_b = AIConversationId::new(); + + append_user(&model, &mut app, conv_a, "a-first"); + append_user(&model, &mut app, conv_b, "b-first"); + append_user(&model, &mut app, conv_a, "a-second"); + + model.read(&app, |m, _| { + let a = m.queue(conv_a); + assert_eq!(a.len(), 2); + assert_eq!(a[0].text(), "a-first"); + assert_eq!(a[1].text(), "a-second"); + let b = m.queue(conv_b); + assert_eq!(b.len(), 1); + assert_eq!(b[0].text(), "b-first"); + }); + }); +} + +#[test] +fn pop_front_removes_head_and_emits_removed() { + with_model(|mut app, model, events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + let _id_b = append_user(&model, &mut app, conv, "second"); + events.borrow_mut().clear(); + + let popped = model.update(&mut app, |m, ctx| m.pop_front(conv, ctx)); + let popped = popped.expect("queue had a head"); + assert_eq!(popped.id(), id_a); + assert_eq!(popped.text(), "first"); + + model.read(&app, |model, _| { + assert_eq!(model.queue(conv).len(), 1); + }); + + let evts = events.borrow(); + assert!(matches!( + evts.as_slice(), + [QueuedQueryEvent::Removed { conversation_id, query_id }] + if *conversation_id == conv && *query_id == id_a + )); + }); +} + +#[test] +fn pop_for_autofire_returns_submit_for_user_managed_head() { + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + append_user(&model, &mut app, conv, "first"); + append_user(&model, &mut app, conv, "second"); + + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + match action { + Some(AutofireAction::Submit { text }) => assert_eq!(text, "first"), + other => panic!("expected Submit, got {other:?}"), + } + + model.read(&app, |model, _| { + assert_eq!(model.queue(conv).len(), 1); + }); + }); +} + +#[test] +fn pop_for_autofire_returns_last_committed_text_when_first_row_is_in_edit_mode() { + // Per spec: even when the first row is in edit mode, auto-fire's PopFromEditMode action + // carries the row's last-committed text, not any uncommitted live-editor buffer text. + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + append_user(&model, &mut app, conv, "second"); + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx)); + + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + match action { + Some(AutofireAction::PopFromEditMode { text }) => assert_eq!(text, "first"), + other => panic!("expected PopFromEditMode, got {other:?}"), + } + model.read(&app, |model, _| { + assert_eq!(model.editing_row(conv), None); + }); + }); +} + +#[test] +fn first_row_is_in_edit_mode_only_when_the_head_row_is_being_edited() { + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + let id_b = append_user(&model, &mut app, conv, "second"); + + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_b, ctx)); + model.read(&app, |m, _| { + assert!(!m.first_row_is_in_edit_mode(conv)); + }); + + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx)); + model.read(&app, |m, _| { + assert!(m.first_row_is_in_edit_mode(conv)); + }); + }); +} + +#[test] +fn enter_edit_mode_locks_to_one_row_at_a_time() { + // Entering edit mode on one row cancels the prior edit state. + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + let id_b = append_user(&model, &mut app, conv, "second"); + + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx)); + model.read(&app, |m, _| assert_eq!(m.editing_row(conv), Some(id_a))); + + // Entering edit mode on a different row replaces the prior edit. + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_b, ctx)); + model.read(&app, |m, _| assert_eq!(m.editing_row(conv), Some(id_b))); + }); +} + +#[test] +fn commit_edit_with_text_replaces_row_and_clears_edit_state() { + // Non-empty edits replace the queued row's text. + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx)); + + model.update(&mut app, |m, ctx| { + m.commit_edit(conv, "first updated".to_owned(), ctx) + }); + + model.read(&app, |m, _| { + let queue = m.queue(conv); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].id(), id_a); + assert_eq!(queue[0].text(), "first updated"); + assert_eq!(m.editing_row(conv), None); + }); + }); +} + +#[test] +fn commit_edit_with_empty_text_restores_original_text() { + // Empty edits restore the original text. + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + append_user(&model, &mut app, conv, "second"); + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx)); + + model.update(&mut app, |m, ctx| m.commit_edit(conv, String::new(), ctx)); + + model.read(&app, |m, _| { + let queue = m.queue(conv); + assert_eq!(queue.len(), 2); + assert_eq!(queue[0].id(), id_a); + assert_eq!(queue[0].text(), "first"); + assert_eq!(queue[1].text(), "second"); + assert_eq!(m.editing_row(conv), None); + }); + }); +} + +#[test] +fn cancel_edit_leaves_row_unchanged_and_clears_edit_state() { + // Canceling an edit leaves the row unchanged. + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx)); + + model.update(&mut app, |m, ctx| m.cancel_edit(conv, ctx)); + + model.read(&app, |m, _| { + let queue = m.queue(conv); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].text(), "first"); + assert_eq!(m.editing_row(conv), None); + }); + }); +} + +#[test] +fn remove_by_id_removes_only_the_targeted_row() { + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "first"); + let _id_b = append_user(&model, &mut app, conv, "second"); + let _id_c = append_user(&model, &mut app, conv, "third"); + + let removed = model.update(&mut app, |m, ctx| m.remove_by_id(conv, id_a, ctx)); + assert_eq!( + removed.map(|r| r.text().to_owned()), + Some("first".to_owned()) + ); + model.read(&app, |m, _| { + let queue = m.queue(conv); + assert_eq!(queue.len(), 2); + assert_eq!(queue[0].text(), "second"); + assert_eq!(queue[1].text(), "third"); + }); + }); +} + +#[test] +fn reorder_moves_user_managed_rows_to_target_index() { + // Reordering moves user-managed rows to the requested target index. + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "a"); + let id_b = append_user(&model, &mut app, conv, "b"); + let id_c = append_user(&model, &mut app, conv, "c"); + + // Move a (index 0) to the end (post-removal index 2). + model.update(&mut app, |m, ctx| m.reorder(conv, id_a, 2, ctx)); + + model.read(&app, |m, _| { + let queue = m.queue(conv); + assert_eq!(queue[0].id(), id_b); + assert_eq!(queue[1].id(), id_c); + assert_eq!(queue[2].id(), id_a); + }); + }); +} + +#[test] +fn reorder_preserves_every_row_when_moving_last_to_front() { + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "a"); + let id_b = append_user(&model, &mut app, conv, "b"); + let id_c = append_user(&model, &mut app, conv, "c"); + let id_d = append_user(&model, &mut app, conv, "d"); + + model.update(&mut app, |m, ctx| m.reorder(conv, id_d, 0, ctx)); + + model.read(&app, |m, _| { + let ids: Vec<_> = m.queue(conv).iter().map(|q| q.id()).collect(); + assert_eq!(ids, vec![id_d, id_a, id_b, id_c]); + }); + }); +} + +#[test] +fn reorder_clamps_target_index_to_queue_len() { + with_model(|mut app, model, _events| { + let conv = AIConversationId::new(); + let id_a = append_user(&model, &mut app, conv, "a"); + let id_b = append_user(&model, &mut app, conv, "b"); + + // Target index >= len after removal should clamp to the end. + model.update(&mut app, |m, ctx| m.reorder(conv, id_a, 99, ctx)); + model.read(&app, |m, _| { + let queue = m.queue(conv); + assert_eq!(queue[0].id(), id_b); + assert_eq!(queue[1].id(), id_a); + }); + }); +} + +#[test] +fn delete_conversation_drops_only_that_conversation_state() { + // Removing one conversation from history should drop its queue + toggle but leave others. + with_model(|mut app, model, _events| { + let history = BlocklistAIHistoryModel::handle(&app); + let terminal_view_id = warpui::EntityId::new(); + let conv_a = history.update(&mut app, |h, ctx| { + h.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let conv_b = history.update(&mut app, |h, ctx| { + h.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + append_user(&model, &mut app, conv_a, "a1"); + append_user(&model, &mut app, conv_b, "b1"); + model.update(&mut app, |m, ctx| m.toggle_queue_next_prompt(conv_a, ctx)); + + history.update(&mut app, |h, ctx| { + h.delete_conversation(conv_a, Some(terminal_view_id), ctx); + }); + + model.read(&app, |m, _| { + assert!(!m.has_queue(conv_a)); + assert!(!m.is_queue_next_prompt_enabled(conv_a)); + let b = m.queue(conv_b); + assert_eq!(b.len(), 1); + assert_eq!(b[0].text(), "b1"); + }); + }); +} + +#[test] +fn clear_conversations_in_terminal_view_drops_every_listed_conversation() { + // ClearedConversationsInTerminalView with multiple ids must drop each listed conversation's queue. + with_model(|mut app, model, _events| { + let history = BlocklistAIHistoryModel::handle(&app); + let terminal_view_id = warpui::EntityId::new(); + let conv_a = history.update(&mut app, |h, ctx| { + h.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let conv_b = history.update(&mut app, |h, ctx| { + h.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + append_user(&model, &mut app, conv_a, "a1"); + append_user(&model, &mut app, conv_b, "b1"); + + history.update(&mut app, |h, ctx| { + h.clear_conversations_in_terminal_view(terminal_view_id, ctx) + }); + + model.read(&app, |m, _| { + assert!(!m.has_queue(conv_a)); + assert!(!m.has_queue(conv_b)); + }); + }); +} diff --git a/app/src/lib.rs b/app/src/lib.rs index aed9415b7a..03cc18930c 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1711,6 +1711,9 @@ pub(crate) fn initialize_app( let conversations = &multi_agent_conversations; ctx.add_singleton_model(move |_| BlocklistAIHistoryModel::new(ai_queries, conversations)); } + // Per-conversation queued prompts. Registered after the history model + // since it subscribes to history events for cleanup. + ctx.add_singleton_model(ai::blocklist::QueuedQueryModel::new); // Cross-pane UI state for the orchestration pill bar. Registered // after the history model since it subscribes to history events. ctx.add_singleton_model(move |ctx| { diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 0552eaee0b..089d6b19c4 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -41,7 +41,7 @@ use crate::ai::blocklist::history_model::CloudConversationData; use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; use crate::ai::blocklist::orchestration_events::OrchestrationEventService; -use crate::ai::blocklist::BlocklistAIHistoryModel; +use crate::ai::blocklist::{BlocklistAIHistoryModel, QueuedQueryModel}; use crate::ai::document::ai_document_model::AIDocumentModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::harness_availability::HarnessAvailabilityModel; @@ -146,6 +146,9 @@ fn initialize_app(app: &mut App) { app.add_singleton_model(NotebookKeybindings::new); app.add_singleton_model(TerminalKeybindings::new); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + // QueuedQueryModel subscribes to history events; register after the + // history model is in place. + app.add_singleton_model(QueuedQueryModel::new); // Pill bar model subscribes to history events; register after the // history model is in place. app.add_singleton_model(|ctx| { diff --git a/app/src/server/telemetry/events.rs b/app/src/server/telemetry/events.rs index c30afbb7ae..9a6cc42735 100644 --- a/app/src/server/telemetry/events.rs +++ b/app/src/server/telemetry/events.rs @@ -27,7 +27,7 @@ use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; use crate::ai::blocklist::{ AIBlockResponseRating, CommandExecutionPermissionAllowedReason, InputType, - InputTypeAutoDetectionSource, + InputTypeAutoDetectionSource, QueuedQueryOrigin, }; use crate::ai::execution_profiles::AskUserQuestionPermission; use crate::ai::mcp::TemplateVariable; @@ -1184,6 +1184,26 @@ pub enum LoginEventSource { AuthModal, } +/// Origin of a queued prompt, mirrored for telemetry so we don't pull serde derives onto the +/// canonical `QueuedQueryOrigin` enum (which doesn't otherwise need them). +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TelemetryQueuedQueryOrigin { + InitialCloudMode, + QueueSlashCommand, + AutoQueueToggle, +} + +impl From for TelemetryQueuedQueryOrigin { + fn from(origin: QueuedQueryOrigin) -> Self { + match origin { + QueuedQueryOrigin::InitialCloudMode => Self::InitialCloudMode, + QueuedQueryOrigin::QueueSlashCommand => Self::QueueSlashCommand, + QueuedQueryOrigin::AutoQueueToggle => Self::AutoQueueToggle, + } + } +} + /// Details about which type of slash command was accepted #[derive(Clone, Debug, Serialize)] pub enum SlashCommandAcceptedDetails { @@ -2919,6 +2939,25 @@ pub enum TelemetryEvent { remote_os: Option, remote_arch: Option, }, + /// Emitted when the user commits a non-empty edit to a queued prompt row. + QueuedPromptEdited { + origin: TelemetryQueuedQueryOrigin, + }, + /// Emitted when the user deletes a queued prompt row via the trash button or the + /// commit-empty edit shortcut. + QueuedPromptDeleted { + origin: TelemetryQueuedQueryOrigin, + }, + /// Emitted when the user reorders a queued prompt row via drag-and-drop. + QueuedPromptReordered { + origin: TelemetryQueuedQueryOrigin, + from_index: usize, + to_index: usize, + }, + /// Emitted when the user toggles the queued prompts panel collapse state. + QueuedPromptPanelCollapseToggled { + collapsed: bool, + }, } impl TelemetryEventTrait for TelemetryEvent { @@ -4741,6 +4780,24 @@ impl TelemetryEvent { | TelemetryEvent::OpenAuthPrivacySettings { source } => Some(json!({ "source": source, })), + TelemetryEvent::QueuedPromptEdited { origin } => Some(json!({ + "origin": origin, + })), + TelemetryEvent::QueuedPromptDeleted { origin } => Some(json!({ + "origin": origin, + })), + TelemetryEvent::QueuedPromptReordered { + origin, + from_index, + to_index, + } => Some(json!({ + "origin": origin, + "from_index": from_index, + "to_index": to_index, + })), + TelemetryEvent::QueuedPromptPanelCollapseToggled { collapsed } => Some(json!({ + "collapsed": collapsed, + })), } } @@ -5165,6 +5222,10 @@ impl TelemetryEvent { | TelemetryEvent::OutOfCreditsBannerClosed { .. } | TelemetryEvent::AutoReloadModalClosed { .. } | TelemetryEvent::AutoReloadToggledFromBillingSettings { .. } + | TelemetryEvent::QueuedPromptEdited { .. } + | TelemetryEvent::QueuedPromptDeleted { .. } + | TelemetryEvent::QueuedPromptReordered { .. } + | TelemetryEvent::QueuedPromptPanelCollapseToggled { .. } | TelemetryEvent::CLISubagentControlStateChanged { .. } | TelemetryEvent::CLISubagentResponsesToggled { .. } | TelemetryEvent::CLISubagentInputDismissed { .. } @@ -5791,6 +5852,12 @@ impl TelemetryEventDesc for TelemetryEventDiscriminants { | Self::RemoteCodebaseAutoIndexRequested => { EnablementState::Flag(FeatureFlag::SshRemoteServer) } + Self::QueuedPromptEdited + | Self::QueuedPromptDeleted + | Self::QueuedPromptReordered + | Self::QueuedPromptPanelCollapseToggled => { + EnablementState::Flag(FeatureFlag::QueueSlashCommand) + } } } @@ -6203,6 +6270,10 @@ impl TelemetryEventDesc for TelemetryEventDiscriminants { Self::RemoteServerReconnectExhausted => "RemoteServer.ReconnectExhausted", Self::RemoteCodebaseIndexStatusChanged => "RemoteCodebaseIndex.StatusChanged", Self::RemoteCodebaseAutoIndexRequested => "RemoteCodebaseIndex.AutoIndexRequested", + Self::QueuedPromptEdited => "QueuedPrompt.Edited", + Self::QueuedPromptDeleted => "QueuedPrompt.Deleted", + Self::QueuedPromptReordered => "QueuedPrompt.Reordered", + Self::QueuedPromptPanelCollapseToggled => "QueuedPrompt.PanelCollapseToggled", #[cfg(windows)] Self::WSLRegistryError => "WSL Distribution Registry Error", #[cfg(windows)] @@ -7268,6 +7339,16 @@ impl TelemetryEventDesc for TelemetryEventDiscriminants { Self::RemoteCodebaseAutoIndexRequested => { "Remote codebase auto-indexing requested one or more repositories" } + Self::QueuedPromptEdited => { + "User committed a non-empty edit to a queued prompt row" + } + Self::QueuedPromptDeleted => "User deleted a queued prompt row", + Self::QueuedPromptReordered => { + "User reordered a queued prompt row via drag-and-drop" + } + Self::QueuedPromptPanelCollapseToggled => { + "User toggled the queued prompts panel collapse state" + } } } } diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index 1c9c68d0d6..9ef98a3cc7 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -1648,9 +1648,13 @@ impl BlockListElement { return true; } - if let Some(RichContentMetadata::AIBlock { .. }) = - self.rich_content_metadata.get(view_id) - { + if matches!( + self.rich_content_metadata.get(view_id), + Some( + RichContentMetadata::AIBlock(_) + | RichContentMetadata::PendingUserQuery { .. } + ) + ) { should_redetermine_focus = false; } diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 30d46b1032..f8790b0e0f 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -134,6 +134,7 @@ use super::view::inline_banner::{ PromptSuggestionBannerState, ZeroStatePromptSuggestionTriggeredFrom, ZeroStatePromptSuggestionType, }; +use super::view::queued_prompts_panel::{QueuedPromptsPanelEvent, QueuedPromptsPanelView}; use super::view::{ ExecuteCommandEvent, SyncInputType, TerminalAction, PADDING_LEFT as TERMINAL_VIEW_PADDING_LEFT, }; @@ -171,8 +172,9 @@ use crate::ai::blocklist::{ AttachmentType, BlocklistAIActionModel, BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputEvent, BlocklistAIInputModel, InputConfig, InputType, - InputTypeAutoDetectionSource, SlashCommandRequest, BLOCK_CONTEXT_ATTACHMENT_REGEX, - DIFF_HUNK_ATTACHMENT_REGEX, DRIVE_OBJECT_ATTACHMENT_REGEX, + InputTypeAutoDetectionSource, QueuedQuery, QueuedQueryModel, QueuedQueryOrigin, + SlashCommandRequest, BLOCK_CONTEXT_ATTACHMENT_REGEX, DIFF_HUNK_ATTACHMENT_REGEX, + DRIVE_OBJECT_ATTACHMENT_REGEX, }; use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; @@ -1673,6 +1675,9 @@ pub struct Input { buy_credits_banner: ViewHandle, agent_status_view: ViewHandle, + /// Optional queued-prompts panel rendered between `agent_status_view` and the input editor. + /// Constructed in [`Input::new`] when [`FeatureFlag::QueueSlashCommand`] is enabled. + queued_prompts_panel: Option>, agent_view_controller: ModelHandle, agent_shortcut_view_model: ModelHandle, ambient_agent_view_state: Option, @@ -3169,7 +3174,6 @@ impl Input { }) .collect_vec(); } - BlocklistAIContextEvent::QueueNextPromptToggled => {} } ctx.notify(); }); @@ -3490,6 +3494,15 @@ impl Input { ) }); + let queued_prompts_panel = FeatureFlag::QueueSlashCommand.is_enabled().then(|| { + let panel = + ctx.add_typed_action_view(|ctx| QueuedPromptsPanelView::new(terminal_view_id, ctx)); + ctx.subscribe_to_view(&panel, |me, _, event, ctx| { + me.handle_queued_prompts_panel_event(event, ctx); + }); + panel + }); + let deferred_remote_operations = DeferredRemoteOperations::new(model.lock().block_list().active_block_id().clone()); @@ -3579,6 +3592,7 @@ impl Input { weak_view_handle: ctx.handle(), buy_credits_banner, agent_status_view, + queued_prompts_panel, agent_view_controller, agent_input_footer, agent_shortcut_view_model, @@ -3651,6 +3665,26 @@ impl Input { &self.agent_status_view } + /// Handles events from the queued-prompts panel: places deleted-row text into an empty editor, + /// and refocuses the input editor when an inline edit finishes. + fn handle_queued_prompts_panel_event( + &mut self, + event: &QueuedPromptsPanelEvent, + ctx: &mut ViewContext, + ) { + match event { + QueuedPromptsPanelEvent::RowDeleted { text } => { + if self.buffer_text(ctx).is_empty() { + self.replace_buffer_content(text, ctx); + } + self.focus_input_box(ctx); + } + QueuedPromptsPanelEvent::EditEnded => { + self.focus_input_box(ctx); + } + } + } + pub fn agent_input_footer(&self) -> &ViewHandle { &self.agent_input_footer } @@ -13194,14 +13228,6 @@ impl Input { return false; } - if !self - .ai_context_model - .as_ref(ctx) - .is_queue_next_prompt_enabled() - { - return false; - } - if !self.ai_input_model.as_ref(ctx).is_ai_input_enabled() { return false; } @@ -13214,9 +13240,11 @@ impl Input { return false; }; - let history = BlocklistAIHistoryModel::handle(ctx); - let should_queue = history - .as_ref(ctx) + if !QueuedQueryModel::as_ref(ctx).is_queue_next_prompt_enabled(conversation_id) { + return false; + } + + let should_queue = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&conversation_id) .is_some_and(|c| { !c.is_empty() && (c.status().is_in_progress() || c.status().is_blocked()) @@ -13259,7 +13287,14 @@ impl Input { self.editor.update(ctx, |editor, ctx| { editor.clear_buffer(ctx); }); - ctx.dispatch_typed_action(&WorkspaceAction::QueuePromptForConversation { prompt }); + + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.append( + conversation_id, + QueuedQuery::new(prompt, QueuedQueryOrigin::AutoQueueToggle), + ctx, + ); + }); true } diff --git a/app/src/terminal/input/agent.rs b/app/src/terminal/input/agent.rs index f6f233a664..498816eabc 100644 --- a/app/src/terminal/input/agent.rs +++ b/app/src/terminal/input/agent.rs @@ -320,7 +320,13 @@ impl Input { app, )); } - column.add_children([ChildView::new(&self.agent_status_view).finish(), input]); + column.add_child(ChildView::new(&self.agent_status_view).finish()); + if let Some(panel) = self.queued_prompts_panel.as_ref() { + if panel.as_ref(app).should_render(app) { + column.add_child(ChildView::new(panel).finish()); + } + } + column.add_child(input); let mut outer_stack = Stack::new().with_constrain_absolute_children(); outer_stack.add_child(column.finish()); diff --git a/app/src/terminal/input/slash_commands/mod.rs b/app/src/terminal/input/slash_commands/mod.rs index 684c088b8b..1746388677 100644 --- a/app/src/terminal/input/slash_commands/mod.rs +++ b/app/src/terminal/input/slash_commands/mod.rs @@ -35,7 +35,8 @@ use crate::ai::blocklist::agent_view::{ #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::ai::blocklist::handoff::PendingCloudLaunch; use crate::ai::blocklist::{ - BlocklistAIHistoryModel, InputTypeAutoDetectionSource, SlashCommandRequest, + BlocklistAIHistoryModel, InputTypeAutoDetectionSource, QueuedQuery, QueuedQueryModel, + QueuedQueryOrigin, SlashCommandRequest, }; use crate::cloud_object::model::persistence::CloudModel; use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; @@ -1068,8 +1069,12 @@ impl Input { .is_some_and(|c| c.status().is_in_progress() || c.status().is_blocked()); if is_in_progress { - ctx.dispatch_typed_action(&WorkspaceAction::QueuePromptForConversation { - prompt, + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.append( + conversation_id, + QueuedQuery::new(prompt, QueuedQueryOrigin::QueueSlashCommand), + ctx, + ); }); } else { self.submit_queued_prompt(prompt, ctx); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index edcaa27b15..c5ef9f91b7 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -132,6 +132,9 @@ pub fn initialize_app(app: &mut App) { AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx) }); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + // QueuedQueryModel subscribes to history events; register after the + // history model is in place. + app.add_singleton_model(crate::ai::blocklist::QueuedQueryModel::new); // Pill bar model subscribes to history events; register after the // history model is in place. app.add_singleton_model(|ctx| { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 717925b047..80913447d3 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -9,6 +9,10 @@ mod context_menu; pub mod init; pub mod inline_banner; pub mod load_ai_conversation; +pub(crate) mod queued_prompts_panel; +#[cfg(test)] +#[path = "view/queued_prompts_tests.rs"] +mod queued_prompts_tests; use ai::agent::action::InsertReviewComment; pub use load_ai_conversation::ConversationRestorationInNewPaneType; // TODO(advait): if we align on prompt suggestions banner in Input, move code out of inline_banner mod. @@ -259,16 +263,18 @@ use crate::ai::blocklist::usage::conversation_usage_view::{ use crate::ai::blocklist::{ ai_brand_color, block_context_from_terminal_model, get_ai_block_overflow_menu_element_position_id, get_attached_blocks_chip_element_position_id, - AIBlock, AIBlockEvent, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent, - BlocklistAIContextModel, BlocklistAIController, BlocklistAIControllerEvent, - BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputEvent, BlocklistAIInputModel, - ClientIdentifiers, ConversationStatusUpdate, InputConfig, InputType, - InputTypeAutoDetectionSource, LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel, - MaaPassiveSuggestionsEvent, MaaPassiveSuggestionsModel, PassiveSuggestionsModels, - PendingAttachment, PendingQueryState, RequestFileEditsFormatKind, ShellCommandExecutor, - ShellCommandExecutorEvent, SlashCommandRequest, StartAgentExecutor, StartAgentExecutorEvent, - StartAgentRequest, ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX, + AIBlock, AIBlockEvent, AutofireAction, BlocklistAIActionEvent, BlocklistAIActionModel, + BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIController, + BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, + BlocklistAIInputEvent, BlocklistAIInputModel, ClientIdentifiers, ConversationStatusUpdate, + InputConfig, InputType, InputTypeAutoDetectionSource, LegacyPassiveSuggestionsEvent, + LegacyPassiveSuggestionsModel, MaaPassiveSuggestionsEvent, MaaPassiveSuggestionsModel, + PassiveSuggestionsModels, PendingAttachment, PendingQueryState, QueuedQueryModel, + RequestFileEditsFormatKind, ShellCommandExecutor, ShellCommandExecutorEvent, + SlashCommandRequest, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, + ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX, }; +use crate::ai::conversation_details_panel::ConversationDetailsPanelEvent; use crate::ai::conversation_utils; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}; @@ -2625,6 +2631,9 @@ pub struct TerminalView { /// The child views that represent rich content. These can be inserted into the block list with /// the `insert_rich_content` helper function. rich_content_views: Vec, + pending_user_query_view_id: Option, + pending_user_query_kind: Option, + queued_prompt_callback: Option, /// Cached view ids for usage footers keyed by the AI block view id that owns them. usage_footer_view_ids: HashMap, @@ -2848,16 +2857,6 @@ pub struct TerminalView { ephemeral_message_model: ModelHandle, - /// Tracks the view ID of an inserted pending user query block, if any. - /// Used to remove the block when summarization completes or is cancelled. - pending_user_query_view_id: Option, - pending_user_query_kind: Option, - - /// Callback for the queued prompt that fires when the current conversation finishes. - /// Stored separately from `conversation_completed_callbacks` so that queuing a prompt - /// (via `/queue`, `/compact-and`, etc.) does not wipe unrelated callbacks. - queued_prompt_callback: Option, - /// Per-session PTY recorder for writing PTY bytes to a file. pty_recorder: ModelHandle, @@ -4142,7 +4141,6 @@ impl TerminalView { ) }); ctx.subscribe_to_view(&conversation_details_panel, |me, _, event, ctx| { - use crate::ai::conversation_details_panel::ConversationDetailsPanelEvent; match event { ConversationDetailsPanelEvent::Close => { me.is_conversation_details_panel_open = false; @@ -4228,6 +4226,9 @@ impl TerminalView { block_filter_editor, active_filter_editor_block_index: None, rich_content_views: Vec::new(), + pending_user_query_view_id: None, + pending_user_query_kind: None, + queued_prompt_callback: None, usage_footer_view_ids: Default::default(), block_onboarding_active: false, onboarding_agentic_suggestions_block: None, @@ -4307,9 +4308,6 @@ impl TerminalView { pending_cloud_mode_start_callback: None, pending_cloud_mode_start_abort_handle: None, ephemeral_message_model, - pending_user_query_view_id: None, - pending_user_query_kind: None, - queued_prompt_callback: None, pty_recorder: ctx .add_model(|ctx| PtyRecorder::new(inactive_pty_reads_rx, window_id, ctx)), active_viewer_driven_size: None, @@ -4781,6 +4779,26 @@ impl TerminalView { .push(Box::new(callback)); } + fn handle_finished_conversation( + &mut self, + conversation_id: AIConversationId, + finish_reason: FinishReason, + ctx: &mut ViewContext, + ) { + let queued_prompt = self.queued_prompt_callback.take(); + let callbacks = self + .conversation_completed_callbacks + .drain(..) + .collect_vec(); + for callback in callbacks { + callback(self, finish_reason, ctx); + } + if let Some(callback) = queued_prompt { + callback(self, finish_reason, ctx); + } + self.drain_queued_prompts(conversation_id, finish_reason, ctx); + } + #[cfg(feature = "local_fs")] fn handle_git_repo_status_event(&mut self, ctx: &mut ViewContext) { if let Some(deferred) = self.deferred_code_review_open.take() { @@ -5071,17 +5089,7 @@ impl TerminalView { } if let Some(reason) = finish_reason { - let queued_prompt = self.queued_prompt_callback.take(); - let callbacks = self - .conversation_completed_callbacks - .drain(..) - .collect_vec(); - for callback in callbacks { - callback(self, reason, ctx); - } - if let Some(callback) = queued_prompt { - callback(self, reason, ctx); - } + self.handle_finished_conversation(*conversation_id, reason, ctx); } // If the most recent action in the current interaction turn created or updated a plan @@ -5138,6 +5146,78 @@ impl TerminalView { } } + /// Drains one prompt from the queued-query singleton for `conversation_id` when that + /// conversation finishes. + fn drain_queued_prompts( + &mut self, + conversation_id: AIConversationId, + finish_reason: FinishReason, + ctx: &mut ViewContext, + ) { + match finish_reason { + FinishReason::Complete => { + let input_is_empty = self.input.as_ref(ctx).buffer_text(ctx).is_empty(); + let first_row_is_in_edit_mode = + QueuedQueryModel::as_ref(ctx).first_row_is_in_edit_mode(conversation_id); + if first_row_is_in_edit_mode && !input_is_empty { + return; + } + + let action = QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.pop_for_autofire(conversation_id, ctx) + }); + match action { + Some(AutofireAction::Submit { text }) => { + self.input.update(ctx, |input, ctx| { + input.submit_queued_prompt(text, ctx); + }); + } + Some(AutofireAction::PopFromEditMode { text }) => { + self.input.update(ctx, |input, ctx| { + if input.buffer_text(ctx).is_empty() { + input.replace_buffer_content(&text, ctx); + input.focus_input_box(ctx); + } + }); + } + None => {} + } + } + FinishReason::Error + | FinishReason::Cancelled + | FinishReason::CancelledDuringRequestedCommandExecution => { + // Only restore the head into the input when the user is + // currently viewing this conversation in agent view. Cancels + // triggered by exiting the agent view leave `agent_view_state` + // Inactive by the time the cancel fires, so the head stays in + // the queue and re-entering the agent view shows the same + // queue the user left. + let is_active_in_agent_view = self + .agent_view_controller + .as_ref(ctx) + .agent_view_state() + .active_conversation_id() + == Some(conversation_id); + if !is_active_in_agent_view { + return; + } + + let input_is_empty = self.input.as_ref(ctx).buffer_text(ctx).is_empty(); + if !input_is_empty { + return; + } + + let popped = QueuedQueryModel::handle(ctx) + .update(ctx, |model, ctx| model.pop_front(conversation_id, ctx)); + if let Some(query) = popped { + self.input.update(ctx, |input, ctx| { + input.replace_buffer_content(query.text(), ctx); + }); + } + } + } + } + fn handle_legacy_passive_suggestions_event( &mut self, _: ModelHandle, @@ -5425,9 +5505,6 @@ impl TerminalView { self.update_pane_configuration(ctx); ctx.notify(); } - BlocklistAIContextEvent::QueueNextPromptToggled => { - ctx.notify(); - } } } @@ -5452,7 +5529,6 @@ impl TerminalView { self.remove_pending_user_query_block(ctx); } } - fn render_owner_for_ai_history_event( &self, history_model: &BlocklistAIHistoryModel, @@ -5961,15 +6037,19 @@ impl TerminalView { .block_list_mut() .remove_command_blocks_for_conversation(*conversation_id); } + BlocklistAIHistoryEvent::RemoveConversation { .. } + | BlocklistAIHistoryEvent::DeletedConversation { .. } => { + // The queue is always for the currently active conversation; agent-view exit + // already wipes it via `ExitedAgentView`, so no per-conversation cleanup is + // needed here. + } BlocklistAIHistoryEvent::CreatedSubtask { .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } | BlocklistAIHistoryEvent::UpdatedTodoList { .. } - | BlocklistAIHistoryEvent::RemoveConversation { .. } | BlocklistAIHistoryEvent::RestoredConversations { .. } | BlocklistAIHistoryEvent::UpgradedTask { .. } | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } | BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } - | BlocklistAIHistoryEvent::DeletedConversation { .. } | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } @@ -14105,28 +14185,9 @@ impl TerminalView { .input_mode .value(); let inverted = input_mode.is_inverted_blocklist(); - let blocklist_selected_text = - self.model - .lock() - .selection_to_string(semantic_selection, inverted, ctx); - blocklist_selected_text.or_else(|| self.pending_user_query_selected_text(ctx)) - } - - /// Returns selected text from the pending user query block, if any. - fn pending_user_query_selected_text(&self, ctx: &AppContext) -> Option { - let view_id = self.pending_user_query_view_id?; - self.rich_content_views - .iter() - .find_map(|rc| match rc.metadata() { - Some(RichContentMetadata::PendingUserQuery { - pending_user_query_block_handle, - }) if pending_user_query_block_handle.id() == view_id => { - pending_user_query_block_handle - .as_ref(ctx) - .selected_text(ctx) - } - _ => None, - }) + self.model + .lock() + .selection_to_string(semantic_selection, inverted, ctx) } /// Gets the selected text from the terminal input editor, if any. @@ -16046,12 +16107,6 @@ impl TerminalView { } } - if let Some(selected_text) = self.pending_user_query_selected_text(ctx) { - ctx.clipboard() - .write(ClipboardContent::plain_text(selected_text)); - return; - } - let semantic_selection = SemanticSelection::as_ref(ctx); if let Some(selected) = self.model.lock().selection_to_string( semantic_selection, @@ -17796,9 +17851,8 @@ impl TerminalView { let selection_settings = SelectionSettings::handle(ctx); let semantic_selection = SemanticSelection::as_ref(ctx); let model = self.model.lock(); - let selected_text = model - .selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx) - .or_else(|| self.pending_user_query_selected_text(ctx)); + let selected_text = + model.selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx); if let Some(selected) = selected_text { selection_settings.update(ctx, |selection_settings, ctx| { selection_settings @@ -19509,18 +19563,6 @@ impl TerminalView { env_var_collection_block.clear_selection(ctx); }); } - Some(RichContentMetadata::PendingUserQuery { - pending_user_query_block_handle, - }) => { - if exempt_rich_content_view_id - .is_some_and(|view_id| pending_user_query_block_handle.id() == view_id) - { - continue; - } - pending_user_query_block_handle.update(ctx, |block, ctx| { - block.clear_selection(ctx); - }); - } Some(RichContentMetadata::WarpifySuccessBlock { .. }) => { // TODO(Simon): We should be checking for WarpifySuccessBlocks here as well. // The `WarpifySuccessBlock` implements a `SelectableArea`. @@ -20013,8 +20055,7 @@ impl TerminalView { } fn active_ai_block(&self, ctx: &AppContext) -> Option<&ViewHandle> { - // Skip trailing non-AI items (usage footers, pending user query blocks) - // as they don't impact the conversation state. + // Skip trailing non-AI items (usage footers) as they don't impact the conversation state. let candidate = self .rich_content_views .iter() @@ -20456,9 +20497,8 @@ impl TerminalView { send_telemetry_from_ctx!(TelemetryEvent::ContextMenuCopySelectedText, ctx); let semantic_selection = SemanticSelection::as_ref(ctx); let model = self.model.lock(); - let selected_text = model - .selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx) - .or_else(|| self.pending_user_query_selected_text(ctx)); + let selected_text = + model.selection_to_string(semantic_selection, self.is_inverted_blocklist(ctx), ctx); if let Some(selected_text) = selected_text { ctx.clipboard() .write(ClipboardContent::plain_text(selected_text)); @@ -26293,8 +26333,13 @@ impl TypedActionView for TerminalView { ctx.notify(); } ToggleQueueNextPrompt => { - self.ai_context_model.update(ctx, |context_model, ctx| { - context_model.toggle_queue_next_prompt(ctx); + let Some(conversation_id) = + BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.view_id) + else { + return; + }; + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.toggle_queue_next_prompt(conversation_id, ctx); }); ctx.notify(); } diff --git a/app/src/terminal/view/ambient_agent/model.rs b/app/src/terminal/view/ambient_agent/model.rs index ad5f756e52..acaed1442c 100644 --- a/app/src/terminal/view/ambient_agent/model.rs +++ b/app/src/terminal/view/ambient_agent/model.rs @@ -234,7 +234,7 @@ pub struct AmbientAgentViewModel { harness_reasoning_level: Option, /// Name of the selected auth secret for the current non-Oz harness. harness_auth_secret_name: Option, - /// Whether the harness CLI + /// Whether the harness CLI (e.g. `claude`, `gemini`) has started running for a non-oz run. /// Used to transition the cloud-mode setup UI out of the pre-first-exchange phase when /// there is no oz `AppendedExchange` to key off of. harness_command_started: bool, diff --git a/app/src/terminal/view/ambient_agent/view_impl.rs b/app/src/terminal/view/ambient_agent/view_impl.rs index e099163530..20b79eaa9d 100644 --- a/app/src/terminal/view/ambient_agent/view_impl.rs +++ b/app/src/terminal/view/ambient_agent/view_impl.rs @@ -106,11 +106,9 @@ impl TerminalView { return; }; - // Tear down the cloud-mode queued-prompt block on terminal / transition - // events that replace it. Legacy `Failed`, `NeedsGithubAuth`, and `Cancelled` hand off - // to the existing error / auth / cancelled UI; `HarnessCommandStarted` hands - // off to the live third-party harness CLI block. Idempotent and cheap when no - // block exists. + // Tear down the Cloud Mode pending prompt on terminal / transition events that replace it. + // Legacy `Failed`, `NeedsGithubAuth`, and `Cancelled` hand off to the existing error / + // auth / cancelled UI; `HarnessCommandStarted` hands off to the live harness CLI block. let should_remove_pending_user_query = match event { AmbientAgentViewModelEvent::Failed { .. } => { !FeatureFlag::CloudModeSetupV2.is_enabled() @@ -151,10 +149,13 @@ impl TerminalView { return; } if FeatureFlag::CloudModeSetupV2.is_enabled() { - // Render the submitted cloud prompt via the queued-prompt UI while the - // real shared-session transcript catches up. `request.prompt` is stored - // stripped of any `/plan` / `/orchestrate` prefix; rebuild the display - // form from `request.mode` so the user sees exactly what they typed. + // Render the submitted cloud prompt while the real shared-session transcript + // catches up. The pending block is removed later by + // `HarnessCommandStarted` / failure / cancel / auth handlers. + // + // `request.prompt` is stored stripped of any `/plan` / `/orchestrate` + // prefix; rebuild the display form from `request.mode` so the user sees + // exactly what they typed. let prompt = ambient_agent_view_model .as_ref(ctx) .request() diff --git a/app/src/terminal/view/queued_prompts_panel.rs b/app/src/terminal/view/queued_prompts_panel.rs new file mode 100644 index 0000000000..43bc1c3a84 --- /dev/null +++ b/app/src/terminal/view/queued_prompts_panel.rs @@ -0,0 +1,841 @@ +//! Multi-prompt queue panel rendered between the warping indicator and the input editor in +//! [`TerminalView`]. +//! +//! Reads from the `QueuedQueryModel` singleton (keyed by `AIConversationId`) for the queue of the +//! currently-active conversation in its parent terminal view, looked up via +//! [`BlocklistAIHistoryModel::active_conversation_id`]. Tracks panel-only UI state (collapse, +//! hover, drag) locally. Emits two high-level events: [`QueuedPromptsPanelEvent::RowDeleted`] and +//! [`QueuedPromptsPanelEvent::EditEnded`], which the host uses to update the input editor. +use std::collections::HashMap; + +use pathfinder_color::ColorU; +use pathfinder_geometry::rect::RectF; +use warp_core::features::FeatureFlag; +use warpui::elements::new_scrollable::{NewScrollable, ScrollableAppearance, SingleAxisConfig}; +use warpui::elements::{ + Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, + CrossAxisAlignment, DragAxis, Draggable, DraggableState, Empty, Expanded, Fill, Flex, + Hoverable, MinSize, MouseStateHandle, ParentElement, Radius, SavePosition, ScrollbarWidth, + Text, DEFAULT_UI_LINE_HEIGHT_RATIO, +}; +use warpui::fonts::{Properties, Style, Weight}; +use warpui::platform::Cursor; +use warpui::{ + AppContext, BlurContext, Element, Entity, EntityId, FocusContext, SingletonEntity, + TypedActionView, View, ViewContext, ViewHandle, +}; + +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::blocklist::{ + BlocklistAIHistoryEvent, BlocklistAIHistoryModel, QueuedQueryEvent, QueuedQueryId, + QueuedQueryModel, +}; +use crate::appearance::Appearance; +use crate::editor::{ + EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpEscapeKey, + PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions, +}; +use crate::send_telemetry_from_ctx; +use crate::server::telemetry::TelemetryEvent; +use crate::ui_components::icons::Icon as TerminalIcon; +use crate::util::truncation::truncate_from_end; +use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme}; + +const MAX_PROMPT_LINES: f32 = 5.; + +/// Returns the position-cache id used to look up a row's bounding rect during a drag. +/// Indexed by the row's current visual index so swaps maintain stable lookups. +fn queue_row_position_id(panel_view_id: EntityId, index: usize) -> String { + format!("queued_prompts_panel:{panel_view_id:?}:row:{index}") +} + +fn build_row_state( + query_id: QueuedQueryId, + ctx: &mut ViewContext, +) -> QueuedPromptRowState { + let edit_button = ctx.add_typed_action_view(move |_| { + ActionButton::new("", NakedTheme) + .with_icon(TerminalIcon::Pencil) + .with_tooltip("Edit queued prompt") + .with_size(ButtonSize::XSmall) + .on_click(move |ctx| { + ctx.dispatch_typed_action(QueuedPromptsPanelAction::StartEditingRow(query_id)); + }) + }); + let delete_button = ctx.add_typed_action_view(move |_| { + ActionButton::new("", NakedTheme) + .with_icon(TerminalIcon::Trash) + .with_tooltip("Delete queued prompt") + .with_size(ButtonSize::XSmall) + .on_click(move |ctx| { + ctx.dispatch_typed_action(QueuedPromptsPanelAction::DeleteRow(query_id)); + }) + }); + + QueuedPromptRowState { + mouse_state: MouseStateHandle::default(), + edit_button, + delete_button, + draggable_state: DraggableState::default(), + } +} + +#[derive(Clone)] +struct QueuedPromptRowState { + mouse_state: MouseStateHandle, + edit_button: ViewHandle, + delete_button: ViewHandle, + draggable_state: DraggableState, +} + +/// View for the multi-prompt queue panel. +pub struct QueuedPromptsPanelView { + view_id: EntityId, + /// Terminal view this panel belongs to. Used to resolve the active conversation via + /// [`BlocklistAIHistoryModel`]. + terminal_view_id: EntityId, + /// Cached active conversation for this panel. `None` means there is no active conversation in + /// the parent terminal view; the panel renders nothing in that case. + active_conversation_id: Option, + /// Reusable editor for whichever row is currently in edit mode. + edit_editor: ViewHandle, + edit_editor_is_single_logical_line: bool, + edit_editor_scroll_state: ClippedScrollStateHandle, + /// Panel-only UI state: whether the body is collapsed. Owned here (not on the singleton) + /// because no other view reads this. Reset whenever the active conversation changes or the + /// queue is cleared. + collapsed: bool, + header_mouse_state: MouseStateHandle, + row_states: HashMap, + dragging_query_id: Option, + drag_start_index: Option, +} + +#[derive(Clone, Debug)] +pub enum QueuedPromptsPanelAction { + ToggleCollapsed, + StartEditingRow(QueuedQueryId), + DeleteRow(QueuedQueryId), + StartDrag(QueuedQueryId), + DragMoved { rect: RectF }, + DropEnd, +} + +/// Events emitted to the parent view ([`TerminalView`]). Two variants cover everything the host +/// needs: place text on delete, and refocus the input box after an edit-mode transition. +#[derive(Clone, Debug)] +pub enum QueuedPromptsPanelEvent { + /// A row was deleted via the trash button. The host should place `text` into the input editor + /// when the editor is empty, and focus the input. + RowDeleted { text: String }, + /// An inline edit was committed or cancelled. The host should refocus the input. + EditEnded, +} + +impl Entity for QueuedPromptsPanelView { + type Event = QueuedPromptsPanelEvent; +} + +impl QueuedPromptsPanelView { + pub fn new(terminal_view_id: EntityId, ctx: &mut ViewContext) -> Self { + let edit_editor = build_edit_editor(ctx); + + ctx.subscribe_to_view(&edit_editor, |me, _, event, ctx| { + me.handle_edit_editor_event(event, ctx); + }); + + let history_handle = BlocklistAIHistoryModel::handle(ctx); + let active_conversation_id = history_handle + .as_ref(ctx) + .active_conversation_id(terminal_view_id); + + ctx.subscribe_to_model(&history_handle, move |me, _, event, ctx| { + me.handle_history_event(event, ctx); + }); + + ctx.subscribe_to_model( + &QueuedQueryModel::handle(ctx), + Self::handle_queued_query_event, + ); + + let mut me = Self { + view_id: ctx.view_id(), + terminal_view_id, + active_conversation_id, + edit_editor, + edit_editor_is_single_logical_line: true, + edit_editor_scroll_state: Default::default(), + collapsed: false, + header_mouse_state: MouseStateHandle::default(), + row_states: HashMap::new(), + dragging_query_id: None, + drag_start_index: None, + }; + if let Some(conv_id) = active_conversation_id { + me.seed_row_states_for(conv_id, ctx); + } + me + } + + fn clear_drag_state(&mut self) { + self.dragging_query_id = None; + self.drag_start_index = None; + } + + /// Reseed `row_states` for `conv_id`'s queue, dropping any state for rows not in that queue. + fn seed_row_states_for(&mut self, conv_id: AIConversationId, ctx: &mut ViewContext) { + let query_ids: Vec = QueuedQueryModel::as_ref(ctx) + .queue(conv_id) + .iter() + .map(|q| q.id()) + .collect(); + self.row_states.retain(|id, _| query_ids.contains(id)); + for id in query_ids { + self.row_states + .entry(id) + .or_insert_with(|| build_row_state(id, ctx)); + } + } + + fn handle_history_event( + &mut self, + event: &BlocklistAIHistoryEvent, + ctx: &mut ViewContext, + ) { + let is_for_this_view = event + .terminal_view_id() + .is_some_and(|id| id == self.terminal_view_id); + if !is_for_this_view { + return; + } + let new_active = + BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_view_id); + if new_active != self.active_conversation_id { + self.active_conversation_id = new_active; + self.row_states.clear(); + self.clear_drag_state(); + self.collapsed = false; + if let Some(conv_id) = new_active { + self.seed_row_states_for(conv_id, ctx); + } + ctx.notify(); + } + } + + fn handle_queued_query_event( + &mut self, + _: warpui::ModelHandle, + event: &QueuedQueryEvent, + ctx: &mut ViewContext, + ) { + let Some(active_conv_id) = self.active_conversation_id else { + return; + }; + // Filter every event to the panel's current active conversation. Other conversations' + // events are still emitted on the singleton but are not relevant to this panel. + let event_conv_id = match event { + QueuedQueryEvent::Appended { + conversation_id, .. + } + | QueuedQueryEvent::Removed { + conversation_id, .. + } + | QueuedQueryEvent::Reordered { conversation_id } + | QueuedQueryEvent::EditEntered { + conversation_id, .. + } + | QueuedQueryEvent::EditCommitted { + conversation_id, .. + } + | QueuedQueryEvent::EditCancelled { + conversation_id, .. + } + | QueuedQueryEvent::Cleared { conversation_id } + | QueuedQueryEvent::QueueNextPromptToggled { conversation_id } => *conversation_id, + }; + if event_conv_id != active_conv_id { + return; + } + match event { + QueuedQueryEvent::Removed { query_id, .. } => { + self.row_states.remove(query_id); + if self.dragging_query_id == Some(*query_id) { + self.clear_drag_state(); + } + if !QueuedQueryModel::as_ref(ctx).has_queue(active_conv_id) { + self.collapsed = false; + } + } + QueuedQueryEvent::EditEntered { query_id, .. } => { + let initial_text = QueuedQueryModel::as_ref(ctx) + .queue(active_conv_id) + .iter() + .find(|row| row.id() == *query_id) + .map(|row| row.text().to_owned()) + .unwrap_or_default(); + self.edit_editor_is_single_logical_line = !initial_text.contains('\n'); + self.edit_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&initial_text, ctx); + editor.select_all(ctx); + }); + ctx.focus(&self.edit_editor); + } + QueuedQueryEvent::EditCommitted { .. } | QueuedQueryEvent::EditCancelled { .. } => { + self.edit_editor.update(ctx, |editor, ctx| { + editor.clear_buffer(ctx); + }); + } + QueuedQueryEvent::Cleared { .. } => { + self.row_states.clear(); + self.clear_drag_state(); + self.collapsed = false; + } + QueuedQueryEvent::Appended { query_id, .. } => { + self.row_states + .entry(*query_id) + .or_insert_with(|| build_row_state(*query_id, ctx)); + } + QueuedQueryEvent::Reordered { .. } + | QueuedQueryEvent::QueueNextPromptToggled { .. } => {} + } + ctx.notify(); + } + + fn handle_edit_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { + match event { + EditorEvent::Enter => self.commit_edit(ctx), + EditorEvent::Escape => self.cancel_edit(ctx), + // Losing focus commits the edit. + EditorEvent::Blurred => self.commit_edit(ctx), + EditorEvent::Edited(_) | EditorEvent::BufferReplaced => { + self.update_edit_editor_line_state(ctx) + } + _ => {} + } + } + + fn update_edit_editor_line_state(&mut self, ctx: &mut ViewContext) { + let is_single_logical_line = self + .edit_editor + .read(ctx, |editor, ctx| !editor.buffer_text(ctx).contains('\n')); + if self.edit_editor_is_single_logical_line != is_single_logical_line { + self.edit_editor_is_single_logical_line = is_single_logical_line; + ctx.notify(); + } + } + + fn editing_row_id(&self, ctx: &AppContext) -> Option { + let conv_id = self.active_conversation_id?; + QueuedQueryModel::as_ref(ctx).editing_row(conv_id) + } + + fn commit_edit(&mut self, ctx: &mut ViewContext) { + let Some(conv_id) = self.active_conversation_id else { + return; + }; + let Some(query_id) = self.editing_row_id(ctx) else { + return; + }; + let origin = QueuedQueryModel::as_ref(ctx) + .queue(conv_id) + .iter() + .find(|row| row.id() == query_id) + .map(|row| row.origin()); + let new_text = self + .edit_editor + .read(ctx, |editor, ctx| editor.buffer_text(ctx).trim().to_owned()); + let was_empty = new_text.is_empty(); + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.commit_edit(conv_id, new_text, ctx); + }); + if let Some(origin) = origin { + if !was_empty { + send_telemetry_from_ctx!( + TelemetryEvent::QueuedPromptEdited { + origin: origin.into(), + }, + ctx + ); + } + } + ctx.emit(QueuedPromptsPanelEvent::EditEnded); + } + + fn cancel_edit(&mut self, ctx: &mut ViewContext) { + let Some(conv_id) = self.active_conversation_id else { + return; + }; + if self.editing_row_id(ctx).is_none() { + return; + } + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.cancel_edit(conv_id, ctx); + }); + ctx.emit(QueuedPromptsPanelEvent::EditEnded); + } + + /// Visibility predicate used by the host to decide whether to render the panel. + pub fn should_render(&self, ctx: &AppContext) -> bool { + if !FeatureFlag::QueueSlashCommand.is_enabled() { + return false; + } + let Some(conv_id) = self.active_conversation_id else { + return false; + }; + QueuedQueryModel::as_ref(ctx).has_queue(conv_id) + } +} + +impl TypedActionView for QueuedPromptsPanelView { + type Action = QueuedPromptsPanelAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + let Some(conv_id) = self.active_conversation_id else { + return; + }; + match action { + QueuedPromptsPanelAction::ToggleCollapsed => { + self.collapsed = !self.collapsed; + send_telemetry_from_ctx!( + TelemetryEvent::QueuedPromptPanelCollapseToggled { + collapsed: self.collapsed, + }, + ctx + ); + ctx.notify(); + } + QueuedPromptsPanelAction::StartEditingRow(query_id) => { + let query_id = *query_id; + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.enter_edit_mode(conv_id, query_id, ctx); + }); + } + QueuedPromptsPanelAction::DeleteRow(query_id) => { + let query_id = *query_id; + let removed = QueuedQueryModel::handle(ctx) + .update(ctx, |model, ctx| model.remove_by_id(conv_id, query_id, ctx)); + if let Some(removed) = removed { + send_telemetry_from_ctx!( + TelemetryEvent::QueuedPromptDeleted { + origin: removed.origin().into(), + }, + ctx + ); + ctx.emit(QueuedPromptsPanelEvent::RowDeleted { + text: removed.text().to_owned(), + }); + } + } + QueuedPromptsPanelAction::StartDrag(query_id) => { + let query_id = *query_id; + // If the row is in edit mode, cancel that edit so dragging is unambiguous. + let editing = QueuedQueryModel::as_ref(ctx).editing_row(conv_id); + if editing == Some(query_id) { + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.cancel_edit(conv_id, ctx); + }); + } + let from_index = QueuedQueryModel::as_ref(ctx) + .queue(conv_id) + .iter() + .position(|q| q.id() == query_id); + self.dragging_query_id = Some(query_id); + self.drag_start_index = from_index; + ctx.notify(); + } + QueuedPromptsPanelAction::DragMoved { rect } => { + let rect = *rect; + let Some(source_id) = self.dragging_query_id else { + return; + }; + let panel_view_id = ctx.view_id(); + let queue_len = QueuedQueryModel::as_ref(ctx).queue(conv_id).len(); + let Some(current_index) = QueuedQueryModel::as_ref(ctx) + .queue(conv_id) + .iter() + .position(|q| q.id() == source_id) + else { + return; + }; + let new_index = + calculate_updated_row_index(panel_view_id, current_index, queue_len, rect, ctx); + if new_index == current_index { + return; + } + QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { + model.reorder(conv_id, source_id, new_index, ctx); + }); + ctx.notify(); + } + QueuedPromptsPanelAction::DropEnd => { + let Some(source_id) = self.dragging_query_id.take() else { + return; + }; + let from_index = self.drag_start_index.take(); + let model_ref = QueuedQueryModel::as_ref(ctx); + let queue = model_ref.queue(conv_id); + let to_index = queue.iter().position(|q| q.id() == source_id); + let origin = to_index.map(|idx| queue[idx].origin()); + if let (Some(from_index), Some(to_index), Some(origin)) = + (from_index, to_index, origin) + { + if from_index != to_index { + send_telemetry_from_ctx!( + TelemetryEvent::QueuedPromptReordered { + origin: origin.into(), + from_index, + to_index, + }, + ctx + ); + } + } + ctx.notify(); + } + } + } +} + +impl View for QueuedPromptsPanelView { + fn ui_name() -> &'static str { + "QueuedPromptsPanelView" + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() && self.editing_row_id(ctx).is_some() { + ctx.focus(&self.edit_editor); + } + } + + /// Commits an in-progress edit when focus leaves the panel. + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() && self.editing_row_id(ctx).is_some() { + self.commit_edit(ctx); + } + } + + fn render(&self, app: &AppContext) -> Box { + if !self.should_render(app) { + return Empty::new().finish(); + } + + let Some(conv_id) = self.active_conversation_id else { + return Empty::new().finish(); + }; + + let appearance = Appearance::as_ref(app); + let queue_model = QueuedQueryModel::as_ref(app); + let queue: Vec<_> = queue_model.queue(conv_id).to_vec(); + let editing_row_id = queue_model.editing_row(conv_id); + let collapsed = self.collapsed; + + let panel_view_id = self.view_id; + let header = render_header(queue.len(), collapsed, &self.header_mouse_state, appearance); + let mut panel = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(header); + + if !collapsed { + let mut body = Flex::column(); + + for (index, query) in queue.iter().enumerate() { + let row_state = self + .row_states + .get(&query.id()) + .expect("queued row state should be seeded by model event") + .clone(); + let is_in_edit_mode = editing_row_id == Some(query.id()); + let is_being_dragged = self.dragging_query_id == Some(query.id()); + let row = render_row(RenderRowProps { + query_id: query.id(), + panel_view_id, + index, + text: query.text().to_owned(), + is_in_edit_mode, + is_being_dragged, + edit_editor: &self.edit_editor, + edit_editor_is_single_logical_line: self.edit_editor_is_single_logical_line, + edit_editor_scroll_state: &self.edit_editor_scroll_state, + row_state, + appearance, + }); + body.add_child(row); + } + + panel.add_child( + Container::new(body.finish()) + .with_horizontal_padding(4.) + .with_vertical_padding(8.) + .finish(), + ); + } + + panel.finish() + } +} + +fn build_edit_editor(ctx: &mut ViewContext) -> ViewHandle { + let appearance = Appearance::as_ref(ctx); + let text_options = TextOptions::ui_text(Some(appearance.ui_font_size()), appearance); + ctx.add_typed_action_view(|ctx| { + let options = EditorOptions { + autogrow: true, + soft_wrap: true, + text: text_options, + propagate_and_no_op_escape_key: PropagateAndNoOpEscapeKey::PropagateFirst, + propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, + propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::AtBoundary, + ..Default::default() + }; + EditorView::new(options, ctx) + }) +} + +fn calculate_updated_row_index( + panel_view_id: EntityId, + current_index: usize, + queue_len: usize, + drag_position: RectF, + ctx: &ViewContext, +) -> usize { + updated_index_from_vertical_drag(current_index, queue_len, drag_position, |index| { + ctx.element_position_by_id(queue_row_position_id(panel_view_id, index)) + }) +} + +fn updated_index_from_vertical_drag( + current_index: usize, + item_count: usize, + drag_position: RectF, + mut item_rect: impl FnMut(usize) -> Option, +) -> usize { + let dragged_midpoint_y = (drag_position.min_y() + drag_position.max_y()) / 2.; + + if current_index > 0 { + if let Some(neighbor_rect) = item_rect(current_index - 1) { + let neighbor_midpoint_y = (neighbor_rect.min_y() + neighbor_rect.max_y()) / 2.; + if dragged_midpoint_y < neighbor_midpoint_y { + return current_index - 1; + } + } + } + + if current_index + 1 < item_count { + if let Some(neighbor_rect) = item_rect(current_index + 1) { + let neighbor_midpoint_y = (neighbor_rect.min_y() + neighbor_rect.max_y()) / 2.; + if dragged_midpoint_y > neighbor_midpoint_y { + return current_index + 1; + } + } + } + + current_index +} + +fn render_header( + count: usize, + collapsed: bool, + header_mouse_state: &MouseStateHandle, + appearance: &Appearance, +) -> Box { + let theme = appearance.theme(); + let label_text = header_label_text(count); + let sub_text_color: ColorU = theme.sub_text_color(theme.surface_1()).into(); + let banner_background: Fill = theme.surface_overlay_1().into(); + let border_color: Fill = theme.split_pane_border_color().into(); + let chevron_icon = if collapsed { + TerminalIcon::ChevronRight + } else { + TerminalIcon::ChevronDown + }; + let ui_font_family = appearance.ui_font_family(); + let ui_font_size = appearance.ui_font_size(); + Hoverable::new(header_mouse_state.clone(), move |_state| { + let chevron = + ConstrainedBox::new(chevron_icon.to_warpui_icon(sub_text_color.into()).finish()) + .with_height(16.) + .with_width(16.) + .finish(); + let label = Text::new(label_text.clone(), ui_font_family, ui_font_size) + .with_style(Properties { + style: Style::Normal, + weight: Weight::Normal, + }) + .with_color(sub_text_color) + .with_selectable(false) + .finish(); + let row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(4.) + .with_child(chevron) + .with_child(label) + .finish(); + Container::new(row) + .with_horizontal_padding(16.) + .with_vertical_padding(8.) + .with_background(banner_background) + .with_border(Border::top(1.).with_border_fill(border_color)) + .finish() + }) + .with_cursor(Cursor::PointingHand) + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(QueuedPromptsPanelAction::ToggleCollapsed); + }) + .finish() +} + +struct RenderRowProps<'a> { + query_id: QueuedQueryId, + panel_view_id: EntityId, + index: usize, + text: String, + is_in_edit_mode: bool, + is_being_dragged: bool, + edit_editor: &'a ViewHandle, + edit_editor_is_single_logical_line: bool, + edit_editor_scroll_state: &'a ClippedScrollStateHandle, + row_state: QueuedPromptRowState, + appearance: &'a Appearance, +} + +fn render_row(props: RenderRowProps<'_>) -> Box { + let RenderRowProps { + query_id, + panel_view_id, + index, + text, + is_in_edit_mode, + is_being_dragged, + edit_editor, + edit_editor_is_single_logical_line, + edit_editor_scroll_state, + row_state, + appearance, + } = props; + + let theme = appearance.theme(); + let dimmed_color: ColorU = theme.sub_text_color(theme.surface_1()).into(); + let foreground_color: ColorU = theme.foreground().into(); + let row_hover_background: Fill = theme.surface_overlay_1().into(); + let ui_font_family = appearance.ui_font_family(); + let ui_font_size = appearance.ui_font_size(); + let editor_line_height = ui_font_size * DEFAULT_UI_LINE_HEIGHT_RATIO; + let max_prompt_height = editor_line_height * MAX_PROMPT_LINES; + let preview_text = truncate_from_end(&text, 200); + let editor_handle = edit_editor.clone(); + let editor_scroll_state = edit_editor_scroll_state.clone(); + + let QueuedPromptRowState { + mouse_state, + edit_button, + delete_button, + draggable_state, + } = row_state; + + let row_inner = Hoverable::new(mouse_state, move |state| { + let prompt_text_or_editor: Box = if is_in_edit_mode { + let editor_scrollable = NewScrollable::vertical( + SingleAxisConfig::Clipped { + handle: editor_scroll_state.clone(), + child: ChildView::new(&editor_handle).finish(), + }, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + Fill::None, + ) + .with_vertical_scrollbar(ScrollableAppearance::new(ScrollbarWidth::Auto, false)) + .with_propagate_mousewheel_if_not_handled(true) + .finish(); + let editor_viewport = Clipped::new(editor_scrollable).finish(); + let editor_viewport = if edit_editor_is_single_logical_line { + MinSize::new(editor_viewport).finish() + } else { + editor_viewport + }; + + ConstrainedBox::new( + Container::new(editor_viewport) + .with_border(Border::all(1.).with_border_fill(theme.outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_horizontal_padding(4.) + .finish(), + ) + .with_max_height(max_prompt_height) + .finish() + } else { + ConstrainedBox::new( + Text::new(preview_text.clone(), ui_font_family, ui_font_size) + .with_color(foreground_color) + .with_selectable(false) + .finish(), + ) + .with_max_height(max_prompt_height) + .finish() + }; + + let drag_handle: Box = ConstrainedBox::new( + TerminalIcon::DragIndicator + .to_warpui_icon(dimmed_color.into()) + .finish(), + ) + .with_height(24.) + .with_width(24.) + .finish(); + + let mut row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.) + .with_child(drag_handle) + .with_child(Expanded::new(1., prompt_text_or_editor).finish()); + + if state.is_hovered() && !is_being_dragged { + let mut buttons = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(4.); + if !is_in_edit_mode { + buttons.add_child(ChildView::new(&edit_button).finish()); + } + buttons.add_child(ChildView::new(&delete_button).finish()); + row.add_child(buttons.finish()); + } + + let row_content = ConstrainedBox::new(row.finish()) + .with_min_height(32.) + .finish(); + let mut container = Container::new(row_content) + .with_horizontal_padding(8.) + .with_vertical_padding(4.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); + if is_being_dragged || state.is_hovered() { + container = container.with_background(row_hover_background); + } + container.finish() + }) + .finish(); + + let position_id = queue_row_position_id(panel_view_id, index); + + if is_in_edit_mode { + return SavePosition::new(row_inner, &position_id).finish(); + } + + let draggable = Draggable::new(draggable_state, row_inner) + .with_drag_axis(DragAxis::VerticalOnly) + .on_drag_start(move |ctx, _, _| { + ctx.dispatch_typed_action(QueuedPromptsPanelAction::StartDrag(query_id)); + }) + .on_drag(|ctx, _, rect, _| { + ctx.dispatch_typed_action(QueuedPromptsPanelAction::DragMoved { rect }); + }) + .on_drop(|ctx, _, _, _| { + ctx.dispatch_typed_action(QueuedPromptsPanelAction::DropEnd); + }) + .finish(); + + SavePosition::new(draggable, &position_id).finish() +} + +/// Returns the user-visible header label for `count` queued prompts. +fn header_label_text(count: usize) -> String { + format!("{count} queued") +} diff --git a/app/src/terminal/view/queued_prompts_tests.rs b/app/src/terminal/view/queued_prompts_tests.rs new file mode 100644 index 0000000000..e16a280c48 --- /dev/null +++ b/app/src/terminal/view/queued_prompts_tests.rs @@ -0,0 +1,213 @@ +//! Tests for the auto-fire drain logic that runs from [`super::TerminalView::drain_queued_prompts`]. +//! +//! `TerminalView` orchestrates the input editor and the singleton `QueuedQueryModel` on +//! `FinishedReceivingOutput`. Constructing a full `TerminalView` in a unit test would require +//! dozens of dependencies, so the tests below exercise the per-conversation singleton semantics +//! that the drain path relies on. +use warpui::App; + +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::blocklist::{ + AutofireAction, BlocklistAIHistoryModel, QueuedQuery, QueuedQueryModel, QueuedQueryOrigin, +}; + +fn user_query(text: &str) -> QueuedQuery { + QueuedQuery::new(text.to_owned(), QueuedQueryOrigin::QueueSlashCommand) +} + +fn with_singleton(test: F) +where + F: FnOnce(App, warpui::ModelHandle, AIConversationId) + 'static, +{ + App::test((), |app| async move { + let _ = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + let model = app.add_singleton_model(QueuedQueryModel::new); + test(app, model, AIConversationId::new()); + }); +} + +#[test] +fn complete_drain_pops_head_and_returns_submit_action() { + // On Complete, the next queued prompt fires via Submit. + with_singleton(|mut app, model, conv| { + model.update(&mut app, |m, ctx| { + m.append(conv, user_query("first"), ctx); + m.append(conv, user_query("second"), ctx); + }); + + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + match action { + Some(AutofireAction::Submit { text }) => assert_eq!(text, "first"), + other => panic!("expected Submit, got {other:?}"), + } + model.read(&app, |m, _| { + assert_eq!(m.queue(conv).len(), 1); + assert_eq!(m.queue(conv)[0].text(), "second"); + }); + }); +} + +#[test] +fn complete_drain_with_first_row_in_edit_mode_returns_pop_from_edit_mode() { + // When the first row is being edited, drain produces a PopFromEditMode action carrying the + // row's last-committed text (per spec, NOT any uncommitted live-editor buffer text). + with_singleton(|mut app, model, conv| { + let id_a = model.update(&mut app, |m, ctx| m.append(conv, user_query("first"), ctx)); + model.update(&mut app, |m, ctx| { + m.append(conv, user_query("second"), ctx); + m.enter_edit_mode(conv, id_a, ctx); + }); + + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + match action { + Some(AutofireAction::PopFromEditMode { text }) => assert_eq!(text, "first"), + other => panic!("expected PopFromEditMode, got {other:?}"), + } + // Edit mode is cleared after pop. + model.read(&app, |m, _| { + assert_eq!(m.editing_row(conv), None); + assert_eq!(m.queue(conv).len(), 1); + assert_eq!(m.queue(conv)[0].text(), "second"); + }); + }); +} + +#[test] +fn complete_drain_with_non_empty_input_preserves_edited_head_row() { + // The host skips autofire when the queue head is being edited and the input already contains + // text, which leaves the queued row in place for the next completion. + with_singleton(|mut app, model, conv| { + let id_a = model.update(&mut app, |m, ctx| m.append(conv, user_query("first"), ctx)); + model.update(&mut app, |m, ctx| { + m.append(conv, user_query("second"), ctx); + m.enter_edit_mode(conv, id_a, ctx); + }); + + let simulated_input_is_non_empty = true; + if !(simulated_input_is_non_empty + && model.read(&app, |m, _| m.first_row_is_in_edit_mode(conv))) + { + model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + } + + model.read(&app, |m, _| { + assert_eq!(m.editing_row(conv), Some(id_a)); + assert_eq!(m.queue(conv).len(), 2); + assert_eq!(m.queue(conv)[0].text(), "first"); + assert_eq!(m.queue(conv)[1].text(), "second"); + }); + }); +} + +#[test] +fn complete_drain_with_empty_queue_returns_none() { + with_singleton(|mut app, model, conv| { + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + assert!(action.is_none()); + }); +} + +#[test] +fn error_or_cancel_drain_pops_front_when_input_is_empty() { + // On Error/Cancelled with an empty input, the next queued prompt's text is restored to the + // input by popping it (which the host then writes into the buffer). + with_singleton(|mut app, model, conv| { + model.update(&mut app, |m, ctx| { + m.append(conv, user_query("first"), ctx); + m.append(conv, user_query("second"), ctx); + }); + + let popped = model.update(&mut app, |m, ctx| m.pop_front(conv, ctx)); + let popped = popped.expect("queue had a head"); + assert_eq!(popped.text(), "first"); + model.read(&app, |m, _| { + assert_eq!(m.queue(conv).len(), 1); + assert_eq!(m.queue(conv)[0].text(), "second"); + }); + }); +} + +#[test] +fn error_or_cancel_drain_leaves_queue_intact_when_input_is_non_empty() { + // When the input is non-empty, the drain skips popping so the queue remains intact. + // + // The host (`TerminalView`) gates the pop on input-empty. We model that here by simply not + // popping when the simulated input is non-empty, and asserting the queue remains unchanged. + with_singleton(|mut app, model, conv| { + model.update(&mut app, |m, ctx| { + m.append(conv, user_query("first"), ctx); + m.append(conv, user_query("second"), ctx); + }); + + let simulated_input_is_non_empty = true; + if !simulated_input_is_non_empty { + model.update(&mut app, |m, ctx| m.pop_front(conv, ctx)); + } + + model.read(&app, |m, _| { + assert_eq!(m.queue(conv).len(), 2); + assert_eq!(m.queue(conv)[0].text(), "first"); + }); + }); +} + +#[test] +fn complete_drain_after_error_drain_continues_with_next_row() { + // After an Error/Cancelled drain pops one row and the user later submits successfully, the + // *next* Complete drain pops the following row. + with_singleton(|mut app, model, conv| { + model.update(&mut app, |m, ctx| { + m.append(conv, user_query("first"), ctx); + m.append(conv, user_query("second"), ctx); + m.append(conv, user_query("third"), ctx); + }); + + // Error: input is empty, pop "first" and restore to input. + let popped = model.update(&mut app, |m, ctx| m.pop_front(conv, ctx)); + assert_eq!( + popped.map(|q| q.text().to_owned()), + Some("first".to_owned()) + ); + + // Complete: pop "second". + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + match action { + Some(AutofireAction::Submit { text }) => assert_eq!(text, "second"), + other => panic!("expected Submit(\"second\"), got {other:?}"), + } + + // Complete again: pop "third". + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + match action { + Some(AutofireAction::Submit { text }) => assert_eq!(text, "third"), + other => panic!("expected Submit(\"third\"), got {other:?}"), + } + + // Queue is now empty; the next drain returns None. + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv, ctx)); + assert!(action.is_none()); + }); +} + +#[test] +fn drain_is_isolated_per_conversation() { + // A drain for conversation A must not pop rows from conversation B. + with_singleton(|mut app, model, conv_a| { + let conv_b = AIConversationId::new(); + model.update(&mut app, |m, ctx| { + m.append(conv_a, user_query("a-first"), ctx); + m.append(conv_b, user_query("b-first"), ctx); + }); + + let action = model.update(&mut app, |m, ctx| m.pop_for_autofire(conv_a, ctx)); + match action { + Some(AutofireAction::Submit { text }) => assert_eq!(text, "a-first"), + other => panic!("expected Submit(\"a-first\"), got {other:?}"), + } + model.read(&app, |m, _| { + assert_eq!(m.queue(conv_a).len(), 0); + assert_eq!(m.queue(conv_b).len(), 1); + assert_eq!(m.queue(conv_b)[0].text(), "b-first"); + }); + }); +} diff --git a/app/src/terminal/view/shared_session/view_impl.rs b/app/src/terminal/view/shared_session/view_impl.rs index 47bee75f50..ff2e51fcf9 100644 --- a/app/src/terminal/view/shared_session/view_impl.rs +++ b/app/src/terminal/view/shared_session/view_impl.rs @@ -1747,6 +1747,9 @@ impl TerminalView { } }); let tombstone_view_id = tombstone_view_handle.id(); + // The cloud-mode queued-prompt block is pinned to the bottom so it stays below any + // streaming agent output. When inserting the conversation-ended tombstone we want the + // tombstone below the queued prompt instead, so unpin the queued prompt first. if self.pending_user_query_kind == Some(PendingUserQueryKind::CloudMode) { if let Some(pending_query_view_id) = self.pending_user_query_view_id { self.model diff --git a/app/src/test_util/terminal.rs b/app/src/test_util/terminal.rs index 5e81c59cf9..c1ac045b7b 100644 --- a/app/src/test_util/terminal.rs +++ b/app/src/test_util/terminal.rs @@ -19,7 +19,7 @@ use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; use crate::ai::blocklist::orchestration_events::OrchestrationEventService; use crate::ai::blocklist::{ - BlocklistAIHistoryModel, BlocklistAIPermissions, SerializedBlockListItem, + BlocklistAIHistoryModel, BlocklistAIPermissions, QueuedQueryModel, SerializedBlockListItem, }; use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; use crate::ai::document::ai_document_model::AIDocumentModel; @@ -92,6 +92,9 @@ pub fn initialize_app_for_terminal_view(app: &mut App) { app.add_singleton_model(LocalWorkflows::new); app.add_singleton_model(|_| History::default()); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + // QueuedQueryModel subscribes to history events; register after the + // history model is in place. + app.add_singleton_model(QueuedQueryModel::new); // Pill bar model subscribes to history events; register after the // history model is in place. app.add_singleton_model(|ctx| OrchestrationPillBarModel::new(Default::default(), ctx)); diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index e1470412f7..c0ff01dfec 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -547,10 +547,6 @@ pub enum WorkspaceAction { /// Optional prompt to send after summarization completes successfully. initial_prompt: Option, }, - /// Queue a prompt to be sent after the current conversation finishes. - QueuePromptForConversation { - prompt: String, - }, /// Install the Warp CLI command to /usr/local/bin #[cfg(target_os = "macos")] InstallCLI, @@ -951,7 +947,6 @@ impl WorkspaceAction { | RunCommand { .. } | InsertInInput { .. } | InsertForkSlashCommand - | QueuePromptForConversation { .. } | AttemptLoginGatedAIUpgrade | UndoTrash(_) | OpenFilePath { .. } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 302d89f0ba..169d24c8b4 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -22655,24 +22655,6 @@ impl TypedActionView for Workspace { } => { self.summarize_active_ai_conversation(prompt.clone(), initial_prompt.clone(), ctx); } - QueuePromptForConversation { prompt } => { - let Some(terminal_view) = self - .active_tab_pane_group() - .as_ref(ctx) - .active_session_view(ctx) - else { - return; - }; - - terminal_view.update(ctx, |terminal, ctx| { - terminal.send_user_query_after_next_conversation_finished( - prompt.clone(), - /* show_close_button */ true, - /* show_send_now_button */ true, - ctx, - ); - }); - } InsertForkSlashCommand => { self.active_tab_pane_group().update(ctx, |pane_group, ctx| { if let Some(terminal_view) = pane_group.active_session_view(ctx) { diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 50d67a4810..d32a8e245a 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -138,6 +138,9 @@ fn initialize_app(app: &mut App) { ) }); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + // QueuedQueryModel subscribes to history events; register after the + // history model is in place. + app.add_singleton_model(crate::ai::blocklist::QueuedQueryModel::new); app.add_singleton_model(|ctx| OrchestrationPillBarModel::new(Default::default(), ctx)); app.add_singleton_model(|_| CLIAgentSessionsModel::new()); app.add_singleton_model(|_| ActiveAgentViewsModel::new()); diff --git a/specs/REMOTE-1543/PRODUCT.md b/specs/REMOTE-1543/PRODUCT.md new file mode 100644 index 0000000000..b60ffc9fd7 --- /dev/null +++ b/specs/REMOTE-1543/PRODUCT.md @@ -0,0 +1,86 @@ +# Queued Prompts UI + +## Summary +Introduce a collapsible "queued prompts" panel for regular Agent Mode queued prompts that supports multiple prompts, in-place editing, drag-to-reorder, and per-row delete. +Queued prompts run sequentially as the agent finishes each preceding exchange. +## Problem +Today regular Agent Mode queueing only supports a single follow-up prompt at a time. +Re-queueing replaces the previous prompt, the user can't reorder or edit what's pending, and there's no way to deal with multiple in-flight ideas without losing work. +## Goals +- Let users queue any number of follow-up prompts while the agent is responding, and have them auto-fire in order. +- Make the queued prompts visible, reorderable, editable, and individually removable from a single panel. +- Preserve the existing regular queue trigger surfaces: the auto-queue toggle and `/queue` slash command. +- Keep compatibility placeholder flows (`/compact-and`, `/fork-and-compact`, Cloud Mode prompts) on their existing pending-user-query UI instead of broadening the new panel. Expanding those surfaces to also use the queued prompt panel will come in a separate, follow-up PR. +- Reuse `QueueSlashCommand` as the regular queue rollout gate for both the trigger surfaces and the visual panel — no new flags introduced. +## Non-goals +- Persisting the queue across app restarts. +- Cross-conversation queueing (the queue belongs to the conversation it was filed against). +- Reordering or editing the prompt that's currently executing (only items still pending in the queue are editable). +- A "Send now" affordance to interrupt the in-flight exchange (explicitly removed; users cancel via the existing stop button if they want to fire a queued prompt earlier). +## Behavior +### Queue panel placement and visibility +1. The queue panel renders between the warping indicator (status bar) and the agent input box, anchored to the bottom of the conversation area, in the same vertical slot the inline menu uses when it's open. +2. The panel is visible whenever the active conversation has at least one queued prompt; otherwise the panel is not rendered (no empty state). +3. The panel has a header `" queued"` with a chevron icon. The body of the panel (everything below the header) is what collapses, not the header itself. Clicking the chevron (or anywhere on the header) toggles the panel between expanded (header + rows visible) and collapsed (only the header is visible). Default state is expanded. The collapsed state persists for the lifetime of the queue (across re-orderings, edits, deletions, additions). Adding a new prompt while collapsed does not auto-expand. +4. `/queue`, the auto-queue toggle, and the visual queue panel continue to be gated by `QueueSlashCommand`. `/compact-and` continues to be gated by `SummarizationConversationCommand`, and `/fork-and-compact` follows the existing fork-command availability. `PendingUserQueryIndicator` remains compatibility infrastructure for legacy pending-user-query placeholders, not a rollout switch for the regular queue panel. +### What gets queued +5. The auto-queue toggle in the warping indicator is per-conversation. When on for the active conversation, any prompt the user submits while that conversation is in progress (`InProgress` or `Blocked`) is appended to that conversation's queue rather than sent. When off, regular submits still cancel-and-resend (existing behavior). Toggling the button toggles the state for the active conversation only; switching to a different conversation shows that conversation's own toggle state. New conversations default the toggle to off. +6. `/queue ` appends `` to the queue when the active conversation is in progress, and behaves like a normal send when the conversation is idle (existing semantics). +7. `/compact-and ` and `/fork-and-compact ` do not create queued-prompts panel rows. Their follow-up prompts stay on the legacy pending-user-query UI while summarization or fork-then-summarization runs. +8. Cloud Mode prompts (both Oz and third-party harness flows) do not create queued-prompts panel rows. Their placeholders stay on the legacy pending-user-query UI and remain lifecycle-owned by the cloud setup / shared-session flow. +9. Submitting in shell mode (input type is Shell, not AI) is never queued — it runs in the terminal as today, regardless of toggle state or in-progress AI status. +10. `/queue` with an empty argument shows an error toast and does not modify the queue (existing behavior). +11. Queues and the auto-queue toggle state are owned per-conversation app-wide. Each conversation has its own queue and its own toggle state, both of which persist across agent-view exit, switching between conversations, and re-entry into the agent view. Switching to a different conversation shows that conversation's queue and toggle state; switching back restores the prior conversation's state. Queue and toggle state are dropped only when the conversation itself is deleted, or when its owning terminal view's conversations are cleared. +### Queue rows +12. Each queue row shows, left to right: + - A drag handle icon (six-dot grid). + - A compact multiline prompt preview, capped by both displayed height and character count so long prompts stay scannable in the queue. + - On hover: a pencil (edit) and a trash (delete) icon-button, right-aligned. +13. Hovering a row reveals the edit/delete icons. Moving the cursor off the row hides them. +14. Every row in the queued-prompts panel is a regular user-managed queued prompt, so the row interactions in (12)–(13) apply uniformly to every visible panel row. +15. Rows render in queue order from top (next to fire) to bottom (last to fire). +### Edit interaction +16. Clicking the pencil icon on a row replaces the row's static preview with an inline multiline editor pre-filled with the current prompt text and selects the entire prompt. +17. The editor is visually outlined while editing, grows until it reaches the same visual line cap as the static row preview, then scrolls internally with a visible scrollbar. Pressing Enter commits the edit (the row's prompt is replaced with the editor contents) and exits edit mode. An empty edit restores the original prompt text and exits edit mode. +18. Pressing Escape cancels the edit and restores the original prompt text. Clicking outside the row, including focusing the main input, commits the current editor text. +19. While a row is in edit mode, that row's drag handle is inert (the row cannot be reordered until the edit is committed or cancelled). Other rows can still be dragged. +20. Only one row can be in edit mode at a time. Clicking the pencil on a different row exits edit mode on the previous row without changing that row's last committed text, then enters edit mode on the new one. +21. Auto-fire never sends a row that is currently in edit mode. If the active conversation reaches `FinishReason::Complete` while the first queue row is in edit mode, then: + - If the main input is empty, that row is removed from the queue and — mirroring the delete behavior in (23)–(24) — the row's prompt text (its last committed value, not any uncommitted text still in the inline editor buffer) is placed in the main input box, and the input is focused. + - If the main input is non-empty, that row stays in the queue and the input is not modified. + Other queue rows are not affected and resume normal sequential firing on the next completion. If the row being edited is not the first row, auto-fire proceeds normally for the actual first row; the edited row is left in place and can become the next-to-fire after rows ahead of it drain. +### Delete interaction +22. Clicking the trash icon on a row removes that row from the queue. +23. If the input box is empty when a row is deleted, the deleted row's prompt text is placed in the input (replacing the empty buffer); the input gains focus. +24. If the input box is non-empty when a row is deleted, the deleted prompt is discarded — the input is not modified. +25. Deleting the last visible row in the queue removes the panel (since the queue is now empty); the collapsed/expanded state resets for any future queue. +### Drag-to-reorder +26. Dragging a row vertically reorders it within the queue. The dragged row is visually highlighted while the queue live-reorders around it. +27. Dropping the row commits the new order. The first row of the new order is what will fire next. +28. Dragging is constrained to the vertical axis — horizontal motion does not change order. +29. Rows reflow as the dragged item crosses the midpoint of a neighboring row, making the tentative new order visible before drop. Reflow live-mutates the queue, so auto-fire (and any other read of the queue head) always sees the current post-drag order rather than the pre-drag order. +30. If the queue is mutated by something other than the drag itself while a drag is active, behavior depends on which row was mutated. If a different row is removed (e.g. auto-fire pops the current head while the user is dragging a non-head row) or appended, the drag continues against the updated queue and subsequent reflow uses the new neighbor positions. If the dragged row itself is removed (only reachable when the dragged row is currently the head and auto-fire pops it), the row disappears and is submitted as the next user query, and the drag is cancelled silently: no reorder is committed, no reorder telemetry fires, and any further mouse motion before mouseup is inert. +31. Legacy pending-user-query placeholders for Cloud Mode, `/compact-and`, and `/fork-and-compact` are outside this panel, so they do not participate in drag-to-reorder. +### Sequential firing +32. When the active conversation reaches `FinishReason::Complete`, the first prompt in the queue is removed and submitted as the next user query in the same conversation, routed through the normal submission path so slash, skill, and session-sharing paths are handled correctly. +33. While that newly-fired prompt is mid-exchange, the rest of the queue stays intact, the panel updates the count to ` queued`, and additional prompts can still be queued at the tail. +34. The cycle continues until either the queue is empty or one of the abort conditions in (35) fires. +### Cancellation and error handling +35. When the active conversation finishes for any non-`Complete` reason — `Error`, `Cancelled`, `CancelledDuringRequestedCommandExecution` — auto-fire pauses immediately. The queue is not flushed. +36. When auto-fire pauses for one of those reasons, the head-restore behavior depends on whether the user is currently viewing the cancelled conversation in agent view: + - If the user is **not** viewing this conversation in agent view (e.g. they triggered the cancel by exiting the agent view, or the conversation was running in the background), the queue is left fully intact — neither the head nor any other row is placed into the input. + - If the user **is** viewing this conversation in agent view (e.g. stop button or `Ctrl-C` while in agent view): + - If the input is currently empty, the first queued prompt is removed from the queue and its text is placed in the input box. The row is removed in this case so that re-submitting the input does not also re-fire the same prompt from the queue. + - If the input is non-empty, the first prompt's text is not placed in the input and the queue is left intact (the first prompt remains in the queue at position 0). + - In all cases all queue rows beyond the first remain intact in the panel, so the user can review, edit, reorder, delete, or send further prompts. +37. Auto-fire resumes naturally the next time the active conversation reaches `FinishReason::Complete` — i.e. the user re-runs or sends a new prompt that succeeds, and from that completion onward the queue resumes draining from the top. +38. Manually cancelling the in-progress agent (stop button or `Ctrl-C` shortcut) is treated as `Cancelled` for the purposes of (35)–(36). +### Conversation lifecycle interactions +39. Exiting the agent view (Esc to terminal, closing the tab/pane) does not discard the queue for that conversation. Re-entering the agent view for that conversation later restores its queue and auto-queue toggle. This matters especially for cloud agents, whose conversations continue running in the background after the user leaves the agent view; their queues continue to drain when the conversation eventually finishes, even if the user has navigated away. +40. Starting a new conversation begins with an empty queue and the auto-queue toggle defaulted off — each conversation has its own queue and toggle, so prior conversations' state is unaffected. +41. The queue belongs to a conversation; if the agent splits the conversation (`/fork`, `/fork-and-compact`), regular queued-prompts panel rows do not carry into the new conversation. Any summarize/fork follow-up placeholder behavior remains separate legacy pending-user-query UI, not queue-panel state. +### Focus +42. The auto-queue toggle keybinding (`Cmd-Shift-J` / `Ctrl-Shift-J`) is unchanged. +43. Submitting from the main input always returns focus to the main input, even when the submission appended to the queue. +### Telemetry +44. Existing `/queue` and auto-queue telemetry events continue to fire. Queue-panel-specific interactions (edit committed, row deleted, row reordered, panel collapsed/expanded) are tracked as new telemetry events so we can measure usage of the new affordances. diff --git a/specs/REMOTE-1543/TECH.md b/specs/REMOTE-1543/TECH.md new file mode 100644 index 0000000000..dc0912c9e5 --- /dev/null +++ b/specs/REMOTE-1543/TECH.md @@ -0,0 +1,112 @@ +# Queued Prompts UI — Technical Spec +See `specs/REMOTE-1543/PRODUCT.md` for user-visible behavior. This document covers the implementation that supports that behavior. +## Context +Regular Agent Mode queued prompts are stored in an app-wide singleton keyed by conversation id, so queue state outlives the agent-view session that originated it. The rendered panel sits next to the input that hosts it, while queue data is owned globally. + +The implementation spans four ownership layers: +- `app/src/ai/blocklist/queued_query.rs` defines the `QueuedQueryModel` singleton. It owns every conversation's queue rows, edit state, and auto-queue toggle, indexed by `AIConversationId`. It self-manages cleanup by subscribing to `BlocklistAIHistoryModel` lifecycle events. +- `app/src/terminal/view.rs` wires the input and panel, subscribes to panel events for input mutation, and drains queued prompts when a conversation finishes — using the `conversation_id` carried on `BlocklistAIControllerEvent::FinishedReceivingOutput`. +- `app/src/terminal/view/queued_prompts_panel.rs` owns panel rendering and row-level interactions: collapse, edit, delete, drag reorder, and panel telemetry. The panel looks up the active conversation for its terminal view via `BlocklistAIHistoryModel` rather than duplicating that state. +- `app/src/terminal/input.rs` and `app/src/terminal/input/slash_commands/mod.rs` route regular queue trigger surfaces into the singleton, scoped to the conversation each trigger fires against. + +Cloud Mode placeholders and compact follow-up placeholders remain on the legacy pending-user-query path. They still use the rich-content machinery in `app/src/terminal/view/pending_user_query.rs`, `app/src/terminal/view/rich_content.rs`, and related terminal selection plumbing because their lifecycle is driven by cloud setup or summarize/fork workflows rather than by regular Agent Mode queue draining. +## Proposed changes +### Queue ownership and data model +`QueuedQueryModel` is an app-wide singleton (`SingletonEntity`) keyed by `AIConversationId`. It is the source of truth for every regular queued prompt anywhere in the app (`app/src/ai/blocklist/queued_query.rs`): +- `queues: HashMap` stores each conversation's per-conversation state. Conversations that have never been touched have no entry; reads against an absent key return empty/false, so a missing entry is indistinguishable from a conversation with an empty queue and toggle off. +- `ConversationQueueState { queue: Vec, editing: Option, queue_next_prompt_enabled: bool }` packages all per-conversation state in one struct so a conversation's queue, in-progress edit, and auto-queue toggle live and die together. +- `QueuedQueryId` gives each row stable identity across edit, delete, and reorder. +- `QueuedQueryOrigin` distinguishes `/queue` rows from auto-queue rows for telemetry without affecting firing semantics. + +Every queue/edit/toggle accessor takes the `conversation_id` it applies to, and the singleton emits `QueuedQueryEvent`s whose payload carries that `conversation_id` so subscribers can filter by the conversation they care about. Panel collapse is panel-local UI state and lives on `QueuedPromptsPanelView`, not on the singleton, because no other consumer cares. + +`QueuedQueryModel` self-manages cleanup by subscribing to `BlocklistAIHistoryModel`: +- `RemoveConversation { conversation_id, .. }` and `DeletedConversation { conversation_id, .. }` drop that conversation's `ConversationQueueState`. +- `ClearedConversationsInTerminalView { cleared_conversation_ids, .. }` drops every entry whose id appears in the cleared list. The history event is extended to include `cleared_conversation_ids: Vec` — `BlocklistAIHistoryModel::clear_conversations_in_terminal_view` already collects those ids; the event just needs to carry them. + +No subscription is added for `ExitedAgentView`. Conversations outlive their visible session — particularly for cloud agents — so the queue and its toggle state must too. +### Trigger routing and enqueue flow +All regular queue entry points target `QueuedQueryModel::append(conversation_id, ...)` on the singleton, scoped to the conversation the trigger fires against: +- The auto-queue path in `Input::maybe_queue_input_for_in_progress_conversation` verifies that the conversation-scoped toggle is enabled, AI input is active, the selected conversation is in progress or blocked, and the prompt is non-empty. It then calls the singleton to append an `AutoQueueToggle` row to that `conversation_id` (`app/src/terminal/input.rs`). The toggle read uses the same conversation id. +- `/queue ` appends a `QueueSlashCommand` row to the in-progress conversation's queue, and otherwise falls back to normal submission (`app/src/terminal/input/slash_commands/mod.rs`). The `conversation_id` is the in-progress conversation derived from the same gating check. +- The `ToggleQueueNextPrompt` action handler in `TerminalView` (`app/src/terminal/view.rs`) looks up the view's active conversation id via `BlocklistAIHistoryModel::active_conversation_id(self.view_id)` and calls `QueuedQueryModel::toggle_queue_next_prompt(conversation_id, ctx)`. When the view has no active conversation, the action is a no-op. + +`FeatureFlag::QueueSlashCommand` remains the single gate for the regular queue experience. It covers the trigger surfaces above and the panel attachment/render path; there is no separate panel-specific rollout switch. +### Panel composition and interaction ownership +`Input::new` constructs `QueuedPromptsPanelView` when the regular queue feature is available, passing the parent terminal view's `EntityId` so the panel can look up the active conversation it should render for (`app/src/terminal/input.rs`). The panel handle is stored on `Input`, and the input render tree places the panel between the status bar and the editor (`app/src/terminal/input/agent.rs`), matching the product placement contract. `Input` subscribes to panel events for cross-component side effects. + +`QueuedPromptsPanelView` intentionally owns only queue-panel concerns (`app/src/terminal/view/queued_prompts_panel.rs`): +- It renders the queue header, expanded rows, hover controls, inline edit editor, and drag handles. +- Static rows render bounded multiline previews: prompt text is character-trimmed before rendering, then constrained to a compact maximum height so queued prompts remain readable without letting one row dominate the panel. +- Edit mode reuses the multiline editor pattern used elsewhere in the client (`EditorOptions` with autogrow + soft wrap), constrains the editor to the same visual height as the static preview, and wraps it in a clipped outlined scroll surface with a scrollbar so larger edits stay bounded inside the row. +- It mutates queue state through `QueuedQueryModel` methods scoped to the panel's current active conversation: `enter_edit_mode`, `remove_by_id`, `commit_edit`, `cancel_edit`, and `reorder`. +- It emits higher-level `QueuedPromptsPanelEvent`s when the host view must coordinate with input focus or buffer placement. +- Panel-only UI state — `collapsed`, `row_states`, drag state — lives on the view itself, not on the singleton, because no other consumer cares. + +The panel resolves "which conversation am I rendering for?" via `BlocklistAIHistoryModel::active_conversation_id(terminal_view_id)`. It subscribes to `BlocklistAIHistoryEvent::SetActiveConversation` filtered to its terminal view so it can re-seed `row_states` and reset `collapsed` when the active conversation changes. It also subscribes to `QueuedQueryModel` events and ignores any whose `conversation_id` does not match the current active conversation. + +`TerminalView::handle_queued_prompts_panel_event` (delegated through `Input`) owns the cross-component consequences the panel should not perform directly: focus restoration and placing deleted text into the main input when the input is empty. +### Drain behavior and conversation lifecycle +When a conversation finishes, `TerminalView` decides how that conversation's queued prompts advance; the queued prompts panel only renders and edits queued rows. `BlocklistAIControllerEvent::FinishedReceivingOutput` carries the `conversation_id` that just finished, and `TerminalView::handle_ai_controller_event` threads it through `handle_finished_conversation` into `drain_queued_prompts(conversation_id, finish_reason, ctx)` (`app/src/terminal/view.rs`). Explicit threading keeps drain correct when the finishing conversation is not the view's currently-active conversation (e.g. a backgrounded child agent). + +`drain_queued_prompts` branches on `FinishReason`: +- `Complete`: pop one queued row from that conversation via `QueuedQueryModel::pop_for_autofire(conversation_id, ctx)`; submit it through `Input::submit_queued_prompt`, or place it into the input if the row was first in queue and in edit mode. +- `Error`, `Cancelled`, or `CancelledDuringRequestedCommandExecution`: first gate on `AgentViewController::agent_view_state().active_conversation_id() == Some(conversation_id)` — restore only when the user is currently viewing this conversation in agent view. `AgentViewController::exit_agent_view_internal` flips the state to `Inactive` before emitting `ExitedAgentView`, so cancels driven by agent-view exit reach the drain with the state already cleared and skip the restore. Backgrounded conversations the user isn't viewing also skip on the same check. Once gated past that, if the input is empty, pop the first row of that conversation's queue and place its text into the input; otherwise leave the queue untouched. + +The model owns row-removal details and per-conversation state mutation, while the terminal owns submission and input mutation. This division keeps queue semantics testable in `queued_query_tests.rs` while preserving terminal-specific side effects in `queued_prompts_tests.rs`. + +Queue and toggle state are dropped only by `QueuedQueryModel`'s own subscriptions to `BlocklistAIHistoryModel`: +- `RemoveConversation { conversation_id, .. }` and `DeletedConversation { conversation_id, .. }` drop that conversation's `ConversationQueueState` entry. +- `ClearedConversationsInTerminalView { cleared_conversation_ids, .. }` drops every entry in the cleared list. The event is extended to carry `cleared_conversation_ids: Vec` alongside the existing `active_conversation_id`. +- `ExitedAgentView` is intentionally not subscribed to. Conversations and their queues outlive the agent-view session that originated them; re-entering the agent view, or switching to the conversation from anywhere else, restores the same queue and toggle state. +### Compatibility boundary for legacy pending placeholders +The regular queue subsystem does not absorb placeholder flows whose lifecycle is unrelated to conversation-completion draining: +- Cloud Mode initial/follow-up placeholders continue using pending-user-query rich content. +- `/compact-and` and `/fork-and-compact` continue using the summarize/fork placeholder path. + +This boundary matters architecturally because these placeholders are owned by cloud or summarize/fork workflows, not by `QueuedQueryModel`. Keeping them separate avoids forcing prompt placeholders into queue APIs whose responsibilities are append, inspect, edit, reorder, and drain regular Agent Mode follow-ups. +### Telemetry +Panel-only interaction telemetry is emitted from `QueuedPromptsPanelView`, where the interaction actually occurs: +- `QueuedPrompt.Edited` +- `QueuedPrompt.Deleted` +- `QueuedPrompt.Reordered` +- `QueuedPrompt.PanelCollapseToggled` + +`app/src/server/telemetry/events.rs (1205-1228, 2947-2971, 5848-5859)` mirrors queue-row origin into telemetry payloads and associates these events with `FeatureFlag::QueueSlashCommand`. +## End-to-end flow +```mermaid +flowchart LR + A["Input auto-queue or /queue"] --> B["QueuedQueryModel::append(conversation_id, ...)"] + B --> E["Panel renders queue for the view's active conversation"] + E --> F["Panel edits / deletes / reorders rows"] + F --> G["QueuedQueryModel mutates the conversation's state"] + G --> E + H["FinishedReceivingOutput { conversation_id }"] --> I["TerminalView::drain_queued_prompts(conversation_id, ...)"] + I --> J["QueuedQueryModel::pop_for_autofire(conversation_id)"] + J --> K["Submit queued prompt via Input"] + J --> L["Restore text into input when behavior requires it"] + M["RemoveConversation / DeletedConversation / ClearedConversationsInTerminalView"] --> N["QueuedQueryModel drops affected ConversationQueueState entries"] +``` +## Testing and validation +Map tests directly to the product behavior in `specs/REMOTE-1543/PRODUCT.md`: +- Behaviors 4-11: regular queue gating, `/queue`, auto-queue, shell-mode exclusion, and per-conversation isolation should stay covered by terminal/input-level tests plus slash-command coverage. Add coverage that appending or toggling on one conversation does not affect another conversation's state. +- Behaviors 12-31: row rendering, collapse/edit/delete/reorder semantics belong in `app/src/terminal/view/queued_prompts_tests.rs` and `app/src/ai/blocklist/queued_query_tests.rs`. Tests construct queues against explicit conversation ids and assert per-conversation isolation. +- Behaviors 32-38: sequential firing, edit-mode drain handling, and cancellation/error restoration belong in `TerminalView::drain_queued_prompts` coverage in `app/src/terminal/view/queued_prompts_tests.rs`. Drain tests construct the queue for a specific conversation id and verify the popped row is from that conversation. +- Behaviors 39-41: conversation/terminal/Agent View cleanup belong in queue-model lifecycle tests. Specifically cover (a) `ExitedAgentView` does not touch queue state, (b) `RemoveConversation`/`DeletedConversation` drops only the targeted conversation's entry, and (c) `ClearedConversationsInTerminalView` drops every conversation in `cleared_conversation_ids`. Also cover the auto-queue toggle: it persists across agent-view exit and is dropped together with the queue on cleanup. +- Behavior 44: telemetry payload/origin plumbing should be covered where telemetry event serialization or event wiring already has local test patterns. + +Validation for this implementation should use: +- `cargo fmt` +- Targeted compile/test coverage for queued prompt model and terminal view queue behavior +- Full presubmit before PR submission + +Do not run the app as part of this change. +## Parallelization +Parallel child agents are not especially helpful for implementing this feature because the queue model, panel, input routing, and terminal drain semantics share tight ownership boundaries and must remain consistent across one architectural thread. Review and validation can be parallelized later, but the primary implementation should stay in a single workstream to avoid churn across the same types and event contracts. +## Risks and mitigations +- **Queue lifecycle drifting from conversation lifecycle**: centralize cleanup inside `QueuedQueryModel`'s own subscriptions to `BlocklistAIHistoryModel` (deletion + clear-conversations). `TerminalView` is no longer responsible for clearing queue state; agent-view exit is intentionally not a cleanup trigger. +- **Drain firing against the wrong conversation**: thread `conversation_id` from `BlocklistAIControllerEvent::FinishedReceivingOutput` explicitly into `drain_queued_prompts` rather than relying on the view's "active conversation" being the same as the finished one. +- **Panel rendering stale rows when active conversation changes**: re-seed `row_states` and reset `collapsed` in the panel's `BlocklistAIHistoryEvent::SetActiveConversation` subscription, and ignore `QueuedQueryEvent`s whose `conversation_id` does not match the current active conversation. +- **Panel owning terminal/input side effects**: keep focus restoration and input-buffer placement in `TerminalView::handle_queued_prompts_panel_event`. +- **Drain behavior losing edit-mode or cancellation semantics**: keep firing policy in `TerminalView::drain_queued_prompts` and row-removal mechanics in `QueuedQueryModel`. +- **Compatibility placeholders leaking into regular queue abstractions**: keep Cloud Mode and compact follow-up placeholders on their existing pending-user-query path because their ownership and removal semantics differ.