diff --git a/ROADMAP.md b/ROADMAP.md index 613e93532..fe4e6ea61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | diff --git a/crates/collab/src/diff_tests.rs b/crates/collab/src/diff_tests.rs index 6141be226..ab3d5be3b 100644 --- a/crates/collab/src/diff_tests.rs +++ b/crates/collab/src/diff_tests.rs @@ -131,9 +131,10 @@ fn document_level_data_round_trips() { x: 1.0, y: 2.0, anchor: None, - first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] }, + first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![], agent: false }, resolved: false, - replies: vec![], + // An AI agent's reply reaches the others with its name and marker. + replies: vec![Message { agent: true, ..Message::new("Claude (agent)", "Done", 2) }], read_by: None, }); b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 }); diff --git a/crates/doc/src/comments.rs b/crates/doc/src/comments.rs index 53edc9c90..c350069be 100644 --- a/crates/doc/src/comments.rs +++ b/crates/doc/src/comments.rs @@ -26,6 +26,9 @@ pub struct Message { pub edited: u64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reactions: Vec, + /// Written by an AI agent (a command run for an MCP client), not by a person in the app. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub agent: bool, } /// A reply in a comment thread. @@ -37,7 +40,7 @@ fn is_zero(v: &u64) -> bool { impl Message { pub fn new(author: impl Into, text: impl Into, created: u64) -> Self { - Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new() } + Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new(), agent: false } } /// `user` left the `emoji` reaction. diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index baac9ab3d..5666d3d11 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -1,8 +1,9 @@ //! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads. //! //! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first -//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only -//! a message's author edits or deletes it, and only the thread's author deletes the thread. +//! message). `author` is who is acting (default: the AI agent the command runs for, else the name +//! from Preferences, `pref.userName`): only a message's author edits or deletes it, and only the +//! thread's author deletes the thread. use std::fmt::Write as _; @@ -16,6 +17,12 @@ use crate::{Session, bad, spec}; /// The name comments are signed with until the user sets theirs. pub const DEFAULT_AUTHOR: &str = "You"; +/// The name of an AI agent that gave none. +pub const DEFAULT_AGENT: &str = "AI agent"; + +/// Most characters of an agent's own name in its comments. +const MAX_AGENT_NAME: usize = 64; + /// Links to a thread: `figcraft://comment/`. const LINK_PREFIX: &str = "figcraft://comment/"; @@ -260,9 +267,27 @@ pub fn user_name(s: &Session) -> String { if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() } } -/// Who is acting: `author` from the params, else the user. +/// How an AI agent's comments are signed: its client's name, marked so it never reads as a +/// person's ("Claude" → "Claude (agent)"), or [`DEFAULT_AGENT`] when it gave none. +pub fn agent_author(name: &str) -> String { + let n: String = name.chars().filter(|c| !c.is_control()).collect(); + let n: String = n.trim().chars().take(MAX_AGENT_NAME).collect(); + if n.is_empty() { DEFAULT_AGENT.into() } else { format!("{n} (agent)") } +} + +/// Who acts when no `author` is given: the AI agent the command runs for, else the user. +pub fn acting_name(s: &Session) -> String { + s.agent.clone().unwrap_or_else(|| user_name(s)) +} + +/// Who is acting: `author` from the params, else [`acting_name`]. fn author(s: &Session, p: &Value) -> String { - string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s)) + string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| acting_name(s)) +} + +/// A new message by `who`, marked as an agent's when the command runs for one. +fn new_message(s: &Session, who: String, text: String) -> Message { + Message { agent: s.agent.is_some(), ..Message::new(who, text, now_secs()) } } fn text(cmd: &str, p: &Value) -> crate::Result { @@ -303,7 +328,7 @@ fn local(doc: &Document, anchor: Option, x: f64, y: f64) -> (f64, f64) { fn message_json(m: &Message) -> Value { json!({ - "author": m.author, "text": m.text, "created": m.created, "edited": m.edited, + "author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited, "reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::>(), }) } @@ -338,7 +363,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value { "x": x, "y": y, "anchor": c.anchor, "frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)), "resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id), - "author": clip_chars(&c.first.author, 80), "preview": clip_chars(&c.first.text, 160), + "author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160), "replies": c.replies.len(), }) } @@ -565,7 +590,7 @@ pub fn specs() -> Vec { x: lx, y: ly, anchor, - first: Message::new(who.clone(), text, now_secs()), + first: new_message(s, who.clone(), text), resolved: false, replies: Vec::new(), read_by: None, @@ -579,7 +604,7 @@ pub fn specs() -> Vec { spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| { let id = req_comment("comment.reply", s, p)?; let who = author(s, p); - let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs()); + let r = new_message(s, who.clone(), text("comment.reply", p)?); let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?; c.replies.push(r); c.resolved = false; @@ -758,7 +783,9 @@ pub fn specs() -> Vec { let id = req_comment("comment.link", s, p)?; Ok(json!({"link": link(id)})) }), - spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }), + spec!("comment.people", "People in comments", "{}", false, |s, _| { + Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)})) + }), spec!( "comment.list", "List comments", @@ -817,7 +844,7 @@ pub fn specs() -> Vec { } Some(_) => return Err(bad("comment.prompt", "`ids` must be an array of comment ids")), }; - let who = user_name(s); + let who = acting_name(s); let selected: Vec<&Comment> = if let Some(ids) = ids { let mut out = Vec::with_capacity(ids.len()); for id in ids { diff --git a/crates/engine/src/comment_agent_tests.rs b/crates/engine/src/comment_agent_tests.rs new file mode 100644 index 000000000..f5a5b6592 --- /dev/null +++ b/crates/engine/src/comment_agent_tests.rs @@ -0,0 +1,78 @@ +//! Comments from an AI agent (#1414): a command run as an agent (`Session::execute_as`, which the +//! control channel's and MCP's `engine.execute {agent}` use) signs comments with the agent's own +//! name and marks them as an agent's; the user's own comments stay as they were. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::comment::agent_author; +use crate::live::initials; + +fn as_agent(s: &mut Session, agent: &str, cmd: &str, p: Value) -> Value { + s.execute_as(Some(agent), cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn thread(s: &mut Session, agent: Option<&str>) -> Value { + s.execute_as(agent, "comment.list", &json!({"filter": "all"})).unwrap()["comments"][0].clone() +} + +#[test] +fn an_agents_comments_carry_its_name_and_the_agent_marker() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "@Claude make this bigger"})).unwrap()["id"].as_u64().unwrap(); + as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "Done"})); + let t = thread(&mut s, None); + assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("You"), Some(false)), "the user's own comment is unchanged: {t}"); + let r = &t["replies"][0]; + assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Claude (agent)"), Some(true)), "{r}"); + let doc = s.doc().unwrap(); + let m = &doc.comment(c).unwrap().replies[0]; + assert!(m.agent && m.author == "Claude (agent)"); + // The agent acts as itself again later: no `author` on any call. + as_agent(&mut s, "Claude", "comment.react", json!({"id": c, "emoji": "👍"})); + assert_eq!(s.doc().unwrap().comment(c).unwrap().first.reactions[0].users, ["Claude (agent)"]); + assert_eq!(as_agent(&mut s, "Claude", "comment.people", json!({}))["you"], "Claude (agent)"); + assert_eq!(s.execute("comment.people", &json!({})).unwrap()["you"], "You", "the user's name never changes"); + assert_eq!(s.agent, None, "the agent acts only for its own call"); +} + +#[test] +fn an_agents_read_state_is_its_own() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Look"})).unwrap()["id"].as_u64().unwrap(); + as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "On it"})); + assert_eq!(thread(&mut s, None)["unread"], true, "the agent's reply is unread for the user"); + as_agent(&mut s, "Claude", "comment.markRead", json!({})); + assert_eq!(thread(&mut s, None)["unread"], true, "the agent reading it doesn't read it for the user"); + assert_eq!(thread(&mut s, Some("Claude"))["unread"], false); +} + +#[test] +fn an_agent_without_a_name_is_still_not_the_user() { + let mut s = Session::new(); + as_agent(&mut s, "", "comment.add", json!({"x": 0, "y": 0, "text": "Hi"})); + let t = thread(&mut s, None); + assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("AI agent"), Some(true)), "{t}"); + // An explicit `author` still wins, and the message is still an agent's. + as_agent(&mut s, "Claude", "comment.reply", json!({"id": t["id"], "text": "x", "author": "Helper"})); + let r = &thread(&mut s, None)["replies"][0]; + assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Helper"), Some(true)), "{r}"); +} + +#[test] +fn agent_names_are_cleaned() { + assert_eq!(agent_author(" Claude "), "Claude (agent)"); + assert_eq!(agent_author("\u{7}\n "), "AI agent"); + assert_eq!(agent_author("a\u{0}b"), "ab (agent)"); + let long = "x".repeat(500); + assert_eq!(agent_author(&long).chars().count(), 64 + " (agent)".len()); +} + +#[test] +fn an_agents_avatar_shows_its_names_initial() { + assert_eq!(initials("Claude (agent)"), "C"); + assert_eq!(initials("AI agent"), "AA"); + assert_eq!(initials("Ada Lovelace"), "AL"); + assert_eq!(initials("(x) [y]"), "([", "no word starts with a letter: as before"); + assert_eq!(initials(" "), "?"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d36f1905c..0b801589d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -148,6 +148,9 @@ pub struct Session { pub events: Arc, /// Events recorded inside a group: (kind, document, path, data). pending_events: Vec<(String, String, Option, Value)>, + /// The AI agent the running command is for ([`Session::execute_as`]), as comments are signed + /// (`"Claude (agent)"`); `None` for the user. + pub agent: Option, } impl Default for Session { @@ -219,6 +222,8 @@ mod collab_tests; #[cfg(test)] mod collab_undo_tests; #[cfg(test)] +mod comment_agent_tests; +#[cfg(test)] mod comments_polish_tests; #[cfg(test)] mod component_codegen_tests; diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs index a9d491e26..633bf9db6 100644 --- a/crates/engine/src/session/exec.rs +++ b/crates/engine/src/session/exec.rs @@ -45,6 +45,16 @@ impl Session { } } + /// [`Session::execute`] for an AI agent named `agent` (the client's name; empty when it gave + /// none), or for the user (`None`): the comments it makes are signed with the agent's name and + /// marked as an agent's. + pub fn execute_as(&mut self, agent: Option<&str>, id: &str, params: &Value) -> Result { + let was = std::mem::replace(&mut self.agent, agent.map(cmd::comment::agent_author)); + let r = self.execute(id, params); + self.agent = was; + r + } + /// Run a command by id. Mutations become one undo step; a failure or panic leaves the document /// as it was. pub fn execute(&mut self, id: &str, params: &Value) -> Result { diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs index 677e24718..6e86c7c72 100644 --- a/crates/engine/src/session/new.rs +++ b/crates/engine/src/session/new.rs @@ -30,6 +30,7 @@ impl Session { background: background::Background::default(), events: Arc::new(events::EventLog::default()), pending_events: Vec::new(), + agent: None, } } diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index a469b28e9..c58d805af 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -343,9 +343,16 @@ pub fn display_name(prefs: &crate::Prefs) -> String { if n == crate::cmd::comment::DEFAULT_AUTHOR { String::new() } else { n.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() } } -/// Initials for an avatar ("Ada Lovelace" → "AL"; "?" for none). +/// Initials for an avatar ("Ada Lovelace" → "AL"; "Claude (agent)" → "C": words that start with a +/// letter or digit, when there are any; "?" for none). pub fn initials(name: &str) -> String { - let mut out: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect(); + let firsts = |alnum: bool| -> String { + name.split_whitespace().filter_map(|w| w.chars().next()).filter(|c| !alnum || c.is_alphanumeric()).take(2).collect() + }; + let mut out = firsts(true); + if out.is_empty() { + out = firsts(false); + } if out.is_empty() { out.push('?'); } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 3a76064b4..c0f948e32 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -55,9 +55,14 @@ impl Headless { } fn execute(&mut self, cmd: &str, params: &Value) -> Result { + self.execute_as(None, cmd, params) + } + + /// A command for `agent` (an AI agent's name, `engine.execute {agent}`) or for the user. + fn execute_as(&mut self, agent: Option<&str>, cmd: &str, params: &Value) -> Result { // What happened in a live session since the last call (latest presence only). self.session.live_poll(); - let r = self.session.execute(cmd, params).map_err(|e| e.to_string()); + let r = self.session.execute_as(agent, cmd, params).map_err(|e| e.to_string()); self.session.live_sync(); // No UI: dialogs and panels requested by commands are dropped. self.session.ui_requests.clear(); @@ -243,7 +248,7 @@ impl Backend for Headless { None | Some(Value::Null) => json!({}), Some(v) => v.clone(), }; - self.execute(&cmd, ¶ms) + self.execute_as(p.get("agent").and_then(Value::as_str), &cmd, ¶ms) } "engine.commands" => Ok(Value::Array( figcraft_engine::all_commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history})).collect(), diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 5cf63c131..060f7a30b 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -104,7 +104,7 @@ pub const PROMPTS: &[PromptDef] = &[ template: r#"Watch for new feedback in this FigCraft document for {minutes} minutes. 1. Start the watcher: wait_for_changes {"after": 0, "timeoutMs": 0, "topics": ["comments"]}. Keep its `cursor` and note `reset`; if the returned cursor is 0, remember that the initial cursor was zero. -2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (author "AI"), verify, then comment.resolve. Leave ambiguous threads open with a question. +2. Handle what is already open: run_command {"command": "comment.list", "params": {"page": "all", "filter": "open"}}. For each thread: inspect the anchor and frame, make the smallest change (one batch per thread), comment.reply with what changed (it is signed with your own agent name), verify, then comment.resolve. Leave ambiguous threads open with a question. 3. Then loop until the budget ends: wait_for_changes {"after": , "timeoutMs": 20000, "topics": ["comments"]}. Set cursor to the result's cursor. For each comment.added or comment.replied event, read the thread with comment.list. If its latest message is your own reply, skip it (comment.reply emits comment.replied too); otherwise fix it the same way. If reset is true, dropped is greater than 0, or the cursor was 0 before this poll and the returned cursor is greater than 0, re-read every open thread and continue with the returned cursor. 4. Stop after {minutes} minutes; summarize the threads you fixed and the ones still open (and why)."#, defaults: &[("minutes", "5")], diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index cd4c7177d..1a25ac7f3 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -52,6 +52,46 @@ pub struct Server { /// The automation roots every request's file access is confined to (`--automation-read-root`, /// `--automation-write-root`), if any. roots: Option>, + /// The client's name from `initialize` (`clientInfo.title`, else `.name`; empty until then): + /// every command runs as this AI agent, so its comments are signed with it, not the user's name. + agent: String, +} + +/// The backend as the MCP client's AI agent: `engine.execute` carries its name (`agent`). +struct AsAgent<'a> { + inner: &'a mut dyn Backend, + agent: &'a str, +} + +impl Backend for AsAgent<'_> { + fn call(&mut self, method: &str, mut params: Value) -> Result { + if method == "engine.execute" + && let Some(p) = params.as_object_mut() + { + p.insert("agent".into(), json!(self.agent)); + } + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + self.inner.has_ui() + } + + fn describe(&self) -> String { + self.inner.describe() + } + + fn render_png(&mut self, ids: &[u64], scale: f64) -> Result, String> { + self.inner.render_png(ids, scale) + } + + fn screenshot_png(&mut self) -> Result, String> { + self.inner.screenshot_png() + } + + fn window_png(&mut self, window: u64) -> Result, String> { + self.inner.window_png(window) + } } fn response(id: Value, result: Value) -> Value { @@ -62,13 +102,20 @@ fn error(id: Value, code: i64, message: impl Into) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) } +/// The name an `initialize` request gives its client (`clientInfo.title`, else `.name`; empty +/// without one). The engine keeps 64 characters of it; this only bounds what each call carries. +fn client_name(params: &Value) -> String { + let name = |k: &str| params.get("clientInfo").and_then(|i| i.get(k)).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()); + name("title").or_else(|| name("name")).unwrap_or_default().chars().take(256).collect() +} + fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { payload.downcast_ref::<&str>().map(ToString::to_string).or_else(|| payload.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } impl Server { pub fn new(backend: Box) -> Self { - Self { backend, initialized: false, roots: None } + Self { backend, initialized: false, roots: None, agent: String::new() } } /// Confine the files every request reads and writes to `roots` (see @@ -192,9 +239,15 @@ impl Server { } } + /// The backend, acting as the client's agent. + fn as_agent(&mut self) -> AsAgent<'_> { + AsAgent { inner: self.backend.as_mut(), agent: &self.agent } + } + fn request(&mut self, method: &str, params: &Value) -> Result { match method { "initialize" => { + self.agent = client_name(params); let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; Ok(json!({ @@ -219,13 +272,13 @@ impl Server { if !crate::tools::TOOL_NAMES.contains(&name) { return Err((INVALID_PARAMS, format!("unknown tool `{name}`"))); } - Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + Ok(call_tool(&mut self.as_agent(), name, &args).to_value()) } "resources/list" => Ok(resources::list()), "resources/templates/list" => Ok(resources::templates()), "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri` (a string)".to_string()))?; - let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e { + let v = resources::read(&mut self.as_agent(), uri).map_err(|e| match e { resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")), resources::ReadError::Backend(why) => (INTERNAL_ERROR, why), })?; diff --git a/crates/mcp/tests/it/agent_comments.rs b/crates/mcp/tests/it/agent_comments.rs new file mode 100644 index 000000000..fe87bf949 --- /dev/null +++ b/crates/mcp/tests/it/agent_comments.rs @@ -0,0 +1,50 @@ +//! An agent attached over MCP comments under its own name (#1414): the client's name from +//! `initialize` (`clientInfo.title`, else `.name`), marked as an agent's, or "AI agent". + +use figcraft_mcp::Server; +use serde_json::{Value, json}; + +use crate::common::Client; + +fn rpc(s: &mut Server, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string(); + serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap() +} + +fn run(s: &mut Server, command: &str, params: Value) -> Value { + let r = rpc(s, "tools/call", json!({"name": "run_command", "arguments": {"command": command, "params": params}})); + assert_eq!(r["result"]["isError"], false, "{r}"); + serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap() +} + +/// The first message's (author, agent marker) after the server comments once. +fn signed(s: &mut Server) -> (String, Value) { + run(s, "comment.add", json!({"x": 10, "y": 10, "text": "Made it bigger"})); + let t = run(s, "comment.list", json!({"filter": "all"}))["comments"][0].clone(); + (t["author"].as_str().unwrap().to_string(), t["agent"].clone()) +} + +#[test] +fn an_agent_comments_under_its_clients_name() { + let mut s = Server::headless(); + rpc( + &mut s, + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "claude-code", "title": "Claude", "version": "1"}}), + ); + assert_eq!(signed(&mut s), ("Claude (agent)".into(), json!(true)), "the title wins over the name"); + let mut c = Client::new(); + c.run("comment.add", json!({"x": 0, "y": 0, "text": "Hi"})); + let t = c.run("comment.list", json!({}))["comments"][0].clone(); + assert_eq!((t["author"].clone(), t["agent"].clone()), (json!("test (agent)"), json!(true)), "the name without a title"); + assert_eq!(c.run("comment.people", json!({}))["you"], "test (agent)", "the agent is itself on every call"); +} + +#[test] +fn an_agent_without_a_name_is_still_an_agent() { + let mut s = Server::headless(); + assert_eq!(signed(&mut s), ("AI agent".into(), json!(true)), "no initialize"); + let mut s = Server::headless(); + rpc(&mut s, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": " ", "version": "1"}})); + assert_eq!(signed(&mut s), ("AI agent".into(), json!(true)), "a blank name"); +} diff --git a/crates/mcp/tests/it/attached.rs b/crates/mcp/tests/it/attached.rs index f1343dd25..2b9e7dc5a 100644 --- a/crates/mcp/tests/it/attached.rs +++ b/crates/mcp/tests/it/attached.rs @@ -64,6 +64,21 @@ fn attached_backend_forwards_everything() { assert_eq!(err["isError"], true); } +#[test] +fn an_attached_agent_comments_under_its_own_name() { + let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap())); + let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "Claude", "version": "1"}}}); + assert!(s.handle_line(&init.to_string()).is_some()); + let run = |s: &mut Server, command: &str, params: Value| { + let r = call(s, "run_command", json!({"command": command, "params": params})); + assert_eq!(r["isError"], false, "{r}"); + serde_json::from_str::(r["content"][0]["text"].as_str().unwrap()).unwrap() + }; + run(&mut s, "comment.add", json!({"x": 10, "y": 10, "text": "Done"})); + let t = run(&mut s, "comment.list", json!({}))["comments"][0].clone(); + assert_eq!((t["author"].clone(), t["agent"].clone()), (json!("Claude (agent)"), json!(true)), "{t}"); +} + #[test] fn attached_to_nothing_fails_cleanly() { let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index 23d23ec99..5233cd7fe 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -1,5 +1,7 @@ //! The crate's integration tests, built as one test binary. +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod agent_comments; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod agent_tasks; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index a00004dca..a8f114fad 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -84,6 +84,7 @@ status = "draft — needs native review" "Advanced stroke" = "إعدادات متقدمة للحد الخارجي" "After delay" = "بعد تأخير" "After delay {ms}ms" = "بعد تأخير {ms} مللي ثانية" +"Agent" = "وكيل" "Align" = "محاذاة" "Align bottom" = "محاذاة لأسفل" "Align center" = "محاذاة للوسط أفقيًا" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index b7d74e60c..be71f7a63 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -79,6 +79,7 @@ status = "draft — needs native review" "Advanced stroke" = "উন্নত স্ট্রোক" "After delay" = "বিলম্বের পর" "After delay {ms}ms" = "{ms}ms বিলম্বের পর" +"Agent" = "এজেন্ট" "Align" = "সারিবদ্ধ করুন" "Align bottom" = "নিচে সারিবদ্ধ করুন" "Align center" = "কেন্দ্রে সারিবদ্ধ করুন" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 04b3001da..c6158f80e 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -74,6 +74,7 @@ status = "draft — needs native review" "Advanced stroke" = "Pokročilé nastavení tahu" "After delay" = "Po prodlevě" "After delay {ms}ms" = "Po prodlevě {ms} ms" +"Agent" = "Agent" "Align" = "Zarovnat" "Align bottom" = "Zarovnat dolů" "Align center" = "Zarovnat na střed" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index fa5a02d81..83a5ca3cb 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -76,6 +76,7 @@ status = "draft — needs native review" "Advanced stroke" = "Erweiterte Kontur" "After delay" = "Nach Verzögerung" "After delay {ms}ms" = "Nach {ms} ms" +"Agent" = "Agent" "Align" = "Ausrichtung" "Align bottom" = "Unten ausrichten" "Align center" = "Zentriert ausrichten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b329884cc..3450db8e8 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -78,6 +78,7 @@ name = "English" "Advanced stroke" = "Advanced stroke" "After delay" = "After delay" "After delay {ms}ms" = "After delay {ms}ms" +"Agent" = "Agent" "Align" = "Align" "Align bottom" = "Align bottom" "Align center" = "Align center" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 844dbb276..9cc288d3c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -101,6 +101,7 @@ status = "draft — needs native review" "Advanced stroke" = "Trazo avanzado" "After delay" = "Tras un retardo" "After delay {ms}ms" = "Tras {ms}ms" +"Agent" = "Agente" "Align" = "Alineación" "Align bottom" = "Alinear abajo" "Align center" = "Alinear al centro" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 55b094d0c..ef5c3c945 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "Reunaviivan lisäasetukset" "After delay" = "Viiveen jälkeen" "After delay {ms}ms" = "Viiveen {ms}ms jälkeen" +"Agent" = "Agentti" "Align" = "Tasaa" "Align bottom" = "Tasaa alas" "Align center" = "Keskitä" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 2d9b9b764..b800e0f53 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -79,6 +79,7 @@ status = "draft — needs native review" "Advanced stroke" = "Contour avancé" "After delay" = "Après un délai" "After delay {ms}ms" = "Après {ms} ms" +"Agent" = "Agent" "Align" = "Alignement" "Align bottom" = "Aligner en bas" "Align center" = "Aligner au centre" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 172043f57..76206f48c 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -81,6 +81,7 @@ status = "draft — needs native review" "Advanced stroke" = "Stroke lanjutan" "After delay" = "Setelah jeda" "After delay {ms}ms" = "Setelah jeda {ms}ms" +"Agent" = "Agen" "Align" = "Ratakan" "Align bottom" = "Ratakan bawah" "Align center" = "Rata tengah" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 0a9c61565..c82ee628b 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -74,6 +74,7 @@ status = "draft — needs native review" "Advanced stroke" = "Contorno avanzato" "After delay" = "Dopo un ritardo" "After delay {ms}ms" = "Dopo un ritardo di {ms} ms" +"Agent" = "Agente" "Align" = "Allinea" "Align bottom" = "Allinea in basso" "Align center" = "Allinea al centro" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 417311b2a..244865e66 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "詳細な線" "After delay" = "遅延後" "After delay {ms}ms" = "{ms}ms 遅延後" +"Agent" = "エージェント" "Align" = "整列" "Align bottom" = "下揃え" "Align center" = "中央揃え" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 845974190..cc21e9ff4 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -81,6 +81,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Advanced stroke" = "კონტურის დამატებითი პარამეტრები" "After delay" = "დაყოვნების შემდეგ" "After delay {ms}ms" = "დაყოვნების შემდეგ: {ms} მწმ" +"Agent" = "აგენტი" "Align" = "სწორება" "Align bottom" = "ქვემოთ სწორება" "Align center" = "ცენტრში სწორება" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 693cf5b61..d10dbdc31 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -76,6 +76,7 @@ status = "draft — needs native review" "Advanced stroke" = "고급 선" "After delay" = "지연 후" "After delay {ms}ms" = "{ms}ms 지연 후" +"Agent" = "에이전트" "Align" = "정렬" "Align bottom" = "아래쪽 정렬" "Align center" = "가운데 정렬" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 63708f823..0dd2a9ae6 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "Geavanceerde lijn" "After delay" = "Na vertraging" "After delay {ms}ms" = "Na {ms}ms vertraging" +"Agent" = "Agent" "Align" = "Uitlijnen" "Align bottom" = "Onderaan uitlijnen" "Align center" = "Midden uitlijnen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index d5402774a..46c951f8a 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -78,6 +78,7 @@ name = "Polski" "Advanced stroke" = "Zaawansowany obrys" "After delay" = "Po opóźnieniu" "After delay {ms}ms" = "Po opóźnieniu {ms} ms" +"Agent" = "Agent" "Align" = "Wyrównaj" "Align bottom" = "Wyrównaj do dołu" "Align center" = "Wyrównaj do środka" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 0f6315569..0704bfc5c 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -74,6 +74,7 @@ status = "draft — needs native review" "Advanced stroke" = "Traço avançado" "After delay" = "Após atraso" "After delay {ms}ms" = "Após {ms} ms" +"Agent" = "Agente" "Align" = "Alinhamento" "Align bottom" = "Alinhar embaixo" "Align center" = "Alinhar ao centro" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6e6932d5d..d1e846639 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -78,6 +78,7 @@ name = "Русский" "Advanced stroke" = "Расширенные настройки обводки" "After delay" = "После задержки" "After delay {ms}ms" = "После задержки {ms} мс" +"Agent" = "Агент" "Align" = "Выравнивание" "Align bottom" = "Выровнять по нижнему краю" "Align center" = "Выровнять по центру" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 01b3d7b99..234158c41 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -78,6 +78,7 @@ name = "Svenska" "Advanced stroke" = "Avancerad kontur" "After delay" = "Efter fördröjning" "After delay {ms}ms" = "Efter fördröjning på {ms} ms" +"Agent" = "Agent" "Align" = "Justera" "Align bottom" = "Justera nederkant" "Align center" = "Centrera" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index dc4ea922a..6afb9206a 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -80,6 +80,7 @@ status = "draft — needs native review" "Advanced stroke" = "เส้นขอบขั้นสูง" "After delay" = "หลังหน่วงเวลา" "After delay {ms}ms" = "หลังหน่วงเวลา {ms} มิลลิวินาที" +"Agent" = "เอเจนต์" "Align" = "จัดแนว" "Align bottom" = "จัดแนวชิดล่าง" "Align center" = "จัดแนวกึ่งกลาง" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 58a0f4e37..0f0f87321 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -78,6 +78,7 @@ name = "Türkçe" "Advanced stroke" = "Gelişmiş kontur" "After delay" = "Gecikmeden sonra" "After delay {ms}ms" = "{ms} ms gecikmeden sonra" +"Agent" = "Ajan" "Align" = "Hizala" "Align bottom" = "Alta hizala" "Align center" = "Ortaya hizala" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 192539122..7c9740ac0 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -75,6 +75,7 @@ status = "draft — needs native review" "Advanced stroke" = "Nét nâng cao" "After delay" = "Sau khoảng trễ" "After delay {ms}ms" = "Sau khoảng trễ {ms}ms" +"Agent" = "Tác nhân" "Align" = "Căn chỉnh" "Align bottom" = "Căn dưới" "Align center" = "Căn giữa" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index ce5056b90..946f26048 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -77,6 +77,7 @@ status = "draft — needs native review" "Advanced stroke" = "高级描边" "After delay" = "延迟后" "After delay {ms}ms" = "延迟 {ms} 毫秒后" +"Agent" = "智能体" "Align" = "对齐" "Align bottom" = "底部对齐" "Align center" = "居中对齐" diff --git a/crates/ui-egui/src/canvas/comments.rs b/crates/ui-egui/src/canvas/comments.rs index 7e3f84cf9..a9e9c1267 100644 --- a/crates/ui-egui/src/canvas/comments.rs +++ b/crates/ui-egui/src/canvas/comments.rs @@ -630,6 +630,19 @@ fn hover_button(ui: &mut Ui, id: Id, icon: Icon, show: bool, tip: &str) -> egui: resp.on_hover_text(tip) } +/// A message's author, with a small "Agent" label when an AI agent wrote it. +fn author_name(ui: &mut Ui, t: &Tokens, m: &Message) { + ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text)); + if !m.agent { + return; + } + let galley = ui.painter().layout_no_wrap(tr("Agent").to_string(), semibold(10.0), t.text_secondary); + let (r, resp) = ui.allocate_exact_size(galley.size() + vec2(8.0, 2.0), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tr("Agent"))); + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text_secondary); +} + /// What every message of a thread needs to know. struct ThreadCtx<'a> { c: &'a Comment, @@ -659,7 +672,7 @@ fn message( let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); avatar(ui.painter(), r.center(), 12.0, &m.author); ui.add_space(4.0); - ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text)); + author_name(ui, &t, m); let when = if m.edited > 0 { trf("{when} · edited", &[("when", &ago(m.created))]) } else { ago(m.created) }; ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index c89a00be0..0ccd32f10 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -73,6 +73,20 @@ fn control_channel_tool_switch_closes_the_unposted_composer() { assert!(comments(&u)["draft"].is_null()); } +#[test] +fn a_control_call_for_an_agent_signs_its_comment_and_the_ui_keeps_yours() { + let mut u = Ui::new(Session::new()); + let run = |u: &mut Ui, p: Value| crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &p).unwrap(); + let c = run(&mut u, json!({"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Bigger please"}}))["id"].clone(); + run(&mut u, json!({"command": "comment.reply", "agent": "Claude", "params": {"id": c, "text": "Done"}})); + u.app.run("comment.reply", json!({"id": c, "text": "Thanks"})); + let doc = u.app.session.doc().unwrap(); + let t = doc.comment(c.as_u64().unwrap()).unwrap(); + let who: Vec<(&str, bool)> = t.messages().map(|m| (m.author.as_str(), m.agent)).collect(); + assert_eq!(who, [("You", false), ("Claude (agent)", true), ("You", false)]); + assert_eq!(u.app.session.agent, None); +} + #[test] fn mention_helpers() { // The `@query` before the cursor. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 23b7a63e3..4cc017e71 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1339,7 +1339,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); let layers_generation = app.session.clipboard.layers_generation; - let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); + let r = app.session.execute_as(p.get("agent").and_then(Value::as_str), cmd, ¶ms).map_err(|e| e.to_string()); app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); app.invalidate_synthetic_copy(layers_generation); // Programmatic calls never open dialogs. diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 1ab6c5888..024b4ca7f 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -16,7 +16,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | -| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | +| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key, or removal of an image in use, is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server that left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot instead of that log, which it can't check against what each client holds. An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would otherwise swap the picture. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | | 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6c6f20cf1..8b7c225fd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,6 +102,11 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] (as in `docs/mcp.md`). A parameter the command's doc (`engine.commands`) doesn't list is an error naming it and the ones it takes, never ignored; `ids` and `id` are always taken. +`agent` (a string, next to `command`) runs the command for an AI agent of that name, as the MCP +server does with its client's name: comments it makes are signed " (agent)" ("AI agent" for +an empty name), marked `"agent": true`, and keep their own read state. Without it the command acts +as the user (the name from Preferences). + ```json {"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} {"id":1,"ok":true,"result":{"id":2}} diff --git a/docs/mcp.md b/docs/mcp.md index c9978dea4..12ab96801 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1163,9 +1163,15 @@ pin stays where it was, on the nearest surviving parent. Driven with `run_comman first one, or `reply`: an index into its replies; deleting the first message deletes the thread), `comment.react {id, emoji, reply?, on?}` (toggles your reaction), `comment.resolve {id, resolved?}`, `comment.move {id, x, y, anchor?}` (pins it to the frame under the new point unless `anchor: false`). -Everyone acts as the name from Preferences (`pref.userName {name}`, default "You") unless `author` -says otherwise; only a message's author can edit or delete it. `@Name` in a message mentions -someone (`comment.people` lists the names used in the file). +An agent comments under its own name, never the user's: every command an MCP client runs acts as +that client's name from `initialize` (`clientInfo.title`, else `clientInfo.name`) with " (agent)" +after it, e.g. "Claude (agent)", or "AI agent" when the client gave none. Its messages, reactions +and read state are its own, it can edit and delete only its own messages, and its messages carry +`"agent": true` in `comment.list` (the app shows an **Agent** label by the name); the user's name in +Preferences is unchanged. It is set once per connection, so no command needs `author`; an `author` +param still wins. In the app, comments act as the name from Preferences (`pref.userName {name}`, +default "You"); only a message's author can edit or delete it. `@Name` in a message mentions +someone (`comment.people` lists the names used in the file and, as `you`, who is acting). Read state is kept per name: a new message is unread for everyone but its author; `comment.markRead {id? | link?, read?}` marks one thread (or, without `id`, all of them) read or diff --git a/docs/roadmap.md b/docs/roadmap.md index bae1acdf0..a89da72c9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | | 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | | 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged |