@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,686 @@
1
+ import {
2
+ defaultChatComposerMessages,
3
+ type ChatComposerMessages,
4
+ } from "@opengeni/react/composer-messages";
5
+ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
6
+ import { useEffect, useState, type ComponentRef, type ReactNode, type Ref } from "react";
7
+ import {
8
+ AccessibilityInfo,
9
+ ActivityIndicator,
10
+ Platform,
11
+ Pressable,
12
+ Text,
13
+ TextInput,
14
+ View,
15
+ type ViewStyle,
16
+ } from "react-native";
17
+ import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";
18
+ import { Button } from "./controls";
19
+ import { Icon, type NativeIconName } from "./icon";
20
+ import { withAlpha } from "./primitives";
21
+ import { fontStyle, useNativeTimelineTheme } from "./theme";
22
+ import type { NativeVoiceInput } from "../voice-input";
23
+
24
+ /* ----------------------------------------------------------------------------
25
+ The session composer, designed for each platform rather than copied from
26
+ the web card. It keeps the web composer's behaviour and copy (the shared
27
+ message catalog): one field, leading actions, the model pill, and a single
28
+ trailing action that sends, pauses a running workstream, or resumes it.
29
+
30
+ iOS 26+: a floating Liquid Glass surface the conversation scrolls under.
31
+ Older iOS or Reduce Transparency: the same shape on a solid raised surface.
32
+ Android: a Material 3 filled container (no glass, no border).
33
+ -------------------------------------------------------------------------- */
34
+
35
+ export interface SessionComposerProps {
36
+ value: string;
37
+ onChangeText: (value: string) => void;
38
+ onSend: () => void;
39
+ canSend: boolean;
40
+ sending?: boolean | undefined;
41
+ placeholder?: string | undefined;
42
+ /** Run state drives the trailing action: pause while running, resume when paused. */
43
+ running?: boolean | undefined;
44
+ paused?: boolean | undefined;
45
+ onPause?: (() => void) | undefined;
46
+ onResume?: (() => void) | undefined;
47
+ pauseBusy?: boolean | undefined;
48
+ /** Leading toolbar content; defaults to an attach button when `onAttach` is set. */
49
+ renderLeading?: (() => ReactNode) | undefined;
50
+ onAttach?: (() => void) | undefined;
51
+ /** Content between leading actions and the trailing action (model/options pill). */
52
+ options?: ReactNode;
53
+ /** Rendered inside the surface above the field (attachment chips, annotations). */
54
+ header?: ReactNode;
55
+ /** Rendered above the surface (queue, approvals, status dock). */
56
+ above?: ReactNode;
57
+ bottomInset?: number | undefined;
58
+ /** Distance from the composer's container bottom to the window bottom (tab bars). */
59
+ keyboardBottomOffset?: number | undefined;
60
+ autoFocus?: boolean | undefined;
61
+ /** Horizontal page inset around the surface (default 12). */
62
+ inset?: number | undefined;
63
+ /** Lift above the keyboard (bottom-docked composer). Off for an inline card. */
64
+ liftWithKeyboard?: boolean | undefined;
65
+ /**
66
+ * Float over the conversation (absolute, transparent around the surface) so
67
+ * content scrolls beneath it. The host reads the occupied height from
68
+ * `onHeightChange` to inset its scroll content.
69
+ */
70
+ floating?: boolean | undefined;
71
+ onHeightChange?: ((height: number) => void) | undefined;
72
+ /** Rendered inside the surface under the header (draft conflict, notices). */
73
+ below?: ReactNode;
74
+ /** The message field, for hosts that focus it (queue Edit, replies). */
75
+ inputRef?: Ref<ComponentRef<typeof TextInput>> | undefined;
76
+ /** The shared composer catalog; hosts translate by overriding entries. */
77
+ messages?: Partial<ChatComposerMessages> | undefined;
78
+ /** Called when the trailing action fires (host haptics). */
79
+ onActionFeedback?: (() => void) | undefined;
80
+ /** Dictation: a mic beside the leading actions, and the recording strip while it runs. */
81
+ voice?: NativeVoiceInput | undefined;
82
+ }
83
+
84
+ /** Whether to draw Liquid Glass: iOS 26+, the native module present, transparency allowed. */
85
+ export function useLiquidGlass(): boolean {
86
+ const [reduceTransparency, setReduceTransparency] = useState(false);
87
+ useEffect(() => {
88
+ if (Platform.OS !== "ios") return;
89
+ void AccessibilityInfo.isReduceTransparencyEnabled().then(setReduceTransparency);
90
+ const subscription = AccessibilityInfo.addEventListener(
91
+ "reduceTransparencyChanged",
92
+ setReduceTransparency,
93
+ );
94
+ return () => subscription.remove();
95
+ }, []);
96
+ if (Platform.OS !== "ios" || reduceTransparency) return false;
97
+ try {
98
+ return isLiquidGlassAvailable();
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * A floating surface for composer-adjacent chrome: glass on iOS 26, a solid
106
+ * raised surface elsewhere.
107
+ */
108
+ export function ComposerSurface({
109
+ children,
110
+ radius,
111
+ style,
112
+ }: {
113
+ children: ReactNode;
114
+ radius: number;
115
+ style?: ViewStyle | undefined;
116
+ }) {
117
+ const theme = useNativeTimelineTheme();
118
+ const c = theme.colors;
119
+ const glass = useLiquidGlass();
120
+ if (glass) {
121
+ // Hosts must keep this surface's ancestors free of animated opacity: iOS
122
+ // does not render glass that was mounted beneath a fading parent.
123
+ // The glass keeps the appearance it was created with, so a change of
124
+ // light or dark mounts a fresh one.
125
+ return (
126
+ <GlassView
127
+ key={theme.scheme}
128
+ glassEffectStyle="regular"
129
+ colorScheme={theme.scheme}
130
+ style={[{ borderRadius: radius }, style]}
131
+ >
132
+ {children}
133
+ </GlassView>
134
+ );
135
+ }
136
+ const android = Platform.OS === "android";
137
+ return (
138
+ <View
139
+ style={[
140
+ {
141
+ borderRadius: radius,
142
+ // Material 3 surfaceContainerHigh: a filled tonal surface, no outline.
143
+ backgroundColor: android ? c["surface-2"] : c["surface-1"],
144
+ ...(android
145
+ ? null
146
+ : {
147
+ borderWidth: 0.5,
148
+ borderColor: c.border,
149
+ shadowColor: "#000",
150
+ shadowOpacity: theme.scheme === "dark" ? 0 : 0.08,
151
+ shadowRadius: 12,
152
+ shadowOffset: { width: 0, height: 4 },
153
+ }),
154
+ },
155
+ style,
156
+ ]}
157
+ >
158
+ {children}
159
+ </View>
160
+ );
161
+ }
162
+
163
+ export function SessionComposer(props: SessionComposerProps) {
164
+ const theme = useNativeTimelineTheme();
165
+ const c = theme.colors;
166
+ const ios = Platform.OS === "ios";
167
+ const [height, setHeight] = useState(22);
168
+ const empty = !props.value;
169
+ useEffect(() => {
170
+ if (empty) setHeight(22);
171
+ }, [empty]);
172
+ // Track the keyboard directly: React Native's KeyboardAvoidingView mis-measures
173
+ // inside modals and overlays; the animated keyboard height does not.
174
+ const keyboard = useAnimatedKeyboard();
175
+ const floating = props.floating ?? false;
176
+ // Only Liquid Glass lets the conversation scroll visibly beneath the composer;
177
+ // a solid surface (Android, older iOS) sits on an opaque band down to the
178
+ // screen edge, or text would peek out under the card and the gesture handle.
179
+ const glass = useLiquidGlass();
180
+ const seeThrough = floating && glass;
181
+ // Floating: iOS tucks the glass toward the home indicator like system bars;
182
+ // Android keeps clear of the gesture handle.
183
+ const restingBottom = floating
184
+ ? ios
185
+ ? Math.max(10, (props.bottomInset ?? 0) - 4)
186
+ : (props.bottomInset ?? 0) + 8
187
+ : Math.max(16, (props.bottomInset ?? 0) + 4);
188
+ const offset = props.keyboardBottomOffset ?? 0;
189
+ const liftWithKeyboard = props.liftWithKeyboard ?? true;
190
+ const lift = useAnimatedStyle(() => {
191
+ if (!liftWithKeyboard) return { paddingBottom: restingBottom };
192
+ const lifted = keyboard.height.value - offset;
193
+ return { paddingBottom: lifted > 0 ? lifted + 8 : restingBottom };
194
+ });
195
+ const messages = { ...defaultChatComposerMessages, ...props.messages };
196
+ const fieldSize = ios ? 17 : 16;
197
+ const compact = !props.options;
198
+ const voice = props.voice?.available ? props.voice : undefined;
199
+ const capturing =
200
+ voice !== undefined &&
201
+ (voice.status === "recording" ||
202
+ voice.status === "requesting-permission" ||
203
+ voice.status === "transcribing");
204
+ const attach = props.renderLeading ? (
205
+ props.renderLeading()
206
+ ) : props.onAttach ? (
207
+ <ToolbarButton icon="plus" accessibilityLabel={messages.attachFiles} onPress={props.onAttach} />
208
+ ) : null;
209
+ const leading =
210
+ attach || voice ? (
211
+ <>
212
+ {attach}
213
+ {voice ? (
214
+ <ToolbarButton icon="mic" accessibilityLabel="Dictate" onPress={voice.start} />
215
+ ) : null}
216
+ </>
217
+ ) : null;
218
+ const field = (inline: boolean) => (
219
+ <TextInput
220
+ ref={props.inputRef}
221
+ accessibilityLabel={messages.inputLabel}
222
+ value={props.value}
223
+ onChangeText={props.onChangeText}
224
+ placeholder={
225
+ props.placeholder ?? (props.paused ? messages.pausedPlaceholder : "Send a follow-up...")
226
+ }
227
+ placeholderTextColor={c["fg-subtle"]}
228
+ multiline
229
+ autoFocus={props.autoFocus}
230
+ selectionColor={c.accent}
231
+ onContentSizeChange={(event) =>
232
+ setHeight(Math.min(150, Math.max(22, event.nativeEvent.contentSize.height)))
233
+ }
234
+ style={{
235
+ ...fontStyle(theme),
236
+ fontSize: fieldSize,
237
+ lineHeight: 22,
238
+ color: c.fg,
239
+ ...(inline
240
+ ? { paddingTop: 7, paddingBottom: 7, paddingHorizontal: 6, minHeight: 36 }
241
+ : { paddingTop: 14, paddingBottom: 2, paddingHorizontal: 16, minHeight: 40 }),
242
+ // The inline field sizes itself; the stacked one tracks its content.
243
+ // (iOS never shrinks a self-sized multiline field, so an empty one is pinned.)
244
+ // A cleared field (after Send) returns to one line: iOS does not report
245
+ // the smaller content size, so the last height would otherwise stick.
246
+ height: ios
247
+ ? inline
248
+ ? props.value
249
+ ? undefined
250
+ : 36
251
+ : (props.value ? height : 22) + 16
252
+ : undefined,
253
+ maxHeight: 166,
254
+ textAlignVertical: inline ? "center" : "top",
255
+ }}
256
+ />
257
+ );
258
+ return (
259
+ <Animated.View
260
+ pointerEvents={floating ? "box-none" : "auto"}
261
+ onLayout={(event) => props.onHeightChange?.(event.nativeEvent.layout.height)}
262
+ style={[
263
+ {
264
+ paddingHorizontal: props.inset ?? 12,
265
+ paddingTop: 6,
266
+ gap: 8,
267
+ backgroundColor: seeThrough ? "transparent" : c.bg,
268
+ },
269
+ floating ? { position: "absolute", left: 0, right: 0, bottom: 0 } : null,
270
+ lift,
271
+ ]}
272
+ >
273
+ {props.above ? <View pointerEvents="box-none">{props.above}</View> : null}
274
+ <ComposerSurface radius={compact ? 22 : 24}>
275
+ {props.header}
276
+ {props.below}
277
+ {voice?.hasSavedRecording && voice.status === "error" ? (
278
+ <SavedRecordingStrip voice={voice} />
279
+ ) : voice?.status === "error" && voice.error ? (
280
+ <Pressable
281
+ accessibilityRole="button"
282
+ accessibilityLabel={`${voice.error} Dismiss`}
283
+ onPress={voice.clearError}
284
+ style={{
285
+ flexDirection: "row",
286
+ alignItems: "center",
287
+ gap: 8,
288
+ paddingHorizontal: 16,
289
+ paddingTop: 10,
290
+ }}
291
+ >
292
+ <Icon name="circle-alert" size={14} color={c["status-failed"]} />
293
+ <Text
294
+ style={{
295
+ ...fontStyle(theme),
296
+ flex: 1,
297
+ fontSize: 13,
298
+ lineHeight: 18,
299
+ color: c["status-failed"],
300
+ }}
301
+ >
302
+ {voice.error}
303
+ </Text>
304
+ <Icon name="x" size={14} color={c["fg-subtle"]} />
305
+ </Pressable>
306
+ ) : null}
307
+ {capturing && voice ? (
308
+ <VoiceStrip voice={voice} />
309
+ ) : compact ? (
310
+ // No options to show: one row, as native messaging apps lay it out.
311
+ <View
312
+ style={{
313
+ flexDirection: "row",
314
+ alignItems: "flex-end",
315
+ paddingHorizontal: 5,
316
+ paddingVertical: 5,
317
+ gap: 2,
318
+ }}
319
+ >
320
+ {leading}
321
+ <View style={{ flex: 1, minWidth: 0 }}>{field(true)}</View>
322
+ <View style={{ paddingBottom: 0 }}>
323
+ <TrailingAction {...props} messages={messages} />
324
+ </View>
325
+ </View>
326
+ ) : (
327
+ <>
328
+ {field(false)}
329
+ <View
330
+ style={{
331
+ flexDirection: "row",
332
+ alignItems: "center",
333
+ paddingLeft: 6,
334
+ paddingRight: 8,
335
+ paddingBottom: 8,
336
+ minHeight: 44,
337
+ gap: 2,
338
+ }}
339
+ >
340
+ {leading}
341
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "center", minWidth: 0 }}>
342
+ {props.options}
343
+ </View>
344
+ <TrailingAction {...props} messages={messages} />
345
+ </View>
346
+ </>
347
+ )}
348
+ </ComposerSurface>
349
+ </Animated.View>
350
+ );
351
+ }
352
+
353
+ /** A quiet round toolbar button (the web composer's leading actions). */
354
+ export function ToolbarButton({
355
+ icon,
356
+ accessibilityLabel,
357
+ onPress,
358
+ }: {
359
+ icon: NativeIconName;
360
+ accessibilityLabel: string;
361
+ onPress: () => void;
362
+ }) {
363
+ const theme = useNativeTimelineTheme();
364
+ return (
365
+ <Pressable
366
+ accessibilityRole="button"
367
+ accessibilityLabel={accessibilityLabel}
368
+ onPress={onPress}
369
+ hitSlop={4}
370
+ style={({ pressed }) => ({
371
+ width: 36,
372
+ height: 36,
373
+ borderRadius: 18,
374
+ alignItems: "center",
375
+ justifyContent: "center",
376
+ backgroundColor: pressed ? theme.colors.hover : "transparent",
377
+ })}
378
+ >
379
+ <Icon name={icon} size={20} color={theme.colors["fg-muted"]} />
380
+ </Pressable>
381
+ );
382
+ }
383
+
384
+ const LEVEL_BARS = 28;
385
+
386
+ function formatDictationTime(seconds: number): string {
387
+ const whole = Math.max(0, Math.floor(seconds));
388
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
389
+ }
390
+
391
+ /**
392
+ * A recording whose text could not be fetched yet. It stays on the device:
393
+ * Retry sends the same audio again; Discard is the only way to drop it.
394
+ */
395
+ function SavedRecordingStrip({ voice }: { voice: NativeVoiceInput }) {
396
+ const theme = useNativeTimelineTheme();
397
+ const c = theme.colors;
398
+ return (
399
+ <View style={{ gap: 6, paddingHorizontal: 16, paddingTop: 10 }}>
400
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
401
+ <Icon name="audio-lines" size={14} color={c["status-waiting"]} />
402
+ <Text
403
+ accessibilityLiveRegion="polite"
404
+ style={{ ...fontStyle(theme), flex: 1, fontSize: 13, lineHeight: 18, color: c.fg }}
405
+ >
406
+ {voice.error ?? "Your recording is saved."}
407
+ </Text>
408
+ </View>
409
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 6 }}>
410
+ <Button label="Discard" variant="ghost" onPress={voice.cancel} />
411
+ <Button label="Retry" variant="primary" onPress={voice.retry} />
412
+ </View>
413
+ </View>
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Dictation in progress, in place of the field: cancel, a live level meter with
419
+ * the elapsed time, and stop (which transcribes into the draft).
420
+ */
421
+ function VoiceStrip({ voice }: { voice: NativeVoiceInput }) {
422
+ const theme = useNativeTimelineTheme();
423
+ const c = theme.colors;
424
+ const [levels, setLevels] = useState<number[]>(() => Array<number>(LEVEL_BARS).fill(0));
425
+ const recording = voice.status === "recording";
426
+ const transcribing = voice.status === "transcribing";
427
+ useEffect(() => {
428
+ if (!recording) return;
429
+ setLevels((current) => [...current.slice(1), voice.level ?? 0.15]);
430
+ }, [recording, voice.durationSeconds, voice.level]);
431
+ const remaining =
432
+ voice.maxDurationSeconds !== null && voice.maxDurationSeconds - voice.durationSeconds <= 10
433
+ ? ` · ${Math.max(0, Math.ceil(voice.maxDurationSeconds - voice.durationSeconds))}s left`
434
+ : "";
435
+ return (
436
+ <View
437
+ style={{
438
+ flexDirection: "row",
439
+ alignItems: "center",
440
+ gap: 8,
441
+ paddingHorizontal: 6,
442
+ paddingVertical: 6,
443
+ minHeight: 56,
444
+ }}
445
+ >
446
+ <Pressable
447
+ accessibilityRole="button"
448
+ accessibilityLabel="Cancel dictation"
449
+ onPress={voice.cancel}
450
+ disabled={transcribing}
451
+ hitSlop={4}
452
+ style={({ pressed }) => ({
453
+ width: 36,
454
+ height: 36,
455
+ borderRadius: 18,
456
+ alignItems: "center",
457
+ justifyContent: "center",
458
+ backgroundColor: pressed ? c.hover : "transparent",
459
+ opacity: transcribing ? 0.4 : 1,
460
+ })}
461
+ >
462
+ <Icon name="x" size={18} color={c["fg-muted"]} />
463
+ </Pressable>
464
+ <View
465
+ accessible
466
+ accessibilityLiveRegion="polite"
467
+ accessibilityLabel={
468
+ transcribing ? "Transcribing" : `Recording, ${formatDictationTime(voice.durationSeconds)}`
469
+ }
470
+ style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 10, minWidth: 0 }}
471
+ >
472
+ {transcribing ? (
473
+ <>
474
+ <ActivityIndicator size="small" color={c["fg-muted"]} />
475
+ <Text style={{ ...fontStyle(theme), fontSize: 15, color: c["fg-muted"] }}>
476
+ Transcribing…
477
+ </Text>
478
+ </>
479
+ ) : (
480
+ <>
481
+ <View
482
+ style={{
483
+ flex: 1,
484
+ height: 28,
485
+ flexDirection: "row",
486
+ alignItems: "center",
487
+ justifyContent: "space-between",
488
+ overflow: "hidden",
489
+ }}
490
+ >
491
+ {levels.map((value, index) => (
492
+ <View
493
+ // Fixed slots: the meter scrolls by shifting values, not keys.
494
+ // oxlint-disable-next-line react/no-array-index-key
495
+ key={index}
496
+ style={{
497
+ width: 3,
498
+ borderRadius: 1.5,
499
+ height: 4 + Math.round(value * 24),
500
+ backgroundColor: index === LEVEL_BARS - 1 ? c.fg : c["fg-subtle"],
501
+ }}
502
+ />
503
+ ))}
504
+ </View>
505
+ <Text
506
+ style={{
507
+ ...fontStyle(theme, 500),
508
+ fontSize: 14,
509
+ color: c["fg-muted"],
510
+ fontVariant: ["tabular-nums"],
511
+ }}
512
+ >
513
+ {formatDictationTime(voice.durationSeconds)}
514
+ {remaining}
515
+ </Text>
516
+ </>
517
+ )}
518
+ </View>
519
+ <Pressable
520
+ accessibilityRole="button"
521
+ accessibilityLabel="Stop and transcribe"
522
+ onPress={voice.stop}
523
+ disabled={!recording}
524
+ hitSlop={6}
525
+ style={({ pressed }) => ({
526
+ width: 34,
527
+ height: 34,
528
+ borderRadius: 17,
529
+ alignItems: "center",
530
+ justifyContent: "center",
531
+ backgroundColor: recording ? c.accent : withAlpha(c.fg, 0.08),
532
+ opacity: pressed ? 0.8 : 1,
533
+ transform: [{ scale: pressed ? 0.94 : 1 }],
534
+ })}
535
+ >
536
+ <Icon
537
+ name="check"
538
+ size={18}
539
+ strokeWidth={2.5}
540
+ color={recording ? c["accent-fg"] : c["fg-subtle"]}
541
+ />
542
+ </Pressable>
543
+ </View>
544
+ );
545
+ }
546
+
547
+ /**
548
+ * One trailing control, as native chat apps do: send when there is something
549
+ * to send (a message to a running agent joins the queue), otherwise pause a
550
+ * running workstream or resume a paused one; an idle empty composer shows a
551
+ * quiet, disabled send.
552
+ */
553
+ function TrailingAction(props: SessionComposerProps & { messages: ChatComposerMessages }) {
554
+ const theme = useNativeTimelineTheme();
555
+ const c = theme.colors;
556
+ const hasText = props.value.trim().length > 0;
557
+ const mode: "send" | "pause" | "resume" | "idle" =
558
+ hasText || props.canSend
559
+ ? "send"
560
+ : props.paused && props.onResume
561
+ ? "resume"
562
+ : props.running && props.onPause
563
+ ? "pause"
564
+ : "idle";
565
+ const active = mode !== "idle";
566
+ const busy = mode === "send" ? props.sending : mode === "idle" ? false : props.pauseBusy;
567
+ const disabled = mode === "send" ? !props.canSend : !active;
568
+ // A disabled send (text typed, but the host cannot send yet) looks disabled too.
569
+ const lit = active && !disabled;
570
+ const icon: NativeIconName = mode === "pause" ? "pause" : mode === "resume" ? "play" : "arrow-up";
571
+ const label =
572
+ mode === "pause"
573
+ ? props.messages.pauseAriaLabel
574
+ : mode === "resume"
575
+ ? props.messages.resumeThisWorkstream
576
+ : props.messages.sendMessageAriaLabel;
577
+ const fg = lit ? c["accent-fg"] : c["fg-subtle"];
578
+ return (
579
+ <Pressable
580
+ accessibilityRole="button"
581
+ accessibilityLabel={label}
582
+ accessibilityState={{ disabled, busy: Boolean(busy) }}
583
+ disabled={disabled || busy}
584
+ hitSlop={6}
585
+ onPress={() => {
586
+ props.onActionFeedback?.();
587
+ if (mode === "send") props.onSend();
588
+ else if (mode === "pause") props.onPause?.();
589
+ else if (mode === "resume") props.onResume?.();
590
+ }}
591
+ style={({ pressed }) => ({
592
+ width: 34,
593
+ height: 34,
594
+ borderRadius: 17,
595
+ alignItems: "center",
596
+ justifyContent: "center",
597
+ backgroundColor: lit ? c.accent : withAlpha(c.fg, 0.08),
598
+ opacity: pressed ? 0.8 : 1,
599
+ transform: [{ scale: pressed ? 0.94 : 1 }],
600
+ })}
601
+ >
602
+ {busy ? (
603
+ <ActivityIndicator size="small" color={fg} />
604
+ ) : (
605
+ <Icon
606
+ name={icon}
607
+ size={icon === "arrow-up" ? 18 : 14}
608
+ strokeWidth={icon === "arrow-up" ? 2.5 : 2}
609
+ color={fg}
610
+ fill={icon === "arrow-up" ? undefined : fg}
611
+ />
612
+ )}
613
+ </Pressable>
614
+ );
615
+ }
616
+
617
+ /**
618
+ * The web model pill ("6 Luna High ⌄"): a quiet toolbar chip that opens host
619
+ * options. As on web, the muted detail (reasoning effort) gives way first, so a
620
+ * long name never pushes it into a clipped "Ext…".
621
+ */
622
+ export function ComposerPill({
623
+ label,
624
+ detail,
625
+ onPress,
626
+ leading,
627
+ fast = false,
628
+ }: {
629
+ label: string;
630
+ /** Secondary text after the label (the reasoning effort); truncates before the label. */
631
+ detail?: string | null | undefined;
632
+ /** Fast latency is on: a filled bolt after the name, as on the web pill. */
633
+ fast?: boolean | undefined;
634
+ onPress?: (() => void) | undefined;
635
+ /** Shown before the label (the web picker's maker mark). */
636
+ leading?: ReactNode;
637
+ }) {
638
+ const theme = useNativeTimelineTheme();
639
+ return (
640
+ <Pressable
641
+ accessibilityRole="button"
642
+ accessibilityLabel={[label, detail, fast ? "Fast" : null].filter(Boolean).join(", ")}
643
+ onPress={onPress}
644
+ disabled={!onPress}
645
+ style={({ pressed }) => ({
646
+ flexDirection: "row",
647
+ alignItems: "center",
648
+ gap: 4,
649
+ flexShrink: 1,
650
+ maxWidth: 260,
651
+ minHeight: 44,
652
+ paddingHorizontal: 8,
653
+ borderRadius: 999,
654
+ backgroundColor: pressed ? theme.colors["surface-2"] : "transparent",
655
+ })}
656
+ >
657
+ {leading}
658
+ <Text
659
+ numberOfLines={1}
660
+ style={{
661
+ ...fontStyle(theme, 500),
662
+ fontSize: theme.size.sm,
663
+ color: theme.colors.fg,
664
+ flexShrink: 1,
665
+ }}
666
+ >
667
+ {label}
668
+ </Text>
669
+ {detail ? (
670
+ <Text
671
+ numberOfLines={1}
672
+ style={{
673
+ ...fontStyle(theme, 500),
674
+ fontSize: theme.size.sm,
675
+ color: theme.colors["fg-muted"],
676
+ flexShrink: 9999,
677
+ }}
678
+ >
679
+ {detail}
680
+ </Text>
681
+ ) : null}
682
+ {fast ? <Icon name="zap" size={13} color={theme.colors.fg} fill={theme.colors.fg} /> : null}
683
+ <Icon name="chevron-down" size={12} color={theme.colors["fg-muted"]} />
684
+ </Pressable>
685
+ );
686
+ }