From 132abcc6728697d72adb0cf9f07d044575cf9f63 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 02:03:27 +0700 Subject: [PATCH] M8: show in the app where an attached AI agent is working (#1416) (#1556) Session::execute_as records the agent's presence: its name, the file, and the innermost layers its last edit changed or the selection it set, for 8 seconds after its last command. The app shows it as an avatar in the people row and an outline with a name tag on the canvas, in its own colour. Display only: no undo step, never saved, never synced to a live session. --- ROADMAP.md | 3 +- crates/engine/src/agent_presence_tests.rs | 84 ++++++++++++++++++ crates/engine/src/lib.rs | 5 ++ crates/engine/src/session/exec.rs | 6 +- crates/engine/src/session/new.rs | 1 + crates/engine/src/sharing.rs | 1 + crates/engine/src/sharing/agent.rs | 77 +++++++++++++++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/live.rs | 51 +++++++---- crates/ui-egui/src/live/agent.rs | 101 ++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 4 + docs/collaboration-parity.md | 4 +- docs/mcp.md | 11 ++- docs/roadmap.md | 3 +- 14 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/agent_presence_tests.rs create mode 100644 crates/engine/src/sharing/agent.rs create mode 100644 crates/ui-egui/src/live/agent.rs diff --git a/ROADMAP.md b/ROADMAP.md index f6b42d5a..9749f11f 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 (a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); 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-30 | minor | M8, #1416: an AI agent attached over MCP shows where it works, as a person in a live session does: its avatar in its own colour in the people row, named as its comments are signed ("Claude (agent)"), and an outline with its name around the layers its last edit changed or it last selected. It goes 8 seconds after its last command and shows only in the file it works in. Display only (no undo step, not saved, never synced to a live session); the engine records it in `Session::execute_as` (`figcraft_engine::agent`). Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | | 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | diff --git a/crates/engine/src/agent_presence_tests.rs b/crates/engine/src/agent_presence_tests.rs new file mode 100644 index 00000000..47a8671e --- /dev/null +++ b/crates/engine/src/agent_presence_tests.rs @@ -0,0 +1,84 @@ +//! An attached AI agent's presence (#1416): its commands set it, on what they edited or selected; +//! it goes a short while after its last command; the user's commands never make one. + +use std::time::Duration; + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::agent::LINGER; + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, agent: Option<&str>, x: i32) -> NodeId { + id(&s.execute_as(agent, "shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap()) +} + +#[test] +fn an_agents_edit_shows_it_on_the_layers_it_changed() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap()); + let r = id(&s.execute_as(Some("Claude"), "shape.rectangle", &json!({"parent": f, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + let p = s.agent_presence.clone().expect("the agent is at work"); + assert_eq!(p.name, "Claude (agent)"); + assert_eq!(p.doc, s.ds().unwrap().uid); + assert_eq!(p.layers, vec![r], "the new layer, not the frame whose children changed with it"); + assert_eq!(s.agent, None, "the command ran as the agent only"); +} + +#[test] +fn an_agents_selection_shows_it_there_and_a_look_keeps_it() { + let mut s = Session::new(); + let (a, b) = (rect(&mut s, None, 0), rect(&mut s, None, 100)); + s.execute_as(Some(""), "select.set", &json!({"ids": [a, b]})).unwrap(); + let p = s.agent_presence.clone().unwrap(); + assert_eq!((p.name.as_str(), p.layers.clone()), ("AI agent", vec![a, b])); + // A command that changes nothing keeps where it works and when it was last seen moves on. + s.execute_as(Some(""), "view.zoomFit", &json!({})).unwrap(); + let again = s.agent_presence.clone().unwrap(); + assert_eq!(again.layers, vec![a, b]); + assert!(again.at >= p.at); + // So does a failed one. + assert!(s.execute_as(Some(""), "node.set", &json!({"ids": [999_999], "props": {}})).is_err()); + assert_eq!(s.agent_presence.as_ref().unwrap().layers, vec![a, b]); +} + +#[test] +fn the_presence_goes_a_short_while_after_the_last_command() { + let mut s = Session::new(); + rect(&mut s, Some("Claude"), 0); + let at = s.agent_presence.as_ref().unwrap().at; + assert!(s.agent_presence_at(at).is_some()); + assert!(s.agent_presence_at(at + LINGER - Duration::from_millis(1)).is_some()); + assert_eq!(s.agent_presence.as_ref().unwrap().left(at + Duration::from_secs(2)), LINGER - Duration::from_secs(2)); + assert!(s.agent_presence_at(at + LINGER).is_none(), "gone after the linger"); +} + +#[test] +fn the_users_commands_never_make_one() { + let mut s = Session::new(); + let a = rect(&mut s, None, 0); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("node.set", &json!({"ids": [a], "props": {"x": 50}})).unwrap(); + assert!(s.agent_presence.is_none()); + // An agent's name on one file: the user's later edits elsewhere don't move it. + rect(&mut s, Some("Claude"), 200); + let before = s.agent_presence.clone(); + s.execute("node.set", &json!({"ids": [a], "props": {"x": 80}})).unwrap(); + assert_eq!(s.agent_presence, before); +} + +#[test] +fn another_file_starts_afresh() { + let mut s = Session::new(); + let a = rect(&mut s, Some("Claude"), 0); + assert_eq!(s.agent_presence.as_ref().unwrap().layers, vec![a]); + s.execute("file.new", &json!({})).unwrap(); + s.execute_as(Some("Claude"), "view.zoomFit", &json!({})).unwrap(); + let p = s.agent_presence.clone().unwrap(); + assert_eq!(p.doc, s.ds().unwrap().uid); + assert!(p.layers.is_empty(), "the old file's layers don't follow it"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7d3164f1..2d9bf932 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -77,6 +77,7 @@ pub use selection::rename; pub use selection::selection_colors; pub use selection::selection_reorder; pub use selection::selection_spacing; +pub use sharing::agent; pub use sharing::background; pub use sharing::branches; pub use sharing::collab; @@ -153,6 +154,8 @@ pub struct Session { /// The AI agent the running command is for ([`Session::execute_as`]), as comments are signed /// (`"Claude (agent)"`); `None` for the user. pub agent: Option, + /// Where an attached AI agent last worked ([`Session::agent_presence_at`]): display only. + pub agent_presence: Option, } impl Default for Session { @@ -164,6 +167,8 @@ impl Default for Session { #[cfg(test)] mod a11y_keys_tests; #[cfg(test)] +mod agent_presence_tests; +#[cfg(test)] mod al_behaviour_tests; #[cfg(test)] mod al_content_align_tests; diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs index 17d191cc..faabb84b 100644 --- a/crates/engine/src/session/exec.rs +++ b/crates/engine/src/session/exec.rs @@ -47,10 +47,14 @@ 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. + /// marked as an agent's, and the app shows where it works ([`Session::agent_presence_at`]). 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 before = agent.and_then(|_| self.agent_before()); let r = self.execute(id, params); + if agent.is_some() { + self.note_agent(before); + } self.agent = was; r } diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs index 6e86c7c7..2e7c4374 100644 --- a/crates/engine/src/session/new.rs +++ b/crates/engine/src/session/new.rs @@ -31,6 +31,7 @@ impl Session { events: Arc::new(events::EventLog::default()), pending_events: Vec::new(), agent: None, + agent_presence: None, } } diff --git a/crates/engine/src/sharing.rs b/crates/engine/src/sharing.rs index 27d1e560..5d516537 100644 --- a/crates/engine/src/sharing.rs +++ b/crates/engine/src/sharing.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod background; pub mod branches; pub mod collab; diff --git a/crates/engine/src/sharing/agent.rs b/crates/engine/src/sharing/agent.rs new file mode 100644 index 00000000..cdf9d9ab --- /dev/null +++ b/crates/engine/src/sharing/agent.rs @@ -0,0 +1,77 @@ +//! Where an AI agent attached over MCP works, for the app to show like a person in a live +//! session: its name and the layers it last edited or selected, until a short while after its last +//! command ([`Session::execute_as`]). Display only: it is no undo step, it is never saved, and it is +//! not presence a live session sends (the agent shows there only if it joins one itself). + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use figcraft_doc::{Document, NodeId}; +use web_time::Instant; + +use crate::{Session, changed_nodes}; + +/// How long an agent's presence shows after its last command. +pub const LINGER: Duration = Duration::from_secs(8); +/// Most layers an agent's presence names. +pub const MAX_LAYERS: usize = 500; + +/// An attached AI agent at work. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentPresence { + /// As its comments are signed ("Claude (agent)"). + pub name: String, + /// The file it last worked in (`DocState::uid`). + pub doc: u64, + /// The layers its last edit changed (the innermost), or that it last selected. + pub layers: Vec, + /// When its last command ran. + pub at: Instant, +} + +impl AgentPresence { + /// How much longer it shows at `now` (zero: gone). + pub fn left(&self, now: Instant) -> Duration { + LINGER.saturating_sub(now.saturating_duration_since(self.at)) + } +} + +/// The document and selection a command started from: (file, document, selection). +pub(crate) type Before = (u64, Arc, Vec); + +/// The layers a command worked on, going from `before` to `after`: the innermost layers it edited +/// (a parent changes with its children's list), else the selection it changed to; `None` when it +/// did neither. +fn touched(before: &Document, after: &Document, sel_before: &[NodeId], sel_after: &[NodeId]) -> Option> { + let changed: Vec = if std::ptr::eq(before, after) { Vec::new() } else { changed_nodes(after, before) }; + let edited: Vec<&figcraft_doc::Node> = changed.iter().filter_map(|i| after.node(*i)).filter(|n| !n.is_page()).collect(); + if !edited.is_empty() { + let parents: HashSet = edited.iter().filter_map(|n| n.parent).collect(); + return Some(edited.iter().map(|n| n.id).filter(|i| !parents.contains(i)).take(MAX_LAYERS).collect()); + } + (sel_before != sel_after).then(|| sel_after.iter().copied().take(MAX_LAYERS).collect()) +} + +impl Session { + /// The document and selection before a command, for [`Session::note_agent`]. + pub(crate) fn agent_before(&self) -> Option { + self.ds().ok().map(|d| (d.uid, d.doc.clone(), d.selection.clone())) + } + + /// The running command is the agent's: it is at work, on what it just edited or selected + /// (else on what it worked on before in the same file). + pub(crate) fn note_agent(&mut self, before: Option) { + let (Some(name), Ok(ds)) = (self.agent.clone(), self.ds()) else { return }; + let uid = ds.uid; + let now = before.filter(|b| b.0 == uid).and_then(|(_, doc, sel)| touched(&doc, &ds.doc, &sel, &ds.selection)); + let kept = self.agent_presence.take().filter(|p| p.doc == uid && p.name == name).map(|p| p.layers); + let layers = now.or(kept).unwrap_or_default(); + self.agent_presence = Some(AgentPresence { name, doc: uid, layers, at: Instant::now() }); + } + + /// The agent at work at `now`, if one ran a command less than [`LINGER`] before. + pub fn agent_presence_at(&self, now: Instant) -> Option<&AgentPresence> { + self.agent_presence.as_ref().filter(|p| !p.left(now).is_zero()) + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a7e41c34..1e7e535e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -298,6 +298,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); crate::live::draw(app, &painter, &ctx); + crate::live::agent::draw(app, &painter, &ctx); crate::live::chat_box(app, &ctx); crate::live::spotlight_banner(app, &ctx, rect); crate::comments::popovers(app, &ctx); diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 31e2f066..2abcbe9a 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -28,6 +28,8 @@ use crate::right::{menu_item, menu_item_with, menu_popup}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets; +pub mod agent; + /// In the browser: what joining needs (a page can't host, and https pages can't reach `ws://`). const BROWSER_NOTE: &str = tr_noop( "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS).", @@ -471,8 +473,9 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); let can_remove = figcraft_engine::live::can_remove_people(&app.session); let mut remove = None; - // Room for the avatars after yours and the chevron. - let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); + let agent = agent::shown(app, ui.ctx()).map(|p| p.name.clone()); + // Room for the avatars after yours (and the agent's) and the chevron. + let fit = (((area.width() - 2.0 * R - 18.0 - STEP * f32::from(u8::from(agent.is_some()))) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; let mut x = area.min.x + R + 2.0; let ring = |ui: &Ui, c: Pos2| ui.painter().circle_stroke(c, R + 1.0, Stroke::new(2.0, t.panel)); @@ -524,6 +527,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ui.painter().circle_filled(c, R, t.pressed); ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text); } + if let Some(name) = &agent { + x += STEP; + agent::avatar(ui, pos2(x, cy), R, name); + } let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0)); let cresp = ui.interact(chev, ui.scope_id().with("live_people"), Sense::click()); a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp)); @@ -531,14 +538,38 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover); } icons::paint(ui.painter(), Rect::from_center_size(chev.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); - menu_popup(&cresp).width(220.0).show(|ui| { + clicked = people_menu(app, &cresp, (&me_name, me_fill), &peers, follow).or(clicked); + if let Some(id) = clicked { + let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) }; + app.run("collab.follow", params); + } + if spotlight { + app.run("collab.spotlight", json!({"on": !spotlighting})); + } + // A server is asked without the window waiting for it. + if let Some(id) = remove { + app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); + } +} + +/// The people row's chevron menu: you and everyone else; the person clicked (to follow). +fn people_menu( + app: &FigApp, + cresp: &egui::Response, + (me_name, me_fill): (&str, Color32), + peers: &[(u32, String, Color32)], + follow: Option, +) -> Option { + let t = Tokens::get(&cresp.ctx); + let mut clicked = None; + menu_popup(cresp).width(220.0).show(|ui| { let row = |ui: &mut Ui, label: &str, fill: Color32, check: bool| { menu_item_with(ui, label, if check { tr("Following") } else { "" }, 22.0, |p, r, _| { avatar(p, pos2(r.max.x - 15.0, r.center().y), 8.0, label, fill) }) }; row(ui, &trf("{name} (you)", &[("name", &me_name)]), me_fill, false); - for (id, name, fill) in &peers { + for (id, name, fill) in peers { if row(ui, name, *fill, follow == Some(*id)) { clicked = Some(*id); ui.close(); @@ -552,17 +583,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ); } }); - if let Some(id) = clicked { - let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) }; - app.run("collab.follow", params); - } - if spotlight { - app.run("collab.spotlight", json!({"on": !spotlighting})); - } - // A server is asked without the window waiting for it. - if let Some(id) = remove { - app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); - } + clicked } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/crates/ui-egui/src/live/agent.rs b/crates/ui-egui/src/live/agent.rs new file mode 100644 index 00000000..b962eccd --- /dev/null +++ b/crates/ui-egui/src/live/agent.rs @@ -0,0 +1,101 @@ +//! An AI agent attached over MCP, shown like a person in a live session while it works: its avatar +//! in the people row and, on the canvas, an outline in its colour around the layers it last edited +//! or selected, with its name. Both go a short while after its last command +//! ([`figcraft_engine::agent`]), and only the file it works in shows them. + +use egui::{Align2, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; +use figcraft_engine::agent::AgentPresence; +use web_time::Instant; + +use crate::FigApp; +use crate::a11y::Tip; +use crate::theme::{AGENT, Tokens}; + +/// The agent at work in the file in front; asks for the frame where it goes. +pub(crate) fn shown<'a>(app: &'a FigApp, ctx: &egui::Context) -> Option<&'a AgentPresence> { + let now = Instant::now(); + let p = app.session.agent_presence_at(now).filter(|p| app.session.ds().is_ok_and(|d| d.uid == p.doc))?; + ctx.request_repaint_after(p.left(now)); + Some(p) +} + +/// Its avatar at `c` in the people row: ringed in its colour while it works, its name as the tip. +pub(super) fn avatar(ui: &mut Ui, c: Pos2, r: f32, name: &str) { + let t = Tokens::get(ui.ctx()); + ui.interact(Rect::from_center_size(c, vec2(2.0 * r, 2.0 * r)), ui.scope_id().with("live_agent"), Sense::hover()).tip(name); + ui.painter().circle_stroke(c, r + 1.0, Stroke::new(2.0, t.panel)); + super::avatar(ui.painter(), c, r, name, AGENT); + ui.painter().circle_stroke(c, r + 2.5, Stroke::new(2.0, AGENT)); +} + +/// Its layers on the page in front outlined in its colour, its name over them. +pub fn draw(app: &FigApp, painter: &egui::Painter, ctx: &egui::Context) { + let Some(p) = shown(app, ctx) else { return }; + let Ok(ds) = app.session.ds() else { return }; + let (doc, page) = (&ds.doc, ds.page); + let mut over: Option = None; + for q in p.layers.iter().filter(|n| doc.page_of(**n) == Some(page)).filter_map(|n| crate::canvas::node_quad(app, doc, *n)) { + painter.add(Shape::closed_line(q.to_vec(), Stroke::new(1.5, AGENT))); + let r = Rect::from_points(&q); + over = Some(over.map_or(r, |o| o.union(r))); + } + if let Some(r) = over { + super::name_tag(painter, pos2(r.min.x, r.min.y - 4.0), Align2::LEFT_BOTTOM, &p.name, AGENT); + } +} + +#[cfg(test)] +mod tests { + use figcraft_engine::Session; + use serde_json::json; + + use super::shown; + use crate::present_tests::Ui; + + fn rect(u: &mut Ui, agent: Option<&str>) { + let mut p = json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 40, "height": 40}}); + if let Some(a) = agent { + p["agent"] = json!(a); + } + let ctx = u.ctx.clone(); + crate::control::handle(&mut u.app, &ctx, "engine.execute", &p).unwrap(); + u.frames(2, Vec::new); + } + + fn labels(u: &mut Ui) -> Vec<(String, egui::Pos2)> { + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.map(|t| t.nodes).unwrap_or_default(); + nodes + .into_iter() + .filter_map(|(_, n)| { + let b = n.bounds()?; + Some((n.label()?.to_string(), egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32))) + }) + .collect() + } + + #[test] + fn the_people_menu_still_lists_you_beside_an_agent() { + let mut u = Ui::new(Session::new()); + u.ctx.enable_accesskit(); + rect(&mut u, Some("Claude")); + let (_, at) = labels(&mut u).into_iter().find(|(l, _)| l == "People in this file").expect("the people row's menu"); + u.button(at, true); + u.button(at, false); + u.frames(2, Vec::new); + let shown = labels(&mut u); + assert!(shown.iter().any(|(l, _)| l.ends_with("(you)")), "{shown:?}"); + } + + #[test] + fn an_attached_agent_shows_in_the_file_it_works_in() { + let mut u = Ui::new(Session::new()); + rect(&mut u, None); + assert!(shown(&u.app, &u.ctx).is_none(), "the control channel's own calls are the user's"); + rect(&mut u, Some("Claude")); + let p = shown(&u.app, &u.ctx).expect("the agent shows"); + assert_eq!((p.name.as_str(), p.layers.len()), ("Claude (agent)", 1)); + u.run("file.new", json!({})); + assert!(shown(&u.app, &u.ctx).is_none(), "another file in front shows no agent"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index acad6f8f..200dd72b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -79,6 +79,10 @@ pub fn under_white(c: Color32) -> Color32 { if c == BRAND { ACCENT } else { c } } +/// An AI agent attached over MCP: its avatar and the outline of the layers it works on (none of +/// the people colours, so it never looks like someone in a live session). +pub const AGENT: Color32 = Color32::from_rgb(0xF5, 0x9E, 0x0B); + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 07327054..d587bb4c 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 (one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); 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, @@ -15,6 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 | | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), 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 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), 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, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); 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 | @@ -28,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Where an AI agent works (M8, #1416): an agent attached over MCP (`figcraft-cli mcp --attach`) changed the file with no sign of where or whether it was still at work. Now, while it runs commands, the people row at the top of the right panel shows its avatar, ringed in its own colour and named as its comments are signed ("Claude (agent)"), and the canvas outlines the layers its last edit changed (the innermost ones), or that it last selected, in that colour with its name. Both go 8 seconds after its last command, and only the file it works in shows them. Display only: no undo step, never saved, never sent to a live session's other people, and the user's own commands never make one (`agent_presence_tests` in the engine, `live::agent` tests in the UI). Ready unchanged | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 3c0f8d5a..1e5ab7d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -19,6 +19,14 @@ The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI: `--attach` also takes `host:port`. The control channel only listens on 127.0.0.1. +While an attached agent works, the app shows where, as it shows a person in a live session: its +avatar, in a colour of its own and named as its comments are signed ("Claude (agent)", see +Comments), joins the people row at the top of the right panel, and the canvas outlines the layers +its last edit changed, or that it last selected, with its name. Both go 8 seconds after its last +command, and only the file it works in shows them. This is display only: no undo step, nothing +saved, nothing sent to a live session's other people, and nothing changes in what the agent may +do. + Attached calls are never automatically replayed after a transport failure: the app may have applied the request before its reply was lost. The error reports that the operation may have completed; inspect the document before retrying an edit. The next explicit call reconnects. @@ -1328,7 +1336,8 @@ joined file opens as a new document). Sessions are per document: every open document has its own session or none, and several run at once (host one document, join another, publish a third). They keep syncing while another document -is active; there your presence is empty (no cursor, selection or view). The `collab.*` commands +is active; there your presence is empty (no cursor, selection or view). An attached agent's own +presence in the app (above) is never part of a live session's presence. The `collab.*` commands and `collab.peers` are about the **active document's** session (`doc.switch {index}` changes which one that is), and `sessions` in `collab.peers` lists them all. Closing a document ends its session only: a host stops hosting it, a guest leaves it. diff --git a/docs/roadmap.md b/docs/roadmap.md index bac54aae..3e11ecf3 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-29 · **Change:** minor (a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); 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-30 | minor | M8, #1416: an attached MCP agent's presence (avatar in the people row, outline and name tag on the layers it last edited or selected), set by `Session::execute_as` and gone 8 seconds after its last command; display only, never synced. Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) | | 2026-10-30 | minor | M5, #405: a manual overlay's ghost shows on the canvas while its Open overlay action is open in the interaction editor, and dragging it sets the action's offset from the layer that opens it (X and Y follow, one undo step). Tests: `overlay_ghost` (ui-egui) | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged |