From 5eb7640b541c5faebd394035ce666499662aeb3c Mon Sep 17 00:00:00 2001 From: franco michetti Date: Sun, 18 Oct 2026 06:59:30 -0300 Subject: [PATCH] Agent chat: pop out into its own window, dock back (#1745) Thanks @onembyte. --- assets/locales/en.json | 3 + assets/locales/ja.json | 3 + assets/locales/zh.json | 3 + crates/ui-egui/src/agent_chat/mod.rs | 7 ++ crates/ui-egui/src/agent_chat/panel.rs | 142 ++++++++++++++++++++-- crates/ui-egui/src/agent_chat/settings.rs | 17 ++- crates/ui-egui/src/agent_chat/tests.rs | 103 ++++++++++++++++ crates/ui-egui/src/prefs.rs | 9 ++ docs/agent-chat.md | 6 + docs/control-protocol.md | 2 +- 10 files changed, 276 insertions(+), 19 deletions(-) diff --git a/assets/locales/en.json b/assets/locales/en.json index 554d4158..838e2166 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -866,6 +866,7 @@ "ui.agent_chat_deny": "Deny", "ui.agent_chat_desktop_only": "The agent chat needs the desktop app.", "ui.agent_chat_detecting": "Looking for agents…", + "ui.agent_chat_dock": "Dock in the side panel", "ui.agent_chat_dock_left": "Dock on the left", "ui.agent_chat_dock_right": "Dock on the right", "ui.agent_chat_edit": "Edit", @@ -887,10 +888,12 @@ "ui.agent_chat_none_found_short": "No agent found", "ui.agent_chat_not_installed": "Not installed", "ui.agent_chat_not_signed_in": "not signed in", + "ui.agent_chat_own_window": "Own window", "ui.agent_chat_permission": "The agent asks to run: {title}", "ui.agent_chat_pick": "Agent", "ui.agent_chat_placeholder": "Ask the agent to change the design…", "ui.agent_chat_placeholder_busy": "Queue a message for when the agent is done (Ctrl+Enter: send now)", + "ui.agent_chat_pop_out": "Open in its own window, which you can move to another screen", "ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.", "ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.", "ui.agent_chat_queue": "Queue", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index c680a69a..4fb2e438 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -866,6 +866,7 @@ "ui.agent_chat_deny": "拒否", "ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。", "ui.agent_chat_detecting": "エージェントを検索中…", + "ui.agent_chat_dock": "サイドパネルに戻す", "ui.agent_chat_dock_left": "左にドッキング", "ui.agent_chat_dock_right": "右にドッキング", "ui.agent_chat_edit": "編集", @@ -887,10 +888,12 @@ "ui.agent_chat_none_found_short": "エージェントが見つかりません", "ui.agent_chat_not_installed": "未インストール", "ui.agent_chat_not_signed_in": "未サインイン", + "ui.agent_chat_own_window": "独立したウィンドウ", "ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}", "ui.agent_chat_pick": "エージェント", "ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…", "ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)", + "ui.agent_chat_pop_out": "独立したウィンドウで開く(別の画面に移動できます)", "ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。", "ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。", "ui.agent_chat_queue": "待機に追加", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 2fc56387..26c7d424 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -866,6 +866,7 @@ "ui.agent_chat_deny": "拒绝", "ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。", "ui.agent_chat_detecting": "正在查找智能体…", + "ui.agent_chat_dock": "停靠到侧面板", "ui.agent_chat_dock_left": "停靠在左侧", "ui.agent_chat_dock_right": "停靠在右侧", "ui.agent_chat_edit": "编辑", @@ -887,10 +888,12 @@ "ui.agent_chat_none_found_short": "未找到智能体", "ui.agent_chat_not_installed": "未安装", "ui.agent_chat_not_signed_in": "未登录", + "ui.agent_chat_own_window": "独立窗口", "ui.agent_chat_permission": "智能体请求运行:{title}", "ui.agent_chat_pick": "智能体", "ui.agent_chat_placeholder": "让智能体修改设计…", "ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)", + "ui.agent_chat_pop_out": "在独立窗口中打开(可移到另一个屏幕)", "ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。", "ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。", "ui.agent_chat_queue": "加入队列", diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index 2734ccfc..22e6982e 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -207,6 +207,8 @@ pub struct AgentChat { pub agent_id: Option, /// The agent the running backend is (for permissions and "always allow"). running_id: String, + /// The popped-out chat window was given its saved place (it is open); see `panel::window_builder`. + pub(crate) window_placed: bool, /// The agent's own modes, and the current one. modes: Vec<(String, String)>, mode: String, @@ -1131,6 +1133,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result Value { "open": c.open, "side": app.preferences.agent_chat_side, "minimized": app.preferences.agent_chat_minimized, + "popped": app.preferences.agent_chat_popped, + "window": app.preferences.agent_chat_window, "width": app.preferences.agent_chat_width, "state": c.state().id(), "agent": c.agent, diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index ad70ac13..325c596e 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -58,6 +58,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) { toggle(app); } + // A window that isn't drawn this frame closes; the next one opens at the saved place. + if !(app.agent_chat.open && app.preferences.agent_chat_popped) { + app.agent_chat.window_placed = false; + } // New chat, when the chat has focus (its input, or the pointer over the panel). let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input"))) || ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p))); @@ -72,6 +76,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { refresh(app, &ctx); } + #[cfg(not(target_arch = "wasm32"))] + if app.preferences.agent_chat_popped { + window(app, &ctx); + return; + } let t = Tokens::get(); let left = app.preferences.agent_chat_side == "left"; let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) }; @@ -107,7 +116,78 @@ fn request_new_chat(app: &mut SolveApp) { } } -/// A small drawn button: `kind` is `minimize`, `close`, `stopturn` or `expand`. +/// A saved window place (`[x, y, width, height]`) that can be used: finite, a usable size, and +/// not absurdly far away. `x` and `y` may be negative: a screen left of or above the main one. +pub fn window_place(saved: Option<[f32; 4]>) -> Option<([f32; 2], [f32; 2])> { + let [x, y, w, h] = saved?; + let usable = [x, y, w, h].iter().all(|v| v.is_finite()) + && (MIN_WIDTH..=10_000.0).contains(&w) + && (200.0..=10_000.0).contains(&h) + && x.abs() <= 100_000.0 + && y.abs() <= 100_000.0; + usable.then_some(([x, y], [w, h])) +} + +/// The chat window's settings. The saved place and size go only to a window that is opening: +/// egui turns a changed place into a move, and the system rounds what it is given, so giving it +/// every frame made the window chase its own rounding (it shook a pixel up and down). +pub fn window_builder(app: &SolveApp) -> egui::ViewportBuilder { + let title = format!("{} — {}", app.session.doc.name, tr("ui.agent_chat")); + let builder = egui::ViewportBuilder::default().with_title(title).with_min_inner_size([MIN_WIDTH, 320.0]); + if app.agent_chat.window_placed { + return builder; + } + match window_place(app.preferences.agent_chat_window) { + Some((pos, size)) => builder.with_position(pos).with_inner_size(size), + None => builder.with_inner_size([app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH), 720.0]), + } +} + +/// The chat in its own window, named after the design: a native window on the desktop, which can +/// go to another screen, or a window floating inside the app where the system can't open one. +/// The system's close button closes the chat (it opens there again); Dock puts it back. +#[cfg(not(target_arch = "wasm32"))] +fn window(app: &mut SolveApp, ctx: &egui::Context) { + let builder = window_builder(app); + app.agent_chat.window_placed = true; + // The side panel isn't drawn: its place no longer counts as "in the chat". + app.agent_chat.panel_rect = None; + ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("sc_agent_chat_window"), builder, |ui, class| { + let t = Tokens::get(); + let frame = egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6)); + let r = egui::CentralPanel::default().frame(frame).show(ui, |ui| body(app, ui, false)); + if class == egui::ViewportClass::EmbeddedWindow { + // Floating inside the app: its handles are the main window's, as the panel's are. + crate::scenario::publish_rect("agent_chat:window", r.response.rect); + app.agent_chat.panel_rect = Some(r.response.rect); + return; + } + let ctx = ui.ctx().clone(); + if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) { + toggle(app); + } + // The whole window is the chat, so New chat works anywhere in it. + if ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) { + request_new_chat(app); + } + let (outer, inner, close) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect, i.viewport().close_requested())); + if let (Some(o), Some(n)) = (outer, inner) { + app.preferences.agent_chat_window = Some([o.min.x, o.min.y, n.width(), n.height()]); + } + if close { + app.agent_chat.open = false; + } + }); +} + +/// Whether the chat's own window should become active because the pointer is over its text box: +/// only when it is in a window of its own that isn't active and the app's main window is (never +/// taking the focus from another app). +pub fn take_focus_for_text(popped: bool, over_text: bool, chat_active: Option, main_active: Option) -> bool { + popped && over_text && chat_active == Some(false) && main_active == Some(true) +} + +/// A small drawn button: `kind` is `minimize`, `close`, `newchat`, `stopturn`, `pop_out` or `dock`. fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response { let t = Tokens::get(); let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); @@ -116,13 +196,29 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response { } let c = r.center(); let s = Stroke::new(1.4, t.text); + let p = ui.painter(); match kind { "minimize" => { - ui.painter().line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s); + p.line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s); } "close" => { - ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s); - ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s); + p.line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s); + p.line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s); + } + // A window with an arrow leaving it by its top right corner. + "pop_out" => { + p.line_segment([c + vec2(-1.0, -5.0), c + vec2(-5.0, -5.0)], s); + p.line_segment([c + vec2(-5.0, -5.0), c + vec2(-5.0, 5.0)], s); + p.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, 5.0)], s); + p.line_segment([c + vec2(5.0, 5.0), c + vec2(5.0, 1.0)], s); + p.line_segment([c + vec2(-1.0, 1.0), c + vec2(5.5, -5.5)], s); + p.line_segment([c + vec2(1.5, -5.5), c + vec2(5.5, -5.5)], s); + p.line_segment([c + vec2(5.5, -5.5), c + vec2(5.5, -1.5)], s); + } + // A window with its right side filled: the panel docked. + "dock" => { + p.rect_stroke(Rect::from_center_size(c, vec2(11.0, 10.0)), 1.0, s, egui::StrokeKind::Middle); + p.rect_filled(Rect::from_min_max(c + vec2(1.5, -5.0), c + vec2(5.5, 5.0)), 0.0, t.text); } "newchat" => { // A speech bubble with a plus. @@ -179,16 +275,20 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) { } /// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop, -/// Minimise and Close. +/// Pop out (or Dock, in its own window), Minimise and Close. In its own window the system moves +/// and minimises it, so there is no grip and no Minimise. fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { let t = Tokens::get(); let state = app.agent_chat.state(); + let popped = app.preferences.agent_chat_popped; ui.horizontal(|ui| { let title = { - let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover()); - for row in 0..3 { - for col in 0..2 { - ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim); + let (grip, _) = ui.allocate_exact_size(vec2(if popped { 0.0 } else { 10.0 }, 18.0), Sense::hover()); + if !popped { + for row in 0..3 { + for col in 0..2 { + ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim); + } } } let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0)); @@ -203,14 +303,16 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { } ui.label(RichText::new(s).color(state_color(state)).small()); // Drag the header across the window to dock on the other side. - let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag()); - crate::scenario::publish_handle("agent_chat:grip", title.center()); - dock_drag(app, ui, &drag, left); + if !popped { + let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag()); + crate::scenario::publish_handle("agent_chat:grip", title.center()); + dock_drag(app, ui, &drag, left); + } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() { app.agent_chat.open = false; } - if glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() { + if !popped && glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() { app.preferences.agent_chat_minimized = true; } #[cfg(not(target_arch = "wasm32"))] @@ -224,6 +326,13 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { request_new_chat(app); } #[cfg(not(target_arch = "wasm32"))] + { + let (kind, tip) = if popped { ("dock", "ui.agent_chat_dock") } else { ("pop_out", "ui.agent_chat_pop_out") }; + if glyph_button(ui, kind, tr(tip)).clicked() { + app.preferences.agent_chat_popped = !popped; + } + } + #[cfg(not(target_arch = "wasm32"))] { let b = if state.running() { ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip")) @@ -345,6 +454,13 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) { }; let r = ui.add(edit); crate::scenario::publish_rect("agent_chat:input", r.rect); + // macOS shows a window's cursors only while it is the active window: over the text box of a + // chat window that isn't, the pointer stays an arrow. Coming from the main window, the chat + // window becomes active, so the text cursor shows and the first click places the caret. + let (chat_active, main_active) = (ui.ctx().input(|i| i.viewport().focused), ui.ctx().input_for(egui::ViewportId::ROOT, |i| i.viewport().focused)); + if take_focus_for_text(app.preferences.agent_chat_popped, r.hovered(), chat_active, main_active) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus); + } let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift); if esc { cancel(app); diff --git a/crates/ui-egui/src/agent_chat/settings.rs b/crates/ui-egui/src/agent_chat/settings.rs index 4109a537..cd4412a3 100644 --- a/crates/ui-egui/src/agent_chat/settings.rs +++ b/crates/ui-egui/src/agent_chat/settings.rs @@ -183,12 +183,19 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| { ui.label(tr("ui.agent_chat_side")); ui.horizontal(|ui| { - let side = &mut app.preferences.agent_chat_side; - if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() { - *side = "left".into(); + let p = &mut app.preferences; + let (left, popped) = (p.agent_chat_side == "left", p.agent_chat_popped); + if ui.radio(left && !popped, tr("ui.agent_chat_left")).clicked() { + p.agent_chat_side = "left".into(); + p.agent_chat_popped = false; } - if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() { - *side = "right".into(); + if ui.radio(!left && !popped, tr("ui.agent_chat_right")).clicked() { + p.agent_chat_side = "right".into(); + p.agent_chat_popped = false; + } + #[cfg(not(target_arch = "wasm32"))] + if ui.radio(popped, tr("ui.agent_chat_own_window")).on_hover_text(tr("ui.agent_chat_pop_out")).clicked() { + p.agent_chat_popped = true; } }); ui.end_row(); diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index fb380f0f..aa9fc49b 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -234,6 +234,109 @@ fn ai_features_and_the_toolbar_button_are_on_by_default() { assert!(p.ai_features && p.ai_toolbar_button); } +/// Pop out puts the chat in its own window (a native window on the desktop, which can go to +/// another screen; embedded here, where the test context has one viewport), and Dock puts it back +/// in the side panel. The control channel does the same. +#[test] +fn the_chat_pops_out_into_its_own_window_and_docks_back() { + use crate::scenario::{handle_at, rect_of}; + let mut h = crate::scenario::Harness::new(); + h.call("ui.agent_chat", json!({"open": true})); + h.frames(3); + assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "docked by default"); + assert!(!h.app.preferences.agent_chat_popped); + let pop = handle_at("agent_chat:pop_out").expect("a pop-out button in the header"); + h.call("ui.click", json!({"x": pop.x, "y": pop.y})); + h.frames(3); + assert!(h.app.preferences.agent_chat_popped); + assert!(rect_of("agent_chat:panel").is_none(), "the side panel is gone"); + assert!(rect_of("agent_chat:window").is_some(), "the chat is in its own window"); + assert_eq!(status(&h.app)["popped"], json!(true)); + // A window has its own minimise; the strip and the side drag are the docked panel's. + assert!(handle_at("agent_chat:minimize").is_none() && handle_at("agent_chat:grip").is_none()); + let dock = handle_at("agent_chat:dock").expect("a dock button in the popped-out header"); + h.call("ui.click", json!({"x": dock.x, "y": dock.y})); + h.frames(3); + assert!(!h.app.preferences.agent_chat_popped); + assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "back in the side panel"); + h.call("ui.agent_chat", json!({"popped": true})); + h.frames(3); + assert!(rect_of("agent_chat:window").is_some() && rect_of("agent_chat:panel").is_none()); + // Closing the chat keeps where it lives: it opens popped out again. + h.call("ui.agent_chat", json!({"open": false})); + h.frames(2); + assert!(rect_of("agent_chat:window").is_none()); + h.call("ui.agent_chat", json!({"open": true})); + h.frames(3); + assert!(rect_of("agent_chat:window").is_some()); +} + +/// The saved place is given to the window once, when it opens; after that the system owns it. +/// Given every frame, it chased the system's own rounding: a window that shook a pixel up and +/// down (686, 687, 686…) on a Retina screen. +#[test] +fn the_window_place_is_given_once_when_it_opens() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 686.0]); + let first = panel::window_builder(&app); + assert_eq!(first.position, Some(egui::pos2(1967.0, 160.0))); + assert_eq!(first.inner_size, Some(egui::vec2(569.0, 686.0))); + app.agent_chat.window_placed = true; + app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 687.0]); + let later = panel::window_builder(&app); + assert_eq!((later.position, later.inner_size), (None, None), "nothing to move or resize once it is open"); + assert!(later.title.is_some()); + // Docked or closed, the next pop-out opens at the saved place again. + let mut h = crate::scenario::Harness::new(); + h.call("ui.agent_chat", json!({"open": true, "popped": true})); + h.frames(2); + assert!(h.app.agent_chat.window_placed); + h.call("ui.agent_chat", json!({"popped": false})); + h.frames(2); + assert!(!h.app.agent_chat.window_placed); + h.call("ui.agent_chat", json!({"popped": true})); + h.frames(2); + h.call("ui.agent_chat", json!({"open": false})); + h.frames(2); + assert!(!h.app.agent_chat.window_placed); +} + +/// macOS shows the text cursor only in the active window: the chat's own window becomes active +/// when the pointer comes over its text box from the app's main window, and never takes the focus +/// from another app. +#[test] +fn the_chat_window_takes_the_focus_for_its_text_box_from_the_main_window_only() { + assert!(panel::take_focus_for_text(true, true, Some(false), Some(true))); + assert!(!panel::take_focus_for_text(true, true, Some(false), Some(false)), "another app is active"); + assert!(!panel::take_focus_for_text(true, true, Some(true), Some(false)), "already active"); + assert!(!panel::take_focus_for_text(true, false, Some(false), Some(true)), "not over the text box"); + assert!(!panel::take_focus_for_text(false, true, Some(false), Some(true)), "docked: the main window shows it"); + assert!(!panel::take_focus_for_text(true, true, None, Some(true)), "unknown: leave it"); +} + +/// Whether the chat is popped out, and its window's place and size, are preferences; ones saved +/// before they existed load docked, and an unusable saved place is ignored. +#[test] +fn the_popped_out_window_and_its_place_are_remembered() { + use crate::prefs::Prefs; + let mut p = Prefs::default(); + assert!(!p.agent_chat_popped && p.agent_chat_window.is_none()); + p.agent_chat_popped = true; + p.agent_chat_window = Some([-1600.0, 80.0, 420.0, 700.0]); + let back: Prefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + assert!(back.agent_chat_popped); + assert_eq!(back.agent_chat_window, Some([-1600.0, 80.0, 420.0, 700.0]), "a place on a screen left of the main one is kept"); + let mut old = serde_json::to_value(Prefs::default()).unwrap(); + old.as_object_mut().unwrap().remove("agent_chat_popped"); + old.as_object_mut().unwrap().remove("agent_chat_window"); + let old: Prefs = serde_json::from_value(old).unwrap(); + assert!(!old.agent_chat_popped && old.agent_chat_window.is_none()); + assert_eq!(panel::window_place(Some([10.0, 20.0, 400.0, 600.0])), Some(([10.0, 20.0], [400.0, 600.0]))); + for bad in [[f32::NAN, 0.0, 400.0, 600.0], [0.0, 0.0, 10.0, 600.0], [0.0, 0.0, 400.0, f32::INFINITY], [1e9, 0.0, 400.0, 600.0]] { + assert_eq!(panel::window_place(Some(bad)), None, "{bad:?}"); + } +} + /// "Ask every time": a modelling command waits for Allow; "Always allow" lets the same command /// through from then on for this agent. "Allow everything": even a save runs without a question. #[test] diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 1eb1691f..35324403 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -369,6 +369,11 @@ pub struct Prefs { pub agent_chat_side: String, pub agent_chat_width: f32, pub agent_chat_minimized: bool, + /// Whether the chat is in its own window instead of the side panel, and that window's place + /// and size as the system last reported them (`[x, y, width, height]`, points; `x` can be + /// negative or past the main screen when the window sits on another one). + pub agent_chat_popped: bool, + pub agent_chat_window: Option<[f32; 4]>, /// Changes the agent may make for one message. pub agent_chat_turn_limit: u32, } @@ -460,6 +465,8 @@ impl Default for Prefs { agent_chat_side: "right".into(), agent_chat_width: 380.0, agent_chat_minimized: false, + agent_chat_popped: false, + agent_chat_window: None, agent_chat_turn_limit: crate::agent_chat::gate::DEFAULT_TURN_LIMIT, } } @@ -619,6 +626,8 @@ fn defaults(app: &mut SolveApp, page: usize) { p.agent_chat_side = d.agent_chat_side; p.agent_chat_width = d.agent_chat_width; p.agent_chat_minimized = d.agent_chat_minimized; + p.agent_chat_popped = d.agent_chat_popped; + p.agent_chat_window = d.agent_chat_window; p.agent_chat_turn_limit = d.agent_chat_turn_limit; } 1 => { diff --git a/docs/agent-chat.md b/docs/agent-chat.md index d0bf0fa7..4b04ae78 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -36,6 +36,12 @@ when you send a message or press **Connect**. window and let go. The side it would land on lights up while you drag. You can also pick **Left** or **Right** under Preferences → AI → Panel. SolidCraft remembers the side. - **Width:** drag the panel's inner edge. SolidCraft remembers it. +- **Its own window:** the pop-out button in the header (a window with an arrow) moves the chat + into a window of its own, named after the design, which you can move anywhere, including + another screen. The window's own close button closes the chat, which opens there again next + time; **Dock** (the same place in its header) puts it back in the side panel. You can also + pick **Own window** under Preferences → AI → Panel. SolidCraft remembers where the window + was and how big. - **Minimise** (the line in the header) folds the panel into a thin strip that keeps the chat icon, the agent's state, and a count of anything waiting for your answer. Click the icon to unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b94ce328..d2729dd1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -156,7 +156,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | -| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) | +| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) | | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |