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