From 133b3df513c462996141765c4d8392853610bafa Mon Sep 17 00:00:00 2001 From: shadowbroker Date: Fri, 9 Oct 2026 13:48:02 -0600 Subject: [PATCH] Ai tab: Enter tells a working agent now, and queued messages can be sent now While the agent worked, Enter queued the message until the turn ended. A QC turn runs for hours, so "I plugged in the customer's two drives" sat in DESKTOP-VRT0DTD's queue for 50 minutes. - Enter while the agent works sends a steer: the agent reads the message after its current step. The queue button still holds one for after the turn, and the hint says which does what. - Each queued message gets a Send now button. It takes the message out of the queue and sends it as a steer. - A plain send that finds the session busy steers instead of queueing. Co-Authored-By: Claude Opus 5.5 --- displays/src/tabs/ai_playground/enhanced.rs | 18 +++++- displays/src/ui_tools/agent_chat/composer.rs | 20 +++--- displays/src/ui_tools/agent_chat/mod.rs | 67 +++++++++++++++++++- 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/displays/src/tabs/ai_playground/enhanced.rs b/displays/src/tabs/ai_playground/enhanced.rs index f5f335c3..1646d908 100644 --- a/displays/src/tabs/ai_playground/enhanced.rs +++ b/displays/src/tabs/ai_playground/enhanced.rs @@ -1922,7 +1922,7 @@ impl EnhancedAiPlayground { match live { Ok(Some(thread)) if viewer.as_ref().is_some_and(|v| v.may_steer(thread.assignee.as_ref())) => { let kind = if thread.is_busy() && kind == "start" { - "queue" + "steer" } else { kind }; @@ -2046,7 +2046,7 @@ impl EnhancedAiPlayground { }); } - /// Resumes the held queue, removes a queued message, or takes one back into the composer. + /// Resumes the held queue, or removes, sends now or takes back one queued message. fn apply_queue_action(&mut self, tid: &str, action: QueueAction) { let thread = RecordId::new("agent_thread", tid); match action { @@ -2059,6 +2059,20 @@ impl EnhancedAiPlayground { } }); } + QueueAction::SendNow(id) => { + self.waiting.retain(|w| w.id != id); + PlatformSpawner::spawn(async move { + match AgentTurn::take_back(&id).await { + Ok(Some(turn)) => { + if let Err(e) = AgentTurn::ask_with(&thread, "steer", &turn.text, &turn.images).await { + log::warn!("could not send a queued message now: {e}"); + } + } + Ok(None) => {} + Err(e) => log::warn!("could not take a queued message out of the queue: {e}"), + } + }); + } QueueAction::Edit(id) => { self.waiting.retain(|w| w.id != id); let tx = self.taken_back_tx.clone(); diff --git a/displays/src/ui_tools/agent_chat/composer.rs b/displays/src/ui_tools/agent_chat/composer.rs index 5bff8199..fe9c489a 100644 --- a/displays/src/ui_tools/agent_chat/composer.rs +++ b/displays/src/ui_tools/agent_chat/composer.rs @@ -115,7 +115,7 @@ impl Composer { ui.add_enabled_ui(enabled, |ui| { self.attachment_strip(ui); let hint = if busy { - "The agent is working: Enter queues this for when it finishes" + "The agent is working: Enter tells it now; the queue button holds a message for after this turn" } else { "Message the agent (Shift+Enter for a new line; paste or drop pictures and files)" }; @@ -169,7 +169,7 @@ impl Composer { }); if enter { action = self - .submit(text, if busy { "queue" } else { "start" }) + .submit(text, if busy { "steer" } else { "start" }) .or(action); ui.memory_mut(|m| m.request_focus(text_id)); } @@ -264,7 +264,7 @@ impl Composer { ui.vertical(|ui| { if ui .add_enabled(ready, Button::new(icons::SEND_NOW).min_size(half)) - .on_hover_text("Send now: tell the agent while it works") + .on_hover_text("Send now: the agent reads it after its current step (Enter)") .clicked() { action = self.submit(text, "steer"); @@ -300,11 +300,7 @@ impl Composer { #[cfg(any(target_os = "ios", target_os = "android"))] let send_size = vec2(SIDE_BUTTON_W, height); let (icon, hover, kind) = if busy { - ( - icons::QUEUE, - "Queue: send when the agent finishes this turn (Enter)", - "queue", - ) + (icons::QUEUE, "Queue: send when the agent finishes this turn", "queue") } else { (icons::SEND, "Send (Enter)", "start") }; @@ -471,7 +467,7 @@ mod tests { } #[test] - fn enter_sends_when_idle_and_queues_while_busy() { + fn enter_sends_when_idle_and_steers_while_busy() { let ctx = Context::default(); let mut composer = Composer::default(); let mut text = "check the disks".to_string(); @@ -493,7 +489,7 @@ mod tests { assert!(text.is_empty(), "the box is cleared once sent"); text = "then the event log".to_string(); - let queued = frame( + let steered = frame( &ctx, &mut composer, &mut text, @@ -501,8 +497,8 @@ mod tests { vec![enter(Modifiers::NONE)], ); assert!( - matches!(queued, Some(ComposerAction::Send { kind: "queue", .. })), - "{queued:?}" + matches!(steered, Some(ComposerAction::Send { kind: "steer", .. })), + "{steered:?}" ); } diff --git a/displays/src/ui_tools/agent_chat/mod.rs b/displays/src/ui_tools/agent_chat/mod.rs index 1d3ad8bd..c353e4bc 100644 --- a/displays/src/ui_tools/agent_chat/mod.rs +++ b/displays/src/ui_tools/agent_chat/mod.rs @@ -187,6 +187,7 @@ pub fn approve_all_chip(ui: &mut Ui, thread: &AgentThread, steerable: bool) -> b pub enum QueueAction { Remove(RecordId), Edit(RecordId), + SendNow(RecordId), Resume, } @@ -229,7 +230,7 @@ pub fn queue_strip(ui: &mut Ui, waiting: &[QueuedTurn]) -> Option { for turn in waiting { ui.push_id(turn.id.key_string(), |ui| { ui.horizontal(|ui| { - let buttons = 2.0 * (24.0 + ui.spacing().item_spacing.x); + let buttons = 3.0 * (24.0 + ui.spacing().item_spacing.x); let first = turn .text .lines() @@ -263,6 +264,13 @@ pub fn queue_strip(ui: &mut Ui, waiting: &[QueuedTurn]) -> Option { { action = Some(QueueAction::Edit(turn.id.clone())); } + if ui + .small_button(icons::SEND_NOW) + .on_hover_text("Send now: the agent reads it after its current step") + .clicked() + { + action = Some(QueueAction::SendNow(turn.id.clone())); + } }); }); }); @@ -401,7 +409,62 @@ pub fn hovered(ui: &Ui, rect: Rect) -> bool { #[cfg(test)] mod tests { use super::*; - use eframe::egui::{Context, RawInput}; + use eframe::egui::{Context, Event, Modifiers, PointerButton, RawInput, pos2}; + + /// Every action the strip answers to a click anywhere in its top 80 points, right to left. + fn strip_actions(waiting: &[QueuedTurn]) -> Vec { + let ctx = Context::default(); + let run = |events: Vec| { + let input = RawInput { + events, + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(420.0, 200.0))), + ..Default::default() + }; + let mut action = None; + let mut out = ctx.run_ui(input, |ui| action = queue_strip(ui, waiting)); + out.textures_delta.clear(); + action + }; + let mut found: Vec = Vec::new(); + for y in (2..80).step_by(4) { + for x in (2..420).rev().step_by(4) { + let pos = pos2(x as f32, y as f32); + let button = |pressed| Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + }; + let action = + run(vec![Event::PointerMoved(pos), button(true)]).or(run(vec![button(false)])); + if let Some(action) = action.filter(|a| !found.contains(a)) { + found.push(action); + } + } + } + found + } + + #[test] + fn each_queued_message_can_be_removed_taken_back_or_sent_now() { + let id = RecordId::new("agent_turn", "q1"); + let waiting = vec![QueuedTurn { + id: id.clone(), + text: "i plugged in the customer's two drives as well".into(), + status: "queued".into(), + tech: None, + image_names: Vec::new(), + created_at: None, + }]; + assert_eq!( + strip_actions(&waiting), + vec![ + QueueAction::Remove(id.clone()), + QueueAction::Edit(id.clone()), + QueueAction::SendNow(id) + ] + ); + } #[test] fn the_context_colour_ramps_from_green_through_amber_to_red() {