From c14f4e7872ab2432113df659f555dda33886773b Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 29 May 2026 11:46:10 -0400 Subject: [PATCH 1/2] add send now button --- app/src/terminal/input.rs | 6 +- app/src/terminal/input_tests.rs | 58 +++++++++++++++++++ app/src/terminal/view/queued_prompts_panel.rs | 44 ++++++++++++-- app/src/terminal/view/queued_prompts_tests.rs | 57 +++++++++++++++++- 4 files changed, 155 insertions(+), 10 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 5e46ec76ed..5d03d4368b 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -3676,14 +3676,16 @@ 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::SendNow { text } => { + self.submit_queued_prompt_for_active_pane(text.clone(), ctx); + self.focus_input_box(ctx); + } QueuedPromptsPanelEvent::RowDeleted { text } => { if self.buffer_text(ctx).is_empty() { self.replace_buffer_content(text, ctx); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index c5ef9f91b7..87ab22ca82 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -1,4 +1,6 @@ +use std::cell::RefCell; use std::collections::HashSet; +use std::rc::Rc; use std::time::Duration; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; @@ -1116,6 +1118,62 @@ fn test_history_up_for_shared_session_executor() { }); } +#[test] +fn send_now_event_submits_through_active_pane_and_preserves_draft() { + // A queued-prompt "send now" surfaces as a SendNow event on the input. The host should + // immediately route the removed prompt through the active-pane submission path (here, the + // shared-session viewer path, which emits SendAgentPrompt) without clobbering a draft the + // user has typed locally. + App::test((), |mut app| async move { + initialize_app(&mut app); + + let tips_model = app.add_model(|_| TipsCompleted::default()); + let (_, terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { + TerminalView::new_for_test(tips_model, None, ctx) + }); + terminal.update(&mut app, |view, _| { + let mut model = view.model.lock(); + model.block_list_mut().set_bootstrapped(); + model + .block_list_mut() + .active_block_for_test() + .set_session_id(SessionId::from(0)); + model.set_shared_session_status(SharedSessionStatus::executor()); + }); + + let input = terminal.read(&app, |view, _| view.input().clone()); + + let submitted_prompts = Rc::new(RefCell::new(Vec::::new())); + let submitted_prompts_for_subscription = submitted_prompts.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { + if let super::Event::SendAgentPrompt { prompt, .. } = event { + submitted_prompts_for_subscription + .borrow_mut() + .push(prompt.clone()); + } + }); + }); + + input.update(&mut app, |input, ctx| { + input.replace_buffer_content("draft in progress", ctx); + input.handle_queued_prompts_panel_event( + &QueuedPromptsPanelEvent::SendNow { + text: "queued prompt".to_owned(), + }, + ctx, + ); + }); + + // The queued prompt was submitted immediately... + assert_eq!(submitted_prompts.borrow().as_slice(), ["queued prompt"]); + // ...and the in-progress draft the user typed was left untouched. + input.read(&app, |input, ctx| { + assert_eq!(input.buffer_text(ctx), "draft in progress"); + }); + }); +} + #[test] fn test_history_up_multiline() { App::test((), |mut app| async move { diff --git a/app/src/terminal/view/queued_prompts_panel.rs b/app/src/terminal/view/queued_prompts_panel.rs index 86d8a25355..3f4e279d89 100644 --- a/app/src/terminal/view/queued_prompts_panel.rs +++ b/app/src/terminal/view/queued_prompts_panel.rs @@ -4,8 +4,8 @@ //! 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. +//! hover, drag) locally. Emits high-level events for immediate submission, deletion, and edit +//! completion, which the host uses to submit or update the input editor. use std::collections::HashMap; use pathfinder_color::ColorU; @@ -61,15 +61,26 @@ fn build_row_state( ctx: &mut ViewContext, ) -> QueuedPromptRowState { let is_initial_cloud_mode_prompt = origin == QueuedQueryOrigin::InitialCloudMode; - let (edit_tooltip, delete_tooltip) = if is_initial_cloud_mode_prompt { + let (send_now_tooltip, edit_tooltip, delete_tooltip) = if is_initial_cloud_mode_prompt { ( INITIAL_CLOUD_MODE_PROMPT_TOOLTIP, INITIAL_CLOUD_MODE_PROMPT_TOOLTIP, + INITIAL_CLOUD_MODE_PROMPT_TOOLTIP, ) } else { - ("Edit queued prompt", "Delete queued prompt") + ("Send now", "Edit", "Delete") }; + let send_now_button = ctx.add_typed_action_view(move |_| { + ActionButton::new("", NakedTheme) + .with_icon(TerminalIcon::ArrowUp) + .with_tooltip(send_now_tooltip) + .with_size(ButtonSize::XSmall) + .with_disabled_theme(NakedTheme) + .on_click(move |ctx| { + ctx.dispatch_typed_action(QueuedPromptsPanelAction::SendNow(query_id)); + }) + }); let edit_button = ctx.add_typed_action_view(move |_| { ActionButton::new("", NakedTheme) .with_icon(TerminalIcon::Pencil) @@ -92,6 +103,7 @@ fn build_row_state( }); if is_initial_cloud_mode_prompt { + send_now_button.update(ctx, |button, ctx| button.set_disabled(true, ctx)); edit_button.update(ctx, |button, ctx| button.set_disabled(true, ctx)); delete_button.update(ctx, |button, ctx| button.set_disabled(true, ctx)); } @@ -99,6 +111,7 @@ fn build_row_state( QueuedPromptRowState { mouse_state: MouseStateHandle::default(), drag_handle_tooltip_state: MouseStateHandle::default(), + send_now_button, edit_button, delete_button, draggable_state: DraggableState::default(), @@ -109,6 +122,7 @@ fn build_row_state( struct QueuedPromptRowState { mouse_state: MouseStateHandle, drag_handle_tooltip_state: MouseStateHandle, + send_now_button: ViewHandle, edit_button: ViewHandle, delete_button: ViewHandle, draggable_state: DraggableState, @@ -143,6 +157,7 @@ pub struct QueuedPromptsPanelView { #[derive(Clone, Debug)] pub enum QueuedPromptsPanelAction { ToggleCollapsed, + SendNow(QueuedQueryId), StartEditingRow(QueuedQueryId), DeleteRow(QueuedQueryId), StartDrag(QueuedQueryId), @@ -150,10 +165,11 @@ pub enum QueuedPromptsPanelAction { 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. +/// Events emitted to the host input view. #[derive(Clone, Debug)] pub enum QueuedPromptsPanelEvent { + /// A row was removed via its send-now button. The host should immediately submit `text`. + SendNow { text: String }, /// 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 }, @@ -454,6 +470,20 @@ impl TypedActionView for QueuedPromptsPanelView { ); ctx.notify(); } + QueuedPromptsPanelAction::SendNow(query_id) => { + let query_id = *query_id; + if self.editing_row_id(ctx) == Some(query_id) { + self.commit_edit(ctx); + } + + let removed = QueuedQueryModel::handle(ctx) + .update(ctx, |model, ctx| model.remove_by_id(conv_id, query_id, ctx)); + if let Some(removed) = removed { + ctx.emit(QueuedPromptsPanelEvent::SendNow { + text: removed.text().to_owned(), + }); + } + } QueuedPromptsPanelAction::StartEditingRow(query_id) => { let query_id = *query_id; QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| { @@ -781,6 +811,7 @@ fn render_row(props: RenderRowProps<'_>) -> Box { let QueuedPromptRowState { mouse_state, drag_handle_tooltip_state, + send_now_button, edit_button, delete_button, draggable_state, @@ -878,6 +909,7 @@ fn render_row(props: RenderRowProps<'_>) -> Box { let mut buttons = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(4.); + buttons.add_child(ChildView::new(&send_now_button).finish()); if !is_in_edit_mode { buttons.add_child(ChildView::new(&edit_button).finish()); } diff --git a/app/src/terminal/view/queued_prompts_tests.rs b/app/src/terminal/view/queued_prompts_tests.rs index c72c22928e..7dbf8870eb 100644 --- a/app/src/terminal/view/queued_prompts_tests.rs +++ b/app/src/terminal/view/queued_prompts_tests.rs @@ -9,8 +9,11 @@ use std::rc::Rc; use std::str::FromStr; use warpui::platform::WindowStyle; -use warpui::{App, SingletonEntity, ViewContext, ViewHandle}; +use warpui::{App, SingletonEntity, TypedActionView, ViewContext, ViewHandle}; +use super::queued_prompts_panel::{ + QueuedPromptsPanelAction, QueuedPromptsPanelEvent, QueuedPromptsPanelView, +}; use super::TerminalView; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::UserQueryMode; @@ -25,7 +28,7 @@ use crate::terminal::input::Event as InputEvent; use crate::terminal::shared_session::SharedSessionStatus; use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent; use crate::test_util::settings::initialize_settings_for_tests; -use crate::test_util::terminal::initialize_app_for_terminal_view; +use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view}; fn user_query(text: &str) -> QueuedQuery { QueuedQuery::new(text.to_owned(), QueuedQueryOrigin::QueueSlashCommand) @@ -745,3 +748,53 @@ fn drain_is_isolated_per_conversation() { }); }); } + +#[test] +fn send_now_action_removes_row_and_emits_send_now_event() { + // Clicking "send now" on a queued row removes exactly that row and asks the host to submit its + // text immediately. The locked initial cloud-mode row is rejected by the model (covered by + // `initial_cloud_mode_head_rejects_user_mutations_and_autofire`) and has its button disabled + // in the panel, so it needs no separate panel test. + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + // The panel keys its queue lookups on the history model's active conversation for its + // terminal view, so seed one and build the panel as a child of that terminal view. + let terminal = add_window_with_terminal(&mut app, None); + let terminal_view_id = terminal.read(&app, |view, _| view.view_id); + let conversation_id = + BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { + let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx); + history.set_active_conversation_id(id, terminal_view_id, ctx); + id + }); + let panel = terminal.update(&mut app, |_, ctx| { + ctx.add_view(|ctx| QueuedPromptsPanelView::new(terminal_view_id, ctx)) + }); + + let query_id = QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { + model.append(conversation_id, user_query("send me now"), ctx) + }); + + let send_now_events = Rc::new(RefCell::new(Vec::::new())); + let send_now_events_for_subscription = send_now_events.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&panel, move |_, event: &QueuedPromptsPanelEvent, _| { + if let QueuedPromptsPanelEvent::SendNow { text } = event { + send_now_events_for_subscription + .borrow_mut() + .push(text.clone()); + } + }); + }); + + panel.update(&mut app, |panel, ctx| { + panel.handle_action(&QueuedPromptsPanelAction::SendNow(query_id), ctx); + }); + + assert_eq!(send_now_events.borrow().as_slice(), ["send me now"]); + QueuedQueryModel::handle(&app).read(&app, |model, _| { + assert!(model.queue(conversation_id).is_empty()); + }); + }); +} From e18ec6da2c86a15d0cb4964376c10e1b8019f3bc Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 29 May 2026 13:03:09 -0400 Subject: [PATCH 2/2] disable send now button while cloud mode setup is in progress --- app/src/terminal/view/queued_prompts_panel.rs | 70 ++++++++++++++++-- app/src/terminal/view/queued_prompts_tests.rs | 74 ++++++++++++++++++- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/app/src/terminal/view/queued_prompts_panel.rs b/app/src/terminal/view/queued_prompts_panel.rs index 3f4e279d89..8a8c1da6a6 100644 --- a/app/src/terminal/view/queued_prompts_panel.rs +++ b/app/src/terminal/view/queued_prompts_panel.rs @@ -48,6 +48,8 @@ use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme const MAX_PROMPT_LINES: f32 = 5.; const INITIAL_CLOUD_MODE_PROMPT_TOOLTIP: &str = "The first cloud-mode prompt cannot be changed."; +const SEND_NOW_DURING_CLOUD_SETUP_TOOLTIP: &str = + "Prompts cannot be sent until environment setup is complete."; /// 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. @@ -61,20 +63,21 @@ fn build_row_state( ctx: &mut ViewContext, ) -> QueuedPromptRowState { let is_initial_cloud_mode_prompt = origin == QueuedQueryOrigin::InitialCloudMode; - let (send_now_tooltip, edit_tooltip, delete_tooltip) = if is_initial_cloud_mode_prompt { + // The send-now tooltip is owned by `update_send_now_availability`, which swaps in a + // "wait for the cloud agent" message while send-now is disabled; "Send now" is the default. + let (edit_tooltip, delete_tooltip) = if is_initial_cloud_mode_prompt { ( INITIAL_CLOUD_MODE_PROMPT_TOOLTIP, INITIAL_CLOUD_MODE_PROMPT_TOOLTIP, - INITIAL_CLOUD_MODE_PROMPT_TOOLTIP, ) } else { - ("Send now", "Edit", "Delete") + ("Edit", "Delete") }; let send_now_button = ctx.add_typed_action_view(move |_| { ActionButton::new("", NakedTheme) .with_icon(TerminalIcon::ArrowUp) - .with_tooltip(send_now_tooltip) + .with_tooltip("Send now") .with_size(ButtonSize::XSmall) .with_disabled_theme(NakedTheme) .on_click(move |ctx| { @@ -103,7 +106,6 @@ fn build_row_state( }); if is_initial_cloud_mode_prompt { - send_now_button.update(ctx, |button, ctx| button.set_disabled(true, ctx)); edit_button.update(ctx, |button, ctx| button.set_disabled(true, ctx)); delete_button.update(ctx, |button, ctx| button.set_disabled(true, ctx)); } @@ -245,6 +247,46 @@ impl QueuedPromptsPanelView { .entry(id) .or_insert_with(|| build_row_state(id, origin, ctx)); } + self.update_send_now_availability(ctx); + } + + /// Updates each row's "send now" button: disabled, with a tooltip explaining the wait, for the + /// locked initial cloud-mode prompt and for every row while that locked row sits at the head of + /// the queue — i.e. while the cloud environment is still setting up, with no live agent yet to + /// receive an immediate submission. Otherwise it is enabled with the default "Send now" tooltip. + fn update_send_now_availability(&mut self, ctx: &mut ViewContext) { + let Some(conv_id) = self.active_conversation_id else { + return; + }; + + let rows: Vec<(QueuedQueryId, QueuedQueryOrigin)> = QueuedQueryModel::as_ref(ctx) + .queue(conv_id) + .iter() + .map(|query| (query.id(), query.origin())) + .collect(); + let cloud_setup_in_progress = rows + .first() + .is_some_and(|(_, origin)| *origin == QueuedQueryOrigin::InitialCloudMode); + for (query_id, origin) in &rows { + let Some(send_now_button) = self + .row_states + .get(query_id) + .map(|state| state.send_now_button.clone()) + else { + continue; + }; + let disabled = + *origin == QueuedQueryOrigin::InitialCloudMode || cloud_setup_in_progress; + let tooltip = if disabled { + SEND_NOW_DURING_CLOUD_SETUP_TOOLTIP + } else { + "Send now" + }; + send_now_button.update(ctx, |button, ctx| { + button.set_disabled(disabled, ctx); + button.set_tooltip(Some(tooltip), ctx); + }); + } } fn handle_history_event( @@ -313,6 +355,8 @@ impl QueuedPromptsPanelView { if !QueuedQueryModel::as_ref(ctx).has_queue(active_conv_id) { self.collapsed = false; } + // Removing the locked initial cloud-mode row re-enables the remaining rows. + self.update_send_now_availability(ctx); } QueuedQueryEvent::EditEntered { query_id, .. } => { let initial_text = QueuedQueryModel::as_ref(ctx) @@ -352,6 +396,8 @@ impl QueuedPromptsPanelView { .entry(*query_id) .or_insert_with(|| build_row_state(*query_id, origin, ctx)); } + // A new row queued while the locked initial row is present must start disabled. + self.update_send_now_availability(ctx); } QueuedQueryEvent::Reordered { .. } | QueuedQueryEvent::QueueNextPromptToggled { .. } @@ -452,6 +498,20 @@ impl QueuedPromptsPanelView { } } +#[cfg(test)] +impl QueuedPromptsPanelView { + /// Test accessor: whether the "send now" button for `query_id` is currently disabled. + pub(super) fn send_now_button_disabled_for_test( + &self, + query_id: QueuedQueryId, + ctx: &AppContext, + ) -> Option { + self.row_states + .get(&query_id) + .map(|state| state.send_now_button.as_ref(ctx).is_disabled()) + } +} + impl TypedActionView for QueuedPromptsPanelView { type Action = QueuedPromptsPanelAction; diff --git a/app/src/terminal/view/queued_prompts_tests.rs b/app/src/terminal/view/queued_prompts_tests.rs index 7dbf8870eb..89a9e9f2e1 100644 --- a/app/src/terminal/view/queued_prompts_tests.rs +++ b/app/src/terminal/view/queued_prompts_tests.rs @@ -768,8 +768,14 @@ fn send_now_action_removes_row_and_emits_send_now_event() { history.set_active_conversation_id(id, terminal_view_id, ctx); id }); + let suggestions_mode_model = { + let input = terminal.read(&app, |view, _| view.input.clone()); + input.read(&app, |input, _| input.suggestions_mode_model().clone()) + }; let panel = terminal.update(&mut app, |_, ctx| { - ctx.add_view(|ctx| QueuedPromptsPanelView::new(terminal_view_id, ctx)) + ctx.add_view(move |ctx| { + QueuedPromptsPanelView::new(terminal_view_id, suggestions_mode_model, ctx) + }) }); let query_id = QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { @@ -798,3 +804,69 @@ fn send_now_action_removes_row_and_emits_send_now_event() { }); }); } + +#[test] +fn send_now_disabled_for_all_rows_while_initial_cloud_mode_row_is_present() { + // While the locked initial cloud-mode prompt sits at the head (cloud environment setup), + // every queued row's "send now" is disabled — there is no live agent to receive it yet. Once + // that row is removed (the agent picked up the prompt), the remaining follow-up rows are + // re-enabled. + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + let terminal_view_id = terminal.read(&app, |view, _| view.view_id); + let conversation_id = + BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| { + let id = history.start_new_conversation(terminal_view_id, false, false, false, ctx); + history.set_active_conversation_id(id, terminal_view_id, ctx); + id + }); + let suggestions_mode_model = { + let input = terminal.read(&app, |view, _| view.input.clone()); + input.read(&app, |input, _| input.suggestions_mode_model().clone()) + }; + let panel = terminal.update(&mut app, |_, ctx| { + ctx.add_view(move |ctx| { + QueuedPromptsPanelView::new(terminal_view_id, suggestions_mode_model, ctx) + }) + }); + + // The locked initial cloud-mode prompt, plus a follow-up queued during setup. + let (initial_id, followup_id) = + QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { + let initial_id = model.append( + conversation_id, + QueuedQuery::new("initial".to_owned(), QueuedQueryOrigin::InitialCloudMode), + ctx, + ); + let followup_id = model.append(conversation_id, user_query("follow up"), ctx); + (initial_id, followup_id) + }); + + // During setup, both rows' "send now" is disabled. + panel.read(&app, |panel, ctx| { + assert_eq!( + panel.send_now_button_disabled_for_test(initial_id, ctx), + Some(true) + ); + assert_eq!( + panel.send_now_button_disabled_for_test(followup_id, ctx), + Some(true) + ); + }); + + // The agent picks up the prompt — the locked initial row is removed. + QueuedQueryModel::handle(&app).update(&mut app, |model, ctx| { + model.remove_initial_cloud_mode_row(conversation_id, ctx); + }); + + // The remaining follow-up row's "send now" is re-enabled. + panel.read(&app, |panel, ctx| { + assert_eq!( + panel.send_now_button_disabled_for_test(followup_id, ctx), + Some(false) + ); + }); + }); +}