M8: MCP and control calls name the document they act on (#998) (#1533)

Tools that act on a document, and the control methods engine.execute,
document.inspect, node.get and ui.render, take an optional `document`
(an open file's id from doc.list / document.inspect, or its path). The
call acts on that file and the tab in front stays in front, so an
attached agent keeps editing its own file when the user switches tabs.
This commit is contained in:
BFlat
2026-10-29 19:47:41 +07:00
committed by GitHub
parent b2421885d0
commit 714bbfcdaa
16 changed files with 289 additions and 18 deletions
+2 -1
View File
@@ -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 (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 (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-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
| 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 |
+1
View File
@@ -344,6 +344,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
let pages: Vec<Value> =
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
json!({
"id": ds.uid,
"name": doc.name,
"kind": doc.kind.id(),
"path": ds.path,
+10 -4
View File
@@ -318,15 +318,21 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)),
spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)),
spec!("doc.list", "List documents", "{}", false, |s, _| {
Ok(Value::Array(
spec!(
"doc.list",
"List documents",
"{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`",
false,
|s, _| {
Ok(Value::Array(
s.docs
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.collect(),
))
}),
}
),
spec!("doc.home", "Home", "{}", false, |s, _| {
s.request_ui("home", json!({}));
ok()
+1
View File
@@ -108,6 +108,7 @@ pub use derived::{changed_nodes, normalize};
pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview};
pub use error::{EngineError, Result, bad};
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UiRequest, clamp_ui_scale};
pub use session::Forward;
pub use view_state::ViewState;
/// The editing session: open documents, the current tool, clipboard and prefs.
+1
View File
@@ -8,6 +8,7 @@ use super::{
};
mod access;
pub use access::Forward;
mod exec;
mod new;
mod undo;
+39
View File
@@ -1,6 +1,45 @@
use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
/// The file [`Session::bring_forward`] put in front for one request, and the tab to bring back.
#[must_use = "pass it to `Session::put_back`"]
pub struct Forward {
target: u64,
shown: Option<u64>,
}
impl Session {
/// The uid of open file `target`: its `id` (from `doc.list` or `document.inspect`) or its path.
pub fn document_uid(&self, target: &Value) -> Result<u64> {
let found = match target {
Value::Number(n) => n.as_u64().and_then(|id| self.docs.iter().find(|d| d.uid == id)),
Value::String(p) => self.docs.iter().find(|d| d.path.as_deref() == Some(p.as_str())),
_ => None,
};
found
.map(|d| d.uid)
.ok_or_else(|| EngineError::Other(format!("no open document {target} (`document` takes an `id` from doc.list, or an open file's path)")))
}
/// Put open file `target` in front for one request (a control call naming its `document`),
/// without the user's tab changing on screen: run the request, then [`Session::put_back`].
pub fn bring_forward(&mut self, target: &Value) -> Result<Forward> {
let uid = self.document_uid(target)?;
let shown = self.ds().ok().map(|d| d.uid);
if let Some(i) = self.docs.iter().position(|d| d.uid == uid) {
self.active = i;
}
Ok(Forward { target: uid, shown })
}
/// Bring back the tab that was in front before [`Session::bring_forward`], unless the request
/// itself moved the front to another open file (`file.open`, `doc.switch`…), as it would have.
pub fn put_back(&mut self, f: Forward) {
let moved = self.docs.iter().any(|d| d.uid == f.target) && self.ds().ok().map(|d| d.uid) != Some(f.target);
if let Some(i) = f.shown.and_then(|u| self.docs.iter().position(|d| d.uid == u)).filter(|_| !moved) {
self.active = i;
}
}
pub fn ds(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
+14 -2
View File
@@ -239,8 +239,8 @@ impl Headless {
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
impl Headless {
fn dispatch(&mut self, method: &str, p: Value) -> Result<Value, String> {
match method {
"engine.execute" => {
let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?.to_string();
@@ -301,6 +301,18 @@ impl Backend for Headless {
other => Err(format!("unknown method `{other}`")),
}
}
}
impl Backend for Headless {
/// With `document` (an `id` from `doc.list`, or an open file's path) the call acts on that
/// file, and the file in front stays in front.
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
let Some(target) = p.get("document").filter(|d| !d.is_null()) else { return self.dispatch(method, p) };
let forward = self.session.bring_forward(target).map_err(|e| e.to_string())?;
let r = self.dispatch(method, p);
self.session.put_back(forward);
r
}
fn has_ui(&self) -> bool {
false
+63 -1
View File
@@ -105,8 +105,39 @@ pub const TOOL_NAMES: &[&str] = &[
"wait_for_changes",
];
/// The tools that take `document`: they act on the file it names instead of the one in front.
const DOCUMENT_TOOLS: &[&str] = &[
"run_command",
"batch",
"create_node",
"set_properties",
"inspect_document",
"get_node",
"select",
"export",
"screenshot",
"save_file",
"undo",
"redo",
];
/// All tools, in `tools/list` order.
pub fn tool_definitions() -> Vec<Value> {
let mut tools = tools();
let document = id_schema(
"The open file to act on: its `id` (from run_command doc.list, or the `document` open_file and inspect_document return) or its path. Default: the file in front. The tab in front stays in front; with an attached app, a screenshot of the window needs the file in front",
);
for t in &mut tools {
if t.get("name").and_then(Value::as_str).is_some_and(|n| DOCUMENT_TOOLS.contains(&n))
&& let Some(props) = t.pointer_mut("/inputSchema/properties").and_then(Value::as_object_mut)
{
props.insert("document".into(), document.clone());
}
}
tools
}
fn tools() -> Vec<Value> {
let empty = || obj(json!({}), &[]);
vec![
tool(
@@ -346,6 +377,29 @@ pub fn tool_definitions() -> Vec<Value> {
// ---------- helpers ----------
/// A backend whose every call names `document` (a tool's `document` argument).
struct InDocument<'a> {
inner: &'a mut dyn Backend,
document: Value,
}
impl Backend for InDocument<'_> {
fn call(&mut self, method: &str, mut params: Value) -> Result<Value, String> {
if let Some(o) = params.as_object_mut() {
o.insert("document".into(), self.document.clone());
}
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
self.inner.has_ui()
}
fn describe(&self) -> String {
self.inner.describe()
}
}
fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result<Value, String> {
b.call("engine.execute", json!({"command": cmd, "params": params}))
}
@@ -468,7 +522,7 @@ fn refuse_unknown_args(schema: &Value, args: &Value, path: &str) -> Result<(), S
/// Run one tool. Every failure becomes an `isError` result (never a panic).
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
let a = match args_obj(args) {
let mut a = match args_obj(args) {
Ok(a) => a,
Err(e) => return ToolResult::error(e),
};
@@ -479,6 +533,14 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
{
return ToolResult::error(e);
}
let mut targeted;
let b: &mut dyn Backend = match a.remove("document").filter(|d| !d.is_null()) {
Some(document) => {
targeted = InDocument { inner: b, document };
&mut targeted
}
None => b,
};
let r = match name {
"list_commands" => list_commands(b, &a),
"run_command" => run_command(b, &a),
+20
View File
@@ -161,3 +161,23 @@ fn reconnect_failure_reports_request_not_sent() {
let error = backend.call("document.inspect", json!({})).unwrap_err();
assert!(error.contains("request was not sent"), "{error}");
}
#[test]
fn a_tool_call_naming_its_document_edits_that_file_and_not_the_one_in_front() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
let text = |r: &Value| -> Value { serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap() };
let back = text(&call(&mut s, "inspect_document", json!({"depth": 0})))["id"].clone();
assert!(back.is_u64(), "inspect_document names the file: {back}");
assert_eq!(call(&mut s, "run_command", json!({"command": "file.new", "params": {"name": "Front"}}))["isError"], false);
let r = call(&mut s, "create_node", json!({"type": "rectangle", "document": back}));
assert_eq!(r["isError"], false, "{r}");
let list = text(&call(&mut s, "run_command", json!({"command": "doc.list"})));
assert_eq!((list[0]["id"].clone(), list[0]["active"].clone(), list[1]["active"].clone()), (back.clone(), json!(false), json!(true)));
let front = text(&call(&mut s, "inspect_document", json!({"depth": 1})));
assert_eq!((front["name"].as_str(), front["pages"][0]["children"].as_array().map_or(0, Vec::len)), (Some("Front"), 0));
let behind = text(&call(&mut s, "inspect_document", json!({"depth": 1, "document": back})));
assert_eq!(behind["pages"][0]["children"].as_array().map_or(0, Vec::len), 1, "{behind}");
let e = call(&mut s, "inspect_document", json!({"document": 999_999}));
assert_eq!(e["isError"], true);
assert_eq!(call(&mut s, "pointer", json!({"x": 0, "y": 0, "document": back}))["isError"], true, "pointer takes no `document`");
}
+1 -1
View File
@@ -135,7 +135,7 @@ fn tools_call_happy_and_error_paths() {
assert!(c.err("open_file", json!({"path": "/definitely/not/here.figcraft"})).contains("here.figcraft"));
// Names a tool or a command doesn't take are refused, not ignored (#1413).
let e = c.err("run_command", json!({"command": "inspect.code", "args": {"id": id, "language": "html"}}));
assert!(e.contains("unknown argument `args` (it takes command, params)"), "{e}");
assert!(e.contains("unknown argument `args` (it takes command, document, params)"), "{e}");
let e = c.err("batch", json!({"commands": [{"command": "comment.list", "args": {"filter": "open"}}]}));
assert!(e.contains("unknown argument `commands[0].args`"), "{e}");
let e = c.err("run_command", json!({"command": "comment.list", "params": {"filterr": "open"}}));
+49 -6
View File
@@ -27,13 +27,15 @@
//! - `app.quit {}`
//!
//! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and
//! panels; without it, requests go to the window last in front.
//! panels; without it, requests go to the window last in front. `engine.execute`,
//! `document.inspect`, `node.get` and `ui.render` also take `document` (an `id` from `doc.list`,
//! or an open file's path) to act on that file without bringing its tab to the front.
use std::sync::{Arc, Mutex};
use egui::Context;
use figcraft_engine::cmd::comment::{Filter, Sort};
use figcraft_engine::{PointerEvent, PointerKind};
use figcraft_engine::{PointerEvent, PointerKind, Session};
use serde_json::{Value, json};
use crate::FigApp;
@@ -356,6 +358,12 @@ fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) {
}
let path = params.get("path").and_then(Value::as_str).map(str::to_string);
let (document, presentation) = app.windows.presentation(&app.session, window);
if let Err(e) = front_only(&app.session, Some(document), "ui.screenshot", &params) {
if !canceled {
let _ = tx.send(ControlReply { id, result: Err(e) });
}
return;
}
app.capture.shots.push(ShotRequest {
id,
path,
@@ -1332,14 +1340,46 @@ fn ui_set_styles(app: &mut FigApp, p: &Value) -> Result<(), String> {
Ok(())
}
/// The methods whose `document` may name a file behind the tab in front.
const DOCUMENT_METHODS: &[&str] = &["engine.execute", "document.inspect", "node.get", "ui.render"];
/// The request's `document` (an `id` from `doc.list`, or an open file's path), when it names one.
fn document_of(p: &Value) -> Option<&Value> {
p.get("document").filter(|d| !d.is_null())
}
/// Run `f` with the request's `document` in front and the user's tab back afterwards, else on the
/// tab in front (the same swap a command run in the background makes).
fn in_document<T>(app: &mut FigApp, p: &Value, f: impl FnOnce(&mut Session) -> Result<T, String>) -> Result<T, String> {
let Some(target) = document_of(p) else { return f(&mut app.session) };
let forward = app.session.bring_forward(target).map_err(|e| e.to_string())?;
let r = f(&mut app.session);
app.session.put_back(forward);
r
}
/// A method that acts on the tab in front (`front`, a document uid) refuses a `document` naming
/// another file instead of quietly acting on the wrong one.
fn front_only(session: &Session, front: Option<u64>, method: &str, p: &Value) -> Result<(), String> {
let Some(target) = document_of(p) else { return Ok(()) };
if Some(session.document_uid(target).map_err(|e| e.to_string())?) == front {
return Ok(());
}
Err(format!("`{method}` acts on the tab in front: `document` can name another open file only for {}", DOCUMENT_METHODS.join(", ")))
}
/// Handle one control method (also used by tests and the headless snapshot tool).
pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result<Value, String> {
if !DOCUMENT_METHODS.contains(&method) {
front_only(&app.session, app.session.ds().ok().map(|d| d.uid), method, p)?;
}
match method {
"engine.execute" => {
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_as(p.get("agent").and_then(Value::as_str), cmd, &params).map_err(|e| e.to_string());
let agent = p.get("agent").and_then(Value::as_str);
let r = in_document(app, p, |s| s.execute_as(agent, cmd, &params).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.
@@ -1358,12 +1398,12 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
)),
"document.inspect" => {
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages")))
in_document(app, p, |s| Ok(figcraft_engine::inspect::document(s, depth, b(p, "allPages"))))
}
"node.get" => {
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?;
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize;
app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string())
in_document(app, p, |s| s.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string()))
}
"window.new" => {
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
@@ -1411,7 +1451,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
if !ids.is_empty() {
params["ids"] = json!(ids);
}
app.session.execute("file.export", &params).map_err(|e| e.to_string())
in_document(app, p, |s| s.execute("file.export", &params).map_err(|e| e.to_string()))
}
"app.quit" => {
app.ui.quit = true;
@@ -1488,3 +1528,6 @@ mod roots_tests;
#[cfg(test)]
mod capture_tests;
#[cfg(test)]
mod document_tests;
@@ -0,0 +1,62 @@
//! A control request naming its `document` acts on that file, and the tab in front stays in front.
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::{FigApp, NoServices};
/// Two files open: "Back" behind "Front", which is in front. Returns the app and Back's id.
fn two_files() -> (FigApp, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Front"})).unwrap();
s.docs[0].doc_mut().name = "Back".into();
let back = s.docs[0].uid;
(FigApp::new(s, Box::new(NoServices)), back)
}
fn handle(app: &mut FigApp, method: &str, p: Value) -> Result<Value, String> {
super::handle(app, &egui::Context::default(), method, &p)
}
fn layers(app: &FigApp, i: usize) -> usize {
let ds = &app.session.docs[i];
ds.doc.node(ds.page).map_or(0, |p| p.children.len())
}
#[test]
fn a_call_naming_the_back_file_edits_it_and_leaves_the_front_one_alone() {
let (mut app, back) = two_files();
let rect = json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 20, "height": 10}, "document": back});
handle(&mut app, "engine.execute", rect).unwrap();
assert_eq!((layers(&app, 0), layers(&app, 1)), (1, 0), "the rectangle landed in Back only");
assert_eq!(app.session.active, 1, "Front stays in front");
let doc = handle(&mut app, "document.inspect", json!({"depth": 1, "document": back})).unwrap();
assert_eq!((doc["name"].as_str(), doc["id"].as_u64()), (Some("Back"), Some(back)));
let listed = handle(&mut app, "engine.execute", json!({"command": "doc.list"})).unwrap();
assert_eq!(listed[0]["id"], json!(back));
assert!(handle(&mut app, "ui.render", json!({"document": back})).is_ok());
// An open file's path names it too.
app.session.docs[0].path = Some("/work/back.figcraft".into());
let doc = handle(&mut app, "document.inspect", json!({"depth": 0, "document": "/work/back.figcraft"})).unwrap();
assert_eq!(doc["id"], json!(back));
assert_eq!(app.session.active, 1);
}
#[test]
fn a_file_that_is_not_open_or_a_ui_method_on_the_back_file_is_an_error() {
let (mut app, back) = two_files();
let e = handle(&mut app, "document.inspect", json!({"document": 999_999})).unwrap_err();
assert!(e.contains("no open document 999999"), "{e}");
let e = handle(&mut app, "ui.type", json!({"text": "x", "document": back})).unwrap_err();
assert!(e.contains("acts on the tab in front"), "{e}");
let front = app.session.docs[1].uid;
assert!(handle(&mut app, "ui.inspect", json!({"document": front})).is_ok(), "the file in front may be named");
assert_eq!(app.session.active, 1);
}
#[test]
fn a_command_that_opens_a_file_still_brings_it_to_the_front() {
let (mut app, back) = two_files();
handle(&mut app, "engine.execute", json!({"command": "file.new", "document": back})).unwrap();
assert_eq!(app.session.active, 2, "a new file opens in front");
}
+7
View File
@@ -65,6 +65,13 @@ window, with its tabs, panels and canvas: `engine.execute` acts on the file it s
last in front (the one the user clicked last, or the last `window.new` / `window.focus`). An
unknown id is an error.
**Documents.** `engine.execute`, `document.inspect`, `node.get` and `ui.render` take an optional
`document`: an open file's `id` (from `doc.list`, or `document.inspect`'s `id`) or its path. The
request acts on that file without bringing its tab to the front; the tab in front comes back
afterwards unless the command opened or switched to another file itself. A file that isn't open is
an error, and so is a `document` naming a file behind the tab in front on any other method
(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows).
## Confining file access
```sh
+15 -1
View File
@@ -345,7 +345,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
| `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). |
| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
@@ -1300,6 +1300,20 @@ opens one (a new file, the file at `path`, or open file `index` moved out of its
focus a window before working on its files, and pass its id to `screenshot {window}` to see it.
Headless (one session, no windows) and in the browser the two commands do nothing.
### Naming the document
Tools act on the file in front, so with an attached app a tab the user clicks mid-task would
change where the next edit lands. `run_command`, `batch`, `create_node`, `set_properties`,
`inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo` and `redo`
take an optional `document`: the file's `id` (in `run_command {command: "doc.list"}`, and the `id`
of the `document` outline `open_file` and `inspect_document` return) or the path of an open file.
The call acts on that file and the tab in front stays in front, unless the command itself opens or
switches to another file (`file.open`, `file.new`, `doc.switch`), as it would without `document`. A
file that isn't open is an error. `screenshot {window}` captures what the window shows, so it
needs the file in front. Over the control channel, `engine.execute`, `document.inspect`,
`node.get` and `ui.render` take the same `document`; other methods refuse one naming a file behind
the tab in front. Headless sessions take it too.
### Live sessions
People (and agents) edit one file together and see each other's cursors, selections and
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (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-28 · **Change:** minor (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-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
| 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 |
File diff suppressed because one or more lines are too long