Agent chat: pop out into its own window, dock back (#1745)
Thanks @onembyte.
This commit is contained in:
1 parent
003abc2149
commit
5eb7640b54
10 files changed
+276
-19
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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": "待機に追加",
|
||||||
|
|||||||
@@ -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": "加入队列",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user