@opengeni/react-native 1.4.5-canary.37615713321001

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.
Files changed (132) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +74 -0
  3. package/dist/adapters.d.ts +53 -0
  4. package/dist/adapters.js +3 -0
  5. package/dist/adapters.js.map +1 -0
  6. package/dist/attachments.d.ts +75 -0
  7. package/dist/chunk-3VSZAQ7G.js +391 -0
  8. package/dist/chunk-3VSZAQ7G.js.map +1 -0
  9. package/dist/chunk-5ZVK7WKA.js +88 -0
  10. package/dist/chunk-5ZVK7WKA.js.map +1 -0
  11. package/dist/chunk-EEDIMY3L.js +240 -0
  12. package/dist/chunk-EEDIMY3L.js.map +1 -0
  13. package/dist/chunk-M23SDI4R.js +83 -0
  14. package/dist/chunk-M23SDI4R.js.map +1 -0
  15. package/dist/chunk-PHJBSAR3.js +2006 -0
  16. package/dist/chunk-PHJBSAR3.js.map +1 -0
  17. package/dist/environment.d.ts +19 -0
  18. package/dist/expo.d.ts +25 -0
  19. package/dist/expo.js +267 -0
  20. package/dist/expo.js.map +1 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.js +3345 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/presentation.d.ts +262 -0
  25. package/dist/read-acknowledgement.d.ts +39 -0
  26. package/dist/realtime/call-events.d.ts +11 -0
  27. package/dist/realtime/call-view.d.ts +26 -0
  28. package/dist/realtime/expo-call.d.ts +11 -0
  29. package/dist/realtime/native-call.d.ts +104 -0
  30. package/dist/realtime/native-realtime.d.ts +35 -0
  31. package/dist/realtime/use-native-session-realtime.d.ts +25 -0
  32. package/dist/session-view.d.ts +87 -0
  33. package/dist/timeline/activity.d.ts +34 -0
  34. package/dist/timeline/composer.d.ts +86 -0
  35. package/dist/timeline/controls.d.ts +27 -0
  36. package/dist/timeline/decisions.d.ts +29 -0
  37. package/dist/timeline/feedback.d.ts +26 -0
  38. package/dist/timeline/icon-nodes.generated.d.ts +738 -0
  39. package/dist/timeline/icon.d.ts +11 -0
  40. package/dist/timeline/index.d.ts +32 -0
  41. package/dist/timeline/index.js +5419 -0
  42. package/dist/timeline/index.js.map +1 -0
  43. package/dist/timeline/markdown.d.ts +11 -0
  44. package/dist/timeline/markdown.js +415 -0
  45. package/dist/timeline/markdown.js.map +1 -0
  46. package/dist/timeline/message-attachments.d.ts +13 -0
  47. package/dist/timeline/message-timeline.d.ts +36 -0
  48. package/dist/timeline/model-picker.d.ts +17 -0
  49. package/dist/timeline/primitives.d.ts +85 -0
  50. package/dist/timeline/queue-dock.d.ts +19 -0
  51. package/dist/timeline/session-actions.d.ts +34 -0
  52. package/dist/timeline/session-list.d.ts +50 -0
  53. package/dist/timeline/session-screen.d.ts +35 -0
  54. package/dist/timeline/session-signals.d.ts +37 -0
  55. package/dist/timeline/sheet.d.ts +14 -0
  56. package/dist/timeline/theme.d.ts +58 -0
  57. package/dist/timeline/thinking-orb.d.ts +13 -0
  58. package/dist/timeline/turn.d.ts +80 -0
  59. package/dist/ui/attention.d.ts +14 -0
  60. package/dist/ui/cards.d.ts +21 -0
  61. package/dist/ui/composer.d.ts +21 -0
  62. package/dist/ui/format.d.ts +13 -0
  63. package/dist/ui/icons.d.ts +9 -0
  64. package/dist/ui/index.d.ts +12 -0
  65. package/dist/ui/index.js +1757 -0
  66. package/dist/ui/index.js.map +1 -0
  67. package/dist/ui/steps-sheet.d.ts +10 -0
  68. package/dist/ui/theme.d.ts +63 -0
  69. package/dist/ui/timeline.d.ts +48 -0
  70. package/dist/ui/web-tokens.gen.d.ts +90 -0
  71. package/dist/use-native-session.d.ts +40 -0
  72. package/dist/view-model.d.ts +52 -0
  73. package/dist/voice-input.d.ts +102 -0
  74. package/dist/webrtc.d.ts +9 -0
  75. package/dist/webrtc.js +19 -0
  76. package/dist/webrtc.js.map +1 -0
  77. package/expo-module.config.json +7 -0
  78. package/ios/OpenGeniCall.podspec +22 -0
  79. package/ios/OpenGeniCallAppDelegateSubscriber.swift +29 -0
  80. package/ios/OpenGeniCallCenter.swift +189 -0
  81. package/ios/OpenGeniCallModule.swift +64 -0
  82. package/package.json +93 -0
  83. package/src/adapters.ts +150 -0
  84. package/src/attachments.ts +396 -0
  85. package/src/environment.tsx +214 -0
  86. package/src/expo.ts +295 -0
  87. package/src/index.ts +98 -0
  88. package/src/presentation.ts +285 -0
  89. package/src/read-acknowledgement.ts +72 -0
  90. package/src/realtime/call-events.ts +41 -0
  91. package/src/realtime/call-view.tsx +220 -0
  92. package/src/realtime/expo-call.ts +49 -0
  93. package/src/realtime/native-call.ts +266 -0
  94. package/src/realtime/native-realtime.ts +115 -0
  95. package/src/realtime/use-native-session-realtime.ts +29 -0
  96. package/src/session-view.tsx +2251 -0
  97. package/src/timeline/activity.tsx +736 -0
  98. package/src/timeline/composer.tsx +686 -0
  99. package/src/timeline/controls.tsx +151 -0
  100. package/src/timeline/decisions.tsx +651 -0
  101. package/src/timeline/feedback.tsx +251 -0
  102. package/src/timeline/icon-nodes.generated.ts +1830 -0
  103. package/src/timeline/icon.tsx +44 -0
  104. package/src/timeline/index.ts +67 -0
  105. package/src/timeline/markdown.tsx +510 -0
  106. package/src/timeline/message-attachments.tsx +155 -0
  107. package/src/timeline/message-timeline.tsx +1042 -0
  108. package/src/timeline/model-picker.tsx +450 -0
  109. package/src/timeline/primitives.tsx +561 -0
  110. package/src/timeline/queue-dock.tsx +331 -0
  111. package/src/timeline/session-actions.tsx +379 -0
  112. package/src/timeline/session-list.tsx +338 -0
  113. package/src/timeline/session-screen.tsx +488 -0
  114. package/src/timeline/session-signals.tsx +312 -0
  115. package/src/timeline/sheet.tsx +153 -0
  116. package/src/timeline/theme.tsx +128 -0
  117. package/src/timeline/thinking-orb.tsx +93 -0
  118. package/src/timeline/turn.tsx +569 -0
  119. package/src/ui/attention.tsx +220 -0
  120. package/src/ui/cards.tsx +267 -0
  121. package/src/ui/composer.tsx +296 -0
  122. package/src/ui/format.ts +86 -0
  123. package/src/ui/icons.tsx +131 -0
  124. package/src/ui/index.ts +18 -0
  125. package/src/ui/steps-sheet.tsx +71 -0
  126. package/src/ui/theme.ts +261 -0
  127. package/src/ui/timeline.tsx +837 -0
  128. package/src/ui/web-tokens.gen.ts +94 -0
  129. package/src/use-native-session.ts +219 -0
  130. package/src/view-model.ts +485 -0
  131. package/src/voice-input.ts +382 -0
  132. package/src/webrtc.ts +30 -0
@@ -0,0 +1,651 @@
1
+ import type {
2
+ HumanInputQuestion,
3
+ SessionHumanInputRequest,
4
+ SubmitHumanInputResponseRequest,
5
+ } from "@opengeni/sdk";
6
+ import type { PendingApproval } from "@opengeni/react/session";
7
+ import {
8
+ answersFromDrafts,
9
+ defaultHumanInputFormMessages,
10
+ emptyDraft,
11
+ humanInputHeading,
12
+ initialDrafts,
13
+ isActionableHumanInputRequest,
14
+ type HumanInputAnswerDraft,
15
+ type HumanInputFormMessages,
16
+ } from "@opengeni/react/timeline-model";
17
+ import { createContext, useContext, useMemo, useRef, useState } from "react";
18
+ import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
19
+ import { Button } from "./controls";
20
+ import { Icon } from "./icon";
21
+ import { blendOver, withAlpha } from "./primitives";
22
+ import { fontStyle, useNativeTimelineTheme } from "./theme";
23
+
24
+ /* ----------------------------------------------------------------------------
25
+ Decision surfaces: the web HumanInputSurface/HumanInputForm (waiting tone,
26
+ question-first, options, Other, Skip / Send answers) and the approval
27
+ Notice strip, with the shared drafting and validation rules.
28
+ -------------------------------------------------------------------------- */
29
+
30
+ function useWaitingTone() {
31
+ const theme = useNativeTimelineTheme();
32
+ const waiting = theme.colors["status-waiting"];
33
+ return {
34
+ theme,
35
+ waiting,
36
+ surface: withAlpha(waiting, 0.05),
37
+ border: withAlpha(waiting, 0.35),
38
+ divider: withAlpha(waiting, 0.2),
39
+ iconBox: withAlpha(waiting, 0.12),
40
+ };
41
+ }
42
+
43
+ export interface HumanInputCardProps {
44
+ requests: SessionHumanInputRequest[];
45
+ onSubmit: (requestId: string, response: SubmitHumanInputResponseRequest) => void | Promise<void>;
46
+ respondingRequestId?: string | null | undefined;
47
+ error?: string | null | undefined;
48
+ /** The shared question-form catalog; hosts translate by overriding entries. */
49
+ messages?: Partial<HumanInputFormMessages> | undefined;
50
+ }
51
+
52
+ const HumanInputMessagesContext = createContext<HumanInputFormMessages>(
53
+ defaultHumanInputFormMessages,
54
+ );
55
+
56
+ /** One decision shell for pending requests, oldest first, "N of M" when stepping. */
57
+ export function HumanInputCard({
58
+ requests,
59
+ onSubmit,
60
+ respondingRequestId = null,
61
+ error,
62
+ messages,
63
+ }: HumanInputCardProps) {
64
+ const merged = useMemo(() => ({ ...defaultHumanInputFormMessages, ...messages }), [messages]);
65
+ const batchTotal = useRef(0);
66
+ const ordered = useMemo(() => {
67
+ const now = Date.now();
68
+ return requests
69
+ .filter((request) => isActionableHumanInputRequest(request, now))
70
+ .sort((a, b) => {
71
+ const aAt = a.createdAt ? Date.parse(a.createdAt) : 0;
72
+ const bAt = b.createdAt ? Date.parse(b.createdAt) : 0;
73
+ return aAt !== bAt ? aAt - bAt : a.id.localeCompare(b.id);
74
+ });
75
+ }, [requests]);
76
+ if (ordered.length === 0) {
77
+ batchTotal.current = 0;
78
+ return null;
79
+ }
80
+ batchTotal.current = Math.max(batchTotal.current, ordered.length);
81
+ const active = ordered[0]!;
82
+ const position = batchTotal.current - ordered.length + 1;
83
+ return (
84
+ <HumanInputMessagesContext.Provider value={merged}>
85
+ <HumanInputForm
86
+ key={active.id}
87
+ request={active}
88
+ progressLabel={batchTotal.current > 1 ? `${position} of ${batchTotal.current}` : null}
89
+ submitting={respondingRequestId !== null}
90
+ error={error ?? null}
91
+ onSubmit={(response) => onSubmit(active.id, response)}
92
+ />
93
+ </HumanInputMessagesContext.Provider>
94
+ );
95
+ }
96
+
97
+ function HumanInputForm({
98
+ request,
99
+ progressLabel,
100
+ submitting,
101
+ error,
102
+ onSubmit,
103
+ }: {
104
+ request: SessionHumanInputRequest;
105
+ progressLabel: string | null;
106
+ submitting: boolean;
107
+ error: string | null;
108
+ onSubmit: (response: SubmitHumanInputResponseRequest) => void | Promise<void>;
109
+ }) {
110
+ const tone = useWaitingTone();
111
+ const { theme } = tone;
112
+ const c = theme.colors;
113
+ const messages = useContext(HumanInputMessagesContext);
114
+ const heading = humanInputHeading(request.questions, messages);
115
+ const single = request.questions.length === 1;
116
+ const required = single && request.questions[0]!.required;
117
+ const [drafts, setDrafts] = useState<Record<string, HumanInputAnswerDraft>>(() =>
118
+ initialDrafts(request.questions),
119
+ );
120
+ const [errors, setErrors] = useState<Record<string, string>>({});
121
+ const [collapsed, setCollapsed] = useState(false);
122
+ const [submitError, setSubmitError] = useState<string | null>(null);
123
+ const [inFlight, setInFlight] = useState(false);
124
+ const busy = submitting || inFlight;
125
+
126
+ const submit = async (response: SubmitHumanInputResponseRequest) => {
127
+ setSubmitError(null);
128
+ setInFlight(true);
129
+ try {
130
+ await onSubmit(response);
131
+ } catch (cause) {
132
+ setSubmitError(cause instanceof Error ? cause.message : String(cause));
133
+ } finally {
134
+ setInFlight(false);
135
+ }
136
+ };
137
+ const send = () => {
138
+ const result = answersFromDrafts(request.questions, drafts, messages);
139
+ setErrors(result.errors);
140
+ if (Object.keys(result.errors).length > 0) return;
141
+ void submit({
142
+ outcome: "answered",
143
+ answers: result.answers,
144
+ } as SubmitHumanInputResponseRequest);
145
+ };
146
+ const update = (id: string, next: (draft: HumanInputAnswerDraft) => HumanInputAnswerDraft) => {
147
+ setDrafts((current) => ({ ...current, [id]: next(current[id] ?? emptyDraft()) }));
148
+ // Editing a question clears its validation message, as on web.
149
+ setErrors((current) => {
150
+ if (!(id in current)) return current;
151
+ const remaining = { ...current };
152
+ delete remaining[id];
153
+ return remaining;
154
+ });
155
+ };
156
+
157
+ return (
158
+ <View
159
+ style={{
160
+ borderRadius: theme.radius.lg,
161
+ borderWidth: 1,
162
+ borderColor: tone.border,
163
+ backgroundColor: tone.surface,
164
+ overflow: "hidden",
165
+ }}
166
+ >
167
+ <View
168
+ style={{
169
+ flexDirection: "row",
170
+ alignItems: "flex-start",
171
+ gap: 12,
172
+ padding: 16,
173
+ paddingBottom: collapsed ? 16 : 14,
174
+ }}
175
+ >
176
+ <View
177
+ style={{
178
+ width: 32,
179
+ height: 32,
180
+ borderRadius: theme.radius.md,
181
+ alignItems: "center",
182
+ justifyContent: "center",
183
+ backgroundColor: tone.iconBox,
184
+ }}
185
+ >
186
+ <Icon name="message-circle-question" size={16} color={tone.waiting} />
187
+ </View>
188
+ <View style={{ flex: 1, minWidth: 0 }}>
189
+ <Text
190
+ style={{
191
+ ...fontStyle(theme, 600),
192
+ fontSize: theme.size.md,
193
+ lineHeight: 24,
194
+ color: c.fg,
195
+ }}
196
+ >
197
+ {heading.title}
198
+ {required ? <Text style={{ color: c["status-failed"] }}> *</Text> : null}
199
+ </Text>
200
+ {heading.description ? (
201
+ <Text
202
+ style={{
203
+ ...fontStyle(theme),
204
+ fontSize: theme.size.sm,
205
+ lineHeight: 18,
206
+ color: c["fg-muted"],
207
+ }}
208
+ >
209
+ {heading.description}
210
+ </Text>
211
+ ) : null}
212
+ {progressLabel ? (
213
+ <Text
214
+ style={{
215
+ ...fontStyle(theme),
216
+ marginTop: 2,
217
+ fontSize: theme.size.xs,
218
+ color: c["fg-subtle"],
219
+ }}
220
+ >
221
+ {progressLabel}
222
+ </Text>
223
+ ) : null}
224
+ </View>
225
+ <Pressable
226
+ accessibilityRole="button"
227
+ accessibilityLabel={collapsed ? messages.expand : messages.collapse}
228
+ onPress={() => setCollapsed((value) => !value)}
229
+ style={{
230
+ width: 32,
231
+ height: 32,
232
+ borderRadius: theme.radius.md,
233
+ alignItems: "center",
234
+ justifyContent: "center",
235
+ }}
236
+ >
237
+ <Icon name={collapsed ? "chevron-down" : "chevron-up"} size={16} color={c["fg-muted"]} />
238
+ </Pressable>
239
+ </View>
240
+ {collapsed ? null : (
241
+ <>
242
+ <ScrollView
243
+ style={{ maxHeight: 360, borderTopWidth: 1, borderTopColor: tone.divider }}
244
+ contentContainerStyle={{ paddingHorizontal: 20, paddingVertical: 14, gap: 18 }}
245
+ nestedScrollEnabled
246
+ keyboardShouldPersistTaps="handled"
247
+ >
248
+ {request.questions.map((question, index) => (
249
+ <QuestionControls
250
+ key={question.id}
251
+ question={question}
252
+ index={single ? null : index + 1}
253
+ draft={drafts[question.id] ?? emptyDraft()}
254
+ error={errors[question.id]}
255
+ disabled={busy}
256
+ onChange={(next) => update(question.id, next)}
257
+ />
258
+ ))}
259
+ </ScrollView>
260
+ <View
261
+ style={{
262
+ flexDirection: "row",
263
+ alignItems: "center",
264
+ justifyContent: "flex-end",
265
+ gap: 8,
266
+ paddingHorizontal: 16,
267
+ paddingVertical: 12,
268
+ borderTopWidth: 1,
269
+ borderTopColor: tone.divider,
270
+ backgroundColor: withAlpha(c["surface-1"], 0.95),
271
+ }}
272
+ >
273
+ {error || submitError ? (
274
+ <Text
275
+ style={{
276
+ ...fontStyle(theme),
277
+ flex: 1,
278
+ fontSize: theme.size.xs,
279
+ color: c["status-failed"],
280
+ }}
281
+ >
282
+ {submitError ?? error}
283
+ </Text>
284
+ ) : null}
285
+ {request.allowSkip ? (
286
+ <Button
287
+ label={messages.skip}
288
+ variant="ghost"
289
+ size="form"
290
+ disabled={busy}
291
+ onPress={() =>
292
+ void submit({ outcome: "skipped" } as SubmitHumanInputResponseRequest)
293
+ }
294
+ />
295
+ ) : null}
296
+ <Button
297
+ label={busy ? messages.submitting : messages.submit}
298
+ variant="primary"
299
+ size="form"
300
+ busy={busy}
301
+ onPress={send}
302
+ />
303
+ </View>
304
+ </>
305
+ )}
306
+ </View>
307
+ );
308
+ }
309
+
310
+ function QuestionControls({
311
+ question,
312
+ index,
313
+ draft,
314
+ error,
315
+ disabled,
316
+ onChange,
317
+ }: {
318
+ question: HumanInputQuestion;
319
+ index: number | null;
320
+ draft: HumanInputAnswerDraft;
321
+ error?: string | undefined;
322
+ disabled: boolean;
323
+ onChange: (next: (draft: HumanInputAnswerDraft) => HumanInputAnswerDraft) => void;
324
+ }) {
325
+ const theme = useNativeTimelineTheme();
326
+ const c = theme.colors;
327
+ const messages = useContext(HumanInputMessagesContext);
328
+ const multi = question.kind === "multi_select";
329
+ const label = { ...fontStyle(theme, 500), fontSize: theme.size.sm, lineHeight: 18, color: c.fg };
330
+ const input = {
331
+ ...fontStyle(theme),
332
+ fontSize: theme.size.sm,
333
+ color: c.fg,
334
+ minHeight: 32,
335
+ paddingHorizontal: 8,
336
+ paddingVertical: 6,
337
+ borderRadius: theme.radius.sm,
338
+ borderWidth: 1,
339
+ borderColor: error ? c["status-failed"] : c.border,
340
+ backgroundColor: c["surface-1"],
341
+ };
342
+ const toggle = (value: string) =>
343
+ onChange((current) => {
344
+ if (!multi) return { ...current, values: [value], otherSelected: false };
345
+ const has = current.values.includes(value);
346
+ return {
347
+ ...current,
348
+ values: has
349
+ ? current.values.filter((entry) => entry !== value)
350
+ : [...current.values, value],
351
+ };
352
+ });
353
+ const hint = multi
354
+ ? messages.selectionHint(question.validation?.minSelections, question.validation?.maxSelections)
355
+ : null;
356
+ return (
357
+ <View style={{ gap: 10 }}>
358
+ {index !== null ? (
359
+ <View>
360
+ <Text
361
+ style={{
362
+ ...fontStyle(theme, 600),
363
+ fontSize: theme.size.sm,
364
+ lineHeight: 18,
365
+ color: c.fg,
366
+ }}
367
+ >
368
+ {`${index}. ${question.label ?? question.prompt}`}
369
+ {question.required ? <Text style={{ color: c["status-failed"] }}> *</Text> : null}
370
+ </Text>
371
+ {question.label ? (
372
+ <Text
373
+ style={{
374
+ ...fontStyle(theme),
375
+ fontSize: theme.size.sm,
376
+ lineHeight: 18,
377
+ color: c["fg-muted"],
378
+ }}
379
+ >
380
+ {question.prompt}
381
+ </Text>
382
+ ) : null}
383
+ </View>
384
+ ) : null}
385
+ {hint ? (
386
+ <Text style={{ ...fontStyle(theme), fontSize: theme.size.xs, color: c["fg-subtle"] }}>
387
+ {hint}
388
+ </Text>
389
+ ) : null}
390
+ {question.kind === "text" ? (
391
+ <TextInput
392
+ accessibilityLabel={question.label ?? question.prompt}
393
+ editable={!disabled}
394
+ multiline
395
+ value={draft.values[0] ?? ""}
396
+ onChangeText={(text) => onChange((current) => ({ ...current, values: [text] }))}
397
+ placeholder="Type your answer..."
398
+ placeholderTextColor={c["fg-subtle"]}
399
+ style={{ ...input, minHeight: 64, textAlignVertical: "top" }}
400
+ />
401
+ ) : (
402
+ <View style={{ gap: 2 }}>
403
+ {question.options.map((option) => {
404
+ const selected = draft.values.includes(option.id);
405
+ return (
406
+ <Pressable
407
+ key={option.id}
408
+ accessibilityRole={multi ? "checkbox" : "radio"}
409
+ accessibilityState={{ checked: selected, disabled }}
410
+ disabled={disabled}
411
+ onPress={() => toggle(option.id)}
412
+ style={{
413
+ flexDirection: "row",
414
+ alignItems: "flex-start",
415
+ gap: 10,
416
+ minHeight: 36,
417
+ paddingVertical: 8,
418
+ }}
419
+ >
420
+ <Selector multi={multi} selected={selected} />
421
+ <View style={{ flex: 1, minWidth: 0 }}>
422
+ <Text style={label}>{option.label}</Text>
423
+ {option.description ? (
424
+ <Text
425
+ style={{
426
+ ...fontStyle(theme),
427
+ fontSize: theme.size.xs,
428
+ lineHeight: 16,
429
+ color: c["fg-muted"],
430
+ }}
431
+ >
432
+ {option.description}
433
+ </Text>
434
+ ) : null}
435
+ </View>
436
+ </Pressable>
437
+ );
438
+ })}
439
+ {question.allowOther ? (
440
+ <View
441
+ style={{
442
+ flexDirection: "row",
443
+ alignItems: "flex-start",
444
+ gap: 10,
445
+ paddingVertical: 8,
446
+ }}
447
+ >
448
+ <Pressable
449
+ accessibilityRole={multi ? "checkbox" : "radio"}
450
+ accessibilityState={{ checked: draft.otherSelected, disabled }}
451
+ accessibilityLabel={messages.other}
452
+ disabled={disabled}
453
+ onPress={() =>
454
+ onChange((current) =>
455
+ multi
456
+ ? { ...current, otherSelected: !current.otherSelected }
457
+ : { ...current, values: [], otherSelected: true },
458
+ )
459
+ }
460
+ style={{ paddingTop: 2 }}
461
+ >
462
+ <Selector multi={multi} selected={draft.otherSelected} />
463
+ </Pressable>
464
+ <View style={{ flex: 1, gap: 6 }}>
465
+ <Text style={label}>{messages.other}</Text>
466
+ <TextInput
467
+ accessibilityLabel={`Other answer for ${question.label ?? question.prompt}`}
468
+ editable={!disabled}
469
+ value={draft.other}
470
+ onFocus={() =>
471
+ onChange((current) =>
472
+ multi
473
+ ? { ...current, otherSelected: true }
474
+ : { ...current, values: [], otherSelected: true },
475
+ )
476
+ }
477
+ onChangeText={(text) => onChange((current) => ({ ...current, other: text }))}
478
+ placeholder="Type a value..."
479
+ placeholderTextColor={c["fg-subtle"]}
480
+ style={input}
481
+ />
482
+ </View>
483
+ </View>
484
+ ) : null}
485
+ </View>
486
+ )}
487
+ {error ? (
488
+ <Text
489
+ accessibilityRole="alert"
490
+ style={{ ...fontStyle(theme), fontSize: theme.size.xs, color: c["status-failed"] }}
491
+ >
492
+ {error}
493
+ </Text>
494
+ ) : null}
495
+ </View>
496
+ );
497
+ }
498
+
499
+ function Selector({ multi, selected }: { multi: boolean; selected: boolean }) {
500
+ const theme = useNativeTimelineTheme();
501
+ const c = theme.colors;
502
+ return (
503
+ <View
504
+ style={{
505
+ marginTop: 2,
506
+ width: 14,
507
+ height: 14,
508
+ borderRadius: multi ? 3 : 7,
509
+ borderWidth: 1,
510
+ borderColor: selected ? c.accent : c["fg-muted"],
511
+ backgroundColor: selected && multi ? c.accent : c["surface-1"],
512
+ alignItems: "center",
513
+ justifyContent: "center",
514
+ }}
515
+ >
516
+ {selected ? (
517
+ multi ? (
518
+ <Icon name="check" size={10} color={c["accent-fg"]} strokeWidth={3} />
519
+ ) : (
520
+ <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: c.accent }} />
521
+ )
522
+ ) : null}
523
+ </View>
524
+ );
525
+ }
526
+
527
+ export interface ApprovalStripMessages {
528
+ approve: string;
529
+ reject: string;
530
+ approved: string;
531
+ rejected: string;
532
+ /** The tool's display name; the web live strip shows the raw name. */
533
+ formatToolName: (name: string) => string;
534
+ }
535
+
536
+ export const defaultApprovalStripMessages: ApprovalStripMessages = {
537
+ approve: "Approve",
538
+ reject: "Reject",
539
+ approved: "Approved",
540
+ rejected: "Rejected",
541
+ formatToolName: (name) => name,
542
+ };
543
+
544
+ export interface ApprovalStripProps {
545
+ approvals: PendingApproval[];
546
+ onDecide: (approvalId: string, decision: "approve" | "reject") => void | Promise<void>;
547
+ messages?: Partial<ApprovalStripMessages> | undefined;
548
+ }
549
+
550
+ /** The web live decision strip: one waiting Notice per pending tool approval. */
551
+ export function ApprovalStrip({ approvals, onDecide, messages: overrides }: ApprovalStripProps) {
552
+ const messages = { ...defaultApprovalStripMessages, ...overrides };
553
+ const tone = useWaitingTone();
554
+ const { theme } = tone;
555
+ const c = theme.colors;
556
+ const [pending, setPending] = useState<Record<string, "approve" | "reject">>({});
557
+ const [settled, setSettled] = useState<Record<string, "approve" | "reject">>({});
558
+ if (approvals.length === 0) return null;
559
+ const decide = async (id: string, decision: "approve" | "reject") => {
560
+ setPending((current) => ({ ...current, [id]: decision }));
561
+ try {
562
+ await onDecide(id, decision);
563
+ setSettled((current) => ({ ...current, [id]: decision }));
564
+ } finally {
565
+ setPending((current) => {
566
+ const next = { ...current };
567
+ delete next[id];
568
+ return next;
569
+ });
570
+ }
571
+ };
572
+ return (
573
+ <ScrollView style={{ maxHeight: 256 }} contentContainerStyle={{ gap: 12 }} nestedScrollEnabled>
574
+ {approvals.map((approval) => {
575
+ const busy = Boolean(pending[approval.id]) || Boolean(settled[approval.id]);
576
+ const payload = JSON.stringify(approval.arguments ?? approval.raw ?? {}, null, 2);
577
+ return (
578
+ <View
579
+ key={approval.id}
580
+ style={{
581
+ borderRadius: theme.radius.lg,
582
+ borderWidth: 1,
583
+ borderColor: withAlpha(tone.waiting, 0.3),
584
+ // Opaque: the strip floats over scrolling conversation.
585
+ backgroundColor: blendOver(c.bg, tone.waiting, 0.06),
586
+ padding: 12,
587
+ flexDirection: "row",
588
+ gap: 10,
589
+ }}
590
+ >
591
+ <View style={{ paddingTop: 2 }}>
592
+ <Icon name="circle-alert" size={16} color={tone.waiting} />
593
+ </View>
594
+ <View style={{ flex: 1, minWidth: 0 }}>
595
+ <Text
596
+ style={{ ...fontStyle(theme, 500), fontSize: 14, lineHeight: 20, color: c.fg }}
597
+ numberOfLines={2}
598
+ >
599
+ {messages.formatToolName(approval.name)}
600
+ </Text>
601
+ <ScrollView
602
+ style={{
603
+ marginTop: 8,
604
+ maxHeight: 160,
605
+ borderRadius: theme.radius.md,
606
+ borderWidth: 1,
607
+ borderColor: c.border,
608
+ backgroundColor: withAlpha(c["surface-2"], 0.6),
609
+ }}
610
+ contentContainerStyle={{ padding: 10 }}
611
+ nestedScrollEnabled
612
+ >
613
+ <Text
614
+ selectable
615
+ style={{
616
+ ...fontStyle(theme, 400, "mono"),
617
+ fontSize: theme.size.sm,
618
+ lineHeight: 20,
619
+ color: c["fg-muted"],
620
+ }}
621
+ >
622
+ {payload}
623
+ </Text>
624
+ </ScrollView>
625
+ <View
626
+ style={{ marginTop: 12, flexDirection: "row", justifyContent: "flex-end", gap: 8 }}
627
+ >
628
+ <Button
629
+ icon="check"
630
+ variant="primary"
631
+ label={settled[approval.id] === "approve" ? messages.approved : messages.approve}
632
+ busy={pending[approval.id] === "approve"}
633
+ disabled={busy}
634
+ onPress={() => void decide(approval.id, "approve")}
635
+ />
636
+ <Button
637
+ icon="x"
638
+ variant="destructive"
639
+ label={settled[approval.id] === "reject" ? messages.rejected : messages.reject}
640
+ busy={pending[approval.id] === "reject"}
641
+ disabled={busy}
642
+ onPress={() => void decide(approval.id, "reject")}
643
+ />
644
+ </View>
645
+ </View>
646
+ </View>
647
+ );
648
+ })}
649
+ </ScrollView>
650
+ );
651
+ }