@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,312 @@
1
+ import type { ClientModel, LineageNode, SessionBackgroundCommand } from "@opengeni/sdk";
2
+ import type { UseGoalResult } from "@opengeni/react/session";
3
+ import { sessionAgentsSignal, sessionGoalStateLabel } from "@opengeni/react/session-agents-model";
4
+ import { useState, type ReactNode } from "react";
5
+ import { ActivityIndicator, Pressable, ScrollView, Text, View, type TextStyle } from "react-native";
6
+ import { Button } from "./controls";
7
+ import { Icon, type NativeIconName } from "./icon";
8
+ import { SessionRow } from "./session-list";
9
+ import { BottomSheet } from "./sheet";
10
+ import { fontStyle, useNativeTimelineTheme, type NativeTimelineTheme } from "./theme";
11
+
12
+ /* ----------------------------------------------------------------------------
13
+ The web session chrome's goal, agents and commands segments: one row of
14
+ quiet chips above the composer, each opening its panel as a sheet. A chip
15
+ appears only when there is something to show.
16
+ -------------------------------------------------------------------------- */
17
+
18
+ export interface SessionCommandsSource {
19
+ commands: SessionBackgroundCommand[];
20
+ loading: boolean;
21
+ error: Error | null;
22
+ cancel: (commandId: string) => Promise<void>;
23
+ }
24
+
25
+ export function SessionSignals(props: {
26
+ goal?: UseGoalResult | null | undefined;
27
+ agents: readonly LineageNode[];
28
+ commandsCount: number;
29
+ /** Mounted only while the commands sheet is open, so a closed chip never polls. */
30
+ renderCommands: (close: () => void) => ReactNode;
31
+ onOpenSession?: ((sessionId: string) => void) | undefined;
32
+ models?: readonly ClientModel[] | undefined;
33
+ readOnly?: boolean | undefined;
34
+ }) {
35
+ const [open, setOpen] = useState<"goal" | "agents" | "commands" | null>(null);
36
+ const goal = props.goal?.goal ?? null;
37
+ const agents = sessionAgentsSignal(props.agents);
38
+ if (!goal && !agents && props.commandsCount === 0) return null;
39
+ const close = () => setOpen(null);
40
+ return (
41
+ <>
42
+ <ScrollView
43
+ horizontal
44
+ showsHorizontalScrollIndicator={false}
45
+ contentContainerStyle={{ gap: 6, alignItems: "center" }}
46
+ style={{ flexGrow: 0, flexShrink: 1 }}
47
+ >
48
+ {goal ? (
49
+ <Chip
50
+ icon="target"
51
+ label="Goal"
52
+ detail={sessionGoalStateLabel(goal)}
53
+ tone={goal.status === "active" ? "running" : "neutral"}
54
+ onPress={() => setOpen("goal")}
55
+ />
56
+ ) : null}
57
+ {agents ? (
58
+ <Chip
59
+ icon="bot"
60
+ label={`${agents.count} agent${agents.count === 1 ? "" : "s"}`}
61
+ detail={agents.detail}
62
+ tone={agents.tone}
63
+ onPress={() => setOpen("agents")}
64
+ />
65
+ ) : null}
66
+ {props.commandsCount > 0 ? (
67
+ <Chip
68
+ icon="terminal"
69
+ label={`${props.commandsCount} command${props.commandsCount === 1 ? "" : "s"}`}
70
+ detail="Running"
71
+ tone="running"
72
+ onPress={() => setOpen("commands")}
73
+ />
74
+ ) : null}
75
+ </ScrollView>
76
+
77
+ <BottomSheet open={open === "goal"} onClose={close} accessibilityLabel="Goal">
78
+ {goal && props.goal ? <GoalPanel goal={props.goal} readOnly={props.readOnly} /> : null}
79
+ </BottomSheet>
80
+ <BottomSheet open={open === "agents"} onClose={close} accessibilityLabel="Agents">
81
+ <SheetTitle>Agents</SheetTitle>
82
+ <ScrollView style={{ paddingHorizontal: 12 }}>
83
+ {props.agents.map((node) => (
84
+ <View key={node.session.id}>
85
+ <SessionRow
86
+ session={node.session}
87
+ models={props.models ?? []}
88
+ onPress={() => {
89
+ close();
90
+ props.onOpenSession?.(node.session.id);
91
+ }}
92
+ />
93
+ {node.children.map((child) => (
94
+ <View key={child.session.id} style={{ paddingLeft: 18 }}>
95
+ <SessionRow
96
+ session={child.session}
97
+ models={props.models ?? []}
98
+ onPress={() => {
99
+ close();
100
+ props.onOpenSession?.(child.session.id);
101
+ }}
102
+ />
103
+ </View>
104
+ ))}
105
+ </View>
106
+ ))}
107
+ </ScrollView>
108
+ </BottomSheet>
109
+ <BottomSheet open={open === "commands"} onClose={close} accessibilityLabel="Commands">
110
+ <SheetTitle>Commands</SheetTitle>
111
+ {open === "commands" ? props.renderCommands(close) : null}
112
+ </BottomSheet>
113
+ </>
114
+ );
115
+ }
116
+
117
+ function Chip(props: {
118
+ icon: NativeIconName;
119
+ label: string;
120
+ detail: string;
121
+ tone: "running" | "waiting" | "neutral";
122
+ onPress: () => void;
123
+ }) {
124
+ const theme = useNativeTimelineTheme();
125
+ const c = theme.colors;
126
+ const toneColor =
127
+ props.tone === "running"
128
+ ? c["status-running"]
129
+ : props.tone === "waiting"
130
+ ? c["status-waiting"]
131
+ : c["fg-subtle"];
132
+ // Compact on the phone: the status reads from the dot (and the sheet); the
133
+ // word stays in the accessibility label. 30pt tall, 44pt to touch.
134
+ return (
135
+ <Pressable
136
+ accessibilityRole="button"
137
+ accessibilityLabel={`${props.label}, ${props.detail}`}
138
+ onPress={props.onPress}
139
+ hitSlop={{ top: 7, bottom: 7, left: 3, right: 3 }}
140
+ style={({ pressed }) => signalChipStyle(theme, pressed)}
141
+ >
142
+ <Icon name={props.icon} size={13} color={c["fg-muted"]} />
143
+ <Text style={signalChipText(theme)}>{props.label}</Text>
144
+ <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: toneColor }} />
145
+ </Pressable>
146
+ );
147
+ }
148
+
149
+ function SheetTitle({ children }: { children: string }) {
150
+ const theme = useNativeTimelineTheme();
151
+ return (
152
+ <Text
153
+ accessibilityRole="header"
154
+ style={{
155
+ ...fontStyle(theme, 600),
156
+ fontSize: 17,
157
+ color: theme.colors.fg,
158
+ paddingHorizontal: 20,
159
+ paddingBottom: 8,
160
+ }}
161
+ >
162
+ {children}
163
+ </Text>
164
+ );
165
+ }
166
+
167
+ function GoalPanel({ goal, readOnly }: { goal: UseGoalResult; readOnly?: boolean | undefined }) {
168
+ const theme = useNativeTimelineTheme();
169
+ const c = theme.colors;
170
+ const record = goal.goal!;
171
+ const body = { ...fontStyle(theme, 400), fontSize: 15, lineHeight: 22, color: c.fg };
172
+ const label = { ...fontStyle(theme, 600), fontSize: 12, color: c["fg-subtle"] };
173
+ return (
174
+ <ScrollView contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 12, gap: 14 }}>
175
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
176
+ <Text style={{ ...fontStyle(theme, 600), fontSize: 17, color: c.fg }}>Goal</Text>
177
+ <Text style={{ ...fontStyle(theme, 500), fontSize: 13, color: c["fg-muted"] }}>
178
+ {sessionGoalStateLabel(record)}
179
+ </Text>
180
+ </View>
181
+ <View style={{ gap: 4 }}>
182
+ <Text style={label}>OBJECTIVE</Text>
183
+ <Text style={body} selectable>
184
+ {record.text}
185
+ </Text>
186
+ </View>
187
+ {record.successCriteria ? (
188
+ <View style={{ gap: 4 }}>
189
+ <Text style={label}>DONE WHEN</Text>
190
+ <Text style={body} selectable>
191
+ {record.successCriteria}
192
+ </Text>
193
+ </View>
194
+ ) : null}
195
+ {record.status === "paused" && record.rationale ? (
196
+ <View style={{ gap: 4 }}>
197
+ <Text style={label}>WHY IT PAUSED</Text>
198
+ <Text style={body}>{record.rationale}</Text>
199
+ </View>
200
+ ) : null}
201
+ {record.status === "completed" && record.evidence ? (
202
+ <View style={{ gap: 4 }}>
203
+ <Text style={label}>EVIDENCE</Text>
204
+ <Text style={body}>{record.evidence}</Text>
205
+ </View>
206
+ ) : null}
207
+ {!readOnly && record.status !== "completed" ? (
208
+ <View style={{ flexDirection: "row", gap: 8 }}>
209
+ {record.status === "active" ? (
210
+ <Button
211
+ label="Pause goal"
212
+ icon="pause"
213
+ busy={goal.updating}
214
+ onPress={() => void goal.pause()}
215
+ />
216
+ ) : (
217
+ <Button
218
+ label="Resume goal"
219
+ icon="play"
220
+ variant="primary"
221
+ busy={goal.updating}
222
+ onPress={() => void goal.resume()}
223
+ />
224
+ )}
225
+ </View>
226
+ ) : null}
227
+ {goal.mutationError ? (
228
+ <Text style={{ ...body, color: c["status-failed"] }}>{goal.mutationError.message}</Text>
229
+ ) : null}
230
+ </ScrollView>
231
+ );
232
+ }
233
+
234
+ /** The web commands panel: each running command with its state and a Stop action. */
235
+ export function SessionCommandsList({
236
+ commands,
237
+ readOnly,
238
+ }: {
239
+ commands: SessionCommandsSource;
240
+ readOnly?: boolean | undefined;
241
+ }) {
242
+ const theme = useNativeTimelineTheme();
243
+ const c = theme.colors;
244
+ if (commands.loading && commands.commands.length === 0) {
245
+ return <ActivityIndicator style={{ padding: 24 }} />;
246
+ }
247
+ if (commands.commands.length === 0) {
248
+ return (
249
+ <Text style={{ ...fontStyle(theme, 400), color: c["fg-muted"], padding: 20 }}>
250
+ No commands are running.
251
+ </Text>
252
+ );
253
+ }
254
+ return (
255
+ <ScrollView contentContainerStyle={{ paddingHorizontal: 16, gap: 8, paddingBottom: 12 }}>
256
+ {commands.commands.map((command) => {
257
+ const running = command.state === "running";
258
+ return (
259
+ <View
260
+ key={command.id}
261
+ style={{
262
+ gap: 6,
263
+ padding: 12,
264
+ borderRadius: theme.radius.md,
265
+ backgroundColor: c["surface-2"],
266
+ }}
267
+ >
268
+ <Text
269
+ numberOfLines={3}
270
+ style={{ ...fontStyle(theme, 400, "mono"), fontSize: 13, color: c.fg }}
271
+ >
272
+ {`$ ${command.commandPreview}`}
273
+ </Text>
274
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
275
+ <Text
276
+ style={{ ...fontStyle(theme, 400), fontSize: 12, color: c["fg-muted"], flex: 1 }}
277
+ >
278
+ {command.provider === "connected_machine" ? "On a machine" : "In the sandbox"} ·{" "}
279
+ {command.state === "stopping" ? "Stopping" : running ? "Running" : "Finished"}
280
+ </Text>
281
+ {running && !readOnly ? (
282
+ <Button
283
+ label="Stop"
284
+ icon="square"
285
+ size="sm"
286
+ onPress={() => void commands.cancel(command.id)}
287
+ />
288
+ ) : null}
289
+ </View>
290
+ </View>
291
+ );
292
+ })}
293
+ </ScrollView>
294
+ );
295
+ }
296
+
297
+ /** The compact signal chip shared by goal, agents, commands and the queue. */
298
+ export function signalChipStyle(theme: NativeTimelineTheme, pressed: boolean) {
299
+ return {
300
+ flexDirection: "row" as const,
301
+ alignItems: "center" as const,
302
+ gap: 5,
303
+ height: 30,
304
+ paddingHorizontal: 10,
305
+ borderRadius: 15,
306
+ backgroundColor: pressed ? theme.colors.hover : theme.colors["surface-2"],
307
+ };
308
+ }
309
+
310
+ export function signalChipText(theme: NativeTimelineTheme): TextStyle {
311
+ return { ...fontStyle(theme, 500), fontSize: 13, color: theme.colors.fg };
312
+ }
@@ -0,0 +1,153 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import {
3
+ AccessibilityInfo,
4
+ Animated,
5
+ Easing,
6
+ KeyboardAvoidingView,
7
+ Modal,
8
+ PanResponder,
9
+ Platform,
10
+ Pressable,
11
+ View,
12
+ useWindowDimensions,
13
+ } from "react-native";
14
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
15
+ import { useNativeTimelineTheme } from "./theme";
16
+
17
+ /**
18
+ * A content-sized bottom sheet: dimmed backdrop, grabber, drag or tap outside
19
+ * to dismiss, keyboard-aware and safe-area padded. The native counterpart of
20
+ * the web's anchored menus at phone width.
21
+ */
22
+ export function BottomSheet(props: {
23
+ open: boolean;
24
+ onClose: () => void;
25
+ accessibilityLabel: string;
26
+ /** Cap as a fraction of the window height (default 0.85). */
27
+ maxHeightRatio?: number | undefined;
28
+ children: ReactNode;
29
+ }) {
30
+ const theme = useNativeTimelineTheme();
31
+ const c = theme.colors;
32
+ const insets = useSafeAreaInsets();
33
+ const { height } = useWindowDimensions();
34
+ const [mounted, setMounted] = useState(props.open);
35
+ const progress = useRef(new Animated.Value(0)).current;
36
+ const drag = useRef(new Animated.Value(0)).current;
37
+ const onClose = useRef(props.onClose);
38
+ onClose.current = props.onClose;
39
+
40
+ useEffect(() => {
41
+ let cancelled = false;
42
+ if (props.open) {
43
+ setMounted(true);
44
+ drag.setValue(0);
45
+ void AccessibilityInfo.isReduceMotionEnabled().then((reduce) => {
46
+ if (cancelled) return;
47
+ Animated.timing(progress, {
48
+ toValue: 1,
49
+ duration: reduce ? 0 : 260,
50
+ easing: Easing.bezier(0.22, 1, 0.36, 1),
51
+ useNativeDriver: true,
52
+ }).start();
53
+ });
54
+ } else if (mounted) {
55
+ Animated.timing(progress, {
56
+ toValue: 0,
57
+ duration: 200,
58
+ easing: Easing.in(Easing.quad),
59
+ useNativeDriver: true,
60
+ }).start(({ finished }) => {
61
+ if (finished && !cancelled) setMounted(false);
62
+ });
63
+ }
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [drag, mounted, progress, props.open]);
68
+
69
+ const pan = useRef(
70
+ PanResponder.create({
71
+ onMoveShouldSetPanResponder: (_event, gesture) =>
72
+ gesture.dy > 6 && Math.abs(gesture.dy) > Math.abs(gesture.dx),
73
+ onPanResponderMove: (_event, gesture) => drag.setValue(Math.max(0, gesture.dy)),
74
+ onPanResponderRelease: (_event, gesture) => {
75
+ if (gesture.dy > 90 || gesture.vy > 0.9) onClose.current();
76
+ else Animated.spring(drag, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start();
77
+ },
78
+ onPanResponderTerminate: () =>
79
+ Animated.spring(drag, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start(),
80
+ }),
81
+ ).current;
82
+
83
+ if (!mounted) return null;
84
+ const translateY = Animated.add(
85
+ progress.interpolate({ inputRange: [0, 1], outputRange: [height, 0] }),
86
+ drag,
87
+ );
88
+ return (
89
+ <Modal
90
+ visible
91
+ transparent
92
+ animationType="none"
93
+ statusBarTranslucent
94
+ onRequestClose={props.onClose}
95
+ >
96
+ <KeyboardAvoidingView
97
+ behavior={Platform.OS === "ios" ? "padding" : undefined}
98
+ style={{ flex: 1, justifyContent: "flex-end" }}
99
+ >
100
+ <Animated.View
101
+ style={{
102
+ position: "absolute",
103
+ top: 0,
104
+ right: 0,
105
+ bottom: 0,
106
+ left: 0,
107
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
108
+ opacity: progress,
109
+ }}
110
+ >
111
+ <Pressable
112
+ style={{ flex: 1 }}
113
+ onPress={props.onClose}
114
+ accessibilityRole="button"
115
+ accessibilityLabel="Close"
116
+ />
117
+ </Animated.View>
118
+ <Animated.View
119
+ accessibilityViewIsModal
120
+ accessibilityLabel={props.accessibilityLabel}
121
+ style={{
122
+ maxHeight: height * (props.maxHeightRatio ?? 0.85),
123
+ backgroundColor: c.bg,
124
+ borderTopLeftRadius: 16,
125
+ borderTopRightRadius: 16,
126
+ paddingBottom: Math.max(insets.bottom, 12),
127
+ transform: [{ translateY }],
128
+ shadowColor: "#000",
129
+ shadowOpacity: 0.12,
130
+ shadowRadius: 16,
131
+ shadowOffset: { width: 0, height: -2 },
132
+ elevation: 16,
133
+ }}
134
+ >
135
+ <View
136
+ {...pan.panHandlers}
137
+ style={{ alignItems: "center", paddingTop: 8, paddingBottom: 4 }}
138
+ >
139
+ <View
140
+ style={{
141
+ width: 36,
142
+ height: 5,
143
+ borderRadius: 3,
144
+ backgroundColor: c["border-strong"] ?? c.border,
145
+ }}
146
+ />
147
+ </View>
148
+ {props.children}
149
+ </Animated.View>
150
+ </KeyboardAvoidingView>
151
+ </Modal>
152
+ );
153
+ }
@@ -0,0 +1,128 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import { Platform, type TextStyle } from "react-native";
3
+ import { webColorsDark, webColorsLight, webLengths } from "../ui/web-tokens.gen";
4
+
5
+ /* ----------------------------------------------------------------------------
6
+ Native timeline theme
7
+
8
+ The web design tokens (generated from the web stylesheet) drive every color,
9
+ size and radius, so the native timeline matches the web app at phone width.
10
+ Hosts override tokens (brand colors, fonts) without forking components.
11
+ -------------------------------------------------------------------------- */
12
+
13
+ export type WebColorToken = keyof typeof webColorsLight;
14
+ export type NativeTimelineColors = Record<WebColorToken, string>;
15
+
16
+ export interface NativeTimelineFonts {
17
+ /** Font family per weight for the sans face; undefined → platform system font. */
18
+ sans?: Partial<Record<"400" | "500" | "600" | "700", string>> | undefined;
19
+ /** Font family per weight for the mono face; undefined → platform monospace. */
20
+ mono?: Partial<Record<"400" | "500", string>> | undefined;
21
+ /** Regular-weight italic family (custom fonts cannot be synthesized italic on iOS). */
22
+ sansItalic?: string | undefined;
23
+ /** Extra sans letter spacing (pt) to match the web face's metrics. */
24
+ sansTracking?: number | undefined;
25
+ }
26
+
27
+ export interface NativeTimelineTheme {
28
+ scheme: "light" | "dark";
29
+ colors: NativeTimelineColors;
30
+ size: {
31
+ xs: number;
32
+ sm: number;
33
+ base: number;
34
+ md: number;
35
+ composer: number;
36
+ };
37
+ radius: { xs: number; sm: number; md: number; lg: number; xl: number; full: number };
38
+ fonts: NativeTimelineFonts;
39
+ }
40
+
41
+ export interface NativeTimelineThemeOverrides {
42
+ light?: Partial<NativeTimelineColors> | undefined;
43
+ dark?: Partial<NativeTimelineColors> | undefined;
44
+ fonts?: NativeTimelineFonts | undefined;
45
+ }
46
+
47
+ export function createNativeTimelineTheme(
48
+ scheme: "light" | "dark",
49
+ overrides: NativeTimelineThemeOverrides = {},
50
+ ): NativeTimelineTheme {
51
+ const base = scheme === "dark" ? webColorsDark : webColorsLight;
52
+ return {
53
+ scheme,
54
+ colors: { ...base, ...(scheme === "dark" ? overrides.dark : overrides.light) },
55
+ size: {
56
+ xs: webLengths["font-size-xs"],
57
+ sm: webLengths["font-size-sm"],
58
+ base: webLengths["font-size-base"],
59
+ md: webLengths["font-size-md"],
60
+ composer: webLengths["font-size-composer"],
61
+ },
62
+ radius: {
63
+ xs: webLengths["radius-xs"],
64
+ sm: webLengths["radius-sm"],
65
+ md: webLengths["radius-md"],
66
+ lg: webLengths["radius-lg"],
67
+ xl: webLengths["radius-xl"],
68
+ full: webLengths["radius-full"],
69
+ },
70
+ fonts: overrides.fonts ?? {},
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Fill for an inset block on the page (code blocks). Web uses `surface-1`, which reads
76
+ * as a block on the component `bg`; when the host page itself is `surface-1` (the
77
+ * white or near-black app canvas), the block falls back to the web component `bg` so
78
+ * it never disappears into the page.
79
+ */
80
+ export function insetSurface(theme: NativeTimelineTheme): string {
81
+ const { colors } = theme;
82
+ if (colors["surface-1"] !== colors.bg) return colors["surface-1"];
83
+ return (theme.scheme === "dark" ? webColorsDark : webColorsLight).bg;
84
+ }
85
+
86
+ const ThemeContext = createContext<NativeTimelineTheme>(createNativeTimelineTheme("light"));
87
+
88
+ export function NativeTimelineThemeProvider({
89
+ scheme,
90
+ overrides,
91
+ children,
92
+ }: {
93
+ scheme: "light" | "dark";
94
+ overrides?: NativeTimelineThemeOverrides | undefined;
95
+ children: ReactNode;
96
+ }) {
97
+ const theme = useMemo(() => createNativeTimelineTheme(scheme, overrides), [scheme, overrides]);
98
+ return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
99
+ }
100
+
101
+ export function useNativeTimelineTheme(): NativeTimelineTheme {
102
+ return useContext(ThemeContext);
103
+ }
104
+
105
+ /** Text style for a web weight, resolving host font families when supplied. */
106
+ export function fontStyle(
107
+ theme: NativeTimelineTheme,
108
+ weight: 400 | 500 | 600 | 700 = 400,
109
+ face: "sans" | "mono" = "sans",
110
+ ): TextStyle {
111
+ if (face === "mono") {
112
+ const family = theme.fonts.mono?.[weight >= 500 ? "500" : "400"] ?? theme.fonts.mono?.["400"];
113
+ return family ? { fontFamily: family } : { fontFamily: MONO_FALLBACK, fontWeight: `${weight}` };
114
+ }
115
+ const key = `${weight}` as "400" | "500" | "600" | "700";
116
+ const family = theme.fonts.sans?.[key];
117
+ // Android measures single-line text without letter spacing, so tracked text loses
118
+ // its last glyphs (a truncated "…" shows as ".."); the tweak only matters on iOS.
119
+ const tracking =
120
+ theme.fonts.sansTracking && Platform.OS !== "android"
121
+ ? { letterSpacing: theme.fonts.sansTracking }
122
+ : {};
123
+ return family ? { fontFamily: family, ...tracking } : { fontWeight: key, ...tracking };
124
+ }
125
+
126
+ const MONO_FALLBACK = "Menlo";
127
+
128
+ export { MONO_FALLBACK };
@@ -0,0 +1,93 @@
1
+ import { MODE_FRAMES, resolvePreset, type OrbState } from "thinking-orbs/engine";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { AccessibilityInfo, AppState } from "react-native";
4
+ import Svg, { Circle, Line } from "react-native-svg";
5
+ import { useNativeTimelineTheme } from "./theme";
6
+
7
+ /** About 30 frames a second: smooth at 64pt without spending a frame budget. */
8
+ const FRAME_MS = 33;
9
+
10
+ function ink(white: number, alpha: number | undefined, dark: boolean): string {
11
+ // The engine's convention: 0 is the darkest ink on paper, mirrored on dark.
12
+ const level = Math.round((dark ? 1 - Math.min(1, Math.max(0, white)) : white) * 255);
13
+ return `rgba(${level},${level},${level},${alpha ?? 1})`;
14
+ }
15
+
16
+ /**
17
+ * The web's ThinkingOrb on native: the same `thinking-orbs` geometry (one
18
+ * frame of dots and edges per instant) drawn with SVG. Pauses in the
19
+ * background and holds a still frame under Reduce Motion, as the web does.
20
+ */
21
+ export function ThinkingOrb({
22
+ state = "searching",
23
+ size = 64,
24
+ speed = 0.8,
25
+ displaySize,
26
+ }: {
27
+ state?: OrbState;
28
+ size?: 64 | 20;
29
+ speed?: number;
30
+ /** Draw the tuned preset larger (for example a call screen) without losing sharpness. */
31
+ displaySize?: number;
32
+ }) {
33
+ const theme = useNativeTimelineTheme();
34
+ const dark = theme.scheme === "dark";
35
+ const preset = useMemo(() => resolvePreset(state, size), [state, size]);
36
+ const [time, setTime] = useState(0.6);
37
+ const [reduceMotion, setReduceMotion] = useState(false);
38
+ const [active, setActive] = useState(AppState.currentState === "active");
39
+ useEffect(() => {
40
+ void AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
41
+ const motion = AccessibilityInfo.addEventListener("reduceMotionChanged", setReduceMotion);
42
+ const app = AppState.addEventListener("change", (next) => setActive(next === "active"));
43
+ return () => {
44
+ motion.remove();
45
+ app.remove();
46
+ };
47
+ }, []);
48
+ useEffect(() => {
49
+ if (reduceMotion || !active) return;
50
+ const started = Date.now();
51
+ const timer = setInterval(() => setTime((Date.now() - started) / 1000), FRAME_MS);
52
+ return () => clearInterval(timer);
53
+ }, [active, reduceMotion]);
54
+ const frame = MODE_FRAMES[preset.mode](
55
+ size,
56
+ reduceMotion ? 0.6 : time * preset.speed * speed,
57
+ preset.opts,
58
+ );
59
+ return (
60
+ <Svg
61
+ width={displaySize ?? size}
62
+ height={displaySize ?? size}
63
+ viewBox={`0 0 ${size} ${size}`}
64
+ accessibilityElementsHidden
65
+ importantForAccessibility="no"
66
+ >
67
+ {frame.lines.map((line, index) => (
68
+ <Line
69
+ // Stateless drawing slots retain their keys as coordinates animate.
70
+ // oxlint-disable-next-line react/no-array-index-key
71
+ key={`l${index}`}
72
+ x1={line.x1}
73
+ y1={line.y1}
74
+ x2={line.x2}
75
+ y2={line.y2}
76
+ stroke={ink(line.white, line.a, dark)}
77
+ strokeWidth={line.w}
78
+ />
79
+ ))}
80
+ {frame.dots.map((dot, index) => (
81
+ <Circle
82
+ // Stateless drawing slots retain their keys as coordinates animate.
83
+ // oxlint-disable-next-line react/no-array-index-key
84
+ key={`d${index}`}
85
+ cx={dot.x}
86
+ cy={dot.y}
87
+ r={dot.r}
88
+ fill={ink(dot.white, dot.a, dark)}
89
+ />
90
+ ))}
91
+ </Svg>
92
+ );
93
+ }