skill_web/components/
instance_editor.rs

1//! Instance configuration editor components
2//!
3//! Provides a visual editor for creating and managing skill instance configurations
4//! with environment variable preview and capabilities management.
5
6use std::collections::HashMap;
7use wasm_bindgen::JsCast;
8use web_sys::HtmlInputElement;
9use yew::prelude::*;
10
11// ============================================================================
12// Types
13// ============================================================================
14
15/// Instance data for editing
16#[derive(Clone, Default, PartialEq)]
17pub struct InstanceData {
18    pub name: String,
19    pub description: String,
20    pub config: HashMap<String, String>,
21    pub is_default: bool,
22    pub capabilities: Capabilities,
23}
24
25/// Capability settings for an instance
26#[derive(Clone, Default, PartialEq)]
27pub struct Capabilities {
28    pub network_access: bool,
29    pub filesystem_access: bool,
30    pub env_access: bool,
31    pub network_allowlist: Vec<String>,
32    pub filesystem_paths: Vec<String>,
33    pub env_vars: Vec<String>,
34}
35
36// ============================================================================
37// InstanceEditor - Main Component
38// ============================================================================
39
40#[derive(Properties, PartialEq)]
41pub struct InstanceEditorProps {
42    /// Skill name this instance belongs to
43    pub skill: String,
44    /// Existing instance data (None for creating new)
45    #[prop_or_default]
46    pub instance: Option<InstanceData>,
47    /// Callback when save is clicked
48    pub on_save: Callback<InstanceData>,
49    /// Callback when cancel is clicked
50    pub on_cancel: Callback<()>,
51}
52
53/// Instance configuration editor component
54#[function_component(InstanceEditor)]
55pub fn instance_editor(props: &InstanceEditorProps) -> Html {
56    // Initialize state from existing instance or defaults
57    let name = use_state(|| {
58        props
59            .instance
60            .as_ref()
61            .map(|i| i.name.clone())
62            .unwrap_or_default()
63    });
64    let description = use_state(|| {
65        props
66            .instance
67            .as_ref()
68            .map(|i| i.description.clone())
69            .unwrap_or_default()
70    });
71    let config = use_state(|| {
72        props
73            .instance
74            .as_ref()
75            .map(|i| i.config.clone())
76            .unwrap_or_default()
77    });
78    let is_default = use_state(|| {
79        props
80            .instance
81            .as_ref()
82            .map(|i| i.is_default)
83            .unwrap_or(false)
84    });
85    let capabilities = use_state(|| {
86        props
87            .instance
88            .as_ref()
89            .map(|i| i.capabilities.clone())
90            .unwrap_or_default()
91    });
92
93    // Validation state
94    let validation_errors = use_state(HashMap::<String, String>::new);
95    let is_testing = use_state(|| false);
96    let test_result = use_state(|| None::<Result<String, String>>);
97
98    // Callbacks
99    let on_name_change = {
100        let name = name.clone();
101        let validation_errors = validation_errors.clone();
102        Callback::from(move |e: InputEvent| {
103            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
104            let value = input.value();
105            name.set(value.clone());
106
107            // Validate name
108            let mut errors = (*validation_errors).clone();
109            if value.is_empty() {
110                errors.insert("name".to_string(), "Instance name is required".to_string());
111            } else if !value.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
112                errors.insert(
113                    "name".to_string(),
114                    "Name can only contain letters, numbers, hyphens, and underscores".to_string(),
115                );
116            } else {
117                errors.remove("name");
118            }
119            validation_errors.set(errors);
120        })
121    };
122
123    let on_description_change = {
124        let description = description.clone();
125        Callback::from(move |e: InputEvent| {
126            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
127            description.set(input.value());
128        })
129    };
130
131    let on_config_change = {
132        let config = config.clone();
133        Callback::from(move |new_config: HashMap<String, String>| {
134            config.set(new_config);
135        })
136    };
137
138    let on_capabilities_change = {
139        let capabilities = capabilities.clone();
140        Callback::from(move |new_caps: Capabilities| {
141            capabilities.set(new_caps);
142        })
143    };
144
145    let on_default_change = {
146        let is_default = is_default.clone();
147        Callback::from(move |e: Event| {
148            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
149            is_default.set(input.checked());
150        })
151    };
152
153    let on_test = {
154        let is_testing = is_testing.clone();
155        let test_result = test_result.clone();
156        let config = config.clone();
157        Callback::from(move |_| {
158            is_testing.set(true);
159            test_result.set(None);
160
161            // Simulate test - in real implementation would call API
162            let config = (*config).clone();
163            let is_testing = is_testing.clone();
164            let test_result = test_result.clone();
165
166            // Check for unresolved env vars
167            let unresolved_count = config
168                .values()
169                .filter(|v| v.contains("${") && v.contains("}"))
170                .count();
171
172            // Simulate async test completion
173            gloo_timers::callback::Timeout::new(500, move || {
174                is_testing.set(false);
175                if unresolved_count == 0 {
176                    test_result.set(Some(Ok("Configuration is valid".to_string())));
177                } else {
178                    test_result.set(Some(Err(format!(
179                        "Unresolved environment variables in {} config value(s)",
180                        unresolved_count
181                    ))));
182                }
183            })
184            .forget();
185        })
186    };
187
188    let on_save = {
189        let on_save = props.on_save.clone();
190        let name = name.clone();
191        let description = description.clone();
192        let config = config.clone();
193        let is_default = is_default.clone();
194        let capabilities = capabilities.clone();
195        let validation_errors = validation_errors.clone();
196        Callback::from(move |_| {
197            // Validate before saving
198            let mut errors = HashMap::new();
199            if (*name).is_empty() {
200                errors.insert("name".to_string(), "Instance name is required".to_string());
201            }
202
203            if !errors.is_empty() {
204                validation_errors.set(errors);
205                return;
206            }
207
208            let data = InstanceData {
209                name: (*name).clone(),
210                description: (*description).clone(),
211                config: (*config).clone(),
212                is_default: *is_default,
213                capabilities: (*capabilities).clone(),
214            };
215            on_save.emit(data);
216        })
217    };
218
219    let on_cancel = {
220        let on_cancel = props.on_cancel.clone();
221        Callback::from(move |_| on_cancel.emit(()))
222    };
223
224    let is_editing = props.instance.is_some();
225    let title = if is_editing {
226        "Edit Instance"
227    } else {
228        "Create Instance"
229    };
230
231    html! {
232        <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
233            // Header
234            <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
235                <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
236                    { title }
237                </h2>
238                <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
239                    { format!("Configure instance for {}", props.skill) }
240                </p>
241            </div>
242
243            // Body - scrollable
244            <div class="flex-1 overflow-y-auto p-6 space-y-6">
245                // Instance Name
246                <div>
247                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
248                        { "Instance Name" }
249                        <span class="text-red-500 ml-1">{ "*" }</span>
250                    </label>
251                    <input
252                        type="text"
253                        value={(*name).clone()}
254                        oninput={on_name_change}
255                        placeholder="e.g., production, staging, dev"
256                        disabled={is_editing}
257                        class={classes!(
258                            "w-full", "px-3", "py-2", "rounded-md", "border",
259                            "bg-white", "dark:bg-gray-900",
260                            "text-gray-900", "dark:text-white",
261                            "focus:ring-2", "focus:ring-primary-500", "focus:border-primary-500",
262                            if validation_errors.contains_key("name") {
263                                "border-red-500"
264                            } else {
265                                "border-gray-300 dark:border-gray-600"
266                            },
267                            if is_editing { "bg-gray-100 dark:bg-gray-800 cursor-not-allowed" } else { "" }
268                        )}
269                    />
270                    if let Some(error) = validation_errors.get("name") {
271                        <p class="mt-1 text-sm text-red-500">{ error }</p>
272                    }
273                </div>
274
275                // Description
276                <div>
277                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
278                        { "Description" }
279                    </label>
280                    <input
281                        type="text"
282                        value={(*description).clone()}
283                        oninput={on_description_change}
284                        placeholder="Optional description for this instance"
285                        class="w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
286                    />
287                </div>
288
289                // Default checkbox
290                <div class="flex items-center gap-2">
291                    <input
292                        type="checkbox"
293                        checked={*is_default}
294                        onchange={on_default_change}
295                        class="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
296                    />
297                    <label class="text-sm text-gray-700 dark:text-gray-300">
298                        { "Set as default instance" }
299                    </label>
300                </div>
301
302                // Configuration key-value pairs
303                <ConfigKeyValueEditor
304                    pairs={(*config).clone()}
305                    on_change={on_config_change}
306                />
307
308                // Environment variable preview
309                <EnvironmentVariablePreview pairs={(*config).clone()} />
310
311                // Capabilities editor
312                <CapabilitiesEditor
313                    capabilities={(*capabilities).clone()}
314                    on_change={on_capabilities_change}
315                />
316
317                // Test result
318                if let Some(result) = &*test_result {
319                    <div class={classes!(
320                        "p-3", "rounded-md", "text-sm",
321                        match result {
322                            Ok(_) => "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800",
323                            Err(_) => "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800",
324                        }
325                    )}>
326                        { match result {
327                            Ok(msg) => html! { <><span class="font-medium">{ "✓ " }</span>{ msg }</> },
328                            Err(msg) => html! { <><span class="font-medium">{ "✗ " }</span>{ msg }</> },
329                        }}
330                    </div>
331                }
332            </div>
333
334            // Footer
335            <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center justify-between">
336                <button
337                    onclick={on_cancel}
338                    class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
339                >
340                    { "Cancel" }
341                </button>
342                <div class="flex gap-2">
343                    <button
344                        onclick={on_test}
345                        disabled={*is_testing}
346                        class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors disabled:opacity-50"
347                    >
348                        if *is_testing {
349                            { "Testing..." }
350                        } else {
351                            { "Test Configuration" }
352                        }
353                    </button>
354                    <button
355                        onclick={on_save}
356                        class="px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors"
357                    >
358                        { if is_editing { "Save Changes" } else { "Create Instance" } }
359                    </button>
360                </div>
361            </div>
362        </div>
363    }
364}
365
366// ============================================================================
367// ConfigKeyValueEditor - Key-value pairs editor
368// ============================================================================
369
370#[derive(Properties, PartialEq)]
371pub struct ConfigKeyValueEditorProps {
372    pub pairs: HashMap<String, String>,
373    pub on_change: Callback<HashMap<String, String>>,
374}
375
376#[function_component(ConfigKeyValueEditor)]
377pub fn config_key_value_editor(props: &ConfigKeyValueEditorProps) -> Html {
378    // Convert to vec for easier manipulation
379    let pairs_vec: Vec<(String, String)> = props.pairs.clone().into_iter().collect();
380
381    let add_pair = {
382        let on_change = props.on_change.clone();
383        let pairs = props.pairs.clone();
384        Callback::from(move |_| {
385            let mut new_pairs = pairs.clone();
386            // Find a unique key
387            let mut key_num = 1;
388            let mut new_key = format!("KEY_{}", key_num);
389            while new_pairs.contains_key(&new_key) {
390                key_num += 1;
391                new_key = format!("KEY_{}", key_num);
392            }
393            new_pairs.insert(new_key, String::new());
394            on_change.emit(new_pairs);
395        })
396    };
397
398    html! {
399        <div class="space-y-3">
400            <div class="flex items-center justify-between">
401                <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
402                    { "Configuration" }
403                </label>
404                <button
405                    onclick={add_pair}
406                    class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium"
407                >
408                    { "+ Add Key" }
409                </button>
410            </div>
411
412            if pairs_vec.is_empty() {
413                <div class="text-sm text-gray-500 dark:text-gray-400 italic py-4 text-center border border-dashed border-gray-300 dark:border-gray-600 rounded-md">
414                    { "No configuration values. Click \"+ Add Key\" to add one." }
415                </div>
416            } else {
417                <div class="space-y-2">
418                    // Header row
419                    <div class="grid grid-cols-12 gap-2 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
420                        <div class="col-span-4">{ "Key" }</div>
421                        <div class="col-span-7">{ "Value" }</div>
422                        <div class="col-span-1"></div>
423                    </div>
424
425                    { for pairs_vec.iter().map(|(key, value)| {
426                        let key = key.clone();
427                        let value = value.clone();
428                        let on_change = props.on_change.clone();
429                        let pairs = props.pairs.clone();
430
431                        // Clone key for each callback that needs it
432                        let key_for_row = key.clone();
433                        let key_for_key_change = key.clone();
434                        let key_for_value_change = key.clone();
435                        let key_for_delete = key.clone();
436
437                        html! {
438                            <ConfigKeyValueRow
439                                key_name={key_for_row}
440                                value={value}
441                                on_key_change={Callback::from({
442                                    let on_change = on_change.clone();
443                                    let pairs = pairs.clone();
444                                    let old_key = key_for_key_change;
445                                    move |new_key: String| {
446                                        let mut new_pairs = pairs.clone();
447                                        if let Some(val) = new_pairs.remove(&old_key) {
448                                            new_pairs.insert(new_key, val);
449                                        }
450                                        on_change.emit(new_pairs);
451                                    }
452                                })}
453                                on_value_change={Callback::from({
454                                    let on_change = on_change.clone();
455                                    let pairs = pairs.clone();
456                                    let key = key_for_value_change;
457                                    move |new_value: String| {
458                                        let mut new_pairs = pairs.clone();
459                                        new_pairs.insert(key.clone(), new_value);
460                                        on_change.emit(new_pairs);
461                                    }
462                                })}
463                                on_delete={Callback::from({
464                                    let on_change = on_change.clone();
465                                    let pairs = pairs.clone();
466                                    let key = key_for_delete;
467                                    move |_| {
468                                        let mut new_pairs = pairs.clone();
469                                        new_pairs.remove(&key);
470                                        on_change.emit(new_pairs);
471                                    }
472                                })}
473                            />
474                        }
475                    }) }
476                </div>
477            }
478
479            <p class="text-xs text-gray-500 dark:text-gray-400">
480                { "Use " }
481                <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded">{ "${VAR_NAME}" }</code>
482                { " to reference environment variables." }
483            </p>
484        </div>
485    }
486}
487
488// ============================================================================
489// ConfigKeyValueRow - Single key-value row
490// ============================================================================
491
492#[derive(Properties, PartialEq)]
493struct ConfigKeyValueRowProps {
494    key_name: String,
495    value: String,
496    on_key_change: Callback<String>,
497    on_value_change: Callback<String>,
498    on_delete: Callback<()>,
499}
500
501#[function_component(ConfigKeyValueRow)]
502fn config_key_value_row(props: &ConfigKeyValueRowProps) -> Html {
503    let on_key_input = {
504        let on_key_change = props.on_key_change.clone();
505        Callback::from(move |e: InputEvent| {
506            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
507            on_key_change.emit(input.value());
508        })
509    };
510
511    let on_value_input = {
512        let on_value_change = props.on_value_change.clone();
513        Callback::from(move |e: InputEvent| {
514            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
515            on_value_change.emit(input.value());
516        })
517    };
518
519    let on_delete = {
520        let on_delete = props.on_delete.clone();
521        Callback::from(move |_| on_delete.emit(()))
522    };
523
524    // Check if value contains env var reference
525    let has_env_ref = props.value.contains("${") && props.value.contains("}");
526
527    html! {
528        <div class="grid grid-cols-12 gap-2 items-center">
529            <div class="col-span-4">
530                <input
531                    type="text"
532                    value={props.key_name.clone()}
533                    oninput={on_key_input}
534                    placeholder="KEY"
535                    class="w-full px-2 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-white font-mono focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
536                />
537            </div>
538            <div class="col-span-7">
539                <div class="relative">
540                    <input
541                        type="text"
542                        value={props.value.clone()}
543                        oninput={on_value_input}
544                        placeholder="value or ${ENV_VAR}"
545                        class={classes!(
546                            "w-full", "px-2", "py-1.5", "text-sm", "rounded", "border",
547                            "bg-white", "dark:bg-gray-900", "text-gray-900", "dark:text-white",
548                            "focus:ring-1", "focus:ring-primary-500", "focus:border-primary-500",
549                            if has_env_ref {
550                                "border-amber-400 dark:border-amber-500 pr-8"
551                            } else {
552                                "border-gray-300 dark:border-gray-600"
553                            }
554                        )}
555                    />
556                    if has_env_ref {
557                        <span class="absolute right-2 top-1/2 -translate-y-1/2 text-amber-500" title="Contains environment variable reference">
558                            { "$" }
559                        </span>
560                    }
561                </div>
562            </div>
563            <div class="col-span-1 flex justify-center">
564                <button
565                    onclick={on_delete}
566                    class="p-1 text-gray-400 hover:text-red-500 transition-colors"
567                    title="Delete"
568                >
569                    <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
570                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
571                    </svg>
572                </button>
573            </div>
574        </div>
575    }
576}
577
578// ============================================================================
579// EnvironmentVariablePreview - Shows resolved env vars
580// ============================================================================
581
582#[derive(Properties, PartialEq)]
583pub struct EnvironmentVariablePreviewProps {
584    pub pairs: HashMap<String, String>,
585}
586
587#[function_component(EnvironmentVariablePreview)]
588pub fn environment_variable_preview(props: &EnvironmentVariablePreviewProps) -> Html {
589    // Extract environment variable references
590    let env_refs: Vec<(String, String, Option<String>)> = props
591        .pairs
592        .iter()
593        .filter_map(|(key, value)| {
594            // Find ${VAR} patterns
595            let mut refs = Vec::new();
596            let mut remaining = value.as_str();
597            while let Some(start) = remaining.find("${") {
598                if let Some(end) = remaining[start..].find('}') {
599                    let var_name = &remaining[start + 2..start + end];
600                    refs.push(var_name.to_string());
601                    remaining = &remaining[start + end + 1..];
602                } else {
603                    break;
604                }
605            }
606            if refs.is_empty() {
607                None
608            } else {
609                Some((key.clone(), refs.join(", "), None)) // None = not resolved in browser
610            }
611        })
612        .collect();
613
614    if env_refs.is_empty() {
615        return html! {};
616    }
617
618    html! {
619        <div class="border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 rounded-md p-4">
620            <div class="flex items-start gap-2">
621                <svg class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
622                    <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" />
623                </svg>
624                <div class="flex-1">
625                    <h4 class="text-sm font-medium text-amber-800 dark:text-amber-200">
626                        { "Environment Variable References" }
627                    </h4>
628                    <p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
629                        { "These will be resolved at runtime on the server." }
630                    </p>
631                    <div class="mt-3 space-y-1">
632                        { for env_refs.iter().map(|(key, vars, _resolved)| {
633                            html! {
634                                <div class="flex items-center gap-2 text-sm">
635                                    <code class="text-amber-700 dark:text-amber-300 font-mono">{ key }</code>
636                                    <span class="text-amber-600 dark:text-amber-400">{ "→" }</span>
637                                    <code class="text-amber-800 dark:text-amber-200 font-mono">{ format!("${{{}}}", vars) }</code>
638                                </div>
639                            }
640                        }) }
641                    </div>
642                </div>
643            </div>
644        </div>
645    }
646}
647
648// ============================================================================
649// CapabilitiesEditor - Sandbox capabilities editor
650// ============================================================================
651
652#[derive(Properties, PartialEq)]
653pub struct CapabilitiesEditorProps {
654    pub capabilities: Capabilities,
655    pub on_change: Callback<Capabilities>,
656}
657
658#[function_component(CapabilitiesEditor)]
659pub fn capabilities_editor(props: &CapabilitiesEditorProps) -> Html {
660    let on_network_change = {
661        let on_change = props.on_change.clone();
662        let caps = props.capabilities.clone();
663        Callback::from(move |e: Event| {
664            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
665            let mut new_caps = caps.clone();
666            new_caps.network_access = input.checked();
667            on_change.emit(new_caps);
668        })
669    };
670
671    let on_filesystem_change = {
672        let on_change = props.on_change.clone();
673        let caps = props.capabilities.clone();
674        Callback::from(move |e: Event| {
675            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
676            let mut new_caps = caps.clone();
677            new_caps.filesystem_access = input.checked();
678            on_change.emit(new_caps);
679        })
680    };
681
682    let on_env_change = {
683        let on_change = props.on_change.clone();
684        let caps = props.capabilities.clone();
685        Callback::from(move |e: Event| {
686            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
687            let mut new_caps = caps.clone();
688            new_caps.env_access = input.checked();
689            on_change.emit(new_caps);
690        })
691    };
692
693    let on_network_allowlist_change = {
694        let on_change = props.on_change.clone();
695        let caps = props.capabilities.clone();
696        Callback::from(move |e: InputEvent| {
697            let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
698            let mut new_caps = caps.clone();
699            new_caps.network_allowlist = input
700                .value()
701                .split(',')
702                .map(|s| s.trim().to_string())
703                .filter(|s| !s.is_empty())
704                .collect();
705            on_change.emit(new_caps);
706        })
707    };
708
709    html! {
710        <div class="space-y-4">
711            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
712                { "Capabilities" }
713            </label>
714
715            <div class="space-y-3 pl-1">
716                // Network Access
717                <div class="space-y-2">
718                    <label class="flex items-center gap-2 cursor-pointer">
719                        <input
720                            type="checkbox"
721                            checked={props.capabilities.network_access}
722                            onchange={on_network_change}
723                            class="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
724                        />
725                        <span class="text-sm text-gray-700 dark:text-gray-300">
726                            { "Network Access" }
727                        </span>
728                    </label>
729
730                    if props.capabilities.network_access {
731                        <div class="ml-6">
732                            <label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
733                                { "Allowed hosts (comma-separated)" }
734                            </label>
735                            <input
736                                type="text"
737                                value={props.capabilities.network_allowlist.join(", ")}
738                                oninput={on_network_allowlist_change}
739                                placeholder="api.example.com, *.internal.net"
740                                class="w-full px-2 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
741                            />
742                        </div>
743                    }
744                </div>
745
746                // Filesystem Access
747                <label class="flex items-center gap-2 cursor-pointer">
748                    <input
749                        type="checkbox"
750                        checked={props.capabilities.filesystem_access}
751                        onchange={on_filesystem_change}
752                        class="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
753                    />
754                    <span class="text-sm text-gray-700 dark:text-gray-300">
755                        { "Filesystem Access" }
756                    </span>
757                </label>
758
759                // Environment Variables
760                <label class="flex items-center gap-2 cursor-pointer">
761                    <input
762                        type="checkbox"
763                        checked={props.capabilities.env_access}
764                        onchange={on_env_change}
765                        class="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
766                    />
767                    <span class="text-sm text-gray-700 dark:text-gray-300">
768                        { "Environment Variables Access" }
769                    </span>
770                </label>
771            </div>
772
773            <p class="text-xs text-gray-500 dark:text-gray-400">
774                { "Capabilities control what resources the skill can access at runtime." }
775            </p>
776        </div>
777    }
778}
779
780// ============================================================================
781// Modal Wrapper - For showing the editor in a modal
782// ============================================================================
783
784#[derive(Properties, PartialEq)]
785pub struct InstanceEditorModalProps {
786    /// Whether the modal is open
787    pub open: bool,
788    /// Skill name
789    pub skill: String,
790    /// Existing instance to edit (None for new)
791    #[prop_or_default]
792    pub instance: Option<InstanceData>,
793    /// Callback when saved
794    pub on_save: Callback<InstanceData>,
795    /// Callback when closed/cancelled
796    pub on_close: Callback<()>,
797}
798
799#[function_component(InstanceEditorModal)]
800pub fn instance_editor_modal(props: &InstanceEditorModalProps) -> Html {
801    if !props.open {
802        return html! {};
803    }
804
805    let on_backdrop_click = {
806        let on_close = props.on_close.clone();
807        Callback::from(move |_| on_close.emit(()))
808    };
809
810    let on_content_click = Callback::from(|e: MouseEvent| {
811        e.stop_propagation();
812    });
813
814    html! {
815        <div
816            class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
817            onclick={on_backdrop_click}
818        >
819            <div onclick={on_content_click} class="animate-scale-in">
820                <InstanceEditor
821                    skill={props.skill.clone()}
822                    instance={props.instance.clone()}
823                    on_save={props.on_save.clone()}
824                    on_cancel={props.on_close.clone()}
825                />
826            </div>
827        </div>
828    }
829}