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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/terminal/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>,
) {
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);
Expand Down
58 changes: 58 additions & 0 deletions app/src/terminal/input_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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::<String>::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 {
Expand Down
102 changes: 97 additions & 5 deletions app/src/terminal/view/queued_prompts_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -61,15 +63,27 @@ fn build_row_state(
ctx: &mut ViewContext<QueuedPromptsPanelView>,
) -> QueuedPromptRowState {
let is_initial_cloud_mode_prompt = origin == QueuedQueryOrigin::InitialCloudMode;
// 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,
)
} else {
("Edit queued prompt", "Delete queued prompt")
("Edit", "Delete")
};

let send_now_button = ctx.add_typed_action_view(move |_| {
ActionButton::new("", NakedTheme)
.with_icon(TerminalIcon::ArrowUp)
.with_tooltip("Send now")
.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)
Expand Down Expand Up @@ -99,6 +113,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(),
Expand All @@ -109,6 +124,7 @@ fn build_row_state(
struct QueuedPromptRowState {
mouse_state: MouseStateHandle,
drag_handle_tooltip_state: MouseStateHandle,
send_now_button: ViewHandle<ActionButton>,
edit_button: ViewHandle<ActionButton>,
delete_button: ViewHandle<ActionButton>,
draggable_state: DraggableState,
Expand Down Expand Up @@ -143,17 +159,19 @@ pub struct QueuedPromptsPanelView {
#[derive(Clone, Debug)]
pub enum QueuedPromptsPanelAction {
ToggleCollapsed,
SendNow(QueuedQueryId),
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.
/// 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 },
Expand Down Expand Up @@ -229,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<Self>) {
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(
Expand Down Expand Up @@ -297,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)
Expand Down Expand Up @@ -336,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 { .. }
Expand Down Expand Up @@ -436,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<bool> {
self.row_states
.get(&query_id)
.map(|state| state.send_now_button.as_ref(ctx).is_disabled())
}
}

impl TypedActionView for QueuedPromptsPanelView {
type Action = QueuedPromptsPanelAction;

Expand All @@ -454,6 +530,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| {
Expand Down Expand Up @@ -781,6 +871,7 @@ fn render_row(props: RenderRowProps<'_>) -> Box<dyn Element> {
let QueuedPromptRowState {
mouse_state,
drag_handle_tooltip_state,
send_now_button,
edit_button,
delete_button,
draggable_state,
Expand Down Expand Up @@ -878,6 +969,7 @@ fn render_row(props: RenderRowProps<'_>) -> Box<dyn Element> {
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());
}
Expand Down
Loading
Loading