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_deny": "Deny",
"ui.agent_chat_desktop_only": "The agent chat needs the desktop app.", "ui.agent_chat_desktop_only": "The agent chat needs the desktop app.",
"ui.agent_chat_detecting": "Looking for agents…", "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_left": "Dock on the left",
"ui.agent_chat_dock_right": "Dock on the right", "ui.agent_chat_dock_right": "Dock on the right",
"ui.agent_chat_edit": "Edit", "ui.agent_chat_edit": "Edit",
@@ -887,10 +888,12 @@
"ui.agent_chat_none_found_short": "No agent found", "ui.agent_chat_none_found_short": "No agent found",
"ui.agent_chat_not_installed": "Not installed", "ui.agent_chat_not_installed": "Not installed",
"ui.agent_chat_not_signed_in": "not signed in", "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_permission": "The agent asks to run: {title}",
"ui.agent_chat_pick": "Agent", "ui.agent_chat_pick": "Agent",
"ui.agent_chat_placeholder": "Ask the agent to change the design…", "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_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": "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_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", "ui.agent_chat_queue": "Queue",
+3
View File
@@ -866,6 +866,7 @@
"ui.agent_chat_deny": "拒否", "ui.agent_chat_deny": "拒否",
"ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。", "ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。",
"ui.agent_chat_detecting": "エージェントを検索中…", "ui.agent_chat_detecting": "エージェントを検索中…",
"ui.agent_chat_dock": "サイドパネルに戻す",
"ui.agent_chat_dock_left": "左にドッキング", "ui.agent_chat_dock_left": "左にドッキング",
"ui.agent_chat_dock_right": "右にドッキング", "ui.agent_chat_dock_right": "右にドッキング",
"ui.agent_chat_edit": "編集", "ui.agent_chat_edit": "編集",
@@ -887,10 +888,12 @@
"ui.agent_chat_none_found_short": "エージェントが見つかりません", "ui.agent_chat_none_found_short": "エージェントが見つかりません",
"ui.agent_chat_not_installed": "未インストール", "ui.agent_chat_not_installed": "未インストール",
"ui.agent_chat_not_signed_in": "未サインイン", "ui.agent_chat_not_signed_in": "未サインイン",
"ui.agent_chat_own_window": "独立したウィンドウ",
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}", "ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
"ui.agent_chat_pick": "エージェント", "ui.agent_chat_pick": "エージェント",
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…", "ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
"ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)", "ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)",
"ui.agent_chat_pop_out": "独立したウィンドウで開く(別の画面に移動できます)",
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。", "ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。", "ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
"ui.agent_chat_queue": "待機に追加", "ui.agent_chat_queue": "待機に追加",
+3
View File
@@ -866,6 +866,7 @@
"ui.agent_chat_deny": "拒绝", "ui.agent_chat_deny": "拒绝",
"ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。", "ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。",
"ui.agent_chat_detecting": "正在查找智能体…", "ui.agent_chat_detecting": "正在查找智能体…",
"ui.agent_chat_dock": "停靠到侧面板",
"ui.agent_chat_dock_left": "停靠在左侧", "ui.agent_chat_dock_left": "停靠在左侧",
"ui.agent_chat_dock_right": "停靠在右侧", "ui.agent_chat_dock_right": "停靠在右侧",
"ui.agent_chat_edit": "编辑", "ui.agent_chat_edit": "编辑",
@@ -887,10 +888,12 @@
"ui.agent_chat_none_found_short": "未找到智能体", "ui.agent_chat_none_found_short": "未找到智能体",
"ui.agent_chat_not_installed": "未安装", "ui.agent_chat_not_installed": "未安装",
"ui.agent_chat_not_signed_in": "未登录", "ui.agent_chat_not_signed_in": "未登录",
"ui.agent_chat_own_window": "独立窗口",
"ui.agent_chat_permission": "智能体请求运行:{title}", "ui.agent_chat_permission": "智能体请求运行:{title}",
"ui.agent_chat_pick": "智能体", "ui.agent_chat_pick": "智能体",
"ui.agent_chat_placeholder": "让智能体修改设计…", "ui.agent_chat_placeholder": "让智能体修改设计…",
"ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)", "ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)",
"ui.agent_chat_pop_out": "在独立窗口中打开(可移到另一个屏幕)",
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。", "ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。", "ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
"ui.agent_chat_queue": "加入队列", "ui.agent_chat_queue": "加入队列",
+7
View File
@@ -207,6 +207,8 @@ pub struct AgentChat {
pub agent_id: Option<String>, pub agent_id: Option<String>,
/// The agent the running backend is (for permissions and "always allow"). /// The agent the running backend is (for permissions and "always allow").
running_id: String, 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. /// The agent's own modes, and the current one.
modes: Vec<(String, String)>, modes: Vec<(String, String)>,
mode: 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) { if let Some(m) = p.get("minimized").and_then(Value::as_bool) {
app.preferences.agent_chat_minimized = m; 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()) { 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); app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH);
panel::forget_width(ctx); panel::forget_width(ctx);
@@ -1233,6 +1238,8 @@ pub fn status(app: &SolveApp) -> Value {
"open": c.open, "open": c.open,
"side": app.preferences.agent_chat_side, "side": app.preferences.agent_chat_side,
"minimized": app.preferences.agent_chat_minimized, "minimized": app.preferences.agent_chat_minimized,
"popped": app.preferences.agent_chat_popped,
"window": app.preferences.agent_chat_window,
"width": app.preferences.agent_chat_width, "width": app.preferences.agent_chat_width,
"state": c.state().id(), "state": c.state().id(),
"agent": c.agent, "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)) { if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
toggle(app); 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). // 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"))) 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))); || 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() { if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ctx); refresh(app, &ctx);
} }
#[cfg(not(target_arch = "wasm32"))]
if app.preferences.agent_chat_popped {
window(app, &ctx);
return;
}
let t = Tokens::get(); let t = Tokens::get();
let left = app.preferences.agent_chat_side == "left"; let left = app.preferences.agent_chat_side == "left";
let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) }; 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 { fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
let t = Tokens::get(); let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); 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 c = r.center();
let s = Stroke::new(1.4, t.text); let s = Stroke::new(1.4, t.text);
let p = ui.painter();
match kind { match kind {
"minimize" => { "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" => { "close" => {
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);
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);
}
// 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" => { "newchat" => {
// A speech bubble with a plus. // 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, /// 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) { fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
let t = Tokens::get(); let t = Tokens::get();
let state = app.agent_chat.state(); let state = app.agent_chat.state();
let popped = app.preferences.agent_chat_popped;
ui.horizontal(|ui| { ui.horizontal(|ui| {
let title = { let title = {
let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover()); let (grip, _) = ui.allocate_exact_size(vec2(if popped { 0.0 } else { 10.0 }, 18.0), Sense::hover());
for row in 0..3 { if !popped {
for col in 0..2 { for row in 0..3 {
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); 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)); 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()); ui.label(RichText::new(s).color(state_color(state)).small());
// Drag the header across the window to dock on the other side. // 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()); if !popped {
crate::scenario::publish_handle("agent_chat:grip", title.center()); let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
dock_drag(app, ui, &drag, left); 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| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() { if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() {
app.agent_chat.open = false; 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; app.preferences.agent_chat_minimized = true;
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -224,6 +326,13 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
request_new_chat(app); request_new_chat(app);
} }
#[cfg(not(target_arch = "wasm32"))] #[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() { let b = if state.running() {
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip")) 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); let r = ui.add(edit);
crate::scenario::publish_rect("agent_chat:input", r.rect); 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); let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
if esc { if esc {
cancel(app); 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| { 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.label(tr("ui.agent_chat_side"));
ui.horizontal(|ui| { ui.horizontal(|ui| {
let side = &mut app.preferences.agent_chat_side; let p = &mut app.preferences;
if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() { let (left, popped) = (p.agent_chat_side == "left", p.agent_chat_popped);
*side = "left".into(); 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() { if ui.radio(!left && !popped, tr("ui.agent_chat_right")).clicked() {
*side = "right".into(); 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(); 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); 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 /// "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. /// through from then on for this agent. "Allow everything": even a save runs without a question.
#[test] #[test]
+9
View File
@@ -369,6 +369,11 @@ pub struct Prefs {
pub agent_chat_side: String, pub agent_chat_side: String,
pub agent_chat_width: f32, pub agent_chat_width: f32,
pub agent_chat_minimized: bool, 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. /// Changes the agent may make for one message.
pub agent_chat_turn_limit: u32, pub agent_chat_turn_limit: u32,
} }
@@ -460,6 +465,8 @@ impl Default for Prefs {
agent_chat_side: "right".into(), agent_chat_side: "right".into(),
agent_chat_width: 380.0, agent_chat_width: 380.0,
agent_chat_minimized: false, agent_chat_minimized: false,
agent_chat_popped: false,
agent_chat_window: None,
agent_chat_turn_limit: crate::agent_chat::gate::DEFAULT_TURN_LIMIT, 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_side = d.agent_chat_side;
p.agent_chat_width = d.agent_chat_width; p.agent_chat_width = d.agent_chat_width;
p.agent_chat_minimized = d.agent_chat_minimized; 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; p.agent_chat_turn_limit = d.agent_chat_turn_limit;
} }
1 => { 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 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. **Left** or **Right** under Preferences → AI → Panel. SolidCraft remembers the side.
- **Width:** drag the panel's inner edge. SolidCraft remembers it. - **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 - **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 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. 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.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.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_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` | | | `ui.resize` | `width`, `height` | |
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |