@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,569 @@
1
+ import type { ActivityItem, TurnOutcome } from "@opengeni/react/session";
2
+ import {
3
+ createTurnSummaryContext,
4
+ formatElapsed,
5
+ GENIE_PREPARING_PHRASES,
6
+ GENIE_WAITING_PHRASES,
7
+ resolveTurnSummaryFacets,
8
+ rollingActivityItem,
9
+ selectTurnSummaryFacets,
10
+ toolDisplayName,
11
+ type TurnSummaryFacetConfiguration,
12
+ } from "@opengeni/react/timeline-model";
13
+ import {
14
+ createContext,
15
+ useContext,
16
+ useEffect,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ type ReactNode,
21
+ } from "react";
22
+ import { Platform, Pressable, Text, View, type TextStyle } from "react-native";
23
+ import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutUp } from "react-native-reanimated";
24
+ import { ActivityRow } from "./activity";
25
+ import { ThinkingOrb } from "./thinking-orb";
26
+ import { Icon } from "./icon";
27
+ import { PulseDot, ShimmerText, ROW_MIN_HEIGHT, useReleaseFollow } from "./primitives";
28
+ import { fontStyle, useNativeTimelineTheme } from "./theme";
29
+
30
+ /* ----------------------------------------------------------------------------
31
+ Native TurnSummary / ActivityRail / RollingActivity — the web components of
32
+ the same names (timeline/turn-summary.tsx, activity-rail.tsx,
33
+ rolling-activity.tsx) in their rolling-turn configuration.
34
+ -------------------------------------------------------------------------- */
35
+
36
+ /** Native counterpart of the web `TurnSummaryStatus` with a renderable preview. */
37
+ export type NativeTurnStatus =
38
+ | { kind: "worked"; durationMs?: number | undefined; label?: string | undefined }
39
+ | {
40
+ kind: "working" | "waiting";
41
+ since?: string | undefined;
42
+ label?: string | undefined;
43
+ preview?: ReactNode;
44
+ };
45
+
46
+ type FoldMemory = Map<string, "open" | "closed">;
47
+ const FoldMemoryContext = createContext<FoldMemory | null>(null);
48
+
49
+ /**
50
+ * A shrink-wrapped single-line summary. Android sizes it to the ellipsized line without
51
+ * the "…" glyph's overhang, which then clips the last dot; a little trailing room keeps
52
+ * the ellipsis whole.
53
+ */
54
+ const TRUNCATED_TAIL: TextStyle =
55
+ Platform.OS === "android" ? { flexShrink: 1, paddingRight: 2 } : { flexShrink: 1 };
56
+ export const FoldMemoryProvider = FoldMemoryContext.Provider;
57
+
58
+ /**
59
+ * An expanded outer turn header stays pinned to the top of the timeline while
60
+ * its section scrolls (web: sticky inside the section). Each open section
61
+ * reports its content-space frame and a renderer for the header row; the
62
+ * timeline draws the pinned copy over the scroll view.
63
+ */
64
+ export type StickyWorkHeader = {
65
+ top: number;
66
+ height: number;
67
+ headerHeight: number;
68
+ render: () => ReactNode;
69
+ };
70
+ export type StickyWorkHeaderRegistry = {
71
+ register: (key: string, entry: StickyWorkHeader | null) => void;
72
+ /** Bring a section's top back into view (after collapsing from the pinned copy). */
73
+ reveal: (top: number) => void;
74
+ };
75
+ const StickyWorkHeaderContext = createContext<StickyWorkHeaderRegistry | null>(null);
76
+ export const StickyWorkHeaderProvider = StickyWorkHeaderContext.Provider;
77
+
78
+ export function useLiveNow(active: boolean): number {
79
+ const [now, setNow] = useState(() => Date.now());
80
+ useEffect(() => {
81
+ if (!active) return;
82
+ const timer = setInterval(() => setNow(Date.now()), 1000);
83
+ return () => clearInterval(timer);
84
+ }, [active]);
85
+ return now;
86
+ }
87
+
88
+ function LiveElapsed({ since, style }: { since: string; style: TextStyle }) {
89
+ const startedAt = Date.parse(since);
90
+ const now = useLiveNow(true);
91
+ if (!Number.isFinite(startedAt)) return null;
92
+ return (
93
+ <Text
94
+ style={[style, { fontVariant: ["tabular-nums"] }]}
95
+ >{` · ${formatElapsed(Math.max(0, now - startedAt))}`}</Text>
96
+ );
97
+ }
98
+
99
+ export interface TurnSummaryProps {
100
+ items: ActivityItem[];
101
+ outcome?: TurnOutcome | undefined;
102
+ failureText?: string | undefined;
103
+ durationMs?: number | undefined;
104
+ defaultOpen?: boolean | undefined;
105
+ liveHeader?: ReactNode;
106
+ bare?: boolean | undefined;
107
+ facets?: TurnSummaryFacetConfiguration | undefined;
108
+ foldKey?: string | undefined;
109
+ contextCompactionCount?: number | undefined;
110
+ status?: NativeTurnStatus | undefined;
111
+ children: ReactNode;
112
+ }
113
+
114
+ export function TurnSummary({
115
+ items,
116
+ outcome,
117
+ failureText,
118
+ durationMs,
119
+ defaultOpen,
120
+ liveHeader,
121
+ bare,
122
+ facets: facetConfiguration,
123
+ foldKey,
124
+ contextCompactionCount,
125
+ status,
126
+ children,
127
+ }: TurnSummaryProps) {
128
+ const theme = useNativeTimelineTheme();
129
+ const foldMemory = useContext(FoldMemoryContext);
130
+ const registerSticky = useContext(StickyWorkHeaderContext);
131
+ const releaseFollow = useReleaseFollow();
132
+ const frame = useRef<{ top: number; height: number; headerHeight: number }>({
133
+ top: 0,
134
+ height: 0,
135
+ headerHeight: 0,
136
+ });
137
+ const remembered = foldKey !== undefined ? foldMemory?.get(foldKey) : undefined;
138
+ const [open, setOpen] = useState(
139
+ remembered === "closed" ? false : remembered === "open" ? true : (defaultOpen ?? false),
140
+ );
141
+ const readerOwnsOpen = useRef(remembered !== undefined);
142
+ useEffect(() => {
143
+ if (status && defaultOpen && remembered === undefined && !readerOwnsOpen.current) setOpen(true);
144
+ }, [status, defaultOpen, remembered]);
145
+ const toggle = (next: boolean) => {
146
+ releaseFollow();
147
+ readerOwnsOpen.current = true;
148
+ if (foldKey !== undefined) foldMemory?.set(foldKey, next ? "open" : "closed");
149
+ setOpen(next);
150
+ };
151
+ const context = useMemo(
152
+ () =>
153
+ createTurnSummaryContext(
154
+ items,
155
+ outcome,
156
+ failureText,
157
+ durationMs,
158
+ contextCompactionCount ?? 0,
159
+ ),
160
+ [items, outcome, failureText, durationMs, contextCompactionCount],
161
+ );
162
+ const definitions = useMemo(
163
+ () => resolveTurnSummaryFacets(facetConfiguration),
164
+ [facetConfiguration],
165
+ );
166
+ const facets = useMemo(
167
+ () => selectTurnSummaryFacets(definitions, context, status?.kind),
168
+ [definitions, context, status?.kind],
169
+ );
170
+ const liveShell = outcome === undefined && open && !bare;
171
+ const text: TextStyle = {
172
+ ...fontStyle(theme),
173
+ fontSize: bare ? theme.size.sm : theme.size.base,
174
+ lineHeight: 20,
175
+ color: liveShell && !status ? theme.colors["fg-subtle"] : theme.colors["fg-muted"],
176
+ };
177
+ const showMarker = !(
178
+ status ||
179
+ outcome === "complete" ||
180
+ (!outcome && (liveHeader || context.settled))
181
+ );
182
+ const facetText = facets
183
+ .map(({ result }) =>
184
+ typeof result.content === "string" || typeof result.content === "number"
185
+ ? String(result.content)
186
+ : "",
187
+ )
188
+ .filter(Boolean);
189
+
190
+ let statusNode: ReactNode = null;
191
+ let hasStatusText = false;
192
+ if (status?.kind === "worked") {
193
+ if (
194
+ status.durationMs !== undefined &&
195
+ Number.isFinite(status.durationMs) &&
196
+ status.durationMs >= 1000
197
+ ) {
198
+ hasStatusText = true;
199
+ statusNode = (
200
+ <Text
201
+ style={text}
202
+ >{`${status.label ?? "Worked for"} ${formatElapsed(status.durationMs)}`}</Text>
203
+ );
204
+ }
205
+ } else if (status) {
206
+ hasStatusText = true;
207
+ const label = status.label ?? (status.kind === "working" ? "Working" : "Waiting");
208
+ statusNode = (
209
+ <>
210
+ <ShimmerText style={text} active={status.kind === "working"}>
211
+ {label}
212
+ </ShimmerText>
213
+ {status.since ? <LiveElapsed since={status.since} style={text} /> : null}
214
+ </>
215
+ );
216
+ }
217
+ const showLive = Boolean(liveHeader) && !open && !status;
218
+ const tail =
219
+ (showLive
220
+ ? ""
221
+ : facetText
222
+ .map((value, index) => (index > 0 || hasStatusText ? ` · ${value}` : value))
223
+ .join("")) + "";
224
+
225
+ const renderHeader = (pinned: boolean) => (
226
+ <Pressable
227
+ accessibilityRole="button"
228
+ accessibilityState={{ expanded: open }}
229
+ onPress={() => (pinned ? collapseFromPin() : toggle(!open))}
230
+ style={({ pressed }) => ({
231
+ flexDirection: "row",
232
+ alignItems: "center",
233
+ minHeight: ROW_MIN_HEIGHT,
234
+ paddingVertical: 10,
235
+ borderRadius: theme.radius.sm,
236
+ // Web `-mx-2 px-2 w-full`: shifted left 8 with the same width.
237
+ ...(bare
238
+ ? { gap: 8, paddingHorizontal: 6 }
239
+ : { gap: 10, paddingHorizontal: 8, marginLeft: -8, marginRight: 8 }),
240
+ backgroundColor: pressed
241
+ ? theme.colors.hover
242
+ : !bare && open
243
+ ? theme.colors.bg
244
+ : "transparent",
245
+ })}
246
+ >
247
+ <View style={{ transform: [{ rotate: open ? "90deg" : "0deg" }] }}>
248
+ <Icon name="chevron-right" size={14} color={theme.colors["fg-subtle"]} />
249
+ </View>
250
+ {showMarker ? (
251
+ <View
252
+ style={{
253
+ width: bare ? 14 : 20,
254
+ height: bare ? 14 : 20,
255
+ alignItems: "center",
256
+ justifyContent: "center",
257
+ }}
258
+ >
259
+ {outcome === "failed" ? (
260
+ <Icon name="triangle-alert" size={12} color={theme.colors["status-failed"]} />
261
+ ) : outcome === "cancelled" ? (
262
+ <Icon name="circle-slash" size={12} color={theme.colors["fg-subtle"]} />
263
+ ) : (
264
+ <PulseDot color={theme.colors["fg-subtle"]} />
265
+ )}
266
+ </View>
267
+ ) : null}
268
+ <View
269
+ style={{
270
+ flexDirection: "row",
271
+ alignItems: "center",
272
+ minWidth: 0,
273
+ ...(status?.kind === "worked" ? { flexShrink: 1 } : { flex: 1 }),
274
+ }}
275
+ >
276
+ {statusNode}
277
+ {showLive ? (
278
+ <View style={{ flex: 1, minWidth: 0 }}>{liveHeader}</View>
279
+ ) : (
280
+ <Text style={[text, TRUNCATED_TAIL]} numberOfLines={1}>
281
+ {tail}
282
+ {outcome === "failed" && failureText ? (
283
+ <Text style={{ color: theme.colors["status-failed"] }}>{` · ${failureText}`}</Text>
284
+ ) : null}
285
+ {outcome === "cancelled" ? (
286
+ <Text style={{ color: theme.colors["fg-subtle"] }}>{" · interrupted"}</Text>
287
+ ) : null}
288
+ </Text>
289
+ )}
290
+ </View>
291
+ {status?.kind === "worked" ? (
292
+ <View
293
+ style={{
294
+ marginLeft: 4,
295
+ height: 1,
296
+ flex: 1,
297
+ minWidth: 0,
298
+ backgroundColor: theme.colors.border,
299
+ }}
300
+ />
301
+ ) : null}
302
+ {status && status.kind !== "worked" && (contextCompactionCount ?? 0) > 0 ? (
303
+ <Icon name="shrink" size={14} color={theme.colors["fg-muted"]} />
304
+ ) : null}
305
+ </Pressable>
306
+ );
307
+ const sticky = !bare && open && foldKey !== undefined && registerSticky !== null;
308
+ const collapseFromPin = () => {
309
+ toggle(false);
310
+ registerSticky?.reveal(frame.current.top);
311
+ };
312
+ const report = () => {
313
+ if (!sticky || !registerSticky || foldKey === undefined) return;
314
+ registerSticky.register(foldKey, { ...frame.current, render: () => renderHeader(true) });
315
+ };
316
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- re-report when the row content changes
317
+ useEffect(() => {
318
+ if (!sticky) {
319
+ if (registerSticky && foldKey !== undefined) registerSticky.register(foldKey, null);
320
+ return;
321
+ }
322
+ report();
323
+ return () => registerSticky?.register(foldKey!, null);
324
+ });
325
+
326
+ return (
327
+ <View
328
+ onLayout={(event) => {
329
+ const { y, height } = event.nativeEvent.layout;
330
+ frame.current = { ...frame.current, top: y, height };
331
+ report();
332
+ }}
333
+ >
334
+ <View
335
+ onLayout={(event) => {
336
+ frame.current = { ...frame.current, headerHeight: event.nativeEvent.layout.height };
337
+ report();
338
+ }}
339
+ >
340
+ {renderHeader(false)}
341
+ </View>
342
+ {status && status.kind !== "worked" && !open && status.preview ? (
343
+ <Pressable
344
+ onPress={() => toggle(true)}
345
+ style={{ paddingBottom: 4, paddingLeft: 24, minWidth: 0 }}
346
+ >
347
+ {status.preview}
348
+ </Pressable>
349
+ ) : null}
350
+ {open ? (
351
+ <Animated.View
352
+ entering={FadeIn.duration(160)}
353
+ style={bare ? { paddingTop: 4, paddingLeft: 20 } : { paddingTop: 8 }}
354
+ >
355
+ {children}
356
+ </Animated.View>
357
+ ) : null}
358
+ </View>
359
+ );
360
+ }
361
+
362
+ /** The stable left rule under a turn/activity body (web TurnRailFrame). */
363
+ export function TurnRailFrame({
364
+ children,
365
+ compact = false,
366
+ }: {
367
+ children: ReactNode;
368
+ compact?: boolean;
369
+ }) {
370
+ const theme = useNativeTimelineTheme();
371
+ return (
372
+ <View
373
+ style={{
374
+ borderLeftWidth: 2,
375
+ borderLeftColor: theme.colors.border,
376
+ paddingLeft: 12,
377
+ gap: compact ? 2 : 16,
378
+ }}
379
+ >
380
+ {children}
381
+ </View>
382
+ );
383
+ }
384
+
385
+ function familyOf(item: ActivityItem): string {
386
+ if (item.kind === "tool-call") return `tool:${item.name}`;
387
+ return item.kind;
388
+ }
389
+
390
+ /** The native ActivityRail (bare): tight same-family rows, breathing room between families. */
391
+ export function ActivityRail({
392
+ items,
393
+ startupActive,
394
+ renderPreparing,
395
+ }: {
396
+ items: ActivityItem[];
397
+ startupActive?: boolean | undefined;
398
+ renderPreparing?: ((props: PreparingProps) => ReactNode) | undefined;
399
+ }) {
400
+ const phases = items.filter((item) => item.kind === "startup-phase");
401
+ const hasWork = items.some(
402
+ (item) =>
403
+ item.kind !== "startup-phase" && (item.kind !== "reasoning" || item.text.trim().length > 0),
404
+ );
405
+ const interrupted = phases.some(
406
+ (item) => item.status === "failed" || item.status === "cancelled",
407
+ );
408
+ const providerResponded = phases.some(
409
+ (item) => item.phase === "provider_first_byte" && item.status === "complete",
410
+ );
411
+ const responsePending = phases.some((item) => item.phase === "provider_first_byte");
412
+ const loading =
413
+ !hasWork &&
414
+ !interrupted &&
415
+ (startupActive ?? (!providerResponded && phases.some((item) => item.status === "running")));
416
+ const visibleItems = items.filter((item) =>
417
+ item.kind === "startup-phase"
418
+ ? item.status === "failed" || item.status === "cancelled"
419
+ : item.kind !== "reasoning" || item.text.trim().length > 0,
420
+ );
421
+ const startedAt = phases.reduce(
422
+ (first, item) => (item.startedAt < first ? item.startedAt : first),
423
+ phases[0]?.startedAt ?? "",
424
+ );
425
+ return (
426
+ <View style={{ gap: 2 }}>
427
+ {loading ? (
428
+ <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(160)}>
429
+ <PreparingState
430
+ startedAt={startedAt}
431
+ phase={responsePending ? "waiting" : "preparing"}
432
+ render={renderPreparing}
433
+ />
434
+ </Animated.View>
435
+ ) : null}
436
+ {visibleItems.map((item, index) => {
437
+ const newFamily = index > 0 && familyOf(item) !== familyOf(visibleItems[index - 1]!);
438
+ return (
439
+ <View key={item.id} style={newFamily ? { marginTop: 12 } : undefined}>
440
+ <ActivityRow item={item} />
441
+ </View>
442
+ );
443
+ })}
444
+ </View>
445
+ );
446
+ }
447
+
448
+ export interface PreparingProps {
449
+ startedAt: string;
450
+ phase: "preparing" | "waiting";
451
+ phrase: string;
452
+ slow: boolean;
453
+ }
454
+
455
+ /** The web GenieLoading: a visual (orb by default; hosts supply a character) + rotating copy. */
456
+ export function PreparingState({
457
+ startedAt,
458
+ phase,
459
+ render,
460
+ }: {
461
+ startedAt: string;
462
+ phase: "preparing" | "waiting";
463
+ render?: ((props: PreparingProps) => ReactNode) | undefined;
464
+ }) {
465
+ const theme = useNativeTimelineTheme();
466
+ const phrases = phase === "waiting" ? GENIE_WAITING_PHRASES : GENIE_PREPARING_PHRASES;
467
+ const [index, setIndex] = useState(() => Math.floor(Math.random() * phrases.length));
468
+ const [slow, setSlow] = useState(false);
469
+ useEffect(() => {
470
+ const update = () => {
471
+ setSlow(Date.now() - Date.parse(startedAt) >= 30_000);
472
+ setIndex(Math.floor(Math.random() * phrases.length));
473
+ };
474
+ const timer = setInterval(update, 5_000);
475
+ return () => clearInterval(timer);
476
+ }, [startedAt, phrases]);
477
+ const phrase = slow
478
+ ? phase === "waiting"
479
+ ? "Still waiting for a response…"
480
+ : "A little longer than usual…"
481
+ : (phrases[index % phrases.length] ?? "");
482
+ if (render) return <>{render({ startedAt, phase, phrase, slow })}</>;
483
+ return (
484
+ <View
485
+ style={{
486
+ flexDirection: "row",
487
+ alignItems: "center",
488
+ gap: 14,
489
+ minHeight: 88,
490
+ paddingVertical: 12,
491
+ }}
492
+ >
493
+ <ThinkingOrb />
494
+ <Animated.Text
495
+ key={phrase}
496
+ entering={FadeIn.duration(650)}
497
+ style={{
498
+ ...fontStyle(theme),
499
+ fontSize: theme.size.sm,
500
+ lineHeight: 18,
501
+ color: theme.colors["fg-muted"],
502
+ }}
503
+ >
504
+ {phrase}
505
+ </Animated.Text>
506
+ </View>
507
+ );
508
+ }
509
+
510
+ /** One stable 24pt viewport; updates replace its content without moving the conversation. */
511
+ export function RollingActivity({
512
+ items,
513
+ previousItem,
514
+ showCount = true,
515
+ }: {
516
+ items: ActivityItem[];
517
+ previousItem?: ActivityItem | undefined;
518
+ showCount?: boolean;
519
+ }) {
520
+ const theme = useNativeTimelineTheme();
521
+ const [mounted, setMounted] = useState(false);
522
+ useEffect(() => setMounted(true), []);
523
+ const selected = rollingActivityItem(items, previousItem, mounted);
524
+ if (!selected) return null;
525
+ const { item, earlierCount } = selected;
526
+ return (
527
+ <View
528
+ style={{ flexDirection: "row", alignItems: "center", gap: 12, minWidth: 0, flex: 1 }}
529
+ accessibilityLabel={
530
+ item.kind === "tool-call"
531
+ ? toolDisplayName(item.name, item.display)
532
+ : item.kind === "reasoning"
533
+ ? "Thinking"
534
+ : "Working"
535
+ }
536
+ >
537
+ <View style={{ flex: 1, minWidth: 0, height: 24, overflow: "hidden" }}>
538
+ <Animated.View
539
+ key={item.id}
540
+ entering={FadeInDown.duration(400)}
541
+ exiting={FadeOutUp.duration(400)}
542
+ style={{
543
+ position: "absolute",
544
+ left: 0,
545
+ right: 0,
546
+ top: 0,
547
+ bottom: 0,
548
+ flexDirection: "row",
549
+ alignItems: "center",
550
+ }}
551
+ >
552
+ <ActivityRow item={item} compact />
553
+ </Animated.View>
554
+ </View>
555
+ {showCount && earlierCount > 0 ? (
556
+ <Text
557
+ style={{
558
+ ...fontStyle(theme),
559
+ fontSize: theme.size.xs,
560
+ color: theme.colors["fg-subtle"],
561
+ fontVariant: ["tabular-nums"],
562
+ }}
563
+ >
564
+ {`+${earlierCount} earlier`}
565
+ </Text>
566
+ ) : null}
567
+ </View>
568
+ );
569
+ }