From d545455b71f09fd439a46d9be641cf6b2ea15591 Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 9 Jun 2026 15:06:33 +0800 Subject: [PATCH] feat(editor): paint a breathing border around each agent's frame During a multi-screen concurrent generation, every screen group's root frame is tagged with a distinct agent colour; the canvas now strokes a pulsing glow + ring in that colour around each one so you can see which agent is building what. Indicators are registered as the concurrent scaffold lands and cleared once the turn finishes. --- .../src/widgets/canvas_agent_overlay.rs | 91 +++++++++++++++++++ .../src/widgets/canvas_viewport.rs | 8 ++ crates/op-editor-ui/src/widgets/mod.rs | 1 + crates/op-host-desktop/src/design_session.rs | 4 + crates/op-orchestrator/src/run.rs | 10 ++ 5 files changed, 114 insertions(+) create mode 100644 crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs diff --git a/crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs b/crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs new file mode 100644 index 000000000..d6aea0e07 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_agent_overlay.rs @@ -0,0 +1,91 @@ +//! Per-agent "breathing" canvas indicators for the concurrent agent +//! team — a soft glow plus a crisp ring in each agent's colour, pulsing +//! around the root frame that agent is building while a multi-screen +//! generation runs. Reads the process-global +//! [`op_editor_core::agent_indicators`] registry every frame. + +use crate::layout_scene::SceneNode; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, Rect}; + +/// One full breathe (0 → 1 → 0) per this many ms. +const GLOW_PERIOD_MS: u64 = 1200; + +/// Paint a breathing border around every active-page root frame that an +/// agent currently owns. `roots` are the page's top-level scene nodes; +/// `viewport_origin` already folds in the pan offset. +pub(crate) fn paint_agent_frame_indicators( + cx: &mut PaintCx<'_>, + roots: &[SceneNode], + viewport_origin: Point2D, + zoom: f32, + now_ms: u64, +) { + let indicators = op_editor_core::agent_indicators::snapshot(); + if indicators.frames.is_empty() { + return; + } + // Bell curve 0 → 1 → 0 across the period (matches the TS glow breath). + let phase = (now_ms % GLOW_PERIOD_MS) as f32 / GLOW_PERIOD_MS as f32; + let breath = (phase * std::f32::consts::PI).sin(); + for (frame_id, tag) in &indicators.frames { + let Some(node) = roots.iter().find(|n| n.id == *frame_id) else { + continue; + }; + let Some(color) = parse_hex(&tag.color) else { + continue; + }; + let b = node.bounds; + let screen = Rect { + origin: Point2D::new( + viewport_origin.x + b.origin.x * zoom, + viewport_origin.y + b.origin.y * zoom, + ), + size: Point2D::new(b.size.x * zoom, b.size.y * zoom), + }; + // Outer soft glow + inner crisp ring, both breathing. + cx.backend.stroke_round_rect( + screen, + 8.0, + Color { + a: breath * 0.4, + ..color + }, + 3.0, + ); + cx.backend + .stroke_round_rect(screen, 8.0, Color { a: breath, ..color }, 1.5); + } +} + +/// Parse a `#RRGGBB` hex string into an opaque [`Color`]. Returns `None` +/// for malformed / non-ASCII input. +fn parse_hex(hex: &str) -> Option { + let h = hex.strip_prefix('#').unwrap_or(hex); + if !h.is_ascii() || h.len() < 6 { + return None; + } + let r = u8::from_str_radix(&h[0..2], 16).ok()? as f32 / 255.0; + let g = u8::from_str_radix(&h[2..4], 16).ok()? as f32 / 255.0; + let b = u8::from_str_radix(&h[4..6], 16).ok()? as f32 / 255.0; + Some(Color { r, g, b, a: 1.0 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_agent_palette_hex() { + let c = parse_hex("#FF6B6B").unwrap(); + assert!((c.r - 1.0).abs() < 1e-6); + assert!((c.g - 0.419).abs() < 0.01); + assert!((c.b - 0.419).abs() < 0.01); + } + + #[test] + fn rejects_short_or_non_ascii_hex() { + assert!(parse_hex("#FFF").is_none()); + assert!(parse_hex("#非ascii").is_none()); + } +} diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport.rs b/crates/op-editor-ui/src/widgets/canvas_viewport.rs index 4b776da2d..778915283 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport.rs @@ -425,6 +425,14 @@ impl<'a> Widget for CanvasViewport<'a> { cull, ); } + // Per-agent breathing borders while a concurrent generation runs. + super::canvas_agent_overlay::paint_agent_frame_indicators( + cx, + &page.children, + viewport_origin, + viewport.zoom, + self.now_ms, + ); } // 3a. Smart-guide alignment lines (magenta) — painted over the diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 192c61e9c..ceba179e7 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -74,6 +74,7 @@ pub mod toolbar; // Step 3 — center canvas that renders document nodes as actual // visual primitives (frame fills, rect strokes, text strings). +mod canvas_agent_overlay; pub mod canvas_viewport; mod canvas_viewport_image; pub mod canvas_viewport_overlay; diff --git a/crates/op-host-desktop/src/design_session.rs b/crates/op-host-desktop/src/design_session.rs index 7c3428371..ad08edfba 100644 --- a/crates/op-host-desktop/src/design_session.rs +++ b/crates/op-host-desktop/src/design_session.rs @@ -176,6 +176,10 @@ impl DesignSession { } } } + if self.finished { + // Drop any agent-team canvas indicators once the turn ends. + op_editor_core::agent_indicators::clear(); + } DesignPoll { progress, summary, diff --git a/crates/op-orchestrator/src/run.rs b/crates/op-orchestrator/src/run.rs index 3255256bf..43a7a34cb 100644 --- a/crates/op-orchestrator/src/run.rs +++ b/crates/op-orchestrator/src/run.rs @@ -482,6 +482,16 @@ async fn run_concurrent_path( } } + // Tag each group's root frame with a distinct agent identity so the + // canvas can paint a per-agent breathing border while the team works. + // Cleared at the start of every concurrent run (host clears it again + // once generation finishes). + let identities = crate::agent_identity::assign_agent_identities(actual_root_ids.len()); + op_editor_core::agent_indicators::clear(); + for (root_id, identity) in actual_root_ids.iter().zip(identities.iter()) { + op_editor_core::agent_indicators::add_frame(root_id, &identity.color, &identity.name); + } + on_progress(Progress::ScaffoldDone); // -- 阶段 3 (并发):run_concurrent --