paseo-form-render 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1053 @@
1
+ import type { PluginHostProps } from "@getpaseo/plugin/client";
2
+ import { useRpc } from "@getpaseo/plugin/client";
3
+ import React, { createContext, useContext, useRef, useState } from "react";
4
+ import { View, Text, TextInput, Pressable, StyleSheet, Switch, ScrollView, Platform } from "react-native";
5
+ import {
6
+ PfrNode,
7
+ SelectNode,
8
+ MultiSelectNode,
9
+ FilePickerNode,
10
+ Tone,
11
+ normalizeExtensions,
12
+ } from "../shared/schema";
13
+ import { extractInitialValues, validateForm, formatSubmissionMessage } from "../shared/form-helpers";
14
+ import { ProjectFileModal } from "./project-file-modal";
15
+
16
+ const MENU_WIDTH = 220;
17
+
18
+ interface OpenDropdownMenu {
19
+ id: string;
20
+ left: number;
21
+ top: number;
22
+ width: number;
23
+ options: Array<{ label: string; value: string }>;
24
+ isMulti: boolean;
25
+ }
26
+
27
+ interface DropdownMenuContextValue {
28
+ openMenuId: string | null;
29
+ toggleMenu: (
30
+ id: string,
31
+ buttonRef: React.RefObject<View | null>,
32
+ options: Array<{ label: string; value: string }>,
33
+ isMulti: boolean,
34
+ ) => void;
35
+ }
36
+
37
+ const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
38
+
39
+ interface NodeRendererProps {
40
+ node: PfrNode;
41
+ values: Record<string, any>;
42
+ errors: Record<string, string>;
43
+ disabled: boolean;
44
+ theme: PluginHostProps["theme"];
45
+ agentId?: string;
46
+ onValueChange: (id: string, value: any) => void;
47
+ onSubmit: (message?: string) => void;
48
+ onReset: () => void;
49
+ }
50
+
51
+ function getToneColors(tone: Tone | undefined, theme: PluginHostProps["theme"]) {
52
+ switch (tone) {
53
+ case "success":
54
+ return { bg: theme.colors.surface2, border: theme.colors.statusSuccess, text: theme.colors.statusSuccess };
55
+ case "warning":
56
+ return { bg: theme.colors.surface2, border: theme.colors.statusWarning, text: theme.colors.statusWarning };
57
+ case "danger":
58
+ return { bg: theme.colors.surface2, border: theme.colors.statusDanger, text: theme.colors.statusDanger };
59
+ case "info":
60
+ return { bg: theme.colors.surface2, border: theme.colors.accent, text: theme.colors.accent };
61
+ case "neutral":
62
+ default:
63
+ return { bg: theme.colors.surface2, border: theme.colors.border, text: theme.colors.foregroundMuted };
64
+ }
65
+ }
66
+
67
+ // Collapsible Dropdown Component for single Select
68
+ function DropdownSelect({
69
+ node,
70
+ value,
71
+ error,
72
+ disabled,
73
+ theme,
74
+ }: {
75
+ node: SelectNode;
76
+ value: string;
77
+ error?: string;
78
+ disabled: boolean;
79
+ theme: PluginHostProps["theme"];
80
+ onChange: (val: string) => void;
81
+ }) {
82
+ const menuCtx = useContext(DropdownMenuContext);
83
+ const buttonRef = useRef<View>(null);
84
+ const isOpen = menuCtx?.openMenuId === node.id;
85
+ const selectedOption = node.options.find((opt) => opt.value === value);
86
+
87
+ return (
88
+ <View style={styles.fieldContainer}>
89
+ <Text style={[styles.fieldLabel, { color: theme.colors.foreground }]}>
90
+ {node.label}
91
+ {node.required && <Text style={{ color: theme.colors.statusDanger }}> *</Text>}
92
+ </Text>
93
+
94
+ {/* Trigger Box */}
95
+ <View ref={buttonRef} collapsable={false}>
96
+ <Pressable
97
+ disabled={disabled}
98
+ onPress={() => menuCtx?.toggleMenu(node.id, buttonRef, node.options, false)}
99
+ style={[
100
+ styles.dropdownTrigger,
101
+ {
102
+ backgroundColor: theme.colors.surface2,
103
+ borderColor: error ? theme.colors.statusDanger : isOpen ? theme.colors.accent : theme.colors.border,
104
+ },
105
+ disabled && { opacity: 0.6 },
106
+ ]}
107
+ >
108
+ <Text
109
+ style={[
110
+ styles.dropdownTriggerText,
111
+ { color: selectedOption ? theme.colors.foreground : theme.colors.foregroundMuted },
112
+ ]}
113
+ >
114
+ {selectedOption ? selectedOption.label : "Select an option..."}
115
+ </Text>
116
+ <Text style={[styles.dropdownChevron, { color: theme.colors.foregroundMuted }]}>
117
+ {isOpen ? "▲" : "▼"}
118
+ </Text>
119
+ </Pressable>
120
+ </View>
121
+
122
+ {error ? <Text style={[styles.errorMessage, { color: theme.colors.statusDanger }]}>{error}</Text> : null}
123
+ </View>
124
+ );
125
+ }
126
+
127
+ // Collapsible Dropdown Component for MultiSelect
128
+ function DropdownMultiSelect({
129
+ node,
130
+ value,
131
+ error,
132
+ disabled,
133
+ theme,
134
+ }: {
135
+ node: MultiSelectNode;
136
+ value: string[];
137
+ error?: string;
138
+ disabled: boolean;
139
+ theme: PluginHostProps["theme"];
140
+ onChange: (val: string[]) => void;
141
+ }) {
142
+ const menuCtx = useContext(DropdownMenuContext);
143
+ const buttonRef = useRef<View>(null);
144
+ const isOpen = menuCtx?.openMenuId === node.id;
145
+ const selectedOptions = node.options.filter((opt) => value.includes(opt.value));
146
+
147
+ return (
148
+ <View style={styles.fieldContainer}>
149
+ <Text style={[styles.fieldLabel, { color: theme.colors.foreground }]}>
150
+ {node.label}
151
+ {node.required && <Text style={{ color: theme.colors.statusDanger }}> *</Text>}
152
+ </Text>
153
+
154
+ {/* Trigger Box */}
155
+ <View ref={buttonRef} collapsable={false}>
156
+ <Pressable
157
+ disabled={disabled}
158
+ onPress={() => menuCtx?.toggleMenu(node.id, buttonRef, node.options, true)}
159
+ style={[
160
+ styles.dropdownTrigger,
161
+ {
162
+ backgroundColor: theme.colors.surface2,
163
+ borderColor: error ? theme.colors.statusDanger : isOpen ? theme.colors.accent : theme.colors.border,
164
+ },
165
+ disabled && { opacity: 0.6 },
166
+ ]}
167
+ >
168
+ <Text
169
+ numberOfLines={1}
170
+ style={[
171
+ styles.dropdownTriggerText,
172
+ { color: selectedOptions.length > 0 ? theme.colors.foreground : theme.colors.foregroundMuted },
173
+ ]}
174
+ >
175
+ {selectedOptions.length > 0
176
+ ? selectedOptions.map((o) => o.label).join(", ")
177
+ : (node.placeholder ?? "Select options...")}
178
+ </Text>
179
+ <Text style={[styles.dropdownChevron, { color: theme.colors.foregroundMuted }]}>
180
+ {isOpen ? "▲" : "▼"}
181
+ </Text>
182
+ </Pressable>
183
+ </View>
184
+
185
+ {error ? <Text style={[styles.errorMessage, { color: theme.colors.statusDanger }]}>{error}</Text> : null}
186
+ </View>
187
+ );
188
+ }
189
+
190
+ function FilePickerField({
191
+ node,
192
+ value,
193
+ error,
194
+ disabled,
195
+ theme,
196
+ agentId,
197
+ onChange,
198
+ }: {
199
+ node: FilePickerNode;
200
+ value: string;
201
+ error?: string;
202
+ disabled: boolean;
203
+ theme: PluginHostProps["theme"];
204
+ agentId?: string;
205
+ onChange: (val: string) => void;
206
+ }) {
207
+ const [modalVisible, setModalVisible] = useState(false);
208
+ const colors = theme.colors;
209
+ const cleanExts = normalizeExtensions(node.extensions, node.extension);
210
+
211
+ return (
212
+ <View style={styles.fieldContainer}>
213
+ <View style={styles.fieldLabelRow}>
214
+ <Text style={[styles.fieldLabel, { color: colors.foreground }]}>
215
+ {node.label}
216
+ {node.required && <Text style={{ color: colors.statusDanger }}> *</Text>}
217
+ </Text>
218
+ {cleanExts && cleanExts.length > 0 && (
219
+ <View style={[styles.extBadge, { backgroundColor: colors.surface2, borderColor: colors.border }]}>
220
+ <Text style={[styles.extBadgeText, { color: colors.accent }]}>
221
+ {cleanExts.map((e) => `.${e}`).join(", ")}
222
+ </Text>
223
+ </View>
224
+ )}
225
+ </View>
226
+
227
+ <View
228
+ style={[
229
+ styles.pickerRow,
230
+ {
231
+ backgroundColor: colors.surface2,
232
+ borderColor: error ? colors.statusDanger : colors.border,
233
+ },
234
+ disabled && { opacity: 0.6 },
235
+ ]}
236
+ >
237
+ <TextInput
238
+ value={value}
239
+ onChangeText={onChange}
240
+ placeholder={
241
+ node.placeholder ||
242
+ (node.pickerType === "folder"
243
+ ? "Enter or select folder..."
244
+ : "Enter or select file...")
245
+ }
246
+ placeholderTextColor={colors.foregroundMuted}
247
+ editable={!disabled}
248
+ autoCapitalize="none"
249
+ autoCorrect={false}
250
+ style={[styles.pickerTextInput, { color: colors.foreground }]}
251
+ />
252
+
253
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
254
+ {value && !disabled ? (
255
+ <Pressable onPress={() => onChange("")} style={styles.pickerClearBtn}>
256
+ <Text style={{ color: colors.foregroundMuted, fontSize: 13 }}>✕</Text>
257
+ </Pressable>
258
+ ) : null}
259
+
260
+ <Pressable
261
+ disabled={disabled}
262
+ onPress={() => setModalVisible(true)}
263
+ style={[styles.pickerBrowseBtn, { backgroundColor: colors.surface1, borderColor: colors.border }]}
264
+ >
265
+ <Text style={{ color: colors.foreground, fontSize: 12, fontWeight: "500" }}>Browse...</Text>
266
+ </Pressable>
267
+ </View>
268
+ </View>
269
+
270
+ {value ? (
271
+ <View style={[styles.pathInspector, { backgroundColor: colors.surface1, borderColor: colors.border }]}>
272
+ <Text style={[styles.pathInspectorLabel, { color: colors.foregroundMuted }]}>Full path:</Text>
273
+ <ScrollView
274
+ horizontal
275
+ showsHorizontalScrollIndicator={true}
276
+ contentContainerStyle={styles.pathInspectorScrollContent}
277
+ style={styles.pathInspectorScroll}
278
+ >
279
+ <Text style={[styles.pathInspectorText, { color: colors.foreground }]}>
280
+ {value}
281
+ </Text>
282
+ </ScrollView>
283
+ </View>
284
+ ) : null}
285
+
286
+ {error && <Text style={[styles.errorMessage, { color: colors.statusDanger }]}>{error}</Text>}
287
+
288
+ <ProjectFileModal
289
+ visible={modalVisible}
290
+ onClose={() => setModalVisible(false)}
291
+ onSelect={(path) => {
292
+ onChange(path);
293
+ setModalVisible(false);
294
+ }}
295
+ initialPath={value}
296
+ agentId={agentId}
297
+ pickerType={node.pickerType}
298
+ extensions={node.extensions}
299
+ extension={node.extension}
300
+ theme={theme}
301
+ />
302
+ </View>
303
+ );
304
+ }
305
+
306
+ export const NodeRenderer: React.FC<NodeRendererProps> = ({
307
+ node,
308
+ values,
309
+ errors,
310
+ disabled,
311
+ theme,
312
+ agentId,
313
+ onValueChange,
314
+ onSubmit,
315
+ onReset,
316
+ }) => {
317
+ switch (node.type) {
318
+ case "Card":
319
+ return (
320
+ <View style={[styles.card, { backgroundColor: theme.colors.surface1, borderColor: theme.colors.border }]}>
321
+ {node.title ? <Text style={[styles.cardTitle, { color: theme.colors.foreground }]}>{node.title}</Text> : null}
322
+ {node.description ? <Text style={[styles.cardDescription, { color: theme.colors.foregroundMuted }]}>{node.description}</Text> : null}
323
+ <View style={styles.cardBody}>
324
+ {(node.children || []).map((child, index) => (
325
+ <NodeRenderer
326
+ key={index}
327
+ node={child}
328
+ values={values}
329
+ errors={errors}
330
+ disabled={disabled}
331
+ theme={theme}
332
+ agentId={agentId}
333
+ onValueChange={onValueChange}
334
+ onSubmit={onSubmit}
335
+ onReset={onReset}
336
+ />
337
+ ))}
338
+ </View>
339
+ </View>
340
+ );
341
+
342
+ case "Stack":
343
+ return (
344
+ <View style={[styles.stack, node.direction === "horizontal" ? styles.stackRow : styles.stackColumn]}>
345
+ {(node.children || []).map((child, index) => (
346
+ <NodeRenderer
347
+ key={index}
348
+ node={child}
349
+ values={values}
350
+ errors={errors}
351
+ disabled={disabled}
352
+ theme={theme}
353
+ agentId={agentId}
354
+ onValueChange={onValueChange}
355
+ onSubmit={onSubmit}
356
+ onReset={onReset}
357
+ />
358
+ ))}
359
+ </View>
360
+ );
361
+
362
+ case "Heading":
363
+ return <Text style={[styles.heading, { color: theme.colors.foreground }]}>{node.text}</Text>;
364
+
365
+ case "Text":
366
+ return (
367
+ <Text
368
+ style={[
369
+ styles.text,
370
+ { color: node.muted ? theme.colors.foregroundMuted : theme.colors.foreground },
371
+ ]}
372
+ >
373
+ {node.text}
374
+ </Text>
375
+ );
376
+
377
+ case "Divider":
378
+ return <View style={[styles.divider, { backgroundColor: theme.colors.border }]} />;
379
+
380
+ case "Metric":
381
+ return (
382
+ <View style={[styles.metricCard, { backgroundColor: theme.colors.surface2, borderColor: theme.colors.border }]}>
383
+ <Text style={[styles.metricLabel, { color: theme.colors.foregroundMuted }]}>{node.label}</Text>
384
+ <Text style={[styles.metricValue, { color: theme.colors.foreground }]}>{String(node.value)}</Text>
385
+ </View>
386
+ );
387
+
388
+ case "Badge": {
389
+ const c = getToneColors(node.tone, theme);
390
+ return (
391
+ <View style={[styles.badge, { backgroundColor: c.bg, borderColor: c.border }]}>
392
+ <Text style={[styles.badgeText, { color: c.text }]}>{node.text}</Text>
393
+ </View>
394
+ );
395
+ }
396
+
397
+ case "Alert": {
398
+ const c = getToneColors(node.tone, theme);
399
+ return (
400
+ <View style={[styles.alert, { backgroundColor: c.bg, borderColor: c.border }]}>
401
+ {node.title ? <Text style={[styles.alertTitle, { color: c.text }]}>{node.title}</Text> : null}
402
+ <Text style={[styles.alertText, { color: c.text }]}>{node.text}</Text>
403
+ </View>
404
+ );
405
+ }
406
+
407
+ case "Progress":
408
+ return (
409
+ <View style={styles.progressContainer}>
410
+ <View style={styles.progressHeader}>
411
+ <Text style={[styles.progressLabel, { color: theme.colors.foreground }]}>{node.label}</Text>
412
+ <Text style={[styles.progressValue, { color: theme.colors.foreground }]}>{node.value}%</Text>
413
+ </View>
414
+ <View style={[styles.progressBarTrack, { backgroundColor: theme.colors.surface2 }]}>
415
+ <View style={[styles.progressBarFill, { backgroundColor: theme.colors.accent, width: `${Math.min(100, Math.max(0, node.value))}%` }]} />
416
+ </View>
417
+ </View>
418
+ );
419
+
420
+ case "Table": {
421
+ const columns = node.columns || [];
422
+ const rows = node.rows || [];
423
+ return (
424
+ <View style={[styles.table, { borderColor: theme.colors.border }]}>
425
+ <View style={[styles.tableHeaderRow, { backgroundColor: theme.colors.surface2, borderBottomColor: theme.colors.border }]}>
426
+ {columns.map((col) => (
427
+ <View key={col.key} style={styles.tableCellContainer}>
428
+ <Text style={[styles.tableHeaderCell, { color: theme.colors.foregroundMuted }]}>
429
+ {col.label}
430
+ </Text>
431
+ </View>
432
+ ))}
433
+ </View>
434
+ {rows.map((row, rIdx) => (
435
+ <View key={rIdx} style={[styles.tableRow, { borderBottomColor: theme.colors.border }]}>
436
+ {columns.map((col) => (
437
+ <View key={col.key} style={styles.tableCellContainer}>
438
+ <Text style={[styles.tableCell, { color: theme.colors.foreground }]}>
439
+ {row[col.key] !== null && row[col.key] !== undefined ? String(row[col.key]) : "-"}
440
+ </Text>
441
+ </View>
442
+ ))}
443
+ </View>
444
+ ))}
445
+ </View>
446
+ );
447
+ }
448
+
449
+ case "Input": {
450
+ const error = errors[node.id];
451
+ const inputType = node.inputType ?? "text";
452
+ const isNumeric = inputType === "number" || inputType === "integer";
453
+ const isEmail = inputType === "email";
454
+ const isPassword = inputType === "password";
455
+ const maxLen = node.maxLength ?? (!isNumeric ? node.max : undefined);
456
+
457
+ return (
458
+ <View style={styles.fieldContainer}>
459
+ <Text style={[styles.fieldLabel, { color: theme.colors.foreground }]}>
460
+ {node.label}
461
+ {node.required && <Text style={{ color: theme.colors.statusDanger }}> *</Text>}
462
+ </Text>
463
+ <TextInput
464
+ style={[
465
+ styles.input,
466
+ {
467
+ backgroundColor: theme.colors.surface2,
468
+ borderColor: error ? theme.colors.statusDanger : theme.colors.border,
469
+ color: theme.colors.foreground,
470
+ },
471
+ node.multiline && styles.inputMultiline,
472
+ disabled && { opacity: 0.6 },
473
+ ]}
474
+ placeholder={node.placeholder}
475
+ placeholderTextColor={theme.colors.foregroundMuted}
476
+ value={values[node.id] !== undefined ? String(values[node.id]) : ""}
477
+ onChangeText={(txt) => onValueChange(node.id, txt)}
478
+ editable={!disabled}
479
+ multiline={node.multiline}
480
+ numberOfLines={node.multiline ? 3 : 1}
481
+ keyboardType={isNumeric ? "numeric" : isEmail ? "email-address" : "default"}
482
+ autoCapitalize={isEmail || isPassword ? "none" : undefined}
483
+ autoCorrect={!isEmail && !isPassword}
484
+ secureTextEntry={isPassword}
485
+ maxLength={maxLen}
486
+ />
487
+ {error ? <Text style={[styles.errorMessage, { color: theme.colors.statusDanger }]}>{error}</Text> : null}
488
+ </View>
489
+ );
490
+ }
491
+
492
+ case "Select": {
493
+ return (
494
+ <DropdownSelect
495
+ node={node}
496
+ value={values[node.id] ?? ""}
497
+ error={errors[node.id]}
498
+ disabled={disabled}
499
+ theme={theme}
500
+ onChange={(val) => onValueChange(node.id, val)}
501
+ />
502
+ );
503
+ }
504
+
505
+ case "MultiSelect": {
506
+ return (
507
+ <DropdownMultiSelect
508
+ node={node}
509
+ value={Array.isArray(values[node.id]) ? values[node.id] : []}
510
+ error={errors[node.id]}
511
+ disabled={disabled}
512
+ theme={theme}
513
+ onChange={(val) => onValueChange(node.id, val)}
514
+ />
515
+ );
516
+ }
517
+
518
+ case "Toggle": {
519
+ const error = errors[node.id];
520
+ const isChecked = Boolean(values[node.id]);
521
+ return (
522
+ <View style={styles.fieldContainer}>
523
+ <View style={styles.toggleRow}>
524
+ <Text style={[styles.fieldLabel, { color: theme.colors.foreground }]}>
525
+ {node.label}
526
+ {node.required && <Text style={{ color: theme.colors.statusDanger }}> *</Text>}
527
+ </Text>
528
+ <Switch
529
+ value={isChecked}
530
+ onValueChange={(val) => onValueChange(node.id, val)}
531
+ disabled={disabled}
532
+ />
533
+ </View>
534
+ {error ? <Text style={[styles.errorMessage, { color: theme.colors.statusDanger }]}>{error}</Text> : null}
535
+ </View>
536
+ );
537
+ }
538
+
539
+ case "Button": {
540
+ const isPrimary = node.variant !== "secondary" && node.variant !== "danger";
541
+ const isDanger = node.variant === "danger";
542
+ return (
543
+ <Pressable
544
+ disabled={disabled}
545
+ style={[
546
+ styles.button,
547
+ isPrimary && { backgroundColor: theme.colors.accent },
548
+ node.variant === "secondary" && {
549
+ backgroundColor: theme.colors.surface2,
550
+ borderWidth: 1,
551
+ borderColor: theme.colors.border,
552
+ },
553
+ isDanger && { backgroundColor: theme.colors.statusDanger },
554
+ disabled && { opacity: 0.5 },
555
+ ]}
556
+ onPress={() => {
557
+ if (node.action === "submit") {
558
+ onSubmit(node.message);
559
+ } else {
560
+ onReset();
561
+ }
562
+ }}
563
+ >
564
+ <Text
565
+ style={[
566
+ styles.buttonText,
567
+ {
568
+ color: node.variant === "secondary" ? theme.colors.foreground : theme.colors.accentForeground,
569
+ },
570
+ ]}
571
+ >
572
+ {node.label}
573
+ </Text>
574
+ </Pressable>
575
+ );
576
+ }
577
+
578
+ case "FilePicker": {
579
+ return (
580
+ <FilePickerField
581
+ node={node}
582
+ value={values[node.id] ?? ""}
583
+ error={errors[node.id]}
584
+ disabled={disabled}
585
+ theme={theme}
586
+ agentId={agentId}
587
+ onChange={(val) => onValueChange(node.id, val)}
588
+ />
589
+ );
590
+ }
591
+
592
+ default:
593
+ return null;
594
+ }
595
+ };
596
+
597
+ export interface PfrFormViewProps {
598
+ form: PfrNode;
599
+ theme: PluginHostProps["theme"];
600
+ agentId?: string;
601
+ onSubmit: (submissionFormattedText: string) => Promise<void> | void;
602
+ }
603
+
604
+ export const PfrFormView: React.FC<PfrFormViewProps> = ({ form, theme, agentId, onSubmit }) => {
605
+ const [values, setValues] = useState<Record<string, any>>(() => extractInitialValues(form));
606
+ const [errors, setErrors] = useState<Record<string, string>>({});
607
+ const [status, setStatus] = useState<"idle" | "submitting" | "submitted">("idle");
608
+ // The dropdown menu floats over the form at its root (measured position), so opening it never moves anything.
609
+ const [menuPos, setMenuPos] = useState<OpenDropdownMenu | null>(null);
610
+ const rootRef = useRef<View>(null);
611
+
612
+ const toggleMenu = (
613
+ id: string,
614
+ buttonRef: React.RefObject<View | null>,
615
+ options: Array<{ label: string; value: string }>,
616
+ isMulti: boolean,
617
+ ) => {
618
+ if (menuPos && menuPos.id === id) {
619
+ setMenuPos(null);
620
+ return;
621
+ }
622
+ rootRef.current?.measureInWindow((rx, ry) => {
623
+ buttonRef.current?.measureInWindow((bx, by, bw, bh) => {
624
+ setMenuPos({
625
+ id,
626
+ left: Math.max(0, bx - rx),
627
+ top: by - ry + bh + 4,
628
+ width: bw > 0 ? bw : MENU_WIDTH,
629
+ options,
630
+ isMulti,
631
+ });
632
+ });
633
+ });
634
+ };
635
+
636
+ const handleValueChange = (id: string, val: any) => {
637
+ setValues((prev) => ({ ...prev, [id]: val }));
638
+ if (errors[id]) {
639
+ setErrors((prev) => {
640
+ const next = { ...prev };
641
+ delete next[id];
642
+ return next;
643
+ });
644
+ }
645
+ };
646
+
647
+ const handleReset = () => {
648
+ setMenuPos(null);
649
+ setValues(extractInitialValues(form));
650
+ setErrors({});
651
+ };
652
+
653
+ const handleSubmit = async (message?: string) => {
654
+ setMenuPos(null);
655
+ const errs = validateForm(form, values);
656
+ if (Object.keys(errs).length > 0) {
657
+ setErrors(errs);
658
+ return;
659
+ }
660
+ setStatus("submitting");
661
+ try {
662
+ const formatted = formatSubmissionMessage(message, form, values);
663
+ await onSubmit(formatted);
664
+ setStatus("submitted");
665
+ } catch (e) {
666
+ setStatus("idle");
667
+ }
668
+ };
669
+
670
+ return (
671
+ <DropdownMenuContext.Provider value={{ openMenuId: menuPos?.id ?? null, toggleMenu }}>
672
+ <View ref={rootRef} collapsable={false} style={[styles.formContainer, menuPos ? { zIndex: 10 } : null]}>
673
+ <NodeRenderer
674
+ node={form}
675
+ values={values}
676
+ errors={errors}
677
+ disabled={status !== "idle"}
678
+ theme={theme}
679
+ agentId={agentId}
680
+ onValueChange={handleValueChange}
681
+ onSubmit={handleSubmit}
682
+ onReset={handleReset}
683
+ />
684
+ {status === "submitted" && (
685
+ <View style={[styles.submittedBanner, { backgroundColor: theme.colors.surface2, borderColor: theme.colors.statusSuccess, borderWidth: 1 }]}>
686
+ <Text style={[styles.submittedText, { color: theme.colors.statusSuccess }]}>✓ Form Submitted</Text>
687
+ </View>
688
+ )}
689
+ {menuPos ? (
690
+ <>
691
+ <Pressable
692
+ accessibilityLabel="Close dropdown menu"
693
+ onPress={() => setMenuPos(null)}
694
+ style={[StyleSheet.absoluteFill, { zIndex: 999 }]}
695
+ />
696
+ <View
697
+ style={[
698
+ styles.dropdownMenu,
699
+ {
700
+ left: menuPos.left,
701
+ top: menuPos.top,
702
+ width: menuPos.width,
703
+ backgroundColor: theme.colors.surface1,
704
+ borderColor: theme.colors.border,
705
+ },
706
+ ]}
707
+ >
708
+ {menuPos.options.map((opt) => {
709
+ const currentVal = values[menuPos.id];
710
+ const isSelected = menuPos.isMulti
711
+ ? (Array.isArray(currentVal) ? currentVal : []).includes(opt.value)
712
+ : (currentVal ?? "") === opt.value;
713
+ return (
714
+ <Pressable
715
+ key={opt.value}
716
+ style={[
717
+ styles.dropdownMenuItem,
718
+ { borderBottomColor: theme.colors.border },
719
+ isSelected && { backgroundColor: theme.colors.surface2 },
720
+ ]}
721
+ onPress={() => {
722
+ if (menuPos.isMulti) {
723
+ const arr = Array.isArray(currentVal) ? currentVal : [];
724
+ const next = isSelected
725
+ ? arr.filter((v) => v !== opt.value)
726
+ : [...arr, opt.value];
727
+ handleValueChange(menuPos.id, next);
728
+ } else {
729
+ handleValueChange(menuPos.id, opt.value);
730
+ setMenuPos(null);
731
+ }
732
+ }}
733
+ >
734
+ <Text
735
+ style={[
736
+ styles.dropdownMenuItemText,
737
+ { color: isSelected ? theme.colors.accent : theme.colors.foreground },
738
+ isSelected && { fontWeight: "600" },
739
+ ]}
740
+ >
741
+ {opt.label}
742
+ </Text>
743
+ {isSelected && (
744
+ <Text style={[styles.dropdownCheckmark, { color: theme.colors.accent }]}>✓</Text>
745
+ )}
746
+ </Pressable>
747
+ );
748
+ })}
749
+ </View>
750
+ </>
751
+ ) : null}
752
+ </View>
753
+ </DropdownMenuContext.Provider>
754
+ );
755
+ };
756
+
757
+ const styles = StyleSheet.create({
758
+ formContainer: {
759
+ marginVertical: 8,
760
+ position: "relative",
761
+ },
762
+ card: {
763
+ borderRadius: 8,
764
+ borderWidth: 1,
765
+ padding: 16,
766
+ gap: 12,
767
+ },
768
+ cardTitle: {
769
+ fontSize: 16,
770
+ fontWeight: "600",
771
+ },
772
+ cardDescription: {
773
+ fontSize: 14,
774
+ },
775
+ cardBody: {
776
+ gap: 12,
777
+ },
778
+ stack: {
779
+ gap: 8,
780
+ },
781
+ stackRow: {
782
+ flexDirection: "row",
783
+ flexWrap: "wrap",
784
+ alignItems: "center",
785
+ },
786
+ stackColumn: {
787
+ flexDirection: "column",
788
+ },
789
+ heading: {
790
+ fontSize: 16,
791
+ fontWeight: "600",
792
+ marginVertical: 4,
793
+ },
794
+ text: {
795
+ fontSize: 15,
796
+ lineHeight: 21,
797
+ },
798
+ divider: {
799
+ height: 1,
800
+ marginVertical: 6,
801
+ },
802
+ metricCard: {
803
+ borderWidth: 1,
804
+ borderRadius: 6,
805
+ padding: 12,
806
+ alignItems: "center",
807
+ minWidth: 100,
808
+ },
809
+ metricLabel: {
810
+ fontSize: 13,
811
+ marginBottom: 4,
812
+ },
813
+ metricValue: {
814
+ fontSize: 18,
815
+ fontWeight: "700",
816
+ },
817
+ badge: {
818
+ borderWidth: 1,
819
+ borderRadius: 12,
820
+ paddingHorizontal: 8,
821
+ paddingVertical: 2,
822
+ alignSelf: "flex-start",
823
+ },
824
+ badgeText: {
825
+ fontSize: 13,
826
+ fontWeight: "500",
827
+ },
828
+ alert: {
829
+ borderWidth: 1,
830
+ borderRadius: 6,
831
+ padding: 12,
832
+ gap: 4,
833
+ },
834
+ alertTitle: {
835
+ fontSize: 15,
836
+ fontWeight: "600",
837
+ },
838
+ alertText: {
839
+ fontSize: 14,
840
+ },
841
+ progressContainer: {
842
+ gap: 6,
843
+ },
844
+ progressHeader: {
845
+ flexDirection: "row",
846
+ justifyContent: "space-between",
847
+ },
848
+ progressLabel: {
849
+ fontSize: 14,
850
+ },
851
+ progressValue: {
852
+ fontSize: 14,
853
+ fontWeight: "500",
854
+ },
855
+ progressBarTrack: {
856
+ height: 8,
857
+ borderRadius: 4,
858
+ overflow: "hidden",
859
+ },
860
+ progressBarFill: {
861
+ height: "100%",
862
+ borderRadius: 4,
863
+ },
864
+ table: {
865
+ borderWidth: 1,
866
+ borderRadius: 6,
867
+ overflow: "hidden",
868
+ },
869
+ tableHeaderRow: {
870
+ flexDirection: "row",
871
+ borderBottomWidth: 1,
872
+ },
873
+ tableCellContainer: {
874
+ flex: 1,
875
+ padding: 8,
876
+ },
877
+ tableHeaderCell: {
878
+ fontSize: 13,
879
+ fontWeight: "600",
880
+ },
881
+ tableRow: {
882
+ flexDirection: "row",
883
+ borderBottomWidth: 1,
884
+ },
885
+ tableCell: {
886
+ fontSize: 13,
887
+ },
888
+ fieldContainer: {
889
+ gap: 4,
890
+ },
891
+ fieldLabel: {
892
+ fontSize: 14,
893
+ fontWeight: "500",
894
+ },
895
+ input: {
896
+ borderWidth: 1,
897
+ borderRadius: 6,
898
+ paddingHorizontal: 12,
899
+ paddingVertical: 8,
900
+ fontSize: 15,
901
+ },
902
+ inputMultiline: {
903
+ minHeight: 64,
904
+ ...Platform.select({
905
+ android: {
906
+ textAlignVertical: "top",
907
+ },
908
+ web: {
909
+ resize: "vertical",
910
+ } as any,
911
+ default: {},
912
+ }),
913
+ },
914
+ dropdownTrigger: {
915
+ borderWidth: 1,
916
+ borderRadius: 6,
917
+ paddingHorizontal: 12,
918
+ paddingVertical: 9,
919
+ flexDirection: "row",
920
+ justifyContent: "space-between",
921
+ alignItems: "center",
922
+ },
923
+ dropdownTriggerText: {
924
+ fontSize: 15,
925
+ flex: 1,
926
+ },
927
+ dropdownChevron: {
928
+ fontSize: 10,
929
+ marginLeft: 8,
930
+ },
931
+ dropdownMenu: {
932
+ position: "absolute",
933
+ zIndex: 1000,
934
+ elevation: 8,
935
+ borderWidth: 1,
936
+ borderRadius: 6,
937
+ paddingVertical: 2,
938
+ shadowColor: "#000",
939
+ shadowOpacity: 0.2,
940
+ shadowRadius: 6,
941
+ shadowOffset: { width: 0, height: 2 },
942
+ },
943
+ dropdownMenuItem: {
944
+ paddingHorizontal: 12,
945
+ paddingVertical: 9,
946
+ flexDirection: "row",
947
+ justifyContent: "space-between",
948
+ alignItems: "center",
949
+ borderBottomWidth: 1,
950
+ },
951
+ dropdownMenuItemText: {
952
+ fontSize: 15,
953
+ },
954
+ dropdownCheckmark: {
955
+ fontSize: 15,
956
+ fontWeight: "600",
957
+ },
958
+ toggleRow: {
959
+ flexDirection: "row",
960
+ justifyContent: "space-between",
961
+ alignItems: "center",
962
+ },
963
+ button: {
964
+ borderRadius: 6,
965
+ paddingHorizontal: 16,
966
+ paddingVertical: 10,
967
+ alignItems: "center",
968
+ justifyContent: "center",
969
+ marginTop: 4,
970
+ },
971
+ buttonText: {
972
+ fontSize: 15,
973
+ fontWeight: "600",
974
+ },
975
+ errorMessage: {
976
+ fontSize: 13,
977
+ },
978
+ submittedBanner: {
979
+ marginTop: 8,
980
+ padding: 8,
981
+ borderRadius: 6,
982
+ alignItems: "center",
983
+ },
984
+ submittedText: {
985
+ fontSize: 14,
986
+ fontWeight: "600",
987
+ },
988
+ fieldLabelRow: {
989
+ flexDirection: "row",
990
+ alignItems: "center",
991
+ justifyContent: "space-between",
992
+ },
993
+ extBadge: {
994
+ paddingHorizontal: 6,
995
+ paddingVertical: 1,
996
+ borderRadius: 4,
997
+ borderWidth: 1,
998
+ },
999
+ extBadgeText: {
1000
+ fontSize: 11,
1001
+ fontWeight: "600",
1002
+ },
1003
+ pickerRow: {
1004
+ borderWidth: 1,
1005
+ borderRadius: 6,
1006
+ paddingLeft: 10,
1007
+ paddingRight: 6,
1008
+ paddingVertical: 4,
1009
+ flexDirection: "row",
1010
+ alignItems: "center",
1011
+ justifyContent: "space-between",
1012
+ minHeight: 38,
1013
+ },
1014
+ pickerTextInput: {
1015
+ flex: 1,
1016
+ fontSize: 13,
1017
+ paddingVertical: 4,
1018
+ paddingRight: 8,
1019
+ },
1020
+ pickerClearBtn: {
1021
+ paddingHorizontal: 6,
1022
+ paddingVertical: 4,
1023
+ },
1024
+ pickerBrowseBtn: {
1025
+ borderWidth: 1,
1026
+ borderRadius: 4,
1027
+ paddingHorizontal: 10,
1028
+ paddingVertical: 5,
1029
+ },
1030
+ pathInspector: {
1031
+ flexDirection: "row",
1032
+ alignItems: "center",
1033
+ borderWidth: 1,
1034
+ borderRadius: 6,
1035
+ paddingHorizontal: 8,
1036
+ paddingVertical: 5,
1037
+ marginTop: 3,
1038
+ },
1039
+ pathInspectorLabel: {
1040
+ fontSize: 11,
1041
+ fontWeight: "600",
1042
+ marginRight: 6,
1043
+ },
1044
+ pathInspectorScroll: {
1045
+ flex: 1,
1046
+ },
1047
+ pathInspectorScrollContent: {
1048
+ alignItems: "center",
1049
+ },
1050
+ pathInspectorText: {
1051
+ fontSize: 12,
1052
+ },
1053
+ });