1use serde::Serialize;
14use std::cell::RefCell;
15
16use crate::signal::Signal;
17use crate::ui_tree::{NodeKind, UITree};
18
19#[derive(Debug, Clone, Serialize)]
23pub struct AgentState {
24 pub interactive_elements: Vec<ElementSummary>,
26 pub data_elements: Vec<ElementSummary>,
28 pub current_route: String,
30}
31
32#[derive(Debug, Clone, Serialize)]
34pub struct ElementSummary {
35 pub id: Option<u64>,
37 pub kind: String,
39 pub label: Option<String>,
41 pub value: Option<String>,
43 pub action: Option<String>,
45 pub params: Vec<(String, String)>,
47 pub description: Option<String>,
49}
50
51thread_local! {
57 static ROUTER: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
58}
59
60fn router() -> Signal<String> {
61 ROUTER.with(|r| {
62 r.borrow_mut()
63 .get_or_insert_with(|| Signal::new("/".to_string()))
64 .clone()
65 })
66}
67
68pub fn route_signal() -> Signal<String> {
80 router()
81}
82
83pub fn current_route() -> String {
85 router().get()
86}
87
88pub fn navigate_to(route: &str) {
91 router().set(route.to_string());
92}
93
94pub fn query_state<Msg>(ui: &UITree<Msg>) -> AgentState {
104 let mut interactive = Vec::new();
105 let mut data = Vec::new();
106 walk(ui, &mut interactive, &mut data);
107 AgentState {
108 interactive_elements: interactive,
109 data_elements: data,
110 current_route: current_route(),
111 }
112}
113
114fn walk<Msg>(
115 node: &UITree<Msg>,
116 interactive: &mut Vec<ElementSummary>,
117 data: &mut Vec<ElementSummary>,
118) {
119 let id = node.meta.data_appfront_id;
120 let ai = &node.meta.ai;
121
122 match &node.kind {
123 NodeKind::Button { label } => {
124 interactive.push(ElementSummary {
125 id,
126 kind: "button".into(),
127 label: Some(label.clone()),
128 value: None,
129 action: ai.action.clone(),
130 params: ai.params.clone(),
131 description: ai.description.clone(),
132 });
133 }
134 NodeKind::Input { value } => {
135 interactive.push(ElementSummary {
136 id,
137 kind: "input".into(),
138 label: None,
139 value: Some(value.clone()),
140 action: ai.action.clone(),
141 params: ai.params.clone(),
142 description: ai.description.clone(),
143 });
144 }
145 NodeKind::Textarea { value } => {
146 interactive.push(ElementSummary {
147 id,
148 kind: "textarea".into(),
149 label: None,
150 value: Some(value.clone()),
151 action: ai.action.clone(),
152 params: ai.params.clone(),
153 description: ai.description.clone(),
154 });
155 }
156 NodeKind::Checkbox { label, checked } => {
157 interactive.push(ElementSummary {
158 id,
159 kind: "checkbox".into(),
160 label: Some(label.clone()),
161 value: Some(checked.to_string()),
162 action: ai.action.clone(),
163 params: ai.params.clone(),
164 description: ai.description.clone(),
165 });
166 }
167 NodeKind::Select { selected, .. } => {
168 interactive.push(ElementSummary {
169 id,
170 kind: "select".into(),
171 label: None,
172 value: Some(selected.clone()),
173 action: ai.action.clone(),
174 params: ai.params.clone(),
175 description: ai.description.clone(),
176 });
177 }
178 NodeKind::Radio { selected, .. } => {
179 interactive.push(ElementSummary {
180 id,
181 kind: "radio".into(),
182 label: None,
183 value: Some(selected.clone()),
184 action: ai.action.clone(),
185 params: ai.params.clone(),
186 description: ai.description.clone(),
187 });
188 }
189 NodeKind::Heading { level, text } => {
190 data.push(ElementSummary {
191 id,
192 kind: format!("h{level}"),
193 label: Some(text.clone()),
194 value: None,
195 action: None,
196 params: Vec::new(),
197 description: None,
198 });
199 }
200 NodeKind::Text { text } => {
201 data.push(ElementSummary {
202 id,
203 kind: "text".into(),
204 label: Some(text.clone()),
205 value: None,
206 action: None,
207 params: Vec::new(),
208 description: None,
209 });
210 }
211 NodeKind::Container { children } => {
212 for child in children {
213 walk(child, interactive, data);
214 }
215 }
216 NodeKind::List { items } => {
217 for item in items {
218 walk(item, interactive, data);
219 }
220 }
221 NodeKind::DataGrid { columns, rows } => {
222 data.push(ElementSummary {
223 id,
224 kind: "data_grid".into(),
225 label: Some(format!("[{}] — {} rows", columns.join(", "), rows.len())),
226 value: None,
227 action: None,
228 params: Vec::new(),
229 description: None,
230 });
231 }
232 NodeKind::Portal { content, .. } => {
233 walk(content, interactive, data);
235 }
236 }
237}
238
239pub fn trigger_event<Msg>(ui: &UITree<Msg>, action: &str, dispatch: &dyn Fn(Msg)) -> bool
249where
250 Msg: Clone,
251{
252 find_and_dispatch(ui, action, dispatch)
253}
254
255fn find_and_dispatch<Msg>(node: &UITree<Msg>, action: &str, dispatch: &dyn Fn(Msg)) -> bool
256where
257 Msg: Clone,
258{
259 if node.meta.ai.action.as_deref() == Some(action) {
260 if let Some(msg) = &node.meta.on_click {
261 dispatch(msg.clone());
262 return true;
263 }
264 }
265 match &node.kind {
266 NodeKind::Container { children } => {
267 for child in children {
268 if find_and_dispatch(child, action, dispatch) {
269 return true;
270 }
271 }
272 }
273 NodeKind::List { items } => {
274 for item in items {
275 if find_and_dispatch(item, action, dispatch) {
276 return true;
277 }
278 }
279 }
280 _ => {}
281 }
282 false
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[derive(Debug, Clone, PartialEq)]
290 enum TestMsg {
291 Submit,
292 }
293
294 fn sample_ui() -> UITree<TestMsg> {
295 UITree::container(|c| {
296 c.heading(1, "Dashboard").class("text-2xl font-bold");
297 c.button("Export")
298 .on_click(TestMsg::Submit)
299 .ai_action("export_data")
300 .ai_param("format", "csv")
301 .ai_description("Export the data as CSV");
302 c.input("hello")
303 .ai_action("search")
304 .ai_param("key", "query");
305 c.list(|l| {
306 l.text("Item A");
307 l.text("Item B");
308 });
309 c.data_grid(["A", "B"], [vec!["1", "2"], vec!["3", "4"]]);
310 })
311 }
312
313 #[test]
314 fn query_state_collects_interactive_and_data() {
315 let ui = sample_ui();
316 let state = query_state(&ui);
317
318 assert_eq!(state.interactive_elements.len(), 2);
320
321 let btn = &state.interactive_elements[0];
322 assert_eq!(btn.kind, "button");
323 assert_eq!(btn.label.as_deref(), Some("Export"));
324 assert_eq!(btn.action.as_deref(), Some("export_data"));
325 assert_eq!(btn.params, vec![("format".into(), "csv".into())]);
326 assert_eq!(btn.description.as_deref(), Some("Export the data as CSV"));
327
328 let input = &state.interactive_elements[1];
329 assert_eq!(input.kind, "input");
330 assert_eq!(input.value.as_deref(), Some("hello"));
331 assert_eq!(input.action.as_deref(), Some("search"));
332
333 assert_eq!(state.data_elements.len(), 4);
335 assert_eq!(state.data_elements[0].kind, "h1");
336 assert_eq!(state.data_elements[0].label.as_deref(), Some("Dashboard"));
337 assert_eq!(state.data_elements[1].kind, "text");
338 assert_eq!(state.data_elements[1].label.as_deref(), Some("Item A"));
339 assert_eq!(state.data_elements[2].kind, "text");
340 assert_eq!(state.data_elements[2].label.as_deref(), Some("Item B"));
341 assert_eq!(state.data_elements[3].kind, "data_grid");
342 assert_eq!(
343 state.data_elements[3].label.as_deref(),
344 Some("[A, B] — 2 rows")
345 );
346
347 assert_eq!(state.current_route, "/");
349 }
350
351 #[test]
352 fn trigger_event_dispatches_matching_action() {
353 let mut ui = sample_ui();
354 ui.assign_ids();
355
356 let dispatched = std::cell::Cell::new(None::<TestMsg>);
357 let dispatch = |msg: TestMsg| {
358 dispatched.set(Some(msg));
359 };
360
361 let result = trigger_event(&ui, "export_data", &dispatch);
362 assert!(result, "should find and dispatch the event");
363 assert_eq!(dispatched.take(), Some(TestMsg::Submit));
364 }
365
366 #[test]
367 fn trigger_event_returns_false_for_unknown_action() {
368 let ui = sample_ui();
369 let result = trigger_event(&ui, "nonexistent", &|_: TestMsg| {});
370 assert!(!result, "unknown action should return false");
371 }
372
373 #[test]
374 fn trigger_event_returns_false_when_no_on_click() {
375 let ui = sample_ui();
377 let result = trigger_event(&ui, "search", &|_: TestMsg| {});
378 assert!(!result, "action without on_click should return false");
379 }
380
381 #[test]
382 fn navigate_updates_route() {
383 let before = current_route();
384 assert_eq!(before, "/", "default route is /");
385
386 navigate_to("/dashboard");
387 assert_eq!(current_route(), "/dashboard");
388
389 navigate_to("/settings");
390 assert_eq!(current_route(), "/settings");
391 }
392
393 #[test]
394 fn route_signal_is_reactive() {
395 use crate::signal::create_effect;
396 use std::rc::Rc;
397
398 let seen = Rc::new(std::cell::RefCell::new(Vec::new()));
399 let route = route_signal();
400
401 let seen_clone = Rc::clone(&seen);
402 let _handle = create_effect(move || {
403 let r = route.get();
404 seen_clone.borrow_mut().push(r);
405 });
406
407 assert_eq!(seen.borrow().len(), 1);
409
410 navigate_to("/foo");
411 assert_eq!(seen.borrow().len(), 2);
413 assert_eq!(seen.borrow()[1], "/foo");
414 }
415
416 #[test]
417 fn query_state_respects_assign_ids() {
418 let mut ui = sample_ui();
419 ui.assign_ids();
420
421 let state = query_state(&ui);
422
423 for el in &state.interactive_elements {
425 assert!(el.id.is_some(), "interactive element should have id");
426 }
427 for el in &state.data_elements {
428 assert!(el.id.is_some(), "data element should have id");
429 }
430
431 assert_eq!(state.interactive_elements[0].id, Some(3));
433 }
434
435 #[test]
436 fn query_state_flat_list_and_container() {
437 let mut ui: UITree<TestMsg> = UITree::container(|c| {
438 c.container(|inner| {
439 inner.button("Nested").ai_action("nested_btn");
440 });
441 c.list(|l| {
442 l.button("List button").ai_action("list_btn");
443 });
444 });
445 ui.assign_ids();
446
447 let state = query_state(&ui);
448
449 assert_eq!(state.interactive_elements.len(), 2);
451 assert_eq!(
452 state.interactive_elements[0].action.as_deref(),
453 Some("nested_btn")
454 );
455 assert_eq!(
456 state.interactive_elements[1].action.as_deref(),
457 Some("list_btn")
458 );
459 }
460}