mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* claim: #7526 * Agent chat: continue across agent-initiated design changes
This commit is contained in:
committed by
GitHub
parent
60de59d5a9
commit
3eb76a8b3b
@@ -320,6 +320,9 @@ impl Loading {
|
||||
pub struct AgentChat {
|
||||
/// The panel is shown (View menu, the application bar's chat button, Ctrl+Shift+A).
|
||||
pub open: bool,
|
||||
/// Only while dispatching this conversation's own admitted tool request. Window state:
|
||||
/// creating/opening a design may carry the conversation, but a person's tab switch may not.
|
||||
pub(crate) following_document: bool,
|
||||
pub items: Vec<Item>,
|
||||
pub input: String,
|
||||
/// The viewer, open on one message's pictures.
|
||||
@@ -444,6 +447,7 @@ impl AgentChat {
|
||||
pub(crate) fn swap_document(&mut self, other: &mut Self) {
|
||||
std::mem::swap(self, other);
|
||||
std::mem::swap(&mut self.open, &mut other.open);
|
||||
std::mem::swap(&mut self.following_document, &mut other.following_document);
|
||||
std::mem::swap(&mut self.settings, &mut other.settings);
|
||||
std::mem::swap(&mut self.control_starter, &mut other.control_starter);
|
||||
std::mem::swap(&mut self.models_cache, &mut other.models_cache);
|
||||
@@ -1052,6 +1056,66 @@ pub fn shutdown(app: &mut SolveApp) {
|
||||
crate::documents::disconnect_background_chats(app, true);
|
||||
}
|
||||
|
||||
/// Mark the origin only for this synchronous dispatch. Deferred work carries the same trusted
|
||||
/// turn flag and enters here when it applies; JSON parameters cannot opt into continuation.
|
||||
pub(crate) fn with_tool_request<T>(
|
||||
app: &mut SolveApp,
|
||||
active: Option<&Arc<std::sync::atomic::AtomicBool>>,
|
||||
run: impl FnOnce(&mut SolveApp) -> T,
|
||||
) -> T {
|
||||
let own = active.is_some_and(|active| {
|
||||
active.load(std::sync::atomic::Ordering::Relaxed) && app.agent_chat.turn.as_ref().is_some_and(|turn| Arc::ptr_eq(active, &turn.active))
|
||||
});
|
||||
// A batch may replace the design and then roll back to its checkpoint. Keep the turn's
|
||||
// original undo boundary when that happens, including edits made before the batch.
|
||||
let before = app.agent_chat.turn.as_ref().filter(|_| own).map(|t| (app.session.doc.clone(), t.start_doc.clone(), t.start_undo));
|
||||
let previous = std::mem::replace(&mut app.agent_chat.following_document, own);
|
||||
let result = run(app);
|
||||
app.agent_chat.following_document = previous;
|
||||
if let Some((doc, start_doc, start_undo)) = before
|
||||
&& Arc::ptr_eq(&doc, &app.session.doc)
|
||||
&& let Some(turn) = app.agent_chat.turn.as_mut()
|
||||
&& active.is_some_and(|active| Arc::ptr_eq(active, &turn.active))
|
||||
{
|
||||
turn.start_doc = start_doc;
|
||||
turn.start_undo = start_undo;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The agent's replacement succeeded. Keep its backend, transcript, queue, limits and validity
|
||||
/// flag; subsequent edits undo in this design and picked geometry belongs to the old one.
|
||||
pub(crate) fn continue_in_document(app: &mut SolveApp) {
|
||||
if let Some(turn) = app.agent_chat.turn.as_mut() {
|
||||
turn.start_doc = app.session.doc.clone();
|
||||
turn.start_undo = app.session.undo.len();
|
||||
}
|
||||
app.agent_chat.picked.clear();
|
||||
app.agent_chat.picked_context = Context::default();
|
||||
app.agent_chat.selected_count = None;
|
||||
}
|
||||
|
||||
/// A newly opened tab has no conversation to displace. Move the initiating chat into it,
|
||||
/// grouping the previous design's completed edits before its session is parked.
|
||||
pub(crate) fn follow_new_tab(app: &mut SolveApp, switch: impl FnOnce(&mut SolveApp)) {
|
||||
if !app.agent_chat.following_document {
|
||||
switch(app);
|
||||
return;
|
||||
}
|
||||
if let Some(turn) = app.agent_chat.turn.as_ref() {
|
||||
let steps =
|
||||
merge_undo(&mut app.session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)]));
|
||||
if steps > 0 {
|
||||
app.agent_chat.push(Item::Changes { steps, doc: app.session.doc.clone(), undone: false });
|
||||
}
|
||||
}
|
||||
let mut chat = AgentChat::default();
|
||||
app.agent_chat.swap_document(&mut chat);
|
||||
switch(app);
|
||||
app.agent_chat.swap_document(&mut chat);
|
||||
continue_in_document(app);
|
||||
}
|
||||
|
||||
/// A tab switch stops this turn before its session leaves the app. Keep the backend's own
|
||||
/// conversation, drafts and queue, but do not send that queue automatically on returning.
|
||||
pub(crate) fn leave_document(app: &mut SolveApp) {
|
||||
|
||||
@@ -712,6 +712,203 @@ fn a_late_sign_in_retry_or_permission_cannot_restart_a_departed_turn() {
|
||||
assert!(app.agent_chat.turn.is_none() && app.agent_chat.queued.is_none());
|
||||
}
|
||||
|
||||
/// Exercise the chat endpoint's gate and the real dispatch paths, including the animated
|
||||
/// pointer and asynchronous file loads. The scripted backend never needs a model account.
|
||||
fn chat_tool(app: &mut SolveApp, tx: &Sender<ControlRequest>, ctx: &egui::Context, method: &str, params: Value) -> Value {
|
||||
let reply = ask(tx, method, params);
|
||||
pump_tools(app);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
app.drain_control(ctx);
|
||||
app.now += 1.0;
|
||||
crate::agent_cursor::step(app, ctx);
|
||||
crate::file_load::tick(app, ctx);
|
||||
app.poll_control_jobs(ctx);
|
||||
match reply.try_recv() {
|
||||
Ok(response) => return response,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => panic!("the chat's tool reply was dropped"),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "the chat's tool did not finish");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_new_design_keeps_its_conversation_and_continues() {
|
||||
// #7526: the old reset disconnected the backend inside its own file.new request.
|
||||
for (method, params, animate) in [
|
||||
("engine.execute", json!({"command": "file.new"}), false),
|
||||
("engine.execute", json!({"command": "FILE.NEW"}), false),
|
||||
("engine.execute", json!({"command": "file.new"}), true),
|
||||
("engine.batch", json!({"commands": [{"command": "file.new"}, boxed(0.0)]}), false),
|
||||
("engine.batch", json!({"commands": [{"command": "file.new"}, boxed(0.0)]}), true),
|
||||
] {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
let (tx, rx) = channel();
|
||||
app.agent_chat.tools_rx = Some(rx);
|
||||
app.preferences.ai_permissions = agents::Mode::All;
|
||||
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
|
||||
send(&mut app, &ctx, "create another design".into());
|
||||
let active = app.agent_chat.turn.as_ref().unwrap().active.clone();
|
||||
app.ui.agent_cursor.show = animate;
|
||||
crate::scenario::publish_handle("toolbar:file.new", egui::pos2(80.0, 100.0));
|
||||
crate::scenario::publish_handle("toolbar:solid.box", egui::pos2(100.0, 100.0));
|
||||
let response = chat_tool(&mut app, &tx, &ctx, method, params);
|
||||
assert_eq!(response["ok"], true, "{response}");
|
||||
assert!(app.agent_chat.backend.is_some(), "the agent must receive its own replacement's reply");
|
||||
assert!(active.load(std::sync::atomic::Ordering::Relaxed));
|
||||
assert!(app.agent_chat.items.iter().any(|i| matches!(i, Item::User { text, .. } if text == "create another design")));
|
||||
assert!(!script.calls().iter().any(|call| matches!(call.as_str(), "cancel" | "new_session")));
|
||||
let response = chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(30.0));
|
||||
assert_eq!(response["ok"], true, "the same agent continues: {response}");
|
||||
assert_eq!(app.agent_chat.turn.as_ref().unwrap().changes, 2, "a replacement does not reset the turn's change budget");
|
||||
let count = if method == "engine.batch" { 2 } else { 1 };
|
||||
assert_eq!(app.session.doc.features.len(), count);
|
||||
ended(&script, "");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.undo.len(), 1, "only the new design's edits form this turn's undo step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.session.doc.features.is_empty(), "undo must not resurrect the previous design");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn an_agents_opened_design_keeps_its_conversation_and_undo_ownership() {
|
||||
for blank in [true, false] {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-chat-open-{}-{blank}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("next.solidcraft");
|
||||
let mut next = solidcraft_engine::Session::default();
|
||||
next.execute("solid.box", &boxed(100.0)["params"]).unwrap();
|
||||
next.execute("file.save_as", &json!({"path": path})).unwrap();
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
let (tx, rx) = channel();
|
||||
app.agent_chat.tools_rx = Some(rx);
|
||||
app.preferences.ai_permissions = agents::Mode::All;
|
||||
if !blank {
|
||||
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
|
||||
app.run("file.save_as", json!({"path": dir.join("first.solidcraft")})).unwrap();
|
||||
}
|
||||
send(&mut app, &ctx, "open another design and add a box".into());
|
||||
let active = app.agent_chat.turn.as_ref().unwrap().active.clone();
|
||||
if !blank {
|
||||
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(20.0))["ok"], true);
|
||||
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(40.0))["ok"], true);
|
||||
}
|
||||
let response =
|
||||
chat_tool(&mut app, &tx, &ctx, "engine.execute", json!({"command": "doc.open", "params": {"path": path}, "allow_paths": [dir]}));
|
||||
assert_eq!(response["ok"], true, "{response}");
|
||||
assert!(app.agent_chat.backend.is_some(), "opening the file must carry the initiating chat");
|
||||
assert!(active.load(std::sync::atomic::Ordering::Relaxed));
|
||||
assert_eq!(app.docs.count(), if blank { 1 } else { 2 });
|
||||
if !blank {
|
||||
let response = chat_tool(
|
||||
&mut app,
|
||||
&tx,
|
||||
&ctx,
|
||||
"engine.execute",
|
||||
json!({
|
||||
"command": "doc.open", "params": {"path": dir.join("first.solidcraft")}, "allow_paths": [dir]
|
||||
}),
|
||||
);
|
||||
assert_eq!(response["ok"], false, "another tab must keep its own conversation: {response}");
|
||||
assert!(response["error"].as_str().unwrap().contains("already open in another tab"));
|
||||
assert_eq!(app.docs.active, 1);
|
||||
}
|
||||
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(130.0))["ok"], true);
|
||||
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(150.0))["ok"], true);
|
||||
assert_eq!(app.session.doc.features.len(), 3);
|
||||
ended(&script, "");
|
||||
poll(&mut app, &ctx);
|
||||
assert!(!script.calls().iter().any(|call| matches!(call.as_str(), "cancel" | "new_session")));
|
||||
assert_eq!(app.session.undo.len(), 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc.features.len(), 1, "the opened design's existing body survives undo");
|
||||
if !blank {
|
||||
crate::documents::switch(&mut app, 0);
|
||||
assert_eq!(app.session.doc.features.len(), 3);
|
||||
assert_eq!(app.session.undo.len(), 2, "the first design keeps its own grouped agent edits");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc.features.len(), 1);
|
||||
assert!(app.agent_chat.backend.is_none(), "the endpoint moved with the conversation");
|
||||
crate::documents::switch(&mut app, 1);
|
||||
assert!(app.agent_chat.backend.is_some());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_failed_new_design_batch_restores_its_undo_boundary() {
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
let (tx, rx) = channel();
|
||||
app.agent_chat.tools_rx = Some(rx);
|
||||
app.preferences.ai_permissions = agents::Mode::All;
|
||||
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
|
||||
send(&mut app, &ctx, "try another design".into());
|
||||
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(20.0))["ok"], true);
|
||||
let before = app.session.doc.clone();
|
||||
let response = chat_tool(
|
||||
&mut app,
|
||||
&tx,
|
||||
&ctx,
|
||||
"engine.batch",
|
||||
json!({"commands": [
|
||||
{"command": "file.new"},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [10, 10]]}}
|
||||
]}),
|
||||
);
|
||||
assert_eq!(response["result"]["completed"], 1, "the new design ran before the failure: {response}");
|
||||
assert_eq!(response["result"]["rolled_back"], true);
|
||||
assert!(Arc::ptr_eq(&app.session.doc, &before));
|
||||
assert!(app.agent_chat.backend.is_some());
|
||||
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(40.0))["ok"], true);
|
||||
ended(&script, "");
|
||||
poll(&mut app, &ctx);
|
||||
assert_eq!(app.session.undo.len(), 2, "edits before and after the failed batch share one turn");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc.features.len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn an_agents_cancelled_open_cannot_replace_the_design_later() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-chat-cancel-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("next.solidcraft");
|
||||
solidcraft_engine::Session::default().execute("file.save_as", &json!({"path": path})).unwrap();
|
||||
let (mut app, script, ctx) = app_with_script();
|
||||
let (tx, rx) = channel();
|
||||
app.agent_chat.tools_rx = Some(rx);
|
||||
app.preferences.ai_permissions = agents::Mode::All;
|
||||
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
|
||||
let original = app.session.doc.clone();
|
||||
send(&mut app, &ctx, "open another design".into());
|
||||
let (started_tx, started_rx) = channel();
|
||||
let (release_tx, release_rx) = channel();
|
||||
app.file_load.worker_gate = Some((started_tx, release_rx));
|
||||
let reply = ask(&tx, "engine.execute", json!({"command": "doc.open", "params": {"path": path}, "allow_paths": [dir]}));
|
||||
pump_tools(&mut app);
|
||||
app.drain_control(&ctx);
|
||||
started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
cancel(&mut app);
|
||||
ended(&script, "cancelled");
|
||||
poll(&mut app, &ctx);
|
||||
release_tx.send(()).unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while !app.file_load.idle() && std::time::Instant::now() < deadline {
|
||||
crate::file_load::tick(&mut app, &ctx);
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(app.file_load.idle());
|
||||
assert_eq!(reply.try_recv().unwrap()["ok"], false, "a stopped turn cannot finish its pending open");
|
||||
assert!(Arc::ptr_eq(&app.session.doc, &original));
|
||||
assert_eq!(app.docs.count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programmatic_replacement_clears_the_conversation_and_invalidates_its_requests() {
|
||||
for command in ["file.new", "FILE.NEW"] {
|
||||
|
||||
@@ -452,7 +452,8 @@ fn finish(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if follow && id == "sketch.create" {
|
||||
app.agent.saved_view = Some(app.cam);
|
||||
}
|
||||
let (req, _rx) = crate::control::ControlRequest::new(job.method, params);
|
||||
let (mut req, _rx) = crate::control::ControlRequest::new(job.method, params);
|
||||
req.chat_turn = job.chat_turn;
|
||||
// What was computed ahead while the pointer glided is found rather than computed again here.
|
||||
let out = app.run_with_kept(job.ahead.map(|a| a.kept), |app| match crate::control::handle(app, ctx, &req) {
|
||||
crate::control::Outcome::Done(v) => v,
|
||||
|
||||
@@ -465,6 +465,10 @@ pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
crate::agent_chat::with_tool_request(app, req.chat_turn.as_ref(), |app| handle_request(app, ctx, req))
|
||||
}
|
||||
|
||||
fn handle_request(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let level = match request_param_check(&req.method, &req.params) {
|
||||
Ok(level) => level,
|
||||
Err(e) => return err(e),
|
||||
|
||||
@@ -315,6 +315,9 @@ pub(crate) fn reuse_path(app: &mut SolveApp, path: &str) -> Result<bool, String>
|
||||
app.docs.slots.iter().position(|s| s.as_ref().is_some_and(|s| matches(&s.session)))
|
||||
};
|
||||
let Some(index) = index else { return Ok(false) };
|
||||
if app.agent_chat.following_document && index != app.docs.active {
|
||||
return Err("the design is already open in another tab; select that tab to continue in its own conversation".into());
|
||||
}
|
||||
if app.file_picker_busy() {
|
||||
return Err("finish choosing a file before switching designs".into());
|
||||
}
|
||||
@@ -354,7 +357,7 @@ pub fn open_tab(app: &mut SolveApp, session: Session) -> Result<(), String> {
|
||||
}
|
||||
let at = app.docs.active + 1;
|
||||
app.docs.slots.insert(at, Some(DocSlot::new(session)));
|
||||
switch(app, at);
|
||||
crate::agent_chat::follow_new_tab(app, |app| switch(app, at));
|
||||
app.fit_view();
|
||||
Ok(())
|
||||
}
|
||||
@@ -370,12 +373,12 @@ pub fn active_is_blank(app: &SolveApp) -> bool {
|
||||
pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
if active_is_blank(app) {
|
||||
let rev = app.session.revision;
|
||||
reset_chat(app);
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
crate::design_properties::close_object(app);
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
app.session.replace_design(session);
|
||||
reset_chat(app);
|
||||
// The blank design's record of its links goes with it; the new one's start now.
|
||||
app.docs.link_stamps.clear();
|
||||
crate::render_workspace::reset_document(app);
|
||||
@@ -493,8 +496,12 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A replacement design starts a fresh conversation while retaining the window's chat services.
|
||||
/// A replacement starts a fresh conversation, unless the current chat itself requested it.
|
||||
pub(crate) fn reset_chat(app: &mut SolveApp) {
|
||||
if app.agent_chat.following_document {
|
||||
crate::agent_chat::continue_in_document(app);
|
||||
return;
|
||||
}
|
||||
// A chat not asked anything yet (an agent connected at start, say) holds nothing of the old
|
||||
// design: it starts over in place, and its agent stays connected.
|
||||
if !app.agent_chat.items.iter().any(|i| matches!(i, crate::agent_chat::Item::User { .. })) {
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct Request {
|
||||
/// The saved design this request is for, when it must not run on another: one shown in its
|
||||
/// place by the time the request starts drops it.
|
||||
pub design: Option<String>,
|
||||
/// The chat turn that requested the load. It must still be current when the file lands.
|
||||
pub(crate) chat_turn: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
@@ -70,6 +72,7 @@ impl Request {
|
||||
options: Default::default(),
|
||||
allow_hosts: Vec::new(),
|
||||
design: None,
|
||||
chat_turn: None,
|
||||
}
|
||||
}
|
||||
fn insert(&self) -> bool {
|
||||
@@ -94,6 +97,10 @@ impl Request {
|
||||
}
|
||||
fn answer(&self, r: Result<Value, String>) {
|
||||
if let Some(reply) = &self.reply {
|
||||
if crate::control::chat_turn_cancelled(self.chat_turn.as_deref()) {
|
||||
let _ = reply.send(crate::control::cancelled_chat_response());
|
||||
return;
|
||||
}
|
||||
let _ = reply.send(match r {
|
||||
Ok(v) => json!({"ok": true, "result": v}),
|
||||
Err(e) => json!({"ok": false, "error": e}),
|
||||
@@ -183,7 +190,7 @@ impl Running {
|
||||
Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision
|
||||
}
|
||||
fn cancelled(&self) -> bool {
|
||||
self.progress.is_cancelled()
|
||||
self.progress.is_cancelled() || crate::control::chat_turn_cancelled(self.request.chat_turn.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +248,10 @@ pub fn begin(app: &mut SolveApp, path: &str, insert: bool) {
|
||||
|
||||
/// Queue a file operation; on the web (no threads) it runs now.
|
||||
pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> {
|
||||
if crate::control::chat_turn_cancelled(request.chat_turn.as_deref()) {
|
||||
request.answer(Err("the agent chat turn ended".into()));
|
||||
return Ok(());
|
||||
}
|
||||
if app.file_load.idle() && reuse_open(app, &request) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -320,7 +331,7 @@ fn reuse_open(app: &mut SolveApp, request: &Request) -> bool {
|
||||
if !request.options.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
crate::documents::reuse_path(app, &request.path)
|
||||
crate::agent_chat::with_tool_request(app, request.chat_turn.as_ref(), |app| crate::documents::reuse_path(app, &request.path))
|
||||
});
|
||||
match reused {
|
||||
Ok(false) => false,
|
||||
@@ -371,7 +382,16 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
|
||||
options.remove("path");
|
||||
options.retain(|_, v| !v.is_null());
|
||||
}
|
||||
let request = Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options, allow_hosts: Vec::new(), design: None };
|
||||
let request = Request {
|
||||
path: path.into(),
|
||||
kind,
|
||||
reply: Some(req.reply.clone()),
|
||||
scope,
|
||||
options,
|
||||
allow_hosts: Vec::new(),
|
||||
design: None,
|
||||
chat_turn: req.chat_turn,
|
||||
};
|
||||
let _ = begin_request(app, request);
|
||||
None
|
||||
}
|
||||
@@ -405,6 +425,10 @@ fn start(app: &mut SolveApp) {
|
||||
return;
|
||||
}
|
||||
let Some(request) = app.file_load.queued.pop_front() else { return };
|
||||
if crate::control::chat_turn_cancelled(request.chat_turn.as_deref()) {
|
||||
request.answer(Err("the agent chat turn ended".into()));
|
||||
return;
|
||||
}
|
||||
if reuse_open(app, &request) {
|
||||
return;
|
||||
}
|
||||
@@ -865,6 +889,18 @@ fn compute(
|
||||
}
|
||||
|
||||
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// Stop must release the waiting slot even if the worker has not reached its next check.
|
||||
let chat_cancelled = app.file_load.running.as_ref().is_some_and(|r| crate::control::chat_turn_cancelled(r.request.chat_turn.as_deref()));
|
||||
if chat_cancelled && let Some(running) = &app.file_load.running {
|
||||
running.progress.cancel();
|
||||
}
|
||||
// As with FileLoad::cancel, a publishing export retains its worker until it reports back.
|
||||
if chat_cancelled
|
||||
&& app.file_load.running.as_ref().is_some_and(|r| r.request.kind != Kind::ExportDxf)
|
||||
&& let Some(running) = app.file_load.running.take()
|
||||
{
|
||||
running.request.answer(Err("the agent chat turn ended".into()));
|
||||
}
|
||||
if let Some(running) = &mut app.file_load.running {
|
||||
step_staged(running);
|
||||
}
|
||||
@@ -922,7 +958,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.session.accept_computed(made.session);
|
||||
app.big_operation = true;
|
||||
} else {
|
||||
crate::documents::adopt(app, made.session);
|
||||
crate::agent_chat::with_tool_request(app, request.chat_turn.as_ref(), |app| crate::documents::adopt(app, made.session));
|
||||
if request.kind == Kind::Open {
|
||||
app.home.add_recent(&request.path);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,13 @@ completed changes in that design's undo history. Returning restores the conversa
|
||||
messages wait for **Send**. Closing a design closes its conversation. Conversations are kept
|
||||
only while the tabs remain open.
|
||||
|
||||
When the agent itself creates a new design or opens a file, its conversation follows the new
|
||||
design and the same turn continues. Its completed changes stay in each design's own undo
|
||||
history. The original tab starts a fresh conversation if you return to it. Opening a file that
|
||||
is already in another tab returns an error instead of displacing that tab's conversation;
|
||||
select that tab yourself to continue there. Your own New, Open and tab switches still stop
|
||||
the current turn. Stop also cancels a file the agent is still opening.
|
||||
|
||||
The input stays enabled during a turn. **Enter** queues the message: it shows in the conversation
|
||||
as a **Queued** bubble that you can **Edit** or remove (×) until it is sent. Several queued
|
||||
messages go as one prompt, in order, when the turn ends normally.
|
||||
|
||||
@@ -187,9 +187,19 @@ parks that state alongside its `Session`; switching restores both together. Pane
|
||||
placement, agent discovery, the latest sign-in evidence per agent, endpoint model cache,
|
||||
settings dialogs and the desktop endpoint starter remain window state. A new tab gets a fresh conversation. Closing a tab drops its
|
||||
backend; closing the last tab or replacing its design (including programmatic New, Open and
|
||||
Recover commands or scripts) clears its conversation.
|
||||
Recover commands or scripts from external callers) clears its conversation.
|
||||
Conversation state is in memory only and does not change the design file format.
|
||||
|
||||
An admitted request from the current chat carries its turn's validity flag through direct
|
||||
dispatch, cursor animation and asynchronous file loading. Only while that request applies,
|
||||
`with_tool_request` marks its document changes as agent-initiated. New/Recover replacements
|
||||
retain the chat and reset its undo boundary to the new design. Opening a new tab groups the
|
||||
old design's completed changes, moves the conversation and its endpoint into the new tab,
|
||||
and starts a new undo segment without resetting the turn's limits. A failed batch that
|
||||
restores its original design also restores its original turn boundary. Opening an already
|
||||
active file is unchanged; opening a file owned by another tab is refused, preserving both
|
||||
conversations. The marker is never taken from JSON and does not persist between requests.
|
||||
|
||||
Leaving a tab cancels its running turn, declines outstanding approvals, and merges completed
|
||||
changes into that design's undo history before swapping sessions. Its backend conversation
|
||||
remains available when the tab is selected again. Pending messages stay with that tab and wait
|
||||
|
||||
Reference in New Issue
Block a user