@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,1042 @@
1
+ import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
+ import {
3
+ buildTimeline,
4
+ groupTimeline,
5
+ type AgentMessageItem,
6
+ type HumanInputItem,
7
+ type TimelineGroup,
8
+ type TimelineItem,
9
+ type UserMessageItem,
10
+ } from "@opengeni/react/session";
11
+ import {
12
+ compactedLandmarkCount,
13
+ durationBetween,
14
+ flattenActivityItems,
15
+ formatClockTime,
16
+ formatRelativeTime,
17
+ isPreparingWork,
18
+ noticeDisplayText,
19
+ noticeTone,
20
+ QUESTION_NAV_MARGIN_PX,
21
+ recordedWaitSummaryText,
22
+ questionNavTarget,
23
+ readableWorkDefaultOpen,
24
+ readableWorkShowsPreview,
25
+ readableWorkStatus,
26
+ SESSION_STATUS_PRESENTATION,
27
+ timelineGroupContainsPresentedImage,
28
+ type TurnSummaryFacetConfiguration,
29
+ } from "@opengeni/react/timeline-model";
30
+ import { useCallback, useMemo, useRef, useState, type ComponentRef, type ReactNode } from "react";
31
+ import {
32
+ Pressable,
33
+ ScrollView,
34
+ Text,
35
+ View,
36
+ type NativeScrollEvent,
37
+ type NativeSyntheticEvent,
38
+ type StyleProp,
39
+ type ViewStyle,
40
+ } from "react-native";
41
+ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
42
+ import { NativeActivityOptionsProvider, type NativeActivityOptions } from "./activity";
43
+ import { Icon } from "./icon";
44
+ import { ReleaseFollowProvider, withAlpha } from "./primitives";
45
+ import { fontStyle, useNativeTimelineTheme } from "./theme";
46
+ import {
47
+ ActivityRail,
48
+ FoldMemoryProvider,
49
+ StickyWorkHeaderProvider,
50
+ type StickyWorkHeader,
51
+ RollingActivity,
52
+ TurnRailFrame,
53
+ TurnSummary,
54
+ type NativeTurnStatus,
55
+ type PreparingProps,
56
+ } from "./turn";
57
+
58
+ /* ----------------------------------------------------------------------------
59
+ Native MessageTimeline: the web MessageTimeline (components/message-timeline)
60
+ in its rolling readable-turn configuration, drawn with native primitives.
61
+ Grouping, fold defaults, statuses and facets come from the shared model.
62
+ -------------------------------------------------------------------------- */
63
+
64
+ export type NativeMarkdownRenderer = (
65
+ text: string,
66
+ options: { tone: "body" | "muted"; streaming?: boolean | undefined },
67
+ ) => ReactNode;
68
+
69
+ export interface NativeMessageTimelineProps extends NativeActivityOptions {
70
+ /** Projected items (preferred) or raw durable events. */
71
+ items?: TimelineItem[] | undefined;
72
+ events?: SessionEvent[] | undefined;
73
+ status?: SessionStatus | null | undefined;
74
+ facets?: TurnSummaryFacetConfiguration | undefined;
75
+ renderMarkdown?: NativeMarkdownRenderer | undefined;
76
+ /** Replace the preparing visual (the web orb) — e.g. a product character. */
77
+ renderPreparing?: ((props: PreparingProps) => ReactNode) | undefined;
78
+ /** Extra actions beside copy under a message (feedback, share, …). */
79
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
80
+ /** Files sent with a user message, shown above its bubble (images as previews). */
81
+ renderUserAttachments?: ((item: UserMessageItem) => ReactNode) | undefined;
82
+ onCopy?: ((text: string) => void) | undefined;
83
+ /** Rendered after the last group (pending questions, approvals, errors). */
84
+ trailing?: ReactNode;
85
+ emptyState?: ReactNode;
86
+ header?: ReactNode;
87
+ contentInsetTop?: number | undefined;
88
+ contentInsetBottom?: number | undefined;
89
+ /** Height of host chrome floating over the timeline's bottom edge (a floating composer). */
90
+ overlayInsetBottom?: number | undefined;
91
+ style?: StyleProp<ViewStyle>;
92
+ }
93
+
94
+ export function MessageTimeline(props: NativeMessageTimelineProps) {
95
+ const theme = useNativeTimelineTheme();
96
+ const items = useMemo(
97
+ () => props.items ?? buildTimeline(props.events ?? []),
98
+ [props.items, props.events],
99
+ );
100
+ const groups = useMemo(() => groupTimeline(items, { readableTurns: true }), [items]);
101
+ const foldMemory = useRef(new Map<string, "open" | "closed">()).current;
102
+ const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);
103
+ const following = useRef(true);
104
+ // Only reader gestures change follow intent; layout-driven scroll events
105
+ // (content growing before the first scroll-to-end) must not unstick it.
106
+ const readerScrolling = useRef(false);
107
+ const [showJump, setShowJump] = useState(false);
108
+ // "Back to your message": prompt rows' content offsets, read on scroll.
109
+ const promptFrames = useRef(new Map<string, { top: number; bottom: number }>()).current;
110
+ const [questionNav, setQuestionNav] = useState<string | null>(null);
111
+ // Expanded outer turn headers, for the pinned copy (web: sticky in its section).
112
+ const stickyHeaders = useRef(new Map<string, StickyWorkHeader>()).current;
113
+ const scrollY = useRef(0);
114
+ const [pinned, setPinned] = useState<{ key: string; offset: number; height: number } | null>(
115
+ null,
116
+ );
117
+ const updatePinned = useCallback(() => {
118
+ const y = scrollY.current;
119
+ let next: { key: string; offset: number; height: number } | null = null;
120
+ for (const [key, entry] of stickyHeaders) {
121
+ const end = entry.top + entry.height - entry.headerHeight;
122
+ if (entry.headerHeight > 0 && entry.top < y && y < end) {
123
+ next = { key, offset: Math.min(0, end - y), height: entry.headerHeight };
124
+ }
125
+ }
126
+ setPinned((current) =>
127
+ current?.key === next?.key &&
128
+ current?.offset === next?.offset &&
129
+ current?.height === next?.height
130
+ ? current
131
+ : next,
132
+ );
133
+ }, [stickyHeaders]);
134
+ const releaseFollow = useCallback(() => {
135
+ following.current = false;
136
+ }, []);
137
+ const registerSticky = useMemo(
138
+ () => ({
139
+ register: (key: string, entry: StickyWorkHeader | null) => {
140
+ if (entry) stickyHeaders.set(key, entry);
141
+ else stickyHeaders.delete(key);
142
+ },
143
+ reveal: (top: number) => {
144
+ // Collapsing from the pinned copy leaves the reader on that turn's row.
145
+ following.current = false;
146
+ setPinned(null);
147
+ scrollRef.current?.scrollTo({ y: Math.max(0, top - 8), animated: false });
148
+ },
149
+ }),
150
+ [stickyHeaders],
151
+ );
152
+ const measure = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
153
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
154
+ return contentSize.height - layoutMeasurement.height - contentOffset.y;
155
+ }, []);
156
+ const onScroll = useCallback(
157
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
158
+ const distance = measure(event);
159
+ if (readerScrolling.current) following.current = distance < 48;
160
+ setShowJump(distance > 240 && !following.current);
161
+ const { contentOffset, layoutMeasurement } = event.nativeEvent;
162
+ scrollY.current = contentOffset.y;
163
+ updatePinned();
164
+ const prompts = [...promptFrames.entries()]
165
+ .map(([key, frame]) => ({ key, ...frame }))
166
+ .sort((a, b) => a.top - b.top);
167
+ const next = questionNavTarget(prompts, {
168
+ top: contentOffset.y,
169
+ height: layoutMeasurement.height,
170
+ });
171
+ setQuestionNav((current) => (current === next ? current : next));
172
+ },
173
+ [measure, promptFrames, updatePinned],
174
+ );
175
+ const onScrollEnd = useCallback(
176
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
177
+ following.current = measure(event) < 48;
178
+ readerScrolling.current = false;
179
+ },
180
+ [measure],
181
+ );
182
+ const onContentSizeChange = useCallback(() => {
183
+ if (following.current) scrollRef.current?.scrollToEnd({ animated: false });
184
+ }, []);
185
+ const activityOptions = useMemo<NativeActivityOptions>(
186
+ () => ({
187
+ toolRenderers: props.toolRenderers,
188
+ renderTool: props.renderTool,
189
+ computeLabel: props.computeLabel,
190
+ renderMarkdown: props.renderMarkdown,
191
+ onOpenSession: props.onOpenSession,
192
+ }),
193
+ [
194
+ props.toolRenderers,
195
+ props.renderTool,
196
+ props.computeLabel,
197
+ props.renderMarkdown,
198
+ props.onOpenSession,
199
+ ],
200
+ );
201
+ const context: GroupContext = {
202
+ facets: props.facets,
203
+ renderMarkdown: props.renderMarkdown,
204
+ renderPreparing: props.renderPreparing,
205
+ renderMessageActions: props.renderMessageActions,
206
+ renderUserAttachments: props.renderUserAttachments,
207
+ onCopy: props.onCopy,
208
+ };
209
+ return (
210
+ <NativeActivityOptionsProvider value={activityOptions}>
211
+ <FoldMemoryProvider value={foldMemory}>
212
+ <StickyWorkHeaderProvider value={registerSticky}>
213
+ <ReleaseFollowProvider value={releaseFollow}>
214
+ <View style={[{ flex: 1, backgroundColor: theme.colors.bg }, props.style]}>
215
+ <ScrollView
216
+ ref={scrollRef}
217
+ onScroll={onScroll}
218
+ onScrollBeginDrag={() => {
219
+ readerScrolling.current = true;
220
+ }}
221
+ onScrollEndDrag={onScrollEnd}
222
+ onMomentumScrollEnd={onScrollEnd}
223
+ onLayout={() => {
224
+ if (following.current) scrollRef.current?.scrollToEnd({ animated: false });
225
+ }}
226
+ scrollEventThrottle={32}
227
+ onContentSizeChange={onContentSizeChange}
228
+ keyboardDismissMode="interactive"
229
+ keyboardShouldPersistTaps="handled"
230
+ contentContainerStyle={{
231
+ paddingTop: props.contentInsetTop ?? 16,
232
+ paddingHorizontal: 16,
233
+ paddingBottom: props.contentInsetBottom ?? 24,
234
+ gap: 20,
235
+ flexGrow: 1,
236
+ }}
237
+ >
238
+ {props.header}
239
+ {groups.length === 0 && props.emptyState ? props.emptyState : null}
240
+ {groups.map((group) => {
241
+ const key = groupKey(group);
242
+ const prompt = group.kind === "item" && group.item.kind === "user-message";
243
+ return prompt ? (
244
+ <View
245
+ key={key}
246
+ onLayout={(event) => {
247
+ const { y, height } = event.nativeEvent.layout;
248
+ promptFrames.set(key, { top: y, bottom: y + height });
249
+ }}
250
+ >
251
+ <TimelineGroupView group={group} context={context} />
252
+ </View>
253
+ ) : (
254
+ <TimelineGroupView key={key} group={group} context={context} />
255
+ );
256
+ })}
257
+ {props.trailing}
258
+ </ScrollView>
259
+ {pinned && stickyHeaders.get(pinned.key) ? (
260
+ <View
261
+ style={{
262
+ position: "absolute",
263
+ top: 0,
264
+ left: 0,
265
+ right: 0,
266
+ paddingHorizontal: 16,
267
+ backgroundColor: theme.colors.bg,
268
+ transform: [{ translateY: pinned.offset }],
269
+ }}
270
+ >
271
+ {stickyHeaders.get(pinned.key)!.render()}
272
+ </View>
273
+ ) : null}
274
+ {/* Web timeline navigation: two identical small round arrow buttons at
275
+ fixed, centered spots that never dodge the content beneath them. */}
276
+ {questionNav ? (
277
+ <View
278
+ pointerEvents="box-none"
279
+ style={{
280
+ position: "absolute",
281
+ // Below a pinned work header so that strip stays a full-width target.
282
+ top: pinned ? pinned.height + pinned.offset + 8 : 12,
283
+ left: 0,
284
+ right: 0,
285
+ alignItems: "center",
286
+ }}
287
+ >
288
+ <NavButton
289
+ icon="arrow-up"
290
+ label="Back to your message"
291
+ onPress={() => {
292
+ const frame = promptFrames.get(questionNav);
293
+ if (!frame) return;
294
+ following.current = false;
295
+ scrollRef.current?.scrollTo({
296
+ y: Math.max(0, frame.top - QUESTION_NAV_MARGIN_PX),
297
+ animated: true,
298
+ });
299
+ }}
300
+ />
301
+ </View>
302
+ ) : null}
303
+ {showJump ? (
304
+ <View
305
+ pointerEvents="box-none"
306
+ style={{
307
+ position: "absolute",
308
+ bottom: (props.overlayInsetBottom ?? 0) + 12,
309
+ left: 0,
310
+ right: 0,
311
+ alignItems: "center",
312
+ }}
313
+ >
314
+ <NavButton
315
+ icon="arrow-down"
316
+ label="Jump to latest"
317
+ onPress={() => {
318
+ following.current = true;
319
+ scrollRef.current?.scrollToEnd({ animated: true });
320
+ }}
321
+ />
322
+ </View>
323
+ ) : null}
324
+ </View>
325
+ </ReleaseFollowProvider>
326
+ </StickyWorkHeaderProvider>
327
+ </FoldMemoryProvider>
328
+ </NativeActivityOptionsProvider>
329
+ );
330
+ }
331
+
332
+ /**
333
+ * The web timeline's navigation button (NAV_BUTTON_CLASS): 32pt round, surface-3
334
+ * at 90% with a border and shadow, a 16pt fg-muted arrow, and a 44pt touch target.
335
+ */
336
+ function NavButton(props: { icon: "arrow-up" | "arrow-down"; label: string; onPress: () => void }) {
337
+ const theme = useNativeTimelineTheme();
338
+ const c = theme.colors;
339
+ return (
340
+ <Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(150)}>
341
+ <Pressable
342
+ accessibilityRole="button"
343
+ accessibilityLabel={props.label}
344
+ onPress={props.onPress}
345
+ hitSlop={6}
346
+ style={({ pressed }) => ({
347
+ width: 32,
348
+ height: 32,
349
+ borderRadius: 16,
350
+ alignItems: "center",
351
+ justifyContent: "center",
352
+ borderWidth: 1,
353
+ borderColor: pressed ? c["border-strong"] : c.border,
354
+ backgroundColor: withAlpha(c["surface-3"], 0.9),
355
+ shadowColor: "#000",
356
+ shadowOpacity: 0.1,
357
+ shadowRadius: 6,
358
+ shadowOffset: { width: 0, height: 2 },
359
+ elevation: 3,
360
+ })}
361
+ >
362
+ {({ pressed }) => (
363
+ <Icon name={props.icon} size={16} color={pressed ? c.fg : c["fg-muted"]} />
364
+ )}
365
+ </Pressable>
366
+ </Animated.View>
367
+ );
368
+ }
369
+
370
+ type GroupContext = {
371
+ facets?: TurnSummaryFacetConfiguration | undefined;
372
+ renderMarkdown?: NativeMarkdownRenderer | undefined;
373
+ renderPreparing?: ((props: PreparingProps) => ReactNode) | undefined;
374
+ renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
375
+ renderUserAttachments?: ((item: UserMessageItem) => ReactNode) | undefined;
376
+ onCopy?: ((text: string) => void) | undefined;
377
+ };
378
+
379
+ function groupKey(group: TimelineGroup): string {
380
+ switch (group.kind) {
381
+ case "item":
382
+ return group.item.kind === "user-message" && group.item.reconciliationKey
383
+ ? `user-${group.item.reconciliationKey}`
384
+ : group.item.id;
385
+ default:
386
+ return group.id;
387
+ }
388
+ }
389
+
390
+ function TimelineGroupView({
391
+ group,
392
+ context,
393
+ insideTurn = false,
394
+ }: {
395
+ group: TimelineGroup;
396
+ context: GroupContext;
397
+ insideTurn?: boolean;
398
+ }) {
399
+ switch (group.kind) {
400
+ case "activity": {
401
+ if (group.work) {
402
+ if (isPreparingWork(group, { startupDetails: false, startupDismissed: insideTurn })) {
403
+ return (
404
+ <ActivityRail
405
+ items={group.items}
406
+ startupActive
407
+ renderPreparing={context.renderPreparing}
408
+ />
409
+ );
410
+ }
411
+ const base = readableWorkStatus({ ...group, work: group.work });
412
+ const status: NativeTurnStatus =
413
+ base.kind === "working"
414
+ ? {
415
+ kind: "working",
416
+ since: base.since,
417
+ label: base.label,
418
+ preview: readableWorkShowsPreview(group) ? (
419
+ <RollingActivity items={group.items} showCount={false} />
420
+ ) : undefined,
421
+ }
422
+ : base.kind === "waiting"
423
+ ? { kind: "waiting", since: base.since, label: base.label }
424
+ : { kind: "worked", durationMs: base.durationMs, label: base.label };
425
+ return (
426
+ <TurnSummary
427
+ items={group.items}
428
+ status={status}
429
+ outcome={group.outcome}
430
+ failureText={group.failureText}
431
+ foldKey={group.id}
432
+ defaultOpen={readableWorkDefaultOpen(group)}
433
+ facets={context.facets}
434
+ contextCompactionCount={compactedLandmarkCount(group.work.details)}
435
+ >
436
+ <TurnRailFrame compact>
437
+ <FoldedGroups groups={group.work.details} context={context} />
438
+ </TurnRailFrame>
439
+ </TurnSummary>
440
+ );
441
+ }
442
+ const preparationOnly =
443
+ !insideTurn &&
444
+ !group.outcome &&
445
+ group.items.every(
446
+ (item) =>
447
+ item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
448
+ );
449
+ if (preparationOnly) {
450
+ return <ActivityRail items={group.items} renderPreparing={context.renderPreparing} />;
451
+ }
452
+ if (insideTurn) {
453
+ return <ActivityRail items={group.items} startupActive={false} />;
454
+ }
455
+ const image = timelineGroupContainsPresentedImage(group);
456
+ return (
457
+ <TurnSummary
458
+ items={group.items}
459
+ outcome={group.outcome}
460
+ failureText={group.failureText}
461
+ defaultOpen={group.outcome === "failed" || image ? true : undefined}
462
+ liveHeader={
463
+ !image && !group.outcome ? <RollingActivity items={group.items} /> : undefined
464
+ }
465
+ foldKey={group.id}
466
+ facets={context.facets}
467
+ >
468
+ <TurnRailFrame>
469
+ <ActivityRail items={group.items} startupActive={false} />
470
+ </TurnRailFrame>
471
+ </TurnSummary>
472
+ );
473
+ }
474
+ case "turn": {
475
+ const activityItems = flattenActivityItems(group.groups);
476
+ const image = timelineGroupContainsPresentedImage(group);
477
+ return (
478
+ <TurnSummary
479
+ items={activityItems}
480
+ outcome={group.outcome}
481
+ failureText={insideTurn ? undefined : group.failureText}
482
+ durationMs={durationBetween(group.startedAt, group.endedAt)}
483
+ defaultOpen={!insideTurn && (group.outcome === "failed" || image) ? true : undefined}
484
+ bare={insideTurn}
485
+ foldKey={group.id}
486
+ facets={context.facets}
487
+ contextCompactionCount={group.contextCompactionCount}
488
+ >
489
+ {insideTurn ? (
490
+ <View style={{ gap: 16 }}>
491
+ <FoldedGroups groups={group.groups} context={context} />
492
+ </View>
493
+ ) : (
494
+ <TurnRailFrame>
495
+ <FoldedGroups groups={group.groups} context={context} />
496
+ </TurnRailFrame>
497
+ )}
498
+ </TurnSummary>
499
+ );
500
+ }
501
+ case "item":
502
+ return <TimelineRow item={group.item} context={context} />;
503
+ }
504
+ }
505
+
506
+ function FoldedGroups({
507
+ groups,
508
+ context,
509
+ }: {
510
+ groups: readonly TimelineGroup[];
511
+ context: GroupContext;
512
+ }) {
513
+ return (
514
+ <>
515
+ {groups.map((child, index) => (
516
+ <View
517
+ key={groupKey(child)}
518
+ style={
519
+ index > 0 && (child.kind !== "activity" || groups[index - 1]?.kind !== "activity")
520
+ ? { marginTop: 12 }
521
+ : undefined
522
+ }
523
+ >
524
+ <TimelineGroupView group={child} context={context} insideTurn />
525
+ </View>
526
+ ))}
527
+ </>
528
+ );
529
+ }
530
+
531
+ function TimelineRow({ item, context }: { item: TimelineItem; context: GroupContext }) {
532
+ const theme = useNativeTimelineTheme();
533
+ switch (item.kind) {
534
+ case "user-message":
535
+ return <UserMessageRow item={item} context={context} />;
536
+ case "agent-message":
537
+ return <AgentMessageRow item={item} context={context} />;
538
+ case "human-input":
539
+ return <HumanInputRow item={item} onCopy={context.onCopy} />;
540
+ case "session-status": {
541
+ const meta = SESSION_STATUS_PRESENTATION[item.status];
542
+ return (
543
+ <SeparatorRow
544
+ dot={item.resolvedAt ? undefined : theme.colors[`status-${meta.tone}`]}
545
+ text={`${item.resolvedAt ? "work resumed" : meta.label.toLowerCase()} · ${formatRelativeTime(item.resolvedAt ?? item.occurredAt)}`}
546
+ />
547
+ );
548
+ }
549
+ case "context-compaction":
550
+ return <SeparatorRow text="context compacted" />;
551
+ case "notice":
552
+ return <NoticeRow item={item} />;
553
+ case "goal":
554
+ return item.text ? (
555
+ <SeparatorRow text={`goal ${item.action} · ${item.text}`} />
556
+ ) : (
557
+ <SeparatorRow text={`goal ${item.action}`} />
558
+ );
559
+ case "worker-completion":
560
+ return (
561
+ <View
562
+ style={{
563
+ borderLeftWidth: 2,
564
+ borderLeftColor: theme.colors.border,
565
+ paddingLeft: 12,
566
+ gap: 4,
567
+ }}
568
+ >
569
+ <Text
570
+ style={{ ...fontStyle(theme, 500), fontSize: theme.size.base, color: theme.colors.fg }}
571
+ >
572
+ Worker reported back
573
+ </Text>
574
+ <Text
575
+ numberOfLines={3}
576
+ style={{
577
+ ...fontStyle(theme),
578
+ fontSize: theme.size.sm,
579
+ lineHeight: 18,
580
+ color: theme.colors["fg-muted"],
581
+ }}
582
+ >
583
+ {item.text}
584
+ </Text>
585
+ </View>
586
+ );
587
+ case "auth-needed":
588
+ return (
589
+ <View
590
+ style={{
591
+ borderRadius: theme.radius.md,
592
+ borderWidth: 1,
593
+ borderColor: theme.colors.border,
594
+ padding: 14,
595
+ backgroundColor: theme.colors["surface-1"],
596
+ }}
597
+ >
598
+ <Text
599
+ style={{ ...fontStyle(theme, 500), fontSize: theme.size.md, color: theme.colors.fg }}
600
+ >
601
+ Connection needed
602
+ </Text>
603
+ <Text
604
+ style={{
605
+ ...fontStyle(theme),
606
+ marginTop: 4,
607
+ fontSize: theme.size.sm,
608
+ color: theme.colors["fg-muted"],
609
+ }}
610
+ >
611
+ {item.providerDomain} needs to be reconnected before the agent can continue.
612
+ </Text>
613
+ </View>
614
+ );
615
+ default:
616
+ return null;
617
+ }
618
+ }
619
+
620
+ /**
621
+ * The web footer stamp. Hermes/iOS ICU joins date and time with " at " where the
622
+ * browser's ICU uses ", "; normalize to the web form.
623
+ */
624
+ function nativeClockTime(iso: string): string {
625
+ return formatClockTime(iso).replace(" at ", ", ");
626
+ }
627
+
628
+ function NoticeRow({ item }: { item: Extract<TimelineItem, { kind: "notice" }> }) {
629
+ const theme = useNativeTimelineTheme();
630
+ const c = theme.colors;
631
+ const [open, setOpen] = useState(false);
632
+ // An agent's own wait is ordinary progress, not a warning: a quiet line that
633
+ // discloses the reason, as on web.
634
+ if (item.recordedOutcome) {
635
+ return (
636
+ <Pressable
637
+ accessibilityRole="button"
638
+ accessibilityState={{ expanded: open }}
639
+ onPress={() => setOpen((value) => !value)}
640
+ style={{ paddingVertical: 4 }}
641
+ >
642
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
643
+ <View style={{ transform: [{ rotate: open ? "90deg" : "0deg" }] }}>
644
+ <Icon name="chevron-right" size={14} color={c["fg-muted"]} />
645
+ </View>
646
+ <Text style={{ ...fontStyle(theme), fontSize: theme.size.sm, color: c["fg-muted"] }}>
647
+ {recordedWaitSummaryText(item).replace(" at ", ", ")}
648
+ </Text>
649
+ </View>
650
+ {open ? (
651
+ <Text
652
+ style={{
653
+ ...fontStyle(theme),
654
+ marginTop: 4,
655
+ paddingLeft: 22,
656
+ fontSize: theme.size.sm,
657
+ lineHeight: 19,
658
+ color: c["fg-muted"],
659
+ }}
660
+ >
661
+ {item.text}
662
+ </Text>
663
+ ) : null}
664
+ </Pressable>
665
+ );
666
+ }
667
+ const tone = noticeTone(item);
668
+ const accent =
669
+ tone === "failed"
670
+ ? c["status-failed"]
671
+ : tone === "waiting"
672
+ ? c["status-waiting"]
673
+ : c["fg-muted"];
674
+ return (
675
+ <View
676
+ accessibilityRole="text"
677
+ style={{
678
+ flexDirection: "row",
679
+ alignItems: "flex-start",
680
+ gap: 10,
681
+ borderRadius: theme.radius.md,
682
+ borderWidth: 1,
683
+ paddingHorizontal: 14,
684
+ paddingVertical: 10,
685
+ borderColor: tone === "neutral" ? c.border : withAlpha(accent, 0.35),
686
+ backgroundColor: tone === "neutral" ? c["surface-1"] : withAlpha(accent, 0.1),
687
+ }}
688
+ >
689
+ <View style={{ marginTop: 2, opacity: item.tone === "cancelled" ? 0.6 : 1 }}>
690
+ <Icon name="triangle-alert" size={16} color={accent} />
691
+ </View>
692
+ <Text style={{ ...fontStyle(theme), flex: 1, fontSize: 14, lineHeight: 20, color: accent }}>
693
+ {noticeDisplayText(item)}
694
+ </Text>
695
+ </View>
696
+ );
697
+ }
698
+
699
+ function SeparatorRow({ text, dot }: { text: string; dot?: string | undefined }) {
700
+ const theme = useNativeTimelineTheme();
701
+ return (
702
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
703
+ <View style={{ height: 1, flex: 1, backgroundColor: theme.colors.border }} />
704
+ {dot ? (
705
+ <View
706
+ style={{ width: 4, height: 4, borderRadius: 2, marginRight: -6, backgroundColor: dot }}
707
+ />
708
+ ) : null}
709
+ <Text
710
+ numberOfLines={1}
711
+ style={{
712
+ ...fontStyle(theme),
713
+ maxWidth: "70%",
714
+ fontSize: theme.size.xs,
715
+ color: theme.colors["fg-subtle"],
716
+ }}
717
+ >
718
+ {text}
719
+ </Text>
720
+ <View style={{ height: 1, flex: 1, backgroundColor: theme.colors.border }} />
721
+ </View>
722
+ );
723
+ }
724
+
725
+ /** Copy + time under a message: the web CopyHoverFrame footer at coarse pointer. */
726
+ function MessageFooter({
727
+ text,
728
+ occurredAt,
729
+ align,
730
+ actions,
731
+ onCopy,
732
+ }: {
733
+ text: string;
734
+ occurredAt: string;
735
+ align: "start" | "end";
736
+ actions?: ReactNode;
737
+ onCopy?: ((text: string) => void) | undefined;
738
+ }) {
739
+ const theme = useNativeTimelineTheme();
740
+ const [copied, setCopied] = useState(false);
741
+ return (
742
+ <View
743
+ style={{
744
+ flexDirection: "row",
745
+ alignItems: "center",
746
+ alignSelf: align === "end" ? "flex-end" : "flex-start",
747
+ gap: 6,
748
+ height: 44,
749
+ }}
750
+ >
751
+ {onCopy ? (
752
+ <Pressable
753
+ accessibilityRole="button"
754
+ accessibilityLabel="Copy message"
755
+ onPress={() => {
756
+ onCopy(text);
757
+ setCopied(true);
758
+ setTimeout(() => setCopied(false), 1400);
759
+ }}
760
+ style={{
761
+ width: 44,
762
+ height: 44,
763
+ alignItems: "center",
764
+ justifyContent: "center",
765
+ borderRadius: theme.radius.sm,
766
+ }}
767
+ >
768
+ <Icon name={copied ? "check" : "copy"} size={14} color={theme.colors["fg-subtle"]} />
769
+ </Pressable>
770
+ ) : null}
771
+ {actions}
772
+ <Text
773
+ style={{
774
+ ...fontStyle(theme),
775
+ fontSize: theme.size.xs,
776
+ color: theme.colors["fg-subtle"],
777
+ fontVariant: ["tabular-nums"],
778
+ }}
779
+ >
780
+ {nativeClockTime(occurredAt)}
781
+ </Text>
782
+ </View>
783
+ );
784
+ }
785
+
786
+ function UserMessageRow({ item, context }: { item: UserMessageItem; context: GroupContext }) {
787
+ const theme = useNativeTimelineTheme();
788
+ const failed = item.delivery?.state === "failed";
789
+ return (
790
+ <Animated.View entering={FadeIn.duration(180)} style={{ alignItems: "flex-end" }}>
791
+ <View style={{ maxWidth: "85%", alignItems: "flex-end", gap: 6 }}>
792
+ {item.resources.some((resource) => resource.kind === "file")
793
+ ? context.renderUserAttachments?.(item)
794
+ : null}
795
+ {item.text ? (
796
+ <View
797
+ style={{
798
+ backgroundColor: theme.colors["surface-2"],
799
+ borderWidth: 1,
800
+ borderColor: theme.colors.border,
801
+ borderTopLeftRadius: 14,
802
+ borderTopRightRadius: 14,
803
+ borderBottomLeftRadius: 14,
804
+ borderBottomRightRadius: 4,
805
+ paddingHorizontal: 16,
806
+ paddingVertical: 10,
807
+ }}
808
+ >
809
+ {item.text ? (
810
+ context.renderMarkdown ? (
811
+ context.renderMarkdown(item.text, { tone: "body" })
812
+ ) : (
813
+ <Text
814
+ selectable
815
+ style={{
816
+ ...fontStyle(theme),
817
+ fontSize: theme.size.md,
818
+ lineHeight: 28,
819
+ color: theme.colors.fg,
820
+ }}
821
+ >
822
+ {item.text}
823
+ </Text>
824
+ )
825
+ ) : null}
826
+ </View>
827
+ ) : null}
828
+ <MessageFooter
829
+ text={item.text}
830
+ occurredAt={item.occurredAt}
831
+ align="end"
832
+ actions={context.renderMessageActions?.(item)}
833
+ onCopy={context.onCopy}
834
+ />
835
+ {failed ? (
836
+ <View
837
+ style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 4 }}
838
+ >
839
+ <Icon name="triangle-alert" size={14} color={theme.colors["status-failed"]} />
840
+ <Text
841
+ style={{
842
+ ...fontStyle(theme),
843
+ fontSize: theme.size.xs,
844
+ color: theme.colors["status-failed"],
845
+ }}
846
+ >
847
+ {item.delivery?.error || "Message not sent"}
848
+ </Text>
849
+ {item.delivery?.onRetry ? (
850
+ <Pressable onPress={item.delivery.onRetry} accessibilityRole="button">
851
+ <Text
852
+ style={{
853
+ ...fontStyle(theme, 500),
854
+ fontSize: theme.size.xs,
855
+ color: theme.colors["status-failed"],
856
+ textDecorationLine: "underline",
857
+ }}
858
+ >
859
+ Retry
860
+ </Text>
861
+ </Pressable>
862
+ ) : null}
863
+ </View>
864
+ ) : null}
865
+ </View>
866
+ </Animated.View>
867
+ );
868
+ }
869
+
870
+ function AgentMessageRow({ item, context }: { item: AgentMessageItem; context: GroupContext }) {
871
+ const theme = useNativeTimelineTheme();
872
+ return (
873
+ <Animated.View entering={FadeIn.duration(180)} style={{ minWidth: 0 }}>
874
+ {context.renderMarkdown ? (
875
+ context.renderMarkdown(item.text, { tone: "body", streaming: item.streaming })
876
+ ) : (
877
+ <Text
878
+ selectable
879
+ style={{
880
+ ...fontStyle(theme),
881
+ fontSize: theme.size.md,
882
+ lineHeight: 28,
883
+ color: theme.colors.fg,
884
+ }}
885
+ >
886
+ {item.text}
887
+ </Text>
888
+ )}
889
+ {item.streaming ? null : (
890
+ <MessageFooter
891
+ text={item.text}
892
+ occurredAt={item.occurredAt}
893
+ align="start"
894
+ actions={context.renderMessageActions?.(item)}
895
+ onCopy={context.onCopy}
896
+ />
897
+ )}
898
+ </Animated.View>
899
+ );
900
+ }
901
+
902
+ function HumanInputRow({
903
+ item,
904
+ onCopy,
905
+ }: {
906
+ item: HumanInputItem;
907
+ onCopy?: ((text: string) => void) | undefined;
908
+ }) {
909
+ const theme = useNativeTimelineTheme();
910
+ const multiple = Math.max(item.questions.length, item.answers.length) > 1;
911
+ const settled =
912
+ item.response.outcome === "answered"
913
+ ? "You answered"
914
+ : item.response.outcome === "skipped"
915
+ ? "Skipped"
916
+ : item.response.outcome === "expired"
917
+ ? "Expired"
918
+ : "Cancelled";
919
+ const answerText = item.answers
920
+ .map((answer) => `${multiple ? `${answer.label}: ` : ""}${answer.values.join(", ")}`)
921
+ .join("\n");
922
+ return (
923
+ <View style={{ gap: 10 }}>
924
+ <View
925
+ style={{
926
+ maxWidth: "90%",
927
+ flexDirection: "row",
928
+ alignItems: "flex-start",
929
+ gap: 12,
930
+ borderWidth: 1,
931
+ borderColor: theme.colors.border,
932
+ backgroundColor: theme.colors["surface-1"],
933
+ borderTopLeftRadius: theme.radius.lg,
934
+ borderTopRightRadius: theme.radius.lg,
935
+ borderBottomRightRadius: theme.radius.lg,
936
+ borderBottomLeftRadius: theme.radius.xs,
937
+ paddingHorizontal: 14,
938
+ paddingVertical: 12,
939
+ }}
940
+ >
941
+ <View
942
+ style={{
943
+ marginTop: 2,
944
+ width: 32,
945
+ height: 32,
946
+ borderRadius: theme.radius.md,
947
+ alignItems: "center",
948
+ justifyContent: "center",
949
+ backgroundColor: withAlpha(theme.colors["status-waiting"], 0.1),
950
+ }}
951
+ >
952
+ <Icon name="message-circle-question" size={16} color={theme.colors["status-waiting"]} />
953
+ </View>
954
+ <View style={{ flex: 1, minWidth: 0 }}>
955
+ <Text
956
+ style={{
957
+ ...fontStyle(theme, 500),
958
+ fontSize: theme.size.xs,
959
+ color: theme.colors["fg-subtle"],
960
+ }}
961
+ >
962
+ Agent asked
963
+ </Text>
964
+ <View style={{ marginTop: 6, gap: 12 }}>
965
+ {item.questions.map((question, index) => (
966
+ <View key={question.id}>
967
+ {question.label ? (
968
+ <Text
969
+ style={{
970
+ ...fontStyle(theme, 600),
971
+ fontSize: theme.size.sm,
972
+ color: theme.colors.fg,
973
+ }}
974
+ >
975
+ {multiple ? `${index + 1}. ` : ""}
976
+ {question.label}
977
+ </Text>
978
+ ) : null}
979
+ <Text
980
+ style={{
981
+ ...fontStyle(theme),
982
+ fontSize: question.label ? theme.size.sm : theme.size.md,
983
+ lineHeight: question.label ? 18 : 24,
984
+ marginTop: question.label ? 2 : 0,
985
+ color: question.label ? theme.colors["fg-muted"] : theme.colors.fg,
986
+ }}
987
+ >
988
+ {question.prompt}
989
+ </Text>
990
+ </View>
991
+ ))}
992
+ </View>
993
+ </View>
994
+ </View>
995
+ <View style={{ alignItems: "flex-end" }}>
996
+ <View
997
+ style={{
998
+ maxWidth: "85%",
999
+ backgroundColor: theme.colors["surface-2"],
1000
+ borderWidth: 1,
1001
+ borderColor: theme.colors.border,
1002
+ borderTopLeftRadius: 14,
1003
+ borderTopRightRadius: 14,
1004
+ borderBottomLeftRadius: 14,
1005
+ borderBottomRightRadius: 4,
1006
+ paddingHorizontal: 16,
1007
+ paddingVertical: 10,
1008
+ }}
1009
+ >
1010
+ <Text
1011
+ style={{
1012
+ ...fontStyle(theme, 500),
1013
+ fontSize: theme.size.xs,
1014
+ color: theme.colors["fg-subtle"],
1015
+ }}
1016
+ >
1017
+ {settled}
1018
+ </Text>
1019
+ {answerText ? (
1020
+ <Text
1021
+ style={{
1022
+ ...fontStyle(theme),
1023
+ marginTop: 2,
1024
+ fontSize: theme.size.md,
1025
+ lineHeight: 24,
1026
+ color: theme.colors.fg,
1027
+ }}
1028
+ >
1029
+ {answerText}
1030
+ </Text>
1031
+ ) : null}
1032
+ </View>
1033
+ <MessageFooter
1034
+ text={answerText || settled}
1035
+ occurredAt={item.occurredAt}
1036
+ align="end"
1037
+ onCopy={onCopy}
1038
+ />
1039
+ </View>
1040
+ </View>
1041
+ );
1042
+ }