feat(ai): stream per-batch reveals and progress in the design loop

Loop-applied batches now register node reveals against the live run
epoch (no more whole-sections popping in silently), the checklist
surfaces tool-call progress per turn, per-batch layout feedback keeps
flowing, and the design loop gets a bounded turn budget sized for
section-by-section batching plus repair turns.
This commit is contained in:
Fini 2026-07-04 02:16:37 +08:00
parent 974a1d1f2e
commit a384a19fae
11 changed files with 538 additions and 26 deletions

View file

@ -203,6 +203,12 @@ pub fn begin() -> u64 {
r.epoch
}
/// Return the currently active run epoch, if a host-owned indicator run is live.
pub fn active_epoch() -> Option<u64> {
let r = REGISTRY.lock().unwrap();
r.run_active.then_some(r.epoch)
}
/// Clear indicators only if `epoch` is still the active run. A newer
/// [`begin`] makes this a no-op, so a stale / cancelled run finishing
/// late can't wipe the indicators of the run that replaced it. The
@ -594,3 +600,19 @@ pub fn apply_remote(remote: &RemoteIndicators) -> bool {
}
has_active_indicators(&r) || r.needs_final_frame
}
/// Latest scheduled reveal END (start + [`REVEAL_DURATION_MS`]) for `epoch`,
/// or `None` when the epoch is stale or nothing is scheduled. The
/// orchestrator's finalize gate polls this so tree-restructuring
/// (`ReplaceSubtree` id churn) waits for the cursor sweep to finish instead
/// of snapping the still-animating tail of the design in at once.
pub fn latest_reveal_end_ms(epoch: u64) -> Option<u64> {
let r = REGISTRY.lock().unwrap();
if r.epoch != epoch {
return None;
}
r.reveals
.values()
.max()
.map(|s| s.saturating_add(REVEAL_DURATION_MS))
}

View file

@ -1,5 +1,6 @@
//! Shared chat turn worker and transcript folding logic.
use std::collections::VecDeque;
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
use std::sync::Mutex;
use std::thread;
@ -92,6 +93,7 @@ pub fn chat_tool_channel() -> (UiChatToolExecutor, Receiver<ChatToolRequest>) {
pub struct ChatSession {
rx: Receiver<ChatDelta>,
tool_rx: Option<Receiver<ChatToolRequest>>,
deferred_tool_requests: VecDeque<ChatToolRequest>,
finished: bool,
}
@ -212,6 +214,7 @@ impl ChatSession {
Self {
rx,
tool_rx,
deferred_tool_requests: VecDeque::new(),
finished: false,
}
}
@ -225,6 +228,7 @@ impl ChatSession {
Self {
rx,
tool_rx,
deferred_tool_requests: VecDeque::new(),
finished: false,
}
}
@ -266,16 +270,20 @@ impl ChatSession {
/// Drain pending host tool requests without blocking.
pub fn drain_tool_requests(&mut self) -> Vec<ChatToolRequest> {
let mut requests: Vec<ChatToolRequest> = self.deferred_tool_requests.drain(..).collect();
let Some(tool_rx) = self.tool_rx.as_ref() else {
return Vec::new();
return requests;
};
let mut requests = Vec::new();
while let Ok(req) = tool_rx.try_recv() {
requests.push(req);
}
requests
}
pub fn defer_tool_request(&mut self, req: ChatToolRequest) {
self.deferred_tool_requests.push_back(req);
}
pub fn finished(&self) -> bool {
self.finished
}

View file

@ -12,7 +12,7 @@ use crate::theme::Theme;
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout};
use op_editor_core::chat::{ChatRole, ChatState};
use op_editor_core::chat::{ChatMessage, ChatRole, ChatState};
pub(crate) const PROGRESS_H: f32 = 2.0;
pub(crate) const HEADER_H: f32 = 32.0;
@ -75,6 +75,10 @@ pub(crate) fn fixed_checklist_items(chat: &ChatState) -> Vec<ChecklistItem> {
use_progress_position_fallback = !steps.is_empty();
}
if steps.is_empty() {
let tool_items = loop_tool_call_items(chat, message);
if !tool_items.is_empty() {
return tool_items;
}
return Vec::new();
}
@ -121,6 +125,57 @@ pub(crate) fn fixed_checklist_items(chat: &ChatState) -> Vec<ChecklistItem> {
}
}
fn loop_tool_call_items(chat: &ChatState, message: &ChatMessage) -> Vec<ChecklistItem> {
if chat.agents_running.1 == 0 || message.tool_calls.is_empty() {
return Vec::new();
}
message
.tool_calls
.iter()
.enumerate()
.map(|(index, call)| {
let status = tool_call_status(&call.args);
let done = status.as_deref() == Some("done");
let failed = status.as_deref() == Some("error");
let active = status.as_deref() == Some("running")
|| (status.is_none() && message.streaming && index + 1 == message.tool_calls.len());
ChecklistItem {
label: tool_call_label(&call.name),
done,
active,
failed,
details: Vec::new(),
expanded: false,
}
})
.collect()
}
fn tool_call_status(args: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(args)
.ok()?
.get("status")?
.as_str()
.map(str::to_string)
}
fn tool_call_label(name: &str) -> String {
match name {
"get_editor_state" => "Read canvas state",
"get_style_guide_tags" | "get_style_guide" => "Load style guide",
"get_variables" => "Read variables",
"batch_get" => "Read components",
"snapshot_layout" => "Inspect layout",
"find_empty_space" => "Find placement",
"emit_elements" => "Emit elements",
"batch_design" => "Apply design batch",
"get_screenshot" => "Verify screenshot",
"spawn_agents" => "Spawn section agents",
other => other,
}
.to_string()
}
pub(crate) fn fixed_checklist_height(chat: &ChatState, collapsed: bool) -> f32 {
let items = fixed_checklist_items(chat);
let count = items.len();
@ -569,6 +624,32 @@ mod tests {
assert_eq!(items[1].label, "Subtask `hero` — Hero section");
}
#[test]
fn fixed_checklist_uses_loop_tool_call_sequence_when_no_plan_steps() {
let mut message = ChatMessage::assistant_streaming();
message.tool_calls = vec![
op_editor_core::ChatToolCall {
name: "get_editor_state".into(),
args: r#"{"status":"done"}"#.into(),
},
op_editor_core::ChatToolCall {
name: "batch_design".into(),
args: r#"{"status":"running"}"#.into(),
},
];
let mut chat = ChatState::default();
chat.agents_running = (1, 1);
chat.messages = vec![message];
let items = fixed_checklist_items(&chat);
assert_eq!(items.len(), 2);
assert_eq!(items[0].label, "Read canvas state");
assert!(items[0].done);
assert_eq!(items[1].label, "Apply design batch");
assert!(items[1].active);
}
#[test]
fn checklist_summary_label_is_ellipsized_to_available_width() {
let label = "Subtask `dumpling-card` · 11 skills · 7355/8000 tok · 27 dropped";

View file

@ -94,6 +94,10 @@ fn drain_tool_requests(
// passes the orchestrator runs per subtask. Runs on the UI thread (the
// owner of the live `EditorState`), like every other tool mutation.
if req.name == op_ai::chat_provider::LOOP_FINALIZE_OP {
if crate::design_loop_indicator::reveal_drain_pending_for_active_epoch() {
session.defer_tool_request(req);
continue;
}
op_orchestrator::apply_loop_finalize(state);
changed = true;
let _ = req.ack.send(ChatToolResult {
@ -243,3 +247,7 @@ fn attach_tool_result_to_transcript(
#[cfg(test)]
#[path = "chat_session_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "chat_session_reveal_tests.rs"]
mod reveal_tests;

View file

@ -418,6 +418,9 @@ pub fn drain_new_chat_request(
// second one here (one "+" click == one new tab).
*current_chat = None;
*current_design = None;
if let Some(epoch) = op_editor_core::agent_indicators::active_epoch() {
op_editor_core::agent_indicators::end_if_epoch(epoch);
}
// A fresh tab must start a fresh provider conversation — forget any
// resumable Claude Code / Copilot session so stale context cannot
// leak into the new chat.
@ -440,6 +443,9 @@ pub fn drain_stop_request(
}
*current_chat = None;
*current_design = None;
if let Some(epoch) = op_editor_core::agent_indicators::active_epoch() {
op_editor_core::agent_indicators::end_if_epoch(epoch);
}
host.mark_editor_state_dirty();
true
}

View file

@ -13,7 +13,9 @@ use op_ai::chat_provider::{ChatProvider, ChatRequest, ThinkingMode};
use op_editor_host_core::chat::ChatSession;
use op_editor_host_core::design::DesignSession;
use op_host_native::WidgetHostNative;
use op_host_services::chat_builtin_http::ConfiguredBuiltinProvider;
use op_host_services::chat_builtin_http::{
ConfiguredBuiltinProvider, DESIGN_LOOP_MAX_OUTPUT_TOKENS,
};
use op_host_services::chat_canvas_tools::{chat_tool_channel, ChatToolRequest};
use op_host_services::chat_system_prompt::chat_history_from_transcript;
use op_host_services::design_agent_tools::design_tool_defs;
@ -127,7 +129,7 @@ fn resolve_design_thinking(model: Option<&str>, chat_default: ThinkingMode) -> T
/// falls through to the orchestrator path).
///
/// Mirrors `launch_if_pending`'s builtin chat branch but uses the
/// design toolset and a 16384-token per-turn budget.
/// design toolset and a section-batch-sized per-turn budget.
pub(super) fn launch_design_loop_turn(
host: &mut WidgetHostNative,
user_text: String,
@ -161,19 +163,18 @@ pub(super) fn launch_design_loop_turn(
system_prompt: op_ai_skills::design_agent_system_prompt().to_string(),
user_message: user_text,
history,
// Per-TURN output cap. The real budget-burner was hidden reasoning, not
// the DSL: a glm-5.2 loop run streamed ~94k thinking chars vs ~4k of
// actual batch_design, blowing the cap so a turn truncated mid-JSON, its
// tool call failed to parse, and the loop stopped early with an
// unfinished design. The root fix is at the wire (the loop now sends
// thinking:{type:disabled} for glm/minimax); 16384 (up from 8192) is
// headroom so a genuinely large section-batch still completes per turn.
max_output_tokens: 16384,
// Design-loop turns should emit one <=25-op section batch, then wait for
// tool feedback. 6144 matches that 4-6k-token batch size and avoids
// rewarding monolithic whole-screen tool calls.
max_output_tokens: DESIGN_LOOP_MAX_OUTPUT_TOKENS,
thinking,
effort,
attachments,
model: None,
};
// The host starts the indicator epoch before the worker can apply its first
// design batch; the indicator pump adopts this epoch for badges/teardown.
op_editor_core::agent_indicators::begin();
*current_chat = Some(ChatSession::start_with_tools(provider, req, Some(tool_rx)));
// Signal one agent running so the chat header shows "1/1 designing…"
// and the canvas indicator pump registers frame glows.

View file

@ -0,0 +1,132 @@
use super::*;
use op_ai::chat_provider::{ChatDelta, ChatRequest, ChatToolExecutor, StopReason};
use op_editor_core::{ChatMessage, PenNodeExt};
struct BatchThenFinalizeProvider {
executor: std::sync::Arc<dyn ChatToolExecutor>,
}
struct BatchThenFinalizeIter {
executor: std::sync::Arc<dyn ChatToolExecutor>,
step: u8,
}
impl Iterator for BatchThenFinalizeIter {
type Item = ChatDelta;
fn next(&mut self) -> Option<ChatDelta> {
self.step += 1;
match self.step {
1 => {
let args = r#"{"operations":"root=I(null,{type:'frame',name:'Header',width:1200,height:64})"}"#;
let _ = self.executor.execute("batch_design", args);
Some(ChatDelta::TextDelta("inserted".into()))
}
2 => {
self.executor.finalize();
Some(ChatDelta::Done {
stop_reason: StopReason::EndTurn,
})
}
_ => None,
}
}
}
impl op_ai::chat_provider::ChatProvider for BatchThenFinalizeProvider {
fn provider_label(&self) -> &str {
"batch-finalize"
}
fn send(&self, _request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + Send> {
Box::new(BatchThenFinalizeIter {
executor: self.executor.clone(),
step: 0,
})
}
}
#[test]
fn pump_defers_loop_finalize_until_registered_reveals_drain() {
let _guard = crate::agent_indicator_test_lock::LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
op_editor_core::agent_indicators::clear();
let epoch = op_editor_core::agent_indicators::begin();
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(ChatMessage::assistant_streaming());
let (executor, tool_rx) = op_host_services::chat_canvas_tools::chat_tool_channel();
let provider = Box::new(BatchThenFinalizeProvider {
executor: std::sync::Arc::new(executor),
});
let mut current = Some(ChatSession::start_with_tools(
provider,
ChatRequest {
user_message: "build a header".into(),
max_output_tokens: 64,
..Default::default()
},
Some(tool_rx),
));
for _ in 0..200 {
pump(&mut host, &mut current, None);
if op_editor_core::agent_indicators::latest_reveal_end_ms(epoch).is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
let reveal_end = op_editor_core::agent_indicators::latest_reveal_end_ms(epoch)
.expect("batch_design should register reveals before finalize");
for _ in 0..20 {
pump(&mut host, &mut current, None);
std::thread::sleep(std::time::Duration::from_millis(1));
}
let header = header_node(&host).expect("Header frame inserted");
assert_eq!(
header.base().role.as_deref(),
None,
"finalize must not run while the reveal queue is still draining"
);
let now = reveal_now_millis();
if reveal_end > now {
std::thread::sleep(std::time::Duration::from_millis(reveal_end - now + 20));
}
for _ in 0..200 {
pump(&mut host, &mut current, None);
if current.is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(current.is_none(), "turn must finish after reveal drain");
let header = header_node(&host).expect("Header frame still present");
assert_eq!(
header.base().role.as_deref(),
Some("navbar"),
"loop-end finalize should run after the reveal queue drains"
);
op_editor_core::agent_indicators::end_if_epoch(epoch);
op_editor_core::agent_indicators::clear();
}
fn header_node(host: &WidgetHostNative) -> Option<&jian_ops_schema::node::PenNode> {
host.editor_state()
.active_children()
.iter()
.find(|node| node.base().name.as_deref() == Some("Header"))
}
fn reveal_now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0)
}

View file

@ -18,6 +18,7 @@
//! `agents_running > 0`, so this module is a no-op for them.
use std::collections::HashSet;
use std::time::{SystemTime, UNIX_EPOCH};
use jian_ops_schema::node::PenNode;
use op_editor_core::agent_indicators;
@ -83,7 +84,7 @@ pub(super) fn pump_indicator(
) {
// Lazy creation when the design loop starts a turn.
if state.chat.agents_running.0 > 0 && indicator.is_none() {
let epoch = agent_indicators::begin();
let epoch = agent_indicators::active_epoch().unwrap_or_else(agent_indicators::begin);
let identities = assign_agent_identities(1);
let id = identities
.into_iter()
@ -113,6 +114,25 @@ pub(super) fn pump_indicator(
}
}
/// True when the active design-loop epoch still has scheduled reveals that
/// should finish before the loop-end structural finalizer rewrites the tree.
pub(crate) fn reveal_drain_pending_for_active_epoch() -> bool {
let Some(epoch) = agent_indicators::active_epoch() else {
return false;
};
let Some(end) = agent_indicators::latest_reveal_end_ms(epoch) else {
return false;
};
reveal_now_millis() < end
}
fn reveal_now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -8,16 +8,10 @@
use super::*;
use op_editor_core::{agent_indicators, EditorState};
use op_host_services::design_agent_tools::execute_design_tool;
use std::sync::{LazyLock, Mutex};
/// A real styleguide name from the embedded corpus.
const REAL_STYLEGUIDE: &str = "ai-product-dark";
/// Serialize tests that touch the process-global indicator registry +
/// the module-level spawn stash so the default parallel runner doesn't
/// race them against each other.
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn spec(prompt: &str, containers: &[&str], styleguide: &str, guidelines: &[&str]) -> SpawnSpec {
SpawnSpec {
prompt: prompt.to_string(),
@ -183,7 +177,9 @@ fn parse_args_invalid_json_errors() {
#[test]
fn stash_top_level_succeeds_nested_refused() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _guard = crate::agent_indicator_test_lock::LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// Clear any leftover stash.
let _ = take_pending_spawn();
@ -214,7 +210,9 @@ fn stash_top_level_succeeds_nested_refused() {
fn distinct_identities_and_epoch_scoped_frame_badges() {
use op_orchestrator::agent_identity::assign_agent_identities;
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _guard = crate::agent_indicator_test_lock::LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// Two specs → two distinct identities (colour + name).
let identities = assign_agent_identities(2);
@ -263,7 +261,9 @@ fn lazy_epoch_keeps_each_active_subs_badges_live_in_sequence() {
// this. Simulate the pump's begin → badge → end cadence for two subs.
use op_orchestrator::agent_identity::assign_agent_identities;
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _guard = crate::agent_indicator_test_lock::LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
agent_indicators::clear();
let ids = assign_agent_identities(2);
@ -331,7 +331,9 @@ fn lazy_epoch_keeps_each_active_subs_badges_live_in_sequence() {
fn pump_with_finished_subs_decrements_then_clears_agents_running() {
use op_host_native::WidgetHostNative;
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _guard = crate::agent_indicator_test_lock::LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut host = WidgetHostNative::new();
// Seed the N/M header as `launch_sub_agents` would for two subs.

View file

@ -21,6 +21,17 @@ use crate::chat_agent_loop::{run_anthropic_agent_loop, run_openai_agent_loop, Ag
use crate::chat_canvas_tools::MAX_TOOL_TURNS;
use crate::chat_runtime::{resolved_skill_preamble, shared_runtime, BlockingRecvIter};
/// Design turns build one <=25-op section batch per model turn, plus repair
/// turns after layoutIssues feedback. 28 sits in the requested 24-32 window:
/// enough for roughly 8-10 sections with fixes, without letting a bad loop run
/// indefinitely. Plain chat keeps [`MAX_TOOL_TURNS`].
pub const DESIGN_LOOP_MAX_TURNS: usize = 28;
/// A <=25-op batch plus one concise tool call fits comfortably in 4-6k output
/// tokens. 6144 keeps section batches complete without encouraging the model to
/// emit a whole screen in one turn.
pub const DESIGN_LOOP_MAX_OUTPUT_TOKENS: u32 = 6_144;
#[derive(Clone)]
pub struct ConfiguredBuiltinProvider {
kind: BuiltinAgentKind,
@ -178,7 +189,11 @@ impl ChatProvider for ConfiguredBuiltinProvider {
max_output_tokens,
tools: provider.tools.clone(),
executor,
max_turns: MAX_TOOL_TURNS,
max_turns: if provider.finalize_on_exit {
DESIGN_LOOP_MAX_TURNS
} else {
MAX_TOOL_TURNS
},
finalize_on_exit: provider.finalize_on_exit,
disable_thinking,
};

View file

@ -10,7 +10,12 @@
//! DSL — giving the loop loops/data-driven emission without a separate
//! element-builder tool.
use std::collections::HashSet;
use std::time::{SystemTime, UNIX_EPOCH};
use jian_ops_schema::node::{ContainerProps, PenNode, TextContent};
use op_ai::chat_provider::{ChatToolDef, ChatToolResult};
use op_editor_core::pen_node_ext::PenNodeExt;
use op_editor_core::EditorState;
use op_mcp::ToolRegistry;
@ -76,6 +81,22 @@ pub fn execute_design_tool(
state: &mut EditorState,
name: &str,
args_json: &str,
) -> (ChatToolResult, bool) {
execute_design_tool_with_reveals(
state,
name,
args_json,
op_editor_core::agent_indicators::active_epoch(),
)
}
/// Execute one design tool call and register entrance reveals for nodes inserted
/// by write batches when the host has an active indicator epoch.
pub fn execute_design_tool_with_reveals(
state: &mut EditorState,
name: &str,
args_json: &str,
indicator_epoch: Option<u64>,
) -> (ChatToolResult, bool) {
let Some(_level) = design_tool_level(name) else {
let envelope = serde_json::json!({ "success": false, "error": format!("tool not available in design agent: {name}") });
@ -87,8 +108,16 @@ pub fn execute_design_tool(
false,
);
};
let reveal_started_ms = reveal_now_millis();
let ids_before = should_register_batch_reveals(name, indicator_epoch)
.then(|| collect_active_node_ids(state));
let registry = design_tool_registry(state, name);
let (mut result, mutated) = execute_with_registry(state, name, args_json, registry);
if mutated && !result.is_error {
if let Some(ids_before) = ids_before.as_ref() {
register_new_node_reveals(ids_before, state, indicator_epoch, reveal_started_ms);
}
}
// Per-batch layout feedback: after every WRITE batch, attach what the real
// layout proves wrong (collapses / table overflow / text overflow) so the
// model sees each batch's geometric consequences immediately and repairs
@ -127,9 +156,25 @@ pub fn execute_agent_tool(
state: &mut EditorState,
name: &str,
args_json: &str,
) -> (ChatToolResult, bool) {
execute_agent_tool_with_reveals(
state,
name,
args_json,
op_editor_core::agent_indicators::active_epoch(),
)
}
/// Host-facing tool router with an explicit indicator epoch for tests and
/// desktop paths that already know the active design-loop epoch.
pub fn execute_agent_tool_with_reveals(
state: &mut EditorState,
name: &str,
args_json: &str,
indicator_epoch: Option<u64>,
) -> (ChatToolResult, bool) {
if design_tool_level(name).is_some() {
execute_design_tool(state, name, args_json)
execute_design_tool_with_reveals(state, name, args_json, indicator_epoch)
} else {
execute_chat_tool(state, name, args_json)
}
@ -164,6 +209,148 @@ fn design_tool_registry(state: &EditorState, requested: &str) -> ToolRegistry {
r
}
fn should_register_batch_reveals(name: &str, indicator_epoch: Option<u64>) -> bool {
indicator_epoch.is_some() && matches!(name, "batch_design" | EMIT_ELEMENTS_TOOL)
}
fn collect_active_node_ids(state: &EditorState) -> HashSet<String> {
let mut out = HashSet::new();
for node in state.active_children() {
collect_node_ids(node, &mut out);
}
out
}
fn collect_node_ids(node: &PenNode, out: &mut HashSet<String>) {
out.insert(node.id_str().to_string());
if let Some(children) = node.children() {
for child in children {
collect_node_ids(child, out);
}
}
}
fn register_new_node_reveals(
ids_before: &HashSet<String>,
state: &EditorState,
indicator_epoch: Option<u64>,
reveal_started_ms: u64,
) {
let Some(epoch) = indicator_epoch else {
return;
};
let mut stream = RevealStream {
index: 0,
next_start_ms: reveal_started_ms,
};
for node in state.active_children() {
register_node_reveals(
node,
ids_before,
epoch,
reveal_started_ms,
0,
None,
&mut stream,
);
}
}
struct RevealStream {
index: u64,
next_start_ms: u64,
}
fn register_node_reveals(
node: &PenNode,
ids_before: &HashSet<String>,
epoch: u64,
reveal_started_ms: u64,
depth: u64,
parent_reveal_start_ms: Option<u64>,
stream: &mut RevealStream,
) {
let id = node.id_str();
let mut own_reveal_start_ms = parent_reveal_start_ms;
if !ids_before.contains(id) && should_reveal_node(node, depth) {
let own_stream_index = stream.index;
stream.index += 1;
let base_start = reveal_started_ms
+ op_editor_core::agent_indicators::reveal_offset_ms(depth, own_stream_index);
let child_runway_start = parent_reveal_start_ms
.map(|started_at| {
started_at.saturating_add(op_editor_core::agent_indicators::REVEAL_CHILD_RUNWAY_MS)
})
.unwrap_or(reveal_started_ms);
let started_at = base_start.max(child_runway_start).max(stream.next_start_ms);
op_editor_core::agent_indicators::add_reveal(epoch, id, started_at);
stream.next_start_ms =
started_at.saturating_add(op_editor_core::agent_indicators::REVEAL_STAGGER_MS);
own_reveal_start_ms = Some(started_at);
}
if let Some(children) = node.children() {
for child in children {
register_node_reveals(
child,
ids_before,
epoch,
reveal_started_ms,
depth + 1,
own_reveal_start_ms,
stream,
);
}
}
}
fn should_reveal_node(node: &PenNode, depth: u64) -> bool {
depth == 0 || node_has_own_visual(node) || node_is_named_structure(node)
}
fn node_has_own_visual(node: &PenNode) -> bool {
match node {
PenNode::Frame(n) => {
container_has_own_visual(&n.container) || n.image_search_query.is_some()
}
PenNode::Group(n) => container_has_own_visual(&n.container),
PenNode::Rectangle(n) => container_has_own_visual(&n.container),
PenNode::Ref(_) => false,
PenNode::Text(n) => match &n.content {
TextContent::Plain(s) => !s.is_empty(),
TextContent::Styled(segments) => !segments.is_empty(),
},
_ => true,
}
}
fn container_has_own_visual(container: &ContainerProps) -> bool {
container
.fill
.as_ref()
.is_some_and(|fills| !fills.is_empty())
|| container.stroke.is_some()
|| container
.effects
.as_ref()
.is_some_and(|effects| !effects.is_empty())
}
fn node_is_named_structure(node: &PenNode) -> bool {
if !node.is_container() {
return false;
}
let base = node.base();
base.role.as_deref().is_some_and(|role| !role.is_empty())
|| base.name.as_deref().is_some_and(|name| !name.is_empty())
}
fn reveal_now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0)
}
/// Extract `description` and `inputSchema` JSON string from `TOOL_SCHEMAS`
/// for the given tool name. Returns `None` when no entry matches.
///
@ -285,6 +472,36 @@ mod tests {
);
}
#[test]
fn execute_design_batch_design_registers_reveals_when_epoch_is_set() {
use op_editor_core::agent_indicators;
agent_indicators::clear();
let epoch = agent_indicators::begin();
let mut state = EditorState::new();
let (result, mutated) = execute_design_tool_with_reveals(
&mut state,
"batch_design",
r#"{"operations":"root=I(null,{type:'frame',name:'Root',width:120,height:80})\ntitle=I(root,{type:'text',name:'Title',content:'Hello',width:80,height:20})"}"#,
Some(epoch),
);
assert!(!result.is_error, "batch_design failed: {}", result.content);
assert!(mutated, "batch_design must mutate the document");
let ids: Vec<String> = collect_active_node_ids(&state).into_iter().collect();
assert!(ids.len() >= 2, "batch inserted a subtree, got {ids:?}");
let snapshot = agent_indicators::snapshot();
for id in ids {
assert!(
snapshot.reveals.contains_key(&id),
"newly inserted node {id} should have a reveal: {:?}",
snapshot.reveals
);
}
agent_indicators::end_if_epoch(epoch);
agent_indicators::clear();
}
#[test]
fn execute_design_batch_design_attaches_per_batch_layout_feedback() {
// A batch that lands an OVERFLOWING table (5×240 fixed columns in a