diff --git a/PROGRESS.md b/PROGRESS.md index 575f4f4..2a2775c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -76,6 +76,11 @@ are credited in `THIRD_PARTY.md`. * **Not carried over**: comments, design tokens, AI image generation, video export, teams; moving layers between pages; remote image URLs (see README Known gaps). +### 2026-10-28: agent reads no longer look like edits + +* Read-only tool calls (`get_node_info`, `get_children`, ...) have honest verbs ("Looking at the design") and light the "Working" pill for 1.5 s instead of 6 s, so it goes out right after a sweep ends. +* A sweep of reads draws at most one layer outline (a read outlines only when no other read ran in the last 2 s) and no longer pans the follow-agent camera; edits behave as before. "Is this a read" comes from `mcp::tools::edits_doc`. + ### 2026-10-28: 0.8.4 (demo video, fresh screenshots) * README opens with a time-lapse of designing a habit-tracker app (`docs/demo.gif`, full video `docs/demo.mp4`), and every screenshot is retaken from that design. diff --git a/src/app.rs b/src/app.rs index bc22bba..63300db 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1595,7 +1595,7 @@ impl App { self.st.mark_agent(&self.ed, who, &c.name, &c.args, &touched); } self.runs = runs; - self.st.log_agent(&c.name, r.as_ref().err().cloned()); + self.st.log_agent(&c.name, &c.args, r.as_ref().err().cloned()); if let Err(e) = &r { log::info!("mcp {}: {e}", c.name); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8be1da5..ddc8ab8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -141,6 +141,11 @@ pub struct UiState { pub libraries_open: bool, /// The last tool an agent ran and when, for the toolbar's activity hint. pub agent: Option<(String, web_time::Instant)>, + /// When the pill stops counting the agent as working: the latest call's window, never + /// shortened by a later, shorter one. + pub agent_until: Option, + /// The last read-only call that reached the canvas, to tell a lone lookup from a sweep. + pub last_read: Option, /// Recent agent tool calls, oldest first (the Agent view). pub agent_log: Vec, /// Layers agents touched in the last [`AGENT_MARK_SECS`], oldest first: the live outlines and @@ -313,6 +318,8 @@ const AGENT_MARK_SECS: f32 = 3.0; /// Outlines kept from one call (its topmost changed layers) and in all. const AGENT_MARKS_PER_CALL: usize = 12; const AGENT_MARKS_MAX: usize = 48; +/// Reads closer together than this are one sweep and draw no outline after the first. +const AGENT_READ_GAP_SECS: f32 = 2.0; impl UiState { /// Records the layers a successful agent call pointed at (`follow_node`) or changed @@ -337,6 +344,15 @@ impl UiState { parent.is_none_or(|par| !touched.iter().any(|(pg, t)| pg == page && *t == par)) }); let at = web_time::Instant::now(); + // A sweep of reads (get_node_info over every layer) must not flash an outline on each: + // only a lookup with no other read just before it shows one. + if !crate::mcp::tools::edits_doc(name, args) { + let sweeping = self.last_read.is_some_and(|t| t.elapsed().as_secs_f32() < AGENT_READ_GAP_SECS); + self.last_read = Some(at); + if sweeping { + return; + } + } let verb = agent_verb(name); for (page_id, node) in key.into_iter().chain(roots.cloned()).take(AGENT_MARKS_PER_CALL) { self.agent_marks.retain(|m| (&m.page_id, m.node) != (&page_id, node)); @@ -471,9 +487,12 @@ impl UiState { } /// Records an agent's tool call for the toolbar hint and the Agent view. - pub fn log_agent(&mut self, name: &str, error: Option) { + pub fn log_agent(&mut self, name: &str, args: &serde_json::Value, error: Option) { let at = web_time::Instant::now(); self.agent = Some((name.to_owned(), at)); + let window = if crate::mcp::tools::edits_doc(name, args) { AGENT_WORKING_SECS as f32 } else { AGENT_READ_WORKING_SECS }; + let until = at + std::time::Duration::from_secs_f32(window); + self.agent_until = Some(self.agent_until.map_or(until, |u| u.max(until))); self.agent_log.push(AgentCall { name: name.to_owned(), at, @@ -487,6 +506,8 @@ impl UiState { /// How long after its last tool call an agent still counts as working. const AGENT_WORKING_SECS: u64 = 6; +/// The same for a read-only call: a sweep of lookups lights the pill only while it runs. +const AGENT_READ_WORKING_SECS: f32 = 1.5; /// Repaint interval of the activity pill's dots and the canvas glow while an agent works. Each /// tick redraws the whole window, so 15 FPS: the pulse still reads smooth. const AGENT_PILL_FRAME_MS: u64 = 66; @@ -496,9 +517,11 @@ pub(crate) fn agent_verb(tool: &str) -> &'static str { match tool { "write_html" | "create_artboard" | "create_shape" => "Drawing layout", "update_styles" | "set_text_content" | "rename_nodes" => "Styling", - "get_screenshot" | "get_jsx" | "get_design_spec" | "get_computed_styles" => { - "Looking at the design" - } + "get_screenshot" | "get_jsx" | "get_design_spec" | "get_computed_styles" | "get_node_info" + | "get_children" | "get_tree_summary" | "get_bounds" | "get_selection" | "get_basic_info" + | "get_inventory" | "get_variables" | "get_components" | "find_nodes" | "get_annotations" + | "get_annotation_template" | "get_font_family_info" | "get_fill_image" | "search_icons" + | "list_resources" | "list_libraries" => "Looking at the design", "lint_design" | "verify_code" | "diff_frames" => "Checking the design", "set_variables" | "set_tokens" | "set_variable_mode" | "import_design_system" => { "Setting up tokens" @@ -513,12 +536,11 @@ pub(crate) fn agent_verb(tool: &str) -> &'static str { } impl UiState { - /// What the agent is doing right now, if it called a tool within [`AGENT_WORKING_SECS`]. + /// What the agent is doing right now, while its latest call's window + /// ([`AGENT_WORKING_SECS`] for an edit, [`AGENT_READ_WORKING_SECS`] for a read) is open. pub fn agent_working(&self) -> Option<&'static str> { - self.agent - .as_ref() - .filter(|(_, at)| at.elapsed().as_secs() < AGENT_WORKING_SECS) - .map(|(tool, _)| agent_verb(tool)) + let open = self.agent_until.is_some_and(|u| web_time::Instant::now() < u); + self.agent.as_ref().filter(|_| open).map(|(tool, _)| agent_verb(tool)) } } @@ -2848,7 +2870,7 @@ mod layer_tests { setup_style(&ctx); let mut ed = many_roots(300); let mut st = UiState::default(); - st.log_agent("get_basic_info", None); + st.log_agent("get_basic_info", &serde_json::json!({}), None); for i in 0..180 { frame(&ctx, &mut ed, &mut st, i, 800.0); } @@ -2891,6 +2913,77 @@ mod layer_tests { assert!(last > quiet, "faded outlines keep repainting: {last:?}"); } + #[test] + fn a_read_sweep_outlines_one_layer_and_follows_nothing_more() { + let mut ed = many_roots(0); + let ids: Vec<_> = (0..5).map(|_| rect(&mut ed, None, true)).collect(); + let mut st = UiState::default(); + for id in &ids { + let key = crate::mcp::exec::node_key(&ed, 0, *id); + st.mark_agent(&ed, String::new(), "get_node_info", &serde_json::json!({ "nodeId": key }), &[]); + if *id == ids[0] { + assert!(st.chat.follow.is_some(), "a lone lookup should still be followed"); + } + st.chat.follow = None; + } + assert_eq!(st.agent_marks.len(), 1); + assert_eq!(st.agent_marks[0].node, ids[0]); + assert!(st.chat.follow.is_none(), "a swept read panned the camera"); + // An edit in the middle of a sweep still marks and follows. + let page = ed.active_page_id().to_string(); + st.mark_agent(&ed, String::new(), "update_styles", &serde_json::json!({}), &[(page, ids[3])]); + assert_eq!(st.agent_marks.last().map(|m| m.node), Some(ids[3])); + assert!(st.chat.follow.is_some()); + } + + #[test] + fn a_lone_read_outlines_again_once_the_sweep_is_over() { + let mut ed = many_roots(0); + let id = rect(&mut ed, None, true); + let key = crate::mcp::exec::node_key(&ed, 0, id); + let args = serde_json::json!({ "nodeId": key }); + let mut st = UiState::default(); + st.mark_agent(&ed, String::new(), "get_node_info", &args, &[]); + st.agent_marks.clear(); + st.last_read = Some(web_time::Instant::now() - std::time::Duration::from_secs_f32(AGENT_READ_GAP_SECS + 0.1)); + st.mark_agent(&ed, String::new(), "get_node_info", &args, &[]); + assert_eq!(st.agent_marks.len(), 1); + } + + #[test] + fn the_pill_lapses_sooner_after_reads_than_after_edits() { + let none = serde_json::json!({}); + let mut st = UiState::default(); + st.log_agent("get_node_info", &none, None); + assert_eq!(st.agent_working(), Some("Looking at the design")); + let past = web_time::Instant::now() - std::time::Duration::from_millis(100); + st.agent_until = Some(past); + assert!(st.agent_working().is_none()); + let mut st = UiState::default(); + st.log_agent("update_styles", &none, None); + let until = st.agent_until.unwrap(); + assert!(until > web_time::Instant::now() + std::time::Duration::from_secs(AGENT_WORKING_SECS - 1)); + // A read after an edit never shortens the edit's window. + st.log_agent("get_node_info", &none, None); + assert_eq!(st.agent_until, Some(until)); + } + + #[test] + fn reads_have_honest_verbs_and_unknown_tools_just_work() { + for t in [ + "get_node_info", "get_children", "get_tree_summary", "get_bounds", "get_selection", "get_basic_info", + "get_inventory", "get_variables", "get_components", "find_nodes", "get_annotations", "search_icons", + ] { + assert_eq!(agent_verb(t), "Looking at the design", "{t}"); + } + assert_eq!(agent_verb("get_feedback"), "Reading notes"); + for t in ["lint_design", "verify_code", "diff_frames"] { + assert_eq!(agent_verb(t), "Checking the design", "{t}"); + } + assert_eq!(agent_verb("write_html"), "Drawing layout"); + assert_eq!(agent_verb("no_such_tool"), "Working"); + } + #[test] fn agent_on_another_page_shows_a_chip_that_switches_to_it() { let ctx = egui::Context::default(); diff --git a/src/ui/panels.rs b/src/ui/panels.rs index 4e6b2f7..916f132 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -488,10 +488,7 @@ pub fn agent(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) { st.review_diff = None; let p = theme::pal(ui); title(ui, "Agent", "Tools an agent ran on this project."); - let live = st - .agent - .as_ref() - .is_some_and(|(_, at)| at.elapsed().as_secs() < super::AGENT_WORKING_SECS); + let live = st.agent_working().is_some(); ui.horizontal(|ui| { kit::chip(ui, if live { "Working" } else { "Idle" }, live); if !st.agent_log.is_empty() && kit::chip(ui, "Clear log", false).clicked() { diff --git a/src/web.rs b/src/web.rs index d299870..a2da186 100644 --- a/src/web.rs +++ b/src/web.rs @@ -444,7 +444,7 @@ impl Web { .err() .filter(|e| ShotRequest::from_marker(e).is_none()) .cloned(); - self.st.log_agent(&call.name, failed); + self.st.log_agent(&call.name, &call.args, failed); match result { Err(e) => match ShotRequest::from_marker(&e) { Some(req) => self.start_screenshot(call.id, req),