Agent chat: pop out into its own window, dock back (#1745)

Thanks @onembyte.
This commit is contained in:
franco michetti authored and GitHub committed 2026-10-18 05:59:30 -04:00
1 parent 003abc2149
commit 5eb7640b54
10 files changed
+276 -19

No files matched your search

+3
View File
@@ -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",
+3
View File
@@ -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": "待機に追加",
+3
View File
@@ -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": "加入队列",
+7
View File
@@ -207,6 +207,8 @@ pub struct AgentChat {
pub agent_id: Option<String>,
/// 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<Val
if let Some(m) = p.get("minimized").and_then(Value::as_bool) {
app.preferences.agent_chat_minimized = m;
}
if let Some(w) = p.get("popped").and_then(Value::as_bool) {
app.preferences.agent_chat_popped = w;
}
if let Some(w) = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()) {
app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH);
panel::forget_width(ctx);
@@ -1233,6 +1238,8 @@ pub fn status(app: &SolveApp) -> 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,
+129 -13
View File
@@ -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<bool>, main_active: Option<bool>) -> 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);
+12 -5
View File
@@ -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();
+103
View File
@@ -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]
+9
View File
@@ -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 => {
+6
View File
@@ -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.
+1 -1
View File
@@ -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) |