1use std::collections::HashMap;
9use std::rc::Rc;
10use wasm_bindgen_futures::spawn_local;
11use yew::prelude::*;
12use yewdux::prelude::*;
13
14use crate::api::{Api, ExecutionResponse, SkillDetail};
15use crate::components::run::{InlineParameterEditor, TerminalOutput};
16use crate::components::notifications::use_notifications;
17use crate::store::skills::{SkillsAction, SkillsStore};
18use crate::components::SearchableSelect;
19
20#[derive(Properties, PartialEq)]
22pub struct RunPageProps {
23 #[prop_or_default]
24 pub selected_skill: Option<String>,
25 #[prop_or_default]
26 pub selected_tool: Option<String>,
27}
28
29#[function_component(RunPage)]
31pub fn run_page(props: &RunPageProps) -> Html {
32 let skills_store = use_store_value::<SkillsStore>();
34 let skills_dispatch = use_dispatch::<SkillsStore>();
35
36 let selected_skill = use_state(|| props.selected_skill.clone());
38 let selected_tool = use_state(|| props.selected_tool.clone());
39 let selected_instance = use_state(|| None::<String>);
40 let parameters = use_state(HashMap::<String, serde_json::Value>::new);
41 let validation_errors = use_state(HashMap::<String, String>::new);
42
43 let all_skill_details = use_state(|| Vec::<SkillDetail>::new());
45 let current_skill_detail = use_state(|| None::<SkillDetail>);
46 let skills_loading = use_state(|| true);
47
48 let execution_result = use_state(|| None::<ExecutionResponse>);
50 let is_executing = use_state(|| false);
51 let terminal_visible = use_state(|| false);
52 let terminal_minimized = use_state(|| false);
53
54 let result_ref = use_node_ref();
56
57 let api = use_memo((), |_| Rc::new(Api::new()));
59
60 let notifications = use_notifications();
62
63 {
65 let selected_skill = selected_skill.clone();
66 let selected_tool = selected_tool.clone();
67 use_effect_with((props.selected_skill.clone(), props.selected_tool.clone()), move |(p_skill, p_tool)| {
68 if let Some(s) = p_skill {
69 selected_skill.set(Some(s.clone()));
70 }
71 if let Some(t) = p_tool {
72 selected_tool.set(Some(t.clone()));
73 }
74 || ()
75 });
76 }
77
78 {
80 let api = api.clone();
81 let skills_dispatch = skills_dispatch.clone();
82 let all_skill_details = all_skill_details.clone();
83 let skills_loading = skills_loading.clone();
84
85 use_effect_with((), move |_| {
86 skills_dispatch.apply(SkillsAction::SetLoading(true));
87 skills_loading.set(true);
88
89 let api = api.clone();
90 let skills_dispatch = skills_dispatch.clone();
91 let all_skill_details = all_skill_details.clone();
92 let skills_loading = skills_loading.clone();
93
94 spawn_local(async move {
95 match api.skills.list_all().await {
96 Ok(skills) => {
97 let store_skills: Vec<crate::store::skills::SkillSummary> = skills
98 .iter()
99 .map(|s| crate::store::skills::SkillSummary {
100 name: s.name.clone(),
101 version: s.version.clone(),
102 description: s.description.clone(),
103 source: s.source.clone(),
104 runtime: match s.runtime.as_str() {
105 "docker" => crate::store::skills::SkillRuntime::Docker,
106 "native" => crate::store::skills::SkillRuntime::Native,
107 _ => crate::store::skills::SkillRuntime::Wasm,
108 },
109 tools_count: s.tools_count,
110 instances_count: s.instances_count,
111 status: crate::store::skills::SkillStatus::Configured,
112 last_used: s.last_used.clone(),
113 execution_count: s.execution_count,
114 })
115 .collect();
116
117 skills_dispatch.apply(SkillsAction::SetSkills(store_skills));
118
119 let mut details = Vec::new();
120 for skill in skills.iter() {
121 match api.skills.get(&skill.name).await {
122 Ok(detail) => details.push(detail),
123 Err(e) => {
124 web_sys::console::error_1(&format!("Failed to load skill {}: {}", skill.name, e).into());
125 }
126 }
127 }
128 all_skill_details.set(details);
129 skills_loading.set(false);
130 }
131 Err(e) => {
132 web_sys::console::error_1(&format!("Failed to load skills: {}", e).into());
133 }
134 }
135 skills_dispatch.apply(SkillsAction::SetLoading(false));
136 });
137 || ()
138 });
139 }
140
141 {
143 let execution_result = execution_result.clone();
144 let result_ref = result_ref.clone();
145 use_effect_with(execution_result, move |result| {
146 if result.is_some() {
147 if let Some(element) = result_ref.cast::<web_sys::Element>() {
148 element.scroll_into_view_with_scroll_into_view_options(
149 web_sys::ScrollIntoViewOptions::new().behavior(web_sys::ScrollBehavior::Smooth)
150 );
151 }
152 }
153 || ()
154 });
155 }
156
157 {
159 let selected_skill = selected_skill.clone();
160 let all_skill_details = all_skill_details.clone();
161 let current_skill_detail = current_skill_detail.clone();
162 let selected_tool = selected_tool.clone();
163 let parameters = parameters.clone();
164
165 use_effect_with((*selected_skill).clone(), move |skill_name| {
166 if let Some(name) = skill_name {
167 let detail = (*all_skill_details).iter()
168 .find(|d| d.summary.name == *name)
169 .cloned();
170 current_skill_detail.set(detail);
171 selected_tool.set(None);
173 parameters.set(HashMap::new());
174 } else {
175 current_skill_detail.set(None);
176 selected_tool.set(None);
177 }
178 || ()
179 });
180 }
181
182 let on_parameter_change = {
184 let parameters = parameters.clone();
185 Callback::from(move |(name, value): (String, serde_json::Value)| {
186 let mut params = (*parameters).clone();
187 params.insert(name, value);
188 parameters.set(params);
189 })
190 };
191
192 let on_execute = {
194 let api = api.clone();
195 let selected_skill = selected_skill.clone();
196 let selected_tool = selected_tool.clone();
197 let selected_instance = selected_instance.clone();
198 let parameters = parameters.clone();
199 let is_executing = is_executing.clone();
200 let execution_result = execution_result.clone();
201 let notifications = notifications.clone();
202
203 Callback::from(move |e: MouseEvent| {
204 e.prevent_default();
205 let skill = (*selected_skill).clone();
206 let tool = (*selected_tool).clone();
207
208 if let (Some(skill_name), Some(tool_name)) = (skill, tool) {
209 is_executing.set(true);
210
211 let api = api.clone();
212 let parameters = (*parameters).clone();
213 let instance = (*selected_instance).clone();
214 let is_executing = is_executing.clone();
215 let execution_result = execution_result.clone();
216 let notifications = notifications.clone();
217
218 spawn_local(async move {
219 let request = crate::api::ExecutionRequest {
220 skill: skill_name.clone(),
221 tool: tool_name.clone(),
222 instance,
223 args: parameters,
224 stream: false,
225 timeout_secs: None,
226 };
227
228 match api.executions.execute(&request).await {
229 Ok(result) => {
230 let duration = result.duration_ms;
231 execution_result.set(Some(result));
232
233 notifications.success(
235 "Execution completed",
236 format!("{}/{} executed successfully in {}ms", skill_name, tool_name, duration)
237 );
238 }
239 Err(e) => {
240 let error_msg = format!("Failed to execute {}/{}: {}", skill_name, tool_name, e);
241 web_sys::console::error_1(&error_msg.clone().into());
242 notifications.error("Execution failed", error_msg);
243 }
244 }
245 is_executing.set(false);
246 });
247 }
248 })
249 };
250
251 let on_terminal_close = {
253 let terminal_visible = terminal_visible.clone();
254 Callback::from(move |_| {
255 terminal_visible.set(false);
256 })
257 };
258
259 let on_terminal_toggle_minimize = {
261 let terminal_minimized = terminal_minimized.clone();
262 Callback::from(move |_| {
263 terminal_minimized.set(!*terminal_minimized);
264 })
265 };
266
267 let on_rerun = {
269 let api = api.clone();
270 let selected_skill = selected_skill.clone();
271 let selected_tool = selected_tool.clone();
272 let selected_instance = selected_instance.clone();
273 let parameters = parameters.clone();
274 let is_executing = is_executing.clone();
275 let execution_result = execution_result.clone();
276 let notifications = notifications.clone();
277
278 Some(Callback::from(move |_: ()| {
279 let skill = (*selected_skill).clone();
280 let tool = (*selected_tool).clone();
281
282 if let (Some(skill_name), Some(tool_name)) = (skill, tool) {
283 is_executing.set(true);
284
285 let api = api.clone();
286 let parameters = (*parameters).clone();
287 let instance = (*selected_instance).clone();
288 let is_executing = is_executing.clone();
289 let execution_result = execution_result.clone();
290 let notifications = notifications.clone();
291
292 spawn_local(async move {
293 let request = crate::api::ExecutionRequest {
294 skill: skill_name.clone(),
295 tool: tool_name.clone(),
296 instance,
297 args: parameters,
298 stream: false,
299 timeout_secs: None,
300 };
301
302 match api.executions.execute(&request).await {
303 Ok(result) => {
304 let duration = result.duration_ms;
305 execution_result.set(Some(result));
306
307 notifications.success(
308 "Execution completed",
309 format!("{}/{} executed successfully in {}ms", skill_name, tool_name, duration)
310 );
311 }
312 Err(e) => {
313 let error_msg = format!("Failed to execute {}/{}: {}", skill_name, tool_name, e);
314 web_sys::console::error_1(&error_msg.clone().into());
315 notifications.error("Execution failed", error_msg);
316 }
317 }
318 is_executing.set(false);
319 });
320 }
321 }))
322 };
323
324 let current_tool_params = current_skill_detail.as_ref()
326 .and_then(|detail| {
327 detail.tools.iter()
328 .find(|t| Some(&t.name) == selected_tool.as_ref())
329 })
330 .map(|tool| tool.parameters.clone())
331 .unwrap_or_default();
332
333 let available_tools = current_skill_detail.as_ref()
335 .map(|detail| detail.tools.clone())
336 .unwrap_or_default();
337
338 let can_execute = selected_skill.is_some()
340 && selected_tool.is_some()
341 && !*is_executing;
342
343 let on_skill_change = {
345 let selected_skill = selected_skill.clone();
346 Callback::from(move |value: String| {
347 if value.is_empty() {
348 selected_skill.set(None);
349 } else {
350 selected_skill.set(Some(value));
351 }
352 })
353 };
354
355 let on_tool_change = {
356 let selected_tool = selected_tool.clone();
357 let parameters = parameters.clone();
358 Callback::from(move |value: String| {
359 if value.is_empty() {
360 selected_tool.set(None);
361 } else {
362 selected_tool.set(Some(value));
363 parameters.set(HashMap::new()); }
365 })
366 };
367
368 html! {
369 <div class="h-full flex flex-col bg-gray-50 dark:bg-gray-900 overflow-hidden">
370 <div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 shrink-0">
372 <div class="max-w-5xl mx-auto flex items-center justify-between">
373 <div>
374 <h1 class="text-xl font-bold text-gray-900 dark:text-white">
375 { "Run Skill" }
376 </h1>
377 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
378 { "Execute skills and tools with a simple dynamic form" }
379 </p>
380 </div>
381 </div>
382 </div>
383
384 <div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
386 <div class="max-w-5xl mx-auto space-y-6">
387
388 <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
390 <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
391 <div class="space-y-2">
393 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
394 { "Select Skill" }
395 </label>
396 <div class="relative">
397 <SearchableSelect
398 options={skills_store.skills.iter().map(|s| s.name.clone()).collect::<Vec<_>>()}
399 selected={selected_skill.as_deref().map(|s| s.to_string())}
400 on_select={on_skill_change}
401 placeholder="Choose a skill..."
402 loading={*skills_loading}
403 />
404 </div>
405 if let Some(_skill_name) = selected_skill.as_ref() {
406 if let Some(detail) = current_skill_detail.as_ref() {
407 <p class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
408 { &detail.summary.description }
409 </p>
410 }
411 }
412 </div>
413
414 <div class="space-y-2">
416 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
417 { "Select Tool" }
418 </label>
419 <SearchableSelect
420 options={available_tools.iter().map(|t| t.name.clone()).collect::<Vec<_>>()}
421 selected={selected_tool.as_deref().map(|s| s.to_string())}
422 on_select={on_tool_change}
423 placeholder="Choose a tool..."
424 disabled={selected_skill.is_none()}
425 />
426 if let Some(tool_name) = selected_tool.as_ref() {
427 if let Some(tool_detail) = available_tools.iter().find(|t| &t.name == tool_name) {
428 <p class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
429 { &tool_detail.description }
430 </p>
431 }
432 }
433 </div>
434 </div>
435 </div>
436
437 <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
439 <div class="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50 flex items-center gap-2">
440 <svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
441 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
442 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
443 </svg>
444 <h3 class="font-medium text-gray-900 dark:text-white">
445 { "Configuration" }
446 </h3>
447 </div>
448
449 <div class="p-6">
450 if selected_skill.is_some() && selected_tool.is_some() {
451 if !current_tool_params.is_empty() {
452 <InlineParameterEditor
453 parameters={current_tool_params}
454 values={(*parameters).clone()}
455 on_change={on_parameter_change}
456 errors={(*validation_errors).clone()}
457 />
458 } else {
459 <div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
460 { "No parameters required for this tool." }
461 </div>
462 }
463
464 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
465 <button
466 class={classes!(
467 "btn",
468 "btn-primary",
469 "px-6",
470 "py-2.5",
471 "rounded-lg",
472 "shadow-sm",
473 "flex",
474 "items-center",
475 "gap-2",
476 (!can_execute).then(|| "opacity-50 cursor-not-allowed")
477 )}
478 onclick={on_execute}
479 disabled={!can_execute}
480 >
481 if *is_executing {
482 <svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
483 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
484 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
485 </svg>
486 { "Executing..." }
487 } else {
488 <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
489 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
490 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
491 </svg>
492 { "Run Command" }
493 }
494 </button>
495 </div>
496 } else {
497 <div class="text-center py-12 text-gray-400 dark:text-gray-500">
498 <svg class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
499 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
500 </svg>
501 <p class="text-sm">
502 { "Select a skill and tool to configure parameters" }
503 </p>
504 </div>
505 }
506 </div>
507 </div>
508
509 <div ref={result_ref} class="card bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
511 <div class="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between">
512 <div class="flex items-center gap-2">
513 <svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
514 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
515 </svg>
516 <h3 class="font-medium text-gray-900 dark:text-white">
517 { "Execution Result" }
518 </h3>
519 </div>
520 if let Some(result) = &*execution_result {
521 <div class="text-xs font-mono text-gray-400 dark:text-gray-500">
522 { format!("ID: {}", &result.id) }
523 </div>
524 }
525 </div>
526
527 <div class="p-6">
528 if let Some(result) = &*execution_result {
529 <div class="border-l-4 border-l-primary-500 pl-4 py-2">
530 <div class="flex items-center justify-between mb-4">
532 <div class="flex items-center gap-3">
533 if result.status == crate::api::types::ExecutionStatus::Success {
534 <div class="p-1.5 rounded-full bg-success-100 dark:bg-success-900/30 text-success-600 dark:text-success-400">
535 <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
536 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
537 </svg>
538 </div>
539 <div>
540 <h4 class="font-semibold text-gray-900 dark:text-white">
541 { "Execution Successful" }
542 </h4>
543 <p class="text-xs text-gray-500 dark:text-gray-400">
544 { format!("Completed in {}ms", result.duration_ms) }
545 </p>
546 </div>
547 } else {
548 <div class="p-1.5 rounded-full bg-error-100 dark:bg-error-900/30 text-error-600 dark:text-error-400">
549 <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
550 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
551 </svg>
552 </div>
553 <div>
554 <h4 class="font-semibold text-gray-900 dark:text-white">
555 { "Execution Failed" }
556 </h4>
557 <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
558 <span class="font-mono text-error-600 dark:text-error-400 bg-error-50 dark:bg-error-900/20 px-1.5 py-0.5 rounded">
559 { format!("{:?}", result.status) }
560 </span>
561 <span>{ format!("({}ms)", result.duration_ms) }</span>
562 </div>
563 </div>
564 }
565 </div>
566 <div class="flex gap-2">
567 <button
568 class="btn btn-secondary text-sm"
569 onclick={{
570 let terminal_visible = terminal_visible.clone();
571 Callback::from(move |_| {
572 terminal_visible.set(true);
573 })
574 }}
575 >
576 { "Show Terminal" }
577 </button>
578 </div>
579 </div>
580
581 <div class="relative group mt-6">
583 <div class="absolute -top-3 left-2 bg-white dark:bg-gray-800 px-2 text-xs font-semibold text-gray-500 dark:text-gray-400 tracking-wider uppercase">
584 { "Output" }
585 </div>
586 <pre class="text-sm bg-gray-900 text-gray-50 p-5 rounded-lg overflow-x-auto max-h-96 font-mono shadow-inner border border-gray-800 leading-relaxed">
587 { &result.output }
588 </pre>
589 <button
590 class="absolute top-3 right-3 p-1.5 rounded bg-gray-800/80 text-gray-400 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity backdrop-blur-sm"
591 title="Copy output"
592 onclick={{
593 let output = result.output.clone();
594 Callback::from(move |_| {
595 if let Some(window) = web_sys::window() {
596 let clipboard = window.navigator().clipboard();
597 let _ = clipboard.write_text(&output);
598 }
599 })
600 }}
601 >
602 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
603 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
604 </svg>
605 </button>
606 </div>
607
608 if let Some(error) = &result.error {
610 <div class="mt-6">
611 <div class="bg-error-50 dark:bg-error-900/10 border border-error-100 dark:border-error-900/30 rounded-lg overflow-hidden">
612 <div class="bg-error-100/50 dark:bg-error-900/20 px-4 py-2 border-b border-error-100 dark:border-error-900/30 flex items-center gap-2">
613 <svg class="w-4 h-4 text-error-600 dark:text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
614 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
615 </svg>
616 <span class="text-xs font-bold text-error-700 dark:text-error-400 uppercase tracking-wide">
617 { "Error Details" }
618 </span>
619 </div>
620 <div class="p-4">
621 <pre class="text-error-600 dark:text-error-300 whitespace-pre-wrap font-mono text-sm leading-relaxed settings-scroll">
622 { error }
623 </pre>
624 </div>
625 </div>
626 </div>
627 }
628 </div>
629 } else {
630 <div class="text-center py-12 text-gray-400 dark:text-gray-500">
631 <p class="text-sm">
632 { "Execution results will appear here" }
633 </p>
634 </div>
635 }
636 </div>
637 </div>
638
639 <div class="h-8"></div>
641 </div>
642 </div>
643
644 <TerminalOutput
646 visible={*terminal_visible}
647 execution={(*execution_result).clone()}
648 on_close={on_terminal_close}
649 on_rerun={on_rerun}
650 minimized={*terminal_minimized}
651 on_toggle_minimize={on_terminal_toggle_minimize}
652 />
653 </div>
654 }
655}