@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,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
|
+
}
|