Agent chat: queue messages while the agent works, Send now, Stop, New chat, and a model picker (#1566, #1571) (#1566)

Thanks @bherbruck.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-15 23:58:28 -04:00
1 parent fdb059cfed
commit baf44657c1
16 files changed
+1293 -33

No files matched your search

+259 -7
View File
@@ -145,6 +145,13 @@ struct Turn {
label: String,
}
/// A message typed while the agent works: shown in the transcript as "Queued" until it is sent.
#[derive(Clone, Debug, PartialEq)]
pub struct Pending {
pub text: String,
pub image: bool,
}
/// A request held for Allow or Deny.
struct Held {
id: u64,
@@ -171,6 +178,25 @@ pub struct AgentChat {
auth_status: Option<String>,
/// A message typed before the agent was ready.
queued: Option<(String, bool)>,
/// Messages typed while the agent works, in order. They go as one prompt when the turn ends
/// (or at once with Send now); after Stop they wait for Send.
pub pending: Vec<Pending>,
/// The queued message being edited, and its text so far.
pub pending_edit: Option<(usize, String)>,
/// Send now was pressed: the queue goes as soon as the cancelled turn has ended.
steering: bool,
/// The turn was stopped: its queue is not sent by itself.
stopped: bool,
/// Why the last turn ended.
ended: Option<String>,
/// New chat while a message ran: its late end is not reported.
discard_end: bool,
/// The mode to put back when the fresh session is ready.
restore_mode: Option<String>,
/// New chat is waiting for the user's yes (a message is running or queued).
pub confirm_new: bool,
/// Where the panel was drawn last (for its shortcuts).
pub panel_rect: Option<egui::Rect>,
/// The last message sent, kept to send again after a sign-in.
last: Option<(String, bool)>,
/// The command the running agent was started with.
@@ -182,6 +208,13 @@ pub struct AgentChat {
/// The agent's own modes, and the current one.
modes: Vec<(String, String)>,
mode: String,
/// The models the agent offers (none: no picker), and the current one.
pub models: Vec<acp::ModelInfo>,
pub model: String,
/// An API endpoint's models, kept per endpoint (its base URL) for the next connection.
models_cache: std::collections::HashMap<String, Vec<acp::ModelInfo>>,
/// The running API agent's endpoint (empty for other agents).
running_endpoint: String,
backend: Option<Box<dyn ChatBackend>>,
turn: Option<Turn>,
held: Vec<Held>,
@@ -227,6 +260,7 @@ impl AgentChat {
}
fn stop_backend(&mut self, wait: bool) {
self.steering = false;
if let Some(mut b) = self.backend.take() {
b.cancel();
b.shutdown(wait);
@@ -275,6 +309,14 @@ impl AgentChat {
self.images = images;
self.state = Some(State::Ready);
self.picking = Some(false);
// Keep the mode across a new chat.
if let Some(m) = self.restore_mode.take()
&& self.modes.iter().any(|(id, _)| *id == m)
&& let Some(b) = self.backend.as_mut()
{
b.set_mode(&m);
self.mode = m;
}
}
Event::Text { thought, text } => {
if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut()
@@ -320,6 +362,15 @@ impl AgentChat {
}
self.mode = current;
}
Event::Models { current, available } => {
if !available.is_empty() {
if !self.running_endpoint.is_empty() {
self.models_cache.insert(self.running_endpoint.clone(), available.clone());
}
self.models = available;
}
self.model = current;
}
Event::AuthRequired { methods } => {
self.auth = methods;
self.state = Some(State::NeedsAuth);
@@ -334,6 +385,10 @@ impl AgentChat {
if self.state() == State::Busy {
self.state = Some(State::Ready);
}
self.ended = Some(reason.clone());
if std::mem::take(&mut self.discard_end) {
return Some(Outcome::TurnEnd);
}
if !matches!(reason.as_str(), "" | "auth" | "error") {
self.notice(format!("({reason})"), false);
}
@@ -347,6 +402,7 @@ impl AgentChat {
}
Event::Exited { tail } => {
self.state = Some(State::Exited);
self.steering = false;
let last: Vec<String> = tail.into_iter().rev().take(6).collect::<Vec<_>>().into_iter().rev().collect();
let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) };
self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true);
@@ -468,6 +524,14 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) {
chat.running_id = entry.id.clone();
chat.modes.clear();
chat.mode.clear();
chat.models.clear();
chat.model.clear();
chat.running_endpoint = if entry.kind == agents::Kind::Api { entry.base_url.clone() } else { String::new() };
// An endpoint's models from the last time, until the new list arrives.
if let Some(list) = chat.models_cache.get(&chat.running_endpoint) {
chat.models = list.clone();
chat.model = entry.model.clone();
}
chat.state = Some(State::Starting);
chat.auth.clear();
chat.auth_status = None;
@@ -496,17 +560,44 @@ pub fn shutdown(app: &mut SolveApp) {
app.agent_chat.stop_backend(true);
}
/// Send what the user typed: now if the agent is ready, else once it is (starting it if needed).
/// Most messages waiting in the queue.
const MAX_PENDING: usize = 20;
/// Said to the agent before a message that interrupted it, so it knows its last step may be
/// incomplete.
const INTERRUPT_NOTE: &str = "[The user interrupted your previous step.]";
/// Send what the user typed: now if the agent is ready, queued behind the running message if it
/// is working, else once it is ready (starting it if needed).
pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
let text: String = text.chars().take(MAX_PROMPT).collect();
if text.trim().is_empty() {
return;
}
let image = app.agent_chat.attach_view && app.agent_chat.images;
if app.agent_chat.state() == State::Busy {
let chat = &mut app.agent_chat;
if chat.pending.len() >= MAX_PENDING {
chat.notice(tr("ui.agent_chat_queue_full"), false);
} else {
chat.pending.push(Pending { text, image });
}
return;
}
// Messages left waiting by Stop go first, in one prompt with this one.
if !app.agent_chat.pending.is_empty() {
app.agent_chat.pending.push(Pending { text, image });
send_queue(app, ctx, false);
return;
}
submit(app, ctx, text, image, false);
}
/// Put `text` in the conversation and send it (`interrupted`: the agent is told it cut in on its
/// previous step).
fn submit(app: &mut SolveApp, ctx: &egui::Context, text: String, image: bool, interrupted: bool) {
app.agent_chat.push(Item::User { text: text.clone(), image });
match app.agent_chat.state() {
State::Ready => start_turn(app, text, image),
State::Busy => app.agent_chat.notice(tr("ui.agent_chat_wait_turn"), false),
State::Ready => start_turn_with(app, text, image, interrupted.then_some(INTERRUPT_NOTE)),
s => {
app.agent_chat.queued = Some((text, image));
#[cfg(not(target_arch = "wasm32"))]
@@ -519,8 +610,105 @@ pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
}
}
/// Send the queued messages as one prompt, in order.
fn send_queue(app: &mut SolveApp, ctx: &egui::Context, interrupted: bool) {
let chat = &mut app.agent_chat;
chat.pending_edit = None;
if chat.pending.is_empty() {
return;
}
let all = std::mem::take(&mut chat.pending);
let image = all.iter().any(|p| p.image);
let text = all.into_iter().map(|p| p.text).collect::<Vec<_>>().join("\n\n");
let text: String = text.chars().take(MAX_PROMPT).collect();
submit(app, ctx, text, image, interrupted);
}
/// Send now: stop the running step and send the queue as the next prompt once the agent has said
/// it stopped (its note tells the agent it was interrupted). With nothing running, sends the queue.
pub fn send_now(app: &mut SolveApp, ctx: &egui::Context) {
if app.agent_chat.pending.is_empty() {
return;
}
if app.agent_chat.state() == State::Busy {
app.agent_chat.steering = true;
app.agent_chat.stopped = false;
interrupt(app);
} else {
send_queue(app, ctx, false);
}
}
/// New chat: stop what runs (as Stop does), clear the conversation and the queue, and start a
/// fresh session on the same agent (no restart); the agent and its mode stay, the design's undo
/// history is untouched.
pub fn new_chat(app: &mut SolveApp) {
let running = app.agent_chat.state() == State::Busy;
if running {
cancel(app);
}
decline_all(app, "the chat was cleared");
end_turn(app);
let chat = &mut app.agent_chat;
chat.confirm_new = false;
chat.items.clear();
chat.pending.clear();
chat.pending_edit = None;
chat.queued = None;
chat.last = None;
chat.steering = false;
chat.stopped = false;
chat.ended = None;
chat.discard_end = running;
if matches!(chat.state(), State::Ready | State::Busy) {
chat.restore_mode = Some(chat.mode.clone()).filter(|m| !m.is_empty());
chat.state = Some(State::Starting);
if let Some(b) = chat.backend.as_mut() {
b.new_session();
}
}
}
/// Whether New chat would throw something away that the user may want (so it asks first).
pub fn new_chat_needs_confirm(app: &SolveApp) -> bool {
app.agent_chat.state() == State::Busy || !app.agent_chat.pending.is_empty()
}
/// Take a queued message out of the queue.
pub fn remove_pending(app: &mut SolveApp, index: usize) {
let chat = &mut app.agent_chat;
if index < chat.pending.len() {
chat.pending.remove(index);
}
chat.pending_edit = None;
}
/// The turn ended: the queue goes on its own after a normal end, and after a Send now; not after
/// Stop, a sign-in request or an error.
fn after_turn(app: &mut SolveApp, ctx: &egui::Context) {
let reason = app.agent_chat.ended.take().unwrap_or_default();
let steering = std::mem::take(&mut app.agent_chat.steering);
if app.agent_chat.state() != State::Ready || app.agent_chat.pending.is_empty() {
return;
}
if steering && !matches!(reason.as_str(), "auth" | "error") {
send_queue(app, ctx, reason == "cancelled");
} else if !app.agent_chat.stopped && reason.is_empty() {
send_queue(app, ctx, false);
}
}
fn start_turn(app: &mut SolveApp, text: String, image: bool) {
let mut parts = vec![PromptPart::Text(text.clone())];
start_turn_with(app, text, image, None);
}
/// Start a message; `note` goes before the text for the agent, not into the conversation.
fn start_turn_with(app: &mut SolveApp, text: String, image: bool, note: Option<&str>) {
let sent = match note {
Some(n) => format!("{n}\n\n{text}"),
None => text.clone(),
};
let mut parts = vec![PromptPart::Text(sent)];
if image {
match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }),
@@ -532,13 +720,24 @@ fn start_turn(app: &mut SolveApp, text: String, image: bool) {
Some(Turn { start_doc: app.session.doc.clone(), start_undo: app.session.undo.len(), changes: 0, requests: 0, limit_told: false, label });
app.agent_chat.last = Some((text, image));
app.agent_chat.state = Some(State::Busy);
app.agent_chat.stopped = false;
app.agent_chat.steering = false;
app.agent_chat.ended = None;
if let Some(b) = app.agent_chat.backend.as_mut() {
b.prompt(&parts);
}
}
/// Ask the agent to stop the message it is on; requests waiting for approval are declined.
/// Stop: ask the agent to stop the message it is on; requests waiting for approval are declined.
/// Messages queued meanwhile stay queued (they wait for Send).
pub fn cancel(app: &mut SolveApp) {
app.agent_chat.steering = false;
app.agent_chat.stopped = true;
interrupt(app);
}
/// Ask the agent to stop the message it is on and decline what waits for approval.
fn interrupt(app: &mut SolveApp) {
decline_all(app, "the user stopped the message");
if let Some(b) = app.agent_chat.backend.as_mut() {
b.cancel();
@@ -775,10 +974,16 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) {
for e in events {
let ready = matches!(e, Event::Ready { .. });
match app.agent_chat.on_event(e) {
Some(Outcome::TurnEnd) => end_turn(app),
Some(Outcome::TurnEnd) => {
end_turn(app);
after_turn(app, ctx);
}
Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options),
None => {}
}
if ready {
apply_saved_model(app);
}
if ready && let Some((text, image)) = app.agent_chat.queued.take() {
start_turn(app, text, image);
}
@@ -804,6 +1009,37 @@ pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) {
app.agent_chat.detecting = Some(rx);
}
/// Choose the model for this chat (the header's model picker): the agent switches to it, and it is
/// remembered for this agent; an API endpoint's agent also keeps it as its default model.
pub fn set_model(app: &mut SolveApp, model: &str) {
if !app.agent_chat.models.iter().any(|m| m.id == model) {
return;
}
if let Some(b) = app.agent_chat.backend.as_mut() {
b.set_model(model);
}
app.agent_chat.model = model.to_string();
let agent = app.agent_chat.running_id.clone();
if let Some(a) = app.preferences.agents.iter_mut().find(|a| a.id == agent && a.kind == agents::Kind::Api) {
a.model = model.to_string();
}
app.preferences.agent_models.insert(agent, model.to_string());
}
/// A new session is ready: put back the model last chosen for this agent, when it offers it.
fn apply_saved_model(app: &mut SolveApp) {
let chat = &app.agent_chat;
let Some(saved) = app.preferences.agent_models.get(&chat.running_id).cloned() else { return };
if saved != chat.model && chat.models.iter().any(|m| m.id == saved) {
set_model(app, &saved);
}
}
/// Whether the model picker shows: only for an agent that reports models.
pub fn models_shown(app: &SolveApp) -> bool {
!app.agent_chat.models.is_empty() && app.agent_chat.state().running()
}
/// Switch the agent to one of its own modes (the header's mode picker).
pub fn set_mode(app: &mut SolveApp, mode: &str) {
if app.agent_chat.modes.iter().any(|(id, _)| id == mode)
@@ -817,7 +1053,7 @@ pub fn set_mode(app: &mut SolveApp, mode: &str) {
/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, `open`,
/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `send`, `attach_view`,
/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held
/// request), `cancel`, `undo` (the newest message's changes); returns the panel's state.
/// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state.
pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Value, String> {
if let Some(e) = p.get("features").and_then(Value::as_bool) {
app.preferences.ai_features = e;
@@ -875,6 +1111,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if let Some(m) = p.get("mode").and_then(Value::as_str) {
set_mode(app, m);
}
if let Some(m) = p.get("model").and_then(Value::as_str) {
set_model(app, m);
}
if !shown(app) {
return Ok(status(app));
}
@@ -932,6 +1171,15 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if p.get("cancel").and_then(Value::as_bool) == Some(true) {
cancel(app);
}
if p.get("new_chat").and_then(Value::as_bool) == Some(true) {
new_chat(app);
}
if p.get("send_now").and_then(Value::as_bool) == Some(true) {
send_now(app, ctx);
}
if let Some(n) = p.get("remove_pending").and_then(Value::as_u64) {
remove_pending(app, n as usize);
}
if p.get("undo").and_then(Value::as_bool) == Some(true) {
let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { .. })) else { return Err("no changes to undo".into()) };
undo_changes(app, n);
@@ -976,6 +1224,8 @@ pub fn status(app: &SolveApp) -> Value {
"permissions": app.preferences.ai_permissions.id(),
"agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": if e.kind == agents::Kind::Api { "api" } else { "acp" }, "found": e.found, "version": e.version, "login": e.login})).collect::<Vec<_>>(),
"currentAgent": agents::current(app).map(|e| e.id),
"models": c.models.iter().map(|m| json!({"id": m.id, "name": m.name, "description": m.description})).collect::<Vec<_>>(),
"model": c.model,
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
"mode": c.mode,
"open": c.open,
@@ -990,6 +1240,8 @@ pub fn status(app: &SolveApp) -> Value {
"toolsPort": c.tools_port,
"pid": pid,
"waiting": c.waiting(),
"pending": c.pending.iter().map(|p| json!({"text": p.text, "image": p.image})).collect::<Vec<_>>(),
"stopped": c.stopped,
"turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})),
"detection": detection,
"items": items,