@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.
- package/LICENSE +190 -0
- package/README.md +74 -0
- package/dist/adapters.d.ts +53 -0
- package/dist/adapters.js +3 -0
- package/dist/adapters.js.map +1 -0
- package/dist/attachments.d.ts +75 -0
- package/dist/chunk-3VSZAQ7G.js +391 -0
- package/dist/chunk-3VSZAQ7G.js.map +1 -0
- package/dist/chunk-5ZVK7WKA.js +88 -0
- package/dist/chunk-5ZVK7WKA.js.map +1 -0
- package/dist/chunk-EEDIMY3L.js +240 -0
- package/dist/chunk-EEDIMY3L.js.map +1 -0
- package/dist/chunk-M23SDI4R.js +83 -0
- package/dist/chunk-M23SDI4R.js.map +1 -0
- package/dist/chunk-PHJBSAR3.js +2006 -0
- package/dist/chunk-PHJBSAR3.js.map +1 -0
- package/dist/environment.d.ts +19 -0
- package/dist/expo.d.ts +25 -0
- package/dist/expo.js +267 -0
- package/dist/expo.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +3345 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +262 -0
- package/dist/read-acknowledgement.d.ts +39 -0
- package/dist/realtime/call-events.d.ts +11 -0
- package/dist/realtime/call-view.d.ts +26 -0
- package/dist/realtime/expo-call.d.ts +11 -0
- package/dist/realtime/native-call.d.ts +104 -0
- package/dist/realtime/native-realtime.d.ts +35 -0
- package/dist/realtime/use-native-session-realtime.d.ts +25 -0
- package/dist/session-view.d.ts +87 -0
- package/dist/timeline/activity.d.ts +34 -0
- package/dist/timeline/composer.d.ts +86 -0
- package/dist/timeline/controls.d.ts +27 -0
- package/dist/timeline/decisions.d.ts +29 -0
- package/dist/timeline/feedback.d.ts +26 -0
- package/dist/timeline/icon-nodes.generated.d.ts +738 -0
- package/dist/timeline/icon.d.ts +11 -0
- package/dist/timeline/index.d.ts +32 -0
- package/dist/timeline/index.js +5419 -0
- package/dist/timeline/index.js.map +1 -0
- package/dist/timeline/markdown.d.ts +11 -0
- package/dist/timeline/markdown.js +415 -0
- package/dist/timeline/markdown.js.map +1 -0
- package/dist/timeline/message-attachments.d.ts +13 -0
- package/dist/timeline/message-timeline.d.ts +36 -0
- package/dist/timeline/model-picker.d.ts +17 -0
- package/dist/timeline/primitives.d.ts +85 -0
- package/dist/timeline/queue-dock.d.ts +19 -0
- package/dist/timeline/session-actions.d.ts +34 -0
- package/dist/timeline/session-list.d.ts +50 -0
- package/dist/timeline/session-screen.d.ts +35 -0
- package/dist/timeline/session-signals.d.ts +37 -0
- package/dist/timeline/sheet.d.ts +14 -0
- package/dist/timeline/theme.d.ts +58 -0
- package/dist/timeline/thinking-orb.d.ts +13 -0
- package/dist/timeline/turn.d.ts +80 -0
- package/dist/ui/attention.d.ts +14 -0
- package/dist/ui/cards.d.ts +21 -0
- package/dist/ui/composer.d.ts +21 -0
- package/dist/ui/format.d.ts +13 -0
- package/dist/ui/icons.d.ts +9 -0
- package/dist/ui/index.d.ts +12 -0
- package/dist/ui/index.js +1757 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/steps-sheet.d.ts +10 -0
- package/dist/ui/theme.d.ts +63 -0
- package/dist/ui/timeline.d.ts +48 -0
- package/dist/ui/web-tokens.gen.d.ts +90 -0
- package/dist/use-native-session.d.ts +40 -0
- package/dist/view-model.d.ts +52 -0
- package/dist/voice-input.d.ts +102 -0
- package/dist/webrtc.d.ts +9 -0
- package/dist/webrtc.js +19 -0
- package/dist/webrtc.js.map +1 -0
- package/expo-module.config.json +7 -0
- package/ios/OpenGeniCall.podspec +22 -0
- package/ios/OpenGeniCallAppDelegateSubscriber.swift +29 -0
- package/ios/OpenGeniCallCenter.swift +189 -0
- package/ios/OpenGeniCallModule.swift +64 -0
- package/package.json +93 -0
- package/src/adapters.ts +150 -0
- package/src/attachments.ts +396 -0
- package/src/environment.tsx +214 -0
- package/src/expo.ts +295 -0
- package/src/index.ts +98 -0
- package/src/presentation.ts +285 -0
- package/src/read-acknowledgement.ts +72 -0
- package/src/realtime/call-events.ts +41 -0
- package/src/realtime/call-view.tsx +220 -0
- package/src/realtime/expo-call.ts +49 -0
- package/src/realtime/native-call.ts +266 -0
- package/src/realtime/native-realtime.ts +115 -0
- package/src/realtime/use-native-session-realtime.ts +29 -0
- package/src/session-view.tsx +2251 -0
- package/src/timeline/activity.tsx +736 -0
- package/src/timeline/composer.tsx +686 -0
- package/src/timeline/controls.tsx +151 -0
- package/src/timeline/decisions.tsx +651 -0
- package/src/timeline/feedback.tsx +251 -0
- package/src/timeline/icon-nodes.generated.ts +1830 -0
- package/src/timeline/icon.tsx +44 -0
- package/src/timeline/index.ts +67 -0
- package/src/timeline/markdown.tsx +510 -0
- package/src/timeline/message-attachments.tsx +155 -0
- package/src/timeline/message-timeline.tsx +1042 -0
- package/src/timeline/model-picker.tsx +450 -0
- package/src/timeline/primitives.tsx +561 -0
- package/src/timeline/queue-dock.tsx +331 -0
- package/src/timeline/session-actions.tsx +379 -0
- package/src/timeline/session-list.tsx +338 -0
- package/src/timeline/session-screen.tsx +488 -0
- package/src/timeline/session-signals.tsx +312 -0
- package/src/timeline/sheet.tsx +153 -0
- package/src/timeline/theme.tsx +128 -0
- package/src/timeline/thinking-orb.tsx +93 -0
- package/src/timeline/turn.tsx +569 -0
- package/src/ui/attention.tsx +220 -0
- package/src/ui/cards.tsx +267 -0
- package/src/ui/composer.tsx +296 -0
- package/src/ui/format.ts +86 -0
- package/src/ui/icons.tsx +131 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/steps-sheet.tsx +71 -0
- package/src/ui/theme.ts +261 -0
- package/src/ui/timeline.tsx +837 -0
- package/src/ui/web-tokens.gen.ts +94 -0
- package/src/use-native-session.ts +219 -0
- package/src/view-model.ts +485 -0
- package/src/voice-input.ts +382 -0
- 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
|
+
}
|