@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,2251 @@
1
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ FlatList,
5
+ KeyboardAvoidingView,
6
+ Linking,
7
+ Platform,
8
+ Pressable,
9
+ ScrollView,
10
+ StyleSheet,
11
+ Text,
12
+ TextInput,
13
+ View,
14
+ type NativeScrollEvent,
15
+ type NativeSyntheticEvent,
16
+ type StyleProp,
17
+ type ViewStyle,
18
+ } from "react-native";
19
+ import type {
20
+ HumanInputAnswer,
21
+ HumanInputQuestion,
22
+ SessionHumanInputRequest,
23
+ SessionTurn,
24
+ } from "@opengeni/sdk";
25
+ import type {
26
+ ActivityItem,
27
+ TimelineGroup,
28
+ TimelineItem,
29
+ ToolCallItem,
30
+ } from "@opengeni/react/session";
31
+ import type { OpenGeniNativeSessionController } from "./use-native-session";
32
+ import type { NativeFileAttachmentsResult } from "./attachments";
33
+ import {
34
+ DEFAULT_OPENGENI_NATIVE_LABELS,
35
+ DEFAULT_OPENGENI_NATIVE_THEME,
36
+ type OpenGeniNativeLabels,
37
+ type OpenGeniNativeTheme,
38
+ } from "./presentation";
39
+ import {
40
+ boundedJson,
41
+ emptyHumanInputDraft,
42
+ formatNativeRelativeTime,
43
+ nativeActivityPresentation,
44
+ nativeContextCompactionPhaseLabel,
45
+ nativeFleetDecisionSummary,
46
+ nativeGoalActionLabel,
47
+ nativeHumanInputRequestPreview,
48
+ nativeHumanInputSummary,
49
+ nativeMemoryActionLabel,
50
+ nativeKnowledgeOutcomeLabel,
51
+ nativeSessionStatusLabel,
52
+ nativeTimelineStatusLabel,
53
+ timelineAccessibilityLabel,
54
+ validateHumanInputAnswers,
55
+ type NativeHumanInputDraft,
56
+ } from "./view-model";
57
+
58
+ const NO_COMPOSER_INSETS = { bottom: 0, left: 0, right: 0 } as const;
59
+ const NO_RESTORED_RESOURCES: readonly unknown[] = [];
60
+
61
+ export type NativeToolRenderer = (
62
+ item: ToolCallItem,
63
+ context: { theme: OpenGeniNativeTheme; labels: OpenGeniNativeLabels; compact: boolean },
64
+ ) => ReactNode;
65
+
66
+ export type NativeMessageRenderer = (
67
+ text: string,
68
+ role: "user" | "assistant" | "reasoning",
69
+ context: { theme: OpenGeniNativeTheme },
70
+ ) => ReactNode;
71
+
72
+ export interface NativeTurnSummaryContext {
73
+ items: readonly ActivityItem[];
74
+ toolCalls: readonly ToolCallItem[];
75
+ outcome: Extract<TimelineGroup, { kind: "turn" }>["outcome"];
76
+ failureText: string | undefined;
77
+ durationMs: number | undefined;
78
+ settled: boolean;
79
+ contextCompactionCount: number;
80
+ }
81
+
82
+ export interface NativeTurnSummaryResult {
83
+ content: string;
84
+ accessibilityLabel?: string | undefined;
85
+ duration?: string | null;
86
+ }
87
+
88
+ export type NativeTurnSummaryRenderer = (
89
+ context: NativeTurnSummaryContext,
90
+ ) => NativeTurnSummaryResult | null;
91
+
92
+ export interface NativeComposerActionContext {
93
+ accessory?: ReactNode | undefined;
94
+ labels: OpenGeniNativeLabels;
95
+ theme: OpenGeniNativeTheme;
96
+ busy: boolean;
97
+ paused: boolean;
98
+ runActive: boolean;
99
+ canSubmit: boolean;
100
+ draftSaving: boolean;
101
+ submitLabel: string;
102
+ onAttachDocument(): void;
103
+ onAttachImage(): void;
104
+ onTogglePause(): void;
105
+ onSubmit(): void;
106
+ }
107
+
108
+ export type NativeComposerActionRenderer = (context: NativeComposerActionContext) => ReactNode;
109
+
110
+ export interface NativeComposerRenderContext extends NativeComposerActionContext {
111
+ value: string;
112
+ onChangeText: (text: string) => void;
113
+ placeholder: string;
114
+ }
115
+
116
+ export type NativeComposerRenderer = (context: NativeComposerRenderContext) => ReactNode;
117
+
118
+ export interface NativeComposerSurfaceContext {
119
+ children: ReactNode;
120
+ style: StyleProp<ViewStyle>;
121
+ }
122
+
123
+ export type NativeComposerSurfaceRenderer = (context: NativeComposerSurfaceContext) => ReactNode;
124
+
125
+ export interface OpenGeniNativeSessionViewProps {
126
+ controller: OpenGeniNativeSessionController;
127
+ theme?: OpenGeniNativeTheme | undefined;
128
+ labels?: Partial<OpenGeniNativeLabels> | undefined;
129
+ header?: ReactNode | undefined;
130
+ composerAccessory?: ReactNode | undefined;
131
+ composerSafeAreaInsets?: { bottom: number; left: number; right: number };
132
+ renderComposer?: NativeComposerRenderer | undefined;
133
+ renderComposerActions?: NativeComposerActionRenderer | undefined;
134
+ renderComposerSurface?: NativeComposerSurfaceRenderer | undefined;
135
+ renderTool?: NativeToolRenderer | undefined;
136
+ renderMessage?: NativeMessageRenderer | undefined;
137
+ summarizeTurn?: NativeTurnSummaryRenderer | undefined;
138
+ formatError?: ((error: Error) => string) | undefined;
139
+ keyboardVerticalOffset?: number | undefined;
140
+ testID?: string | undefined;
141
+ }
142
+
143
+ export function OpenGeniNativeSessionView({
144
+ controller,
145
+ theme = DEFAULT_OPENGENI_NATIVE_THEME,
146
+ labels: labelOverrides,
147
+ header,
148
+ composerAccessory,
149
+ composerSafeAreaInsets = NO_COMPOSER_INSETS,
150
+ renderComposer,
151
+ renderComposerActions,
152
+ renderComposerSurface,
153
+ renderTool,
154
+ renderMessage,
155
+ summarizeTurn,
156
+ formatError,
157
+ keyboardVerticalOffset = 0,
158
+ testID,
159
+ }: OpenGeniNativeSessionViewProps) {
160
+ const labels = useMemo(
161
+ () => ({ ...DEFAULT_OPENGENI_NATIVE_LABELS, ...labelOverrides }),
162
+ [labelOverrides],
163
+ );
164
+ const listRef = useRef<FlatList<TimelineGroup>>(null);
165
+ const followTail = useRef(true);
166
+ const humanInputRequests = controller.humanInput.requests;
167
+ const humanInputRequestKey = humanInputRequests.map((request) => request.id).join("|");
168
+ const [humanInputOpen, setHumanInputOpen] = useState(true);
169
+ const humanInputExpanded = humanInputRequests.length > 0 && humanInputOpen;
170
+ const auxiliarySurfacesVisible =
171
+ controller.queue.queue.length > 0 ||
172
+ (controller.queue.effectiveControl?.state === "paused" &&
173
+ controller.queue.effectiveControl.resumeOptions.length > 1);
174
+
175
+ useEffect(() => {
176
+ if (humanInputRequestKey) setHumanInputOpen(true);
177
+ }, [humanInputRequestKey]);
178
+
179
+ const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
180
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
181
+ followTail.current = contentSize.height - (contentOffset.y + layoutMeasurement.height) < 96;
182
+ }, []);
183
+
184
+ return (
185
+ <KeyboardAvoidingView
186
+ behavior={Platform.OS === "ios" ? "padding" : undefined}
187
+ keyboardVerticalOffset={keyboardVerticalOffset}
188
+ style={[styles.root, { backgroundColor: theme.colors.background }]}
189
+ testID={testID}
190
+ >
191
+ {header}
192
+ <View style={styles.body}>
193
+ <View style={styles.timelineColumn}>
194
+ <View style={styles.timelineStage}>
195
+ <ConnectionBanner
196
+ controller={controller}
197
+ formatError={formatError}
198
+ labels={labels}
199
+ theme={theme}
200
+ />
201
+ <View style={humanInputExpanded ? styles.timelineHidden : styles.timeline}>
202
+ <FlatList
203
+ contentContainerStyle={[
204
+ styles.timelineContent,
205
+ renderComposer ? styles.timelineContentNativeComposer : null,
206
+ controller.timelineGroups.length === 0 && styles.emptyTimeline,
207
+ ]}
208
+ data={controller.timelineGroups}
209
+ keyboardDismissMode="interactive"
210
+ keyboardShouldPersistTaps="handled"
211
+ keyExtractor={timelineGroupKey}
212
+ ListHeaderComponent={
213
+ controller.hasOlder ? (
214
+ <ActionButton
215
+ label={controller.loadingOlder ? labels.loadingEarlier : labels.loadEarlier}
216
+ onPress={() => void controller.loadOlder()}
217
+ theme={theme}
218
+ disabled={controller.loadingOlder}
219
+ variant="quiet"
220
+ />
221
+ ) : undefined
222
+ }
223
+ ListEmptyComponent={
224
+ controller.initialLoading ? (
225
+ <ActivityIndicator color={theme.colors.accent} />
226
+ ) : (
227
+ <View accessible accessibilityRole="summary" style={styles.emptyState}>
228
+ <Text style={[styles.emptyTitle, { color: theme.colors.text }]}>
229
+ {labels.emptyTitle}
230
+ </Text>
231
+ <Text
232
+ style={[styles.emptyDescription, { color: theme.colors.secondaryText }]}
233
+ >
234
+ {labels.emptyDescription}
235
+ </Text>
236
+ </View>
237
+ )
238
+ }
239
+ onContentSizeChange={() => {
240
+ if (followTail.current) listRef.current?.scrollToEnd({ animated: true });
241
+ }}
242
+ onScroll={onScroll}
243
+ ref={listRef}
244
+ renderItem={({ item }) => (
245
+ <TimelineGroupRow
246
+ group={item}
247
+ labels={labels}
248
+ renderMessage={renderMessage}
249
+ renderTool={renderTool}
250
+ summarizeTurn={summarizeTurn}
251
+ theme={theme}
252
+ />
253
+ )}
254
+ scrollEventThrottle={32}
255
+ style={styles.timeline}
256
+ />
257
+ </View>
258
+ {humanInputRequests.length > 0 ? (
259
+ <HumanInputWorkspace
260
+ expanded={humanInputOpen}
261
+ labels={labels}
262
+ onRespond={(requestId, response) =>
263
+ void controller.humanInput.respond(requestId, response)
264
+ }
265
+ onToggle={() => setHumanInputOpen((current) => !current)}
266
+ requests={humanInputRequests}
267
+ respondingRequestId={controller.humanInput.respondingRequestId}
268
+ theme={theme}
269
+ />
270
+ ) : null}
271
+ {controller.approvals.length > 0 ? (
272
+ <View pointerEvents="box-none" style={styles.approvalOverlay}>
273
+ <ApprovalSurface controller={controller} labels={labels} theme={theme} />
274
+ </View>
275
+ ) : null}
276
+ </View>
277
+ {auxiliarySurfacesVisible ? (
278
+ <ScrollView
279
+ contentContainerStyle={styles.sessionSurfacesContent}
280
+ keyboardShouldPersistTaps="handled"
281
+ style={styles.sessionSurfaces}
282
+ >
283
+ <SessionSurfaces controller={controller} labels={labels} theme={theme} />
284
+ </ScrollView>
285
+ ) : null}
286
+ <View
287
+ style={[
288
+ styles.composerDock,
289
+ {
290
+ backgroundColor: theme.colors.background,
291
+ borderTopColor: theme.colors.border,
292
+ borderTopWidth: StyleSheet.hairlineWidth,
293
+ paddingBottom: Math.max(8, composerSafeAreaInsets.bottom),
294
+ paddingLeft: Math.max(12, composerSafeAreaInsets.left + 12),
295
+ paddingRight: Math.max(12, composerSafeAreaInsets.right + 12),
296
+ },
297
+ ]}
298
+ >
299
+ <OpenGeniNativeAttachmentStrip
300
+ attachments={controller.attachments}
301
+ labels={labels}
302
+ onRemoveRestoredResource={controller.composer.removeRestoredResource}
303
+ restoredResources={controller.composer.restoredResources}
304
+ theme={theme}
305
+ />
306
+ <Composer
307
+ accessory={composerAccessory}
308
+ controller={controller}
309
+ labels={labels}
310
+ renderActions={renderComposerActions}
311
+ renderComposer={renderComposer}
312
+ renderSurface={renderComposerSurface}
313
+ theme={theme}
314
+ />
315
+ </View>
316
+ </View>
317
+ </View>
318
+ </KeyboardAvoidingView>
319
+ );
320
+ }
321
+
322
+ function timelineGroupKey(group: TimelineGroup): string {
323
+ return group.kind === "item" ? `item-${group.item.id}` : group.id;
324
+ }
325
+
326
+ function TimelineGroupRow({
327
+ group,
328
+ labels,
329
+ renderMessage,
330
+ renderTool,
331
+ summarizeTurn,
332
+ theme,
333
+ }: {
334
+ group: TimelineGroup;
335
+ labels: OpenGeniNativeLabels;
336
+ renderMessage?: NativeMessageRenderer | undefined;
337
+ renderTool?: NativeToolRenderer | undefined;
338
+ summarizeTurn?: NativeTurnSummaryRenderer | undefined;
339
+ theme: OpenGeniNativeTheme;
340
+ }) {
341
+ switch (group.kind) {
342
+ case "item":
343
+ return (
344
+ <TimelineRow
345
+ item={group.item}
346
+ labels={labels}
347
+ renderMessage={renderMessage}
348
+ renderTool={renderTool}
349
+ theme={theme}
350
+ />
351
+ );
352
+ case "activity":
353
+ return (
354
+ <ActivityRail
355
+ items={group.items}
356
+ labels={labels}
357
+ renderMessage={renderMessage}
358
+ renderTool={renderTool}
359
+ theme={theme}
360
+ />
361
+ );
362
+ case "turn":
363
+ return (
364
+ <SettledTurn
365
+ group={group}
366
+ labels={labels}
367
+ renderMessage={renderMessage}
368
+ renderTool={renderTool}
369
+ summarizeTurn={summarizeTurn}
370
+ theme={theme}
371
+ />
372
+ );
373
+ }
374
+ }
375
+
376
+ function SettledTurn({
377
+ group,
378
+ labels,
379
+ renderMessage,
380
+ renderTool,
381
+ summarizeTurn,
382
+ theme,
383
+ }: {
384
+ group: Extract<TimelineGroup, { kind: "turn" }>;
385
+ labels: OpenGeniNativeLabels;
386
+ renderMessage?: NativeMessageRenderer | undefined;
387
+ renderTool?: NativeToolRenderer | undefined;
388
+ summarizeTurn?: NativeTurnSummaryRenderer | undefined;
389
+ theme: OpenGeniNativeTheme;
390
+ }) {
391
+ const [open, setOpen] = useState(group.outcome === "failed");
392
+ const items = useMemo(() => collectTurnActivityItems(group.groups), [group.groups]);
393
+ const durationMs = turnDurationMs(group.startedAt, group.endedAt);
394
+ const summary = summarizeTurn?.({
395
+ items,
396
+ toolCalls: items.filter((item): item is ToolCallItem => item.kind === "tool-call"),
397
+ outcome: group.outcome,
398
+ failureText: group.failureText,
399
+ durationMs,
400
+ settled: items.every((item) => !activityItemRunning(item)),
401
+ contextCompactionCount: group.contextCompactionCount ?? 0,
402
+ }) ?? {
403
+ content: `${labels.activity} · ${items.length}`,
404
+ accessibilityLabel: `${labels.activity}, ${items.length}`,
405
+ };
406
+ return (
407
+ <View style={styles.turnGroup}>
408
+ <Pressable
409
+ accessibilityLabel={summary.accessibilityLabel ?? summary.content}
410
+ accessibilityRole="button"
411
+ accessibilityState={{ expanded: open }}
412
+ onPress={() => setOpen((current) => !current)}
413
+ style={({ pressed }) => [
414
+ styles.turnSummary,
415
+ {
416
+ backgroundColor:
417
+ group.outcome === "failed" ? theme.colors.dangerSurface : theme.colors.elevated,
418
+ borderColor: group.outcome === "failed" ? theme.colors.danger : theme.colors.border,
419
+ opacity: pressed ? 0.72 : 1,
420
+ },
421
+ ]}
422
+ >
423
+ <Text style={[styles.turnChevron, { color: theme.colors.mutedText }]}>
424
+ {open ? "⌄" : "›"}
425
+ </Text>
426
+ <Text
427
+ accessibilityElementsHidden
428
+ importantForAccessibility="no"
429
+ style={[
430
+ styles.turnOutcome,
431
+ {
432
+ color:
433
+ group.outcome === "failed"
434
+ ? theme.colors.danger
435
+ : group.outcome === "complete"
436
+ ? theme.colors.success
437
+ : theme.colors.mutedText,
438
+ },
439
+ ]}
440
+ >
441
+ {group.outcome === "complete" ? "✓" : group.outcome === "failed" ? "!" : "–"}
442
+ </Text>
443
+ <Text
444
+ numberOfLines={1}
445
+ style={[
446
+ styles.turnSummaryText,
447
+ {
448
+ color: group.outcome === "failed" ? theme.colors.danger : theme.colors.secondaryText,
449
+ },
450
+ ]}
451
+ >
452
+ {summary.content}
453
+ </Text>
454
+ {summary.duration ? (
455
+ <Text style={[styles.turnDuration, { color: theme.colors.mutedText }]}>
456
+ {summary.duration}
457
+ </Text>
458
+ ) : null}
459
+ </Pressable>
460
+ {open ? (
461
+ <View style={[styles.turnRail, { borderLeftColor: theme.colors.border }]}>
462
+ {group.groups.map((child) => (
463
+ <TimelineGroupRow
464
+ group={child}
465
+ key={timelineGroupKey(child)}
466
+ labels={labels}
467
+ renderMessage={renderMessage}
468
+ renderTool={renderTool}
469
+ summarizeTurn={summarizeTurn}
470
+ theme={theme}
471
+ />
472
+ ))}
473
+ {group.failureText ? (
474
+ <Text style={[styles.secondaryText, { color: theme.colors.danger }]}>
475
+ {group.failureText}
476
+ </Text>
477
+ ) : null}
478
+ </View>
479
+ ) : null}
480
+ </View>
481
+ );
482
+ }
483
+
484
+ function collectTurnActivityItems(groups: readonly TimelineGroup[]): ActivityItem[] {
485
+ return groups.flatMap((group): ActivityItem[] => {
486
+ switch (group.kind) {
487
+ case "activity":
488
+ return group.items;
489
+ case "turn":
490
+ return collectTurnActivityItems(group.groups);
491
+ case "item":
492
+ return isActivityItem(group.item) ? [group.item] : [];
493
+ }
494
+ });
495
+ }
496
+
497
+ function isActivityItem(item: TimelineItem): item is ActivityItem {
498
+ return (
499
+ (item.kind === "agent-message" && item.phase === "commentary") ||
500
+ item.kind === "reasoning" ||
501
+ item.kind === "tool-call" ||
502
+ item.kind === "worker" ||
503
+ item.kind === "sandbox" ||
504
+ item.kind === "memory" ||
505
+ item.kind === "fleet-decision"
506
+ );
507
+ }
508
+
509
+ function activityItemRunning(item: ActivityItem): boolean {
510
+ return (
511
+ ((item.kind === "reasoning" || item.kind === "agent-message") && item.streaming) ||
512
+ ((item.kind === "tool-call" || item.kind === "worker" || item.kind === "sandbox") &&
513
+ item.status === "running")
514
+ );
515
+ }
516
+
517
+ function turnDurationMs(startedAt: string, endedAt: string): number | undefined {
518
+ const start = Date.parse(startedAt);
519
+ const end = Date.parse(endedAt);
520
+ return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : undefined;
521
+ }
522
+
523
+ function ActivityRail({
524
+ items,
525
+ labels,
526
+ renderMessage,
527
+ renderTool,
528
+ theme,
529
+ }: {
530
+ items: ActivityItem[];
531
+ labels: OpenGeniNativeLabels;
532
+ renderMessage?: NativeMessageRenderer | undefined;
533
+ renderTool?: NativeToolRenderer | undefined;
534
+ theme: OpenGeniNativeTheme;
535
+ }) {
536
+ return (
537
+ <View style={[styles.activityRail, { borderLeftColor: theme.colors.border }]}>
538
+ {items.map((item) => (
539
+ <TimelineRow
540
+ compactActivity
541
+ item={item}
542
+ key={item.id}
543
+ labels={labels}
544
+ renderMessage={renderMessage}
545
+ renderTool={renderTool}
546
+ theme={theme}
547
+ />
548
+ ))}
549
+ </View>
550
+ );
551
+ }
552
+
553
+ function ConnectionBanner({
554
+ controller,
555
+ formatError,
556
+ labels,
557
+ theme,
558
+ }: {
559
+ controller: OpenGeniNativeSessionController;
560
+ formatError?: ((error: Error) => string) | undefined;
561
+ labels: OpenGeniNativeLabels;
562
+ theme: OpenGeniNativeTheme;
563
+ }) {
564
+ const message = !controller.active
565
+ ? labels.offlinePaused
566
+ : controller.connectionState === "reconnecting"
567
+ ? labels.reconnecting
568
+ : controller.connectionState === "connecting"
569
+ ? labels.connecting
570
+ : null;
571
+ const error = controller.error;
572
+ if (!message && !error) return null;
573
+ const displayMessage = error ? (formatError?.(error) ?? labels.error) : message;
574
+
575
+ return (
576
+ <View
577
+ accessibilityLiveRegion="polite"
578
+ style={[
579
+ styles.banner,
580
+ {
581
+ backgroundColor: error ? theme.colors.dangerSurface : theme.colors.elevated,
582
+ },
583
+ ]}
584
+ >
585
+ <Text
586
+ numberOfLines={3}
587
+ style={[
588
+ styles.bannerText,
589
+ { color: error ? theme.colors.danger : theme.colors.secondaryText },
590
+ ]}
591
+ >
592
+ {displayMessage}
593
+ </Text>
594
+ {error ? (
595
+ <ActionButton
596
+ label={labels.retry}
597
+ onPress={() => void controller.refresh()}
598
+ theme={theme}
599
+ variant="quiet"
600
+ />
601
+ ) : null}
602
+ </View>
603
+ );
604
+ }
605
+
606
+ function TimelineRow({
607
+ compactActivity = false,
608
+ item,
609
+ labels,
610
+ renderMessage,
611
+ renderTool,
612
+ theme,
613
+ }: {
614
+ compactActivity?: boolean | undefined;
615
+ item: TimelineItem;
616
+ labels: OpenGeniNativeLabels;
617
+ renderMessage?: NativeMessageRenderer | undefined;
618
+ renderTool?: NativeToolRenderer | undefined;
619
+ theme: OpenGeniNativeTheme;
620
+ }) {
621
+ if (item.kind === "tool-call" && renderTool) {
622
+ const rendered = renderTool(item, { compact: compactActivity, labels, theme });
623
+ if (rendered !== null && rendered !== undefined) return <>{rendered}</>;
624
+ }
625
+
626
+ if (item.kind === "user-message" || item.kind === "agent-message") {
627
+ const user = item.kind === "user-message";
628
+ const role = user ? "user" : "assistant";
629
+ return (
630
+ <View
631
+ accessible
632
+ accessibilityLabel={timelineAccessibilityLabel(item, labels)}
633
+ style={[styles.messageRow, user ? styles.userMessageRow : styles.agentMessageRow]}
634
+ >
635
+ <View
636
+ style={[
637
+ styles.messageBubble,
638
+ user ? styles.userMessageBubble : styles.agentMessage,
639
+ {
640
+ backgroundColor: user ? theme.colors.userBubble : "transparent",
641
+ borderColor: user ? theme.colors.border : "transparent",
642
+ },
643
+ ]}
644
+ >
645
+ {renderMessage ? (
646
+ renderMessage(item.text, role, { theme })
647
+ ) : (
648
+ <Text
649
+ selectable
650
+ style={[
651
+ styles.messageText,
652
+ { color: user ? theme.colors.userBubbleText : theme.colors.text },
653
+ ]}
654
+ >
655
+ {item.text}
656
+ </Text>
657
+ )}
658
+ {item.kind === "agent-message" && item.streaming ? (
659
+ <ActivityIndicator color={theme.colors.accent} size="small" />
660
+ ) : null}
661
+ </View>
662
+ </View>
663
+ );
664
+ }
665
+
666
+ if (item.kind === "reasoning") {
667
+ if (compactActivity) {
668
+ return (
669
+ <ReasoningDisclosure
670
+ item={item}
671
+ labels={labels}
672
+ renderMessage={renderMessage}
673
+ theme={theme}
674
+ />
675
+ );
676
+ }
677
+ return (
678
+ <TimelineCard item={item} labels={labels} theme={theme} title={labels.reasoning}>
679
+ {renderMessage ? (
680
+ renderMessage(item.text, "reasoning", { theme })
681
+ ) : (
682
+ <Text selectable style={[styles.secondaryText, { color: theme.colors.secondaryText }]}>
683
+ {item.text}
684
+ </Text>
685
+ )}
686
+ </TimelineCard>
687
+ );
688
+ }
689
+
690
+ if (item.kind === "notice") {
691
+ return <NoticeRow item={item} labels={labels} theme={theme} />;
692
+ }
693
+
694
+ if (item.kind === "session-status") {
695
+ return <SessionStatusRow item={item} labels={labels} theme={theme} />;
696
+ }
697
+
698
+ if (item.kind === "tool-call") {
699
+ return (
700
+ <NativeActivityDisclosure
701
+ compact={compactActivity}
702
+ item={item}
703
+ labels={labels}
704
+ theme={theme}
705
+ />
706
+ );
707
+ }
708
+
709
+ if (isActivityItem(item)) {
710
+ return (
711
+ <NativeActivityDisclosure
712
+ compact={compactActivity}
713
+ item={item}
714
+ labels={labels}
715
+ theme={theme}
716
+ />
717
+ );
718
+ }
719
+
720
+ const title = timelineTitle(item, labels);
721
+ const detail = timelineSummary(item, labels);
722
+ if (compactActivity) {
723
+ return (
724
+ <View
725
+ accessible
726
+ accessibilityLabel={timelineAccessibilityLabel(item, labels)}
727
+ style={styles.step}
728
+ >
729
+ <Text style={[styles.stepLabel, { color: theme.colors.mutedText }]}>{title}</Text>
730
+ {detail ? (
731
+ <Text selectable style={[styles.secondaryText, { color: theme.colors.secondaryText }]}>
732
+ {detail}
733
+ </Text>
734
+ ) : null}
735
+ </View>
736
+ );
737
+ }
738
+ return (
739
+ <TimelineCard item={item} labels={labels} theme={theme} title={title}>
740
+ {detail ? (
741
+ <Text selectable style={[styles.secondaryText, { color: theme.colors.secondaryText }]}>
742
+ {detail}
743
+ </Text>
744
+ ) : null}
745
+ </TimelineCard>
746
+ );
747
+ }
748
+
749
+ function NativeActivityDisclosure({
750
+ compact,
751
+ item,
752
+ labels,
753
+ theme,
754
+ }: {
755
+ compact: boolean;
756
+ item: Exclude<ActivityItem, { kind: "reasoning" }>;
757
+ labels: OpenGeniNativeLabels;
758
+ theme: OpenGeniNativeTheme;
759
+ }) {
760
+ const presentation = nativeActivityPresentation(item, labels);
761
+ const [open, setOpen] = useState(presentation.expandedByDefault);
762
+ const expandable = Boolean(presentation.detail);
763
+ const foreground =
764
+ presentation.tone === "failed"
765
+ ? theme.colors.danger
766
+ : presentation.tone === "running"
767
+ ? theme.colors.accent
768
+ : theme.colors.mutedText;
769
+ const technical = item.kind === "tool-call" || item.kind === "sandbox";
770
+
771
+ return (
772
+ <View
773
+ style={[
774
+ styles.activityDisclosure,
775
+ !compact && styles.activityDisclosureCard,
776
+ !compact && {
777
+ backgroundColor: theme.colors.surface,
778
+ borderColor: presentation.tone === "failed" ? theme.colors.danger : theme.colors.border,
779
+ },
780
+ ]}
781
+ >
782
+ <Pressable
783
+ accessible
784
+ accessibilityLabel={timelineAccessibilityLabel(item, labels)}
785
+ accessibilityRole={expandable ? "button" : undefined}
786
+ accessibilityState={expandable ? { expanded: open } : undefined}
787
+ disabled={!expandable}
788
+ onPress={() => setOpen((current) => !current)}
789
+ style={({ pressed }) => [
790
+ styles.activityDisclosureHeader,
791
+ { opacity: pressed && expandable ? 0.68 : 1 },
792
+ ]}
793
+ >
794
+ <Text style={[styles.activityDisclosureChevron, { color: theme.colors.mutedText }]}>
795
+ {expandable ? (open ? "⌄" : "›") : "·"}
796
+ </Text>
797
+ <View style={[styles.activityStatusDot, { backgroundColor: foreground }]} />
798
+ <Text
799
+ numberOfLines={1}
800
+ style={[
801
+ styles.activityDisclosureTitle,
802
+ { color: presentation.tone === "failed" ? theme.colors.danger : theme.colors.text },
803
+ ]}
804
+ >
805
+ {presentation.title}
806
+ </Text>
807
+ {!open && presentation.preview ? (
808
+ <Text
809
+ numberOfLines={1}
810
+ style={[styles.activityDisclosurePreview, { color: theme.colors.mutedText }]}
811
+ >
812
+ {presentation.preview}
813
+ </Text>
814
+ ) : null}
815
+ {presentation.status ? (
816
+ <Text numberOfLines={1} style={[styles.activityDisclosureStatus, { color: foreground }]}>
817
+ {presentation.status}
818
+ </Text>
819
+ ) : null}
820
+ </Pressable>
821
+ {open && presentation.detail ? (
822
+ <View style={styles.activityDisclosureBody}>
823
+ <Text
824
+ selectable
825
+ style={[
826
+ technical ? styles.codeText : styles.secondaryText,
827
+ { color: theme.colors.secondaryText },
828
+ ]}
829
+ >
830
+ {presentation.detail}
831
+ </Text>
832
+ </View>
833
+ ) : null}
834
+ </View>
835
+ );
836
+ }
837
+
838
+ function timelineTitle(
839
+ item: Exclude<
840
+ TimelineItem,
841
+ { kind: "user-message" | "agent-message" | "reasoning" | "tool-call" }
842
+ >,
843
+ labels: OpenGeniNativeLabels,
844
+ ): string {
845
+ switch (item.kind) {
846
+ case "worker":
847
+ case "worker-completion":
848
+ return labels.worker;
849
+ case "sandbox":
850
+ return item.name;
851
+ case "startup-phase":
852
+ return labels.activity;
853
+ case "session-status":
854
+ return nativeSessionStatusLabel(item.status, labels);
855
+ case "human-input":
856
+ return labels.humanInputAsked;
857
+ case "goal":
858
+ return nativeGoalActionLabel(item.action, labels);
859
+ case "context-compaction":
860
+ return nativeContextCompactionPhaseLabel(item.phase, labels);
861
+ case "memory":
862
+ return nativeMemoryActionLabel(item, labels);
863
+ case "knowledge":
864
+ return nativeKnowledgeOutcomeLabel(item, labels);
865
+ case "fleet-decision":
866
+ return labels.fleetDecision;
867
+ case "turn-end":
868
+ return labels.turn;
869
+ case "notice":
870
+ case "machine-input-batch":
871
+ case "auth-needed":
872
+ return labels.activity;
873
+ }
874
+ }
875
+
876
+ function ReasoningDisclosure({
877
+ item,
878
+ labels,
879
+ renderMessage,
880
+ theme,
881
+ }: {
882
+ item: Extract<TimelineItem, { kind: "reasoning" }>;
883
+ labels: OpenGeniNativeLabels;
884
+ renderMessage?: NativeMessageRenderer | undefined;
885
+ theme: OpenGeniNativeTheme;
886
+ }) {
887
+ const [open, setOpen] = useState(false);
888
+ const presentation = nativeActivityPresentation(item, labels);
889
+ const { preview, title } = presentation;
890
+
891
+ return (
892
+ <View style={styles.disclosure}>
893
+ <Pressable
894
+ accessibilityLabel={`${title}: ${preview}`}
895
+ accessibilityRole="button"
896
+ accessibilityState={{ expanded: open }}
897
+ onPress={() => setOpen((current) => !current)}
898
+ style={({ pressed }) => [
899
+ styles.disclosureHeader,
900
+ { backgroundColor: pressed ? theme.colors.elevated : "transparent" },
901
+ ]}
902
+ >
903
+ <Text style={[styles.disclosureChevron, { color: theme.colors.mutedText }]}>
904
+ {open ? "⌄" : "›"}
905
+ </Text>
906
+ <Text style={[styles.disclosureIcon, { color: theme.colors.mutedText }]}>◌</Text>
907
+ <Text
908
+ numberOfLines={1}
909
+ style={[
910
+ styles.disclosureTitle,
911
+ {
912
+ color: theme.colors.mutedText,
913
+ fontStyle: item.streaming ? "normal" : "italic",
914
+ fontWeight: item.streaming ? "600" : "400",
915
+ },
916
+ ]}
917
+ >
918
+ {title}
919
+ </Text>
920
+ {!open ? (
921
+ <Text
922
+ numberOfLines={1}
923
+ style={[styles.disclosurePreview, { color: theme.colors.mutedText }]}
924
+ >
925
+ {preview}
926
+ </Text>
927
+ ) : null}
928
+ </Pressable>
929
+ {open ? (
930
+ <View style={styles.disclosureBody}>
931
+ {renderMessage ? (
932
+ renderMessage(item.text, "reasoning", { theme })
933
+ ) : (
934
+ <Text selectable style={[styles.secondaryText, { color: theme.colors.secondaryText }]}>
935
+ {presentation.detail}
936
+ </Text>
937
+ )}
938
+ </View>
939
+ ) : null}
940
+ </View>
941
+ );
942
+ }
943
+
944
+ function NoticeRow({
945
+ item,
946
+ labels,
947
+ theme,
948
+ }: {
949
+ item: Extract<TimelineItem, { kind: "notice" }>;
950
+ labels: OpenGeniNativeLabels;
951
+ theme: OpenGeniNativeTheme;
952
+ }) {
953
+ const [detailsOpen, setDetailsOpen] = useState(false);
954
+ const action = item.action;
955
+ const failed = item.tone === "failed";
956
+ const waiting = item.tone === "waiting" || item.tone === "input";
957
+ const foreground = failed
958
+ ? theme.colors.danger
959
+ : waiting
960
+ ? theme.colors.warning
961
+ : theme.colors.secondaryText;
962
+ const background = failed
963
+ ? theme.colors.dangerSurface
964
+ : waiting
965
+ ? theme.colors.warningSurface
966
+ : theme.colors.elevated;
967
+
968
+ return (
969
+ <View
970
+ accessibilityLiveRegion="polite"
971
+ accessible
972
+ accessibilityLabel={timelineAccessibilityLabel(item, labels)}
973
+ style={[styles.notice, { backgroundColor: background, borderColor: foreground }]}
974
+ >
975
+ <Text style={[styles.noticeIcon, { color: foreground }]}>!</Text>
976
+ <View style={styles.noticeContent}>
977
+ <Text selectable style={[styles.secondaryText, { color: foreground }]}>
978
+ {item.text}
979
+ </Text>
980
+ {item.details ? (
981
+ <View style={styles.noticeDetails}>
982
+ <Pressable
983
+ accessibilityRole="button"
984
+ accessibilityState={{ expanded: detailsOpen }}
985
+ onPress={() => setDetailsOpen((current) => !current)}
986
+ >
987
+ <Text style={[styles.noticeAction, { color: foreground }]}>
988
+ {detailsOpen ? "⌄" : "›"} {item.details.label}
989
+ </Text>
990
+ </Pressable>
991
+ {detailsOpen ? (
992
+ <Text selectable style={[styles.codeText, { color: foreground }]}>
993
+ {boundedJson(item.details.value)}
994
+ </Text>
995
+ ) : null}
996
+ </View>
997
+ ) : null}
998
+ </View>
999
+ {action ? (
1000
+ <Pressable
1001
+ accessibilityRole="link"
1002
+ onPress={() => void Linking.openURL(action.url)}
1003
+ style={styles.noticeLink}
1004
+ >
1005
+ <Text style={[styles.noticeAction, { color: foreground }]}>{action.label}</Text>
1006
+ </Pressable>
1007
+ ) : null}
1008
+ </View>
1009
+ );
1010
+ }
1011
+
1012
+ function SessionStatusRow({
1013
+ item,
1014
+ labels,
1015
+ theme,
1016
+ }: {
1017
+ item: Extract<TimelineItem, { kind: "session-status" }>;
1018
+ labels: OpenGeniNativeLabels;
1019
+ theme: OpenGeniNativeTheme;
1020
+ }) {
1021
+ const label = nativeSessionStatusLabel(item.status, labels).toLocaleLowerCase();
1022
+ const dotColor = sessionStatusColor(item.status, theme);
1023
+ const relativeTime = formatNativeRelativeTime(item.occurredAt, labels);
1024
+
1025
+ return (
1026
+ <View
1027
+ accessibilityRole="text"
1028
+ accessible
1029
+ accessibilityLabel={`${label}${relativeTime ? `, ${relativeTime}` : ""}`}
1030
+ style={styles.sessionStatus}
1031
+ >
1032
+ <View style={[styles.sessionStatusRule, { backgroundColor: theme.colors.border }]} />
1033
+ <View style={styles.sessionStatusLabel}>
1034
+ <View style={[styles.sessionStatusDot, { backgroundColor: dotColor }]} />
1035
+ <Text style={[styles.sessionStatusText, { color: theme.colors.mutedText }]}>
1036
+ {label}
1037
+ {relativeTime ? ` · ${relativeTime}` : ""}
1038
+ </Text>
1039
+ </View>
1040
+ <View style={[styles.sessionStatusRule, { backgroundColor: theme.colors.border }]} />
1041
+ </View>
1042
+ );
1043
+ }
1044
+
1045
+ function sessionStatusColor(
1046
+ status: Extract<TimelineItem, { kind: "session-status" }>["status"],
1047
+ theme: OpenGeniNativeTheme,
1048
+ ): string {
1049
+ switch (status) {
1050
+ case "failed":
1051
+ return theme.colors.danger;
1052
+ case "requires_action":
1053
+ case "waiting_capacity":
1054
+ return theme.colors.warning;
1055
+ case "idle":
1056
+ return theme.colors.success;
1057
+ case "running":
1058
+ case "recovering":
1059
+ return theme.colors.accent;
1060
+ default:
1061
+ return theme.colors.mutedText;
1062
+ }
1063
+ }
1064
+
1065
+ function TimelineCard({
1066
+ children,
1067
+ item,
1068
+ labels,
1069
+ theme,
1070
+ title,
1071
+ }: {
1072
+ children: ReactNode;
1073
+ item: TimelineItem;
1074
+ labels: OpenGeniNativeLabels;
1075
+ theme: OpenGeniNativeTheme;
1076
+ title: string;
1077
+ }) {
1078
+ return (
1079
+ <View
1080
+ accessible
1081
+ accessibilityLabel={timelineAccessibilityLabel(item, labels)}
1082
+ style={[
1083
+ styles.timelineCard,
1084
+ { backgroundColor: theme.colors.surface, borderColor: theme.colors.border },
1085
+ ]}
1086
+ >
1087
+ <Text style={[styles.cardTitle, { color: theme.colors.text }]}>{title}</Text>
1088
+ {children}
1089
+ </View>
1090
+ );
1091
+ }
1092
+
1093
+ function timelineSummary(
1094
+ item: Exclude<
1095
+ TimelineItem,
1096
+ { kind: "user-message" | "agent-message" | "reasoning" | "tool-call" }
1097
+ >,
1098
+ labels: OpenGeniNativeLabels,
1099
+ ): string {
1100
+ switch (item.kind) {
1101
+ case "worker":
1102
+ return item.prompt ?? nativeTimelineStatusLabel(item.status, labels);
1103
+ case "worker-completion":
1104
+ return item.text;
1105
+ case "sandbox":
1106
+ return item.output || item.command || nativeTimelineStatusLabel(item.status, labels);
1107
+ case "startup-phase":
1108
+ return nativeTimelineStatusLabel(item.status, labels);
1109
+ case "session-status":
1110
+ return nativeSessionStatusLabel(item.status, labels);
1111
+ case "human-input":
1112
+ return nativeHumanInputSummary(item, labels);
1113
+ case "goal":
1114
+ return item.text ?? "";
1115
+ case "notice":
1116
+ return item.text;
1117
+ case "context-compaction":
1118
+ return "";
1119
+ case "machine-input-batch":
1120
+ return item.members.map((member) => member.summary).join("\n");
1121
+ case "auth-needed":
1122
+ return item.providerDomain;
1123
+ case "memory":
1124
+ return item.replacementPreview ?? item.preview;
1125
+ case "knowledge":
1126
+ return item.filename ?? "";
1127
+ case "fleet-decision":
1128
+ return nativeFleetDecisionSummary(item, labels);
1129
+ case "turn-end":
1130
+ return item.failureText ?? nativeTimelineStatusLabel(item.outcome, labels);
1131
+ }
1132
+ }
1133
+
1134
+ function ApprovalSurface({
1135
+ controller,
1136
+ labels,
1137
+ theme,
1138
+ }: {
1139
+ controller: OpenGeniNativeSessionController;
1140
+ labels: OpenGeniNativeLabels;
1141
+ theme: OpenGeniNativeTheme;
1142
+ }) {
1143
+ return (
1144
+ <View
1145
+ pointerEvents="auto"
1146
+ style={[
1147
+ styles.approvalSurface,
1148
+ { backgroundColor: theme.colors.warningSurface, borderColor: theme.colors.warning },
1149
+ ]}
1150
+ >
1151
+ <Text style={[styles.actionTitle, { color: theme.colors.text }]}>
1152
+ {labels.approvalsTitle}
1153
+ </Text>
1154
+ {controller.approvals.map((approval) => (
1155
+ <View key={approval.id} style={styles.approvalItem}>
1156
+ <Text style={[styles.cardTitle, { color: theme.colors.text }]}>{approval.name}</Text>
1157
+ <Text
1158
+ numberOfLines={3}
1159
+ selectable
1160
+ style={[styles.codeText, { color: theme.colors.secondaryText }]}
1161
+ >
1162
+ {boundedJson(approval.arguments)}
1163
+ </Text>
1164
+ <View style={styles.buttonRow}>
1165
+ <ActionButton
1166
+ label={labels.deny}
1167
+ onPress={() => void controller.control.reject(approval.id)}
1168
+ theme={theme}
1169
+ variant="danger"
1170
+ disabled={controller.control.responding}
1171
+ />
1172
+ <ActionButton
1173
+ label={labels.approve}
1174
+ onPress={() => void controller.control.approve(approval.id)}
1175
+ theme={theme}
1176
+ disabled={controller.control.responding}
1177
+ />
1178
+ </View>
1179
+ </View>
1180
+ ))}
1181
+ </View>
1182
+ );
1183
+ }
1184
+
1185
+ function SessionSurfaces({
1186
+ controller,
1187
+ labels,
1188
+ theme,
1189
+ }: {
1190
+ controller: OpenGeniNativeSessionController;
1191
+ labels: OpenGeniNativeLabels;
1192
+ theme: OpenGeniNativeTheme;
1193
+ }) {
1194
+ return (
1195
+ <View style={styles.actionSurfaces}>
1196
+ {controller.queue.effectiveControl?.state === "paused" &&
1197
+ controller.queue.effectiveControl.resumeOptions.length > 1 ? (
1198
+ <View style={styles.actionGroup}>
1199
+ <Text style={[styles.actionTitle, { color: theme.colors.text }]}>{labels.resume}</Text>
1200
+ {controller.queue.effectiveControl.resumeOptions
1201
+ .filter((option) => option.scope !== "selected")
1202
+ .map((option) => (
1203
+ <View
1204
+ key={`${option.scope}:${option.targetId ?? "current"}`}
1205
+ style={[
1206
+ styles.actionCard,
1207
+ {
1208
+ backgroundColor: theme.colors.warningSurface,
1209
+ borderColor: theme.colors.warning,
1210
+ },
1211
+ ]}
1212
+ >
1213
+ <Text style={[styles.secondaryText, { color: theme.colors.text }]}>
1214
+ {option.impactCopy}
1215
+ </Text>
1216
+ <ActionButton
1217
+ label={labels.resume}
1218
+ onPress={() => void controller.composer.resumeScope(option)}
1219
+ theme={theme}
1220
+ disabled={controller.composer.resuming}
1221
+ />
1222
+ </View>
1223
+ ))}
1224
+ </View>
1225
+ ) : null}
1226
+ {controller.queue.queue.length > 0 ? (
1227
+ <QueueSurface controller={controller} labels={labels} theme={theme} />
1228
+ ) : null}
1229
+ </View>
1230
+ );
1231
+ }
1232
+
1233
+ function QueueSurface({
1234
+ controller,
1235
+ labels,
1236
+ theme,
1237
+ }: {
1238
+ controller: OpenGeniNativeSessionController;
1239
+ labels: OpenGeniNativeLabels;
1240
+ theme: OpenGeniNativeTheme;
1241
+ }) {
1242
+ const [open, setOpen] = useState(false);
1243
+ const edit = async (turn: SessionTurn) => {
1244
+ if (controller.composer.hasDraftContent()) return;
1245
+ const draft = await controller.queue.editTurn(turn.id, {
1246
+ expectedDraftRevision: controller.composer.draftRevision,
1247
+ replaceDraft: true,
1248
+ });
1249
+ if (draft) controller.composer.applyDraft(draft);
1250
+ };
1251
+ const move = (index: number, direction: -1 | 1) => {
1252
+ const turn = controller.queue.queue[index];
1253
+ if (!turn) return;
1254
+ const beforeTurnId =
1255
+ direction < 0
1256
+ ? (controller.queue.queue[index - 1]?.id ?? null)
1257
+ : (controller.queue.queue[index + 2]?.id ?? null);
1258
+ void controller.queue.moveTurn(turn.id, beforeTurnId);
1259
+ };
1260
+
1261
+ return (
1262
+ <View
1263
+ style={[
1264
+ styles.queueSurface,
1265
+ { backgroundColor: theme.colors.surface, borderColor: theme.colors.border },
1266
+ ]}
1267
+ >
1268
+ <Pressable
1269
+ accessibilityLabel={`${labels.queueTitle}, ${controller.queue.queue.length}`}
1270
+ accessibilityRole="button"
1271
+ accessibilityState={{ expanded: open }}
1272
+ onPress={() => setOpen((value) => !value)}
1273
+ style={({ pressed }) => [styles.queueDisclosure, { opacity: pressed ? 0.72 : 1 }]}
1274
+ >
1275
+ <Text style={[styles.queueChevron, { color: theme.colors.mutedText }]}>
1276
+ {open ? "⌄" : "›"}
1277
+ </Text>
1278
+ <Text numberOfLines={1} style={[styles.queueTitle, { color: theme.colors.text }]}>
1279
+ {controller.queue.queue.length} {labels.queueTitle}
1280
+ </Text>
1281
+ {!open ? (
1282
+ <Text numberOfLines={1} style={[styles.queuePreview, { color: theme.colors.mutedText }]}>
1283
+ {controller.queue.queue[0]?.prompt}
1284
+ </Text>
1285
+ ) : null}
1286
+ </Pressable>
1287
+ {open
1288
+ ? controller.queue.queue.map((turn, index) => (
1289
+ <View key={turn.id} style={[styles.queueRow, { borderTopColor: theme.colors.border }]}>
1290
+ <Text numberOfLines={3} style={[styles.queuePrompt, { color: theme.colors.text }]}>
1291
+ {turn.prompt}
1292
+ </Text>
1293
+ <View style={styles.queueActions}>
1294
+ {index > 0 ? (
1295
+ <ActionButton
1296
+ compact
1297
+ label={labels.moveEarlier}
1298
+ onPress={() => move(index, -1)}
1299
+ theme={theme}
1300
+ variant="quiet"
1301
+ disabled={controller.queue.mutating}
1302
+ />
1303
+ ) : null}
1304
+ {index < controller.queue.queue.length - 1 ? (
1305
+ <ActionButton
1306
+ compact
1307
+ label={labels.moveLater}
1308
+ onPress={() => move(index, 1)}
1309
+ theme={theme}
1310
+ variant="quiet"
1311
+ disabled={controller.queue.mutating}
1312
+ />
1313
+ ) : null}
1314
+ <ActionButton
1315
+ compact
1316
+ label={labels.edit}
1317
+ onPress={() => void edit(turn)}
1318
+ theme={theme}
1319
+ variant="quiet"
1320
+ disabled={controller.composer.hasDraftContent() || controller.queue.mutating}
1321
+ />
1322
+ <ActionButton
1323
+ compact
1324
+ label={labels.runNext}
1325
+ onPress={() => void controller.queue.steerTurn(turn.id)}
1326
+ theme={theme}
1327
+ variant="quiet"
1328
+ disabled={controller.queue.mutating}
1329
+ />
1330
+ <ActionButton
1331
+ compact
1332
+ label={labels.remove}
1333
+ onPress={() => void controller.queue.removeTurn(turn.id)}
1334
+ theme={theme}
1335
+ variant="danger"
1336
+ disabled={controller.queue.mutating}
1337
+ />
1338
+ </View>
1339
+ </View>
1340
+ ))
1341
+ : null}
1342
+ </View>
1343
+ );
1344
+ }
1345
+
1346
+ function HumanInputWorkspace({
1347
+ expanded,
1348
+ labels,
1349
+ onRespond,
1350
+ onToggle,
1351
+ requests,
1352
+ respondingRequestId,
1353
+ theme,
1354
+ }: {
1355
+ expanded: boolean;
1356
+ labels: OpenGeniNativeLabels;
1357
+ onRespond: (
1358
+ requestId: string,
1359
+ response: { outcome: "answered"; answers: HumanInputAnswer[] } | { outcome: "skipped" },
1360
+ ) => void;
1361
+ onToggle: () => void;
1362
+ requests: readonly SessionHumanInputRequest[];
1363
+ respondingRequestId: string | null;
1364
+ theme: OpenGeniNativeTheme;
1365
+ }) {
1366
+ const preview = nativeHumanInputRequestPreview(requests);
1367
+ const identityLabel = preview ? `${labels.questionsTitle}: ${preview}` : labels.questionsTitle;
1368
+
1369
+ return (
1370
+ <View
1371
+ style={expanded ? styles.humanInputWorkspaceExpanded : styles.humanInputWorkspaceCollapsed}
1372
+ testID="opengeni-human-input-workspace"
1373
+ >
1374
+ <Pressable
1375
+ accessibilityLabel={identityLabel}
1376
+ accessibilityRole="button"
1377
+ accessibilityState={{ expanded }}
1378
+ onPress={onToggle}
1379
+ style={({ pressed }) => [
1380
+ styles.humanInputDisclosure,
1381
+ { backgroundColor: pressed ? theme.colors.elevated : "transparent" },
1382
+ ]}
1383
+ >
1384
+ <Text style={[styles.disclosureChevron, { color: theme.colors.mutedText }]}>
1385
+ {expanded ? "⌄" : "›"}
1386
+ </Text>
1387
+ <Text
1388
+ numberOfLines={1}
1389
+ style={[styles.humanInputDisclosureTitle, { color: theme.colors.text }]}
1390
+ >
1391
+ {labels.questionsTitle}
1392
+ </Text>
1393
+ {!expanded && preview ? (
1394
+ <Text
1395
+ numberOfLines={1}
1396
+ style={[styles.disclosurePreview, { color: theme.colors.mutedText }]}
1397
+ >
1398
+ {preview}
1399
+ </Text>
1400
+ ) : null}
1401
+ </Pressable>
1402
+ {expanded ? (
1403
+ <ScrollView
1404
+ contentContainerStyle={styles.humanInputWorkspaceBody}
1405
+ keyboardShouldPersistTaps="handled"
1406
+ style={styles.humanInputWorkspaceScroll}
1407
+ >
1408
+ {requests.map((request) => (
1409
+ <HumanInputCard
1410
+ key={request.id}
1411
+ labels={labels}
1412
+ onRespond={(response) => onRespond(request.id, response)}
1413
+ request={request}
1414
+ responding={respondingRequestId === request.id}
1415
+ theme={theme}
1416
+ />
1417
+ ))}
1418
+ </ScrollView>
1419
+ ) : null}
1420
+ </View>
1421
+ );
1422
+ }
1423
+
1424
+ function HumanInputCard({
1425
+ labels,
1426
+ onRespond,
1427
+ request,
1428
+ responding,
1429
+ theme,
1430
+ }: {
1431
+ labels: OpenGeniNativeLabels;
1432
+ onRespond: (
1433
+ response: { outcome: "answered"; answers: HumanInputAnswer[] } | { outcome: "skipped" },
1434
+ ) => void;
1435
+ request: SessionHumanInputRequest;
1436
+ responding: boolean;
1437
+ theme: OpenGeniNativeTheme;
1438
+ }) {
1439
+ const [drafts, setDrafts] = useState<Record<string, NativeHumanInputDraft>>({});
1440
+ const projection = validateHumanInputAnswers(request.questions, drafts);
1441
+
1442
+ const update = (
1443
+ questionId: string,
1444
+ updater: (current: NativeHumanInputDraft) => NativeHumanInputDraft,
1445
+ ) => {
1446
+ setDrafts((current) => ({
1447
+ ...current,
1448
+ [questionId]: updater(current[questionId] ?? emptyHumanInputDraft()),
1449
+ }));
1450
+ };
1451
+
1452
+ return (
1453
+ <View style={styles.actionGroup}>
1454
+ <View
1455
+ style={[
1456
+ styles.actionCard,
1457
+ { backgroundColor: theme.colors.surface, borderColor: theme.colors.border },
1458
+ ]}
1459
+ >
1460
+ {request.questions.map((question) => (
1461
+ <QuestionField
1462
+ draft={drafts[question.id]}
1463
+ key={question.id}
1464
+ labels={labels}
1465
+ onChange={(next) => update(question.id, () => next)}
1466
+ question={question}
1467
+ theme={theme}
1468
+ />
1469
+ ))}
1470
+ <View style={styles.buttonRow}>
1471
+ {request.allowSkip ? (
1472
+ <ActionButton
1473
+ label={labels.skip}
1474
+ onPress={() => onRespond({ outcome: "skipped" })}
1475
+ theme={theme}
1476
+ variant="quiet"
1477
+ disabled={responding}
1478
+ />
1479
+ ) : null}
1480
+ <ActionButton
1481
+ label={labels.submitAnswers}
1482
+ onPress={() => onRespond({ outcome: "answered", answers: projection.answers })}
1483
+ theme={theme}
1484
+ disabled={!projection.valid || responding}
1485
+ />
1486
+ </View>
1487
+ </View>
1488
+ </View>
1489
+ );
1490
+ }
1491
+
1492
+ function QuestionField({
1493
+ draft,
1494
+ labels,
1495
+ onChange,
1496
+ question,
1497
+ theme,
1498
+ }: {
1499
+ draft: NativeHumanInputDraft | undefined;
1500
+ labels: OpenGeniNativeLabels;
1501
+ onChange: (draft: NativeHumanInputDraft) => void;
1502
+ question: HumanInputQuestion;
1503
+ theme: OpenGeniNativeTheme;
1504
+ }) {
1505
+ const current = draft ?? emptyHumanInputDraft();
1506
+ const values = current.values;
1507
+ const toggle = (id: string) => {
1508
+ const next =
1509
+ question.kind === "single_select"
1510
+ ? [id]
1511
+ : values.includes(id)
1512
+ ? values.filter((value) => value !== id)
1513
+ : [...values, id];
1514
+ onChange({
1515
+ ...current,
1516
+ values: next,
1517
+ ...(question.kind === "single_select" ? { otherSelected: false } : {}),
1518
+ });
1519
+ };
1520
+
1521
+ return (
1522
+ <View style={styles.question}>
1523
+ <Text style={[styles.cardTitle, { color: theme.colors.text }]}>
1524
+ {question.label ?? question.prompt}
1525
+ {question.required ? ` · ${labels.required}` : ""}
1526
+ </Text>
1527
+ {question.label ? (
1528
+ <Text style={[styles.secondaryText, { color: theme.colors.secondaryText }]}>
1529
+ {question.prompt}
1530
+ </Text>
1531
+ ) : null}
1532
+ {question.helpText ? (
1533
+ <Text style={[styles.helpText, { color: theme.colors.mutedText }]}>
1534
+ {question.helpText}
1535
+ </Text>
1536
+ ) : null}
1537
+ {question.kind === "text" ? (
1538
+ <TextInput
1539
+ accessibilityLabel={question.label ?? question.prompt}
1540
+ multiline
1541
+ onChangeText={(text) => onChange({ ...current, values: text ? [text] : [] })}
1542
+ style={[
1543
+ styles.questionInput,
1544
+ {
1545
+ backgroundColor: theme.colors.background,
1546
+ borderColor: theme.colors.border,
1547
+ color: theme.colors.text,
1548
+ },
1549
+ ]}
1550
+ value={values[0] ?? ""}
1551
+ />
1552
+ ) : (
1553
+ <View style={styles.optionList}>
1554
+ {question.options.map((option) => {
1555
+ const selected = values.includes(option.id);
1556
+ return (
1557
+ <Pressable
1558
+ accessibilityRole={question.kind === "single_select" ? "radio" : "checkbox"}
1559
+ accessibilityState={{ checked: selected }}
1560
+ key={option.id}
1561
+ onPress={() => toggle(option.id)}
1562
+ style={[
1563
+ styles.option,
1564
+ {
1565
+ backgroundColor: selected ? theme.colors.elevated : theme.colors.background,
1566
+ borderColor: selected ? theme.colors.accent : theme.colors.border,
1567
+ },
1568
+ ]}
1569
+ >
1570
+ <Text style={[styles.optionLabel, { color: theme.colors.text }]}>
1571
+ {option.label}
1572
+ </Text>
1573
+ {option.description ? (
1574
+ <Text style={[styles.helpText, { color: theme.colors.secondaryText }]}>
1575
+ {option.description}
1576
+ </Text>
1577
+ ) : null}
1578
+ </Pressable>
1579
+ );
1580
+ })}
1581
+ </View>
1582
+ )}
1583
+ {question.kind !== "text" && question.allowOther ? (
1584
+ <View
1585
+ style={[
1586
+ styles.option,
1587
+ {
1588
+ backgroundColor: current.otherSelected
1589
+ ? theme.colors.elevated
1590
+ : theme.colors.background,
1591
+ borderColor: current.otherSelected ? theme.colors.accent : theme.colors.border,
1592
+ },
1593
+ ]}
1594
+ >
1595
+ <Pressable
1596
+ accessibilityRole={question.kind === "single_select" ? "radio" : "checkbox"}
1597
+ accessibilityState={{ checked: current.otherSelected }}
1598
+ onPress={() =>
1599
+ onChange({
1600
+ ...current,
1601
+ otherSelected: !current.otherSelected,
1602
+ ...(question.kind === "single_select" && !current.otherSelected
1603
+ ? { values: [] }
1604
+ : {}),
1605
+ })
1606
+ }
1607
+ style={styles.otherOptionToggle}
1608
+ >
1609
+ <Text style={[styles.optionLabel, { color: theme.colors.text }]}>{labels.other}</Text>
1610
+ </Pressable>
1611
+ <TextInput
1612
+ accessibilityLabel={labels.other}
1613
+ editable={current.otherSelected}
1614
+ onChangeText={(other) => onChange({ ...current, other })}
1615
+ placeholder={labels.other}
1616
+ placeholderTextColor={theme.colors.mutedText}
1617
+ style={[
1618
+ styles.otherInput,
1619
+ {
1620
+ backgroundColor: theme.colors.background,
1621
+ borderColor: theme.colors.border,
1622
+ color: theme.colors.text,
1623
+ opacity: current.otherSelected ? 1 : 0.5,
1624
+ },
1625
+ ]}
1626
+ value={current.other}
1627
+ />
1628
+ </View>
1629
+ ) : null}
1630
+ </View>
1631
+ );
1632
+ }
1633
+
1634
+ export function OpenGeniNativeAttachmentStrip({
1635
+ attachments,
1636
+ labels,
1637
+ onRemoveRestoredResource,
1638
+ restoredResources = NO_RESTORED_RESOURCES,
1639
+ theme,
1640
+ }: {
1641
+ attachments: NativeFileAttachmentsResult;
1642
+ labels: OpenGeniNativeLabels;
1643
+ onRemoveRestoredResource?: ((index: number) => void) | undefined;
1644
+ restoredResources?: readonly unknown[] | undefined;
1645
+ theme: OpenGeniNativeTheme;
1646
+ }) {
1647
+ const error = attachments.error;
1648
+ if (attachments.attachments.length === 0 && restoredResources.length === 0 && !error) return null;
1649
+
1650
+ return (
1651
+ <ScrollView
1652
+ contentContainerStyle={styles.attachmentStrip}
1653
+ horizontal
1654
+ keyboardShouldPersistTaps="handled"
1655
+ showsHorizontalScrollIndicator={false}
1656
+ >
1657
+ {error ? (
1658
+ <View
1659
+ style={[
1660
+ styles.attachmentChip,
1661
+ { backgroundColor: theme.colors.dangerSurface, borderColor: theme.colors.danger },
1662
+ ]}
1663
+ >
1664
+ <Text style={[styles.attachmentName, { color: theme.colors.danger }]}>
1665
+ {labels.attachmentError}
1666
+ </Text>
1667
+ <ActionButton
1668
+ label={labels.dismiss}
1669
+ onPress={() => attachments.clearError()}
1670
+ theme={theme}
1671
+ variant="quiet"
1672
+ />
1673
+ </View>
1674
+ ) : null}
1675
+ {attachments.attachments.map((attachment) => (
1676
+ <View
1677
+ key={attachment.id}
1678
+ style={[
1679
+ styles.attachmentChip,
1680
+ { backgroundColor: theme.colors.elevated, borderColor: theme.colors.border },
1681
+ ]}
1682
+ >
1683
+ <Text numberOfLines={1} style={[styles.attachmentName, { color: theme.colors.text }]}>
1684
+ {attachment.name}
1685
+ </Text>
1686
+ <Text
1687
+ style={[
1688
+ styles.helpText,
1689
+ {
1690
+ color:
1691
+ attachment.status === "failed" ? theme.colors.danger : theme.colors.mutedText,
1692
+ },
1693
+ ]}
1694
+ >
1695
+ {attachment.status === "preparing"
1696
+ ? labels.preparingAttachment
1697
+ : attachment.status === "uploading"
1698
+ ? labels.uploading
1699
+ : attachment.status === "failed"
1700
+ ? labels.uploadFailed
1701
+ : formatBytes(attachment.sizeBytes)}
1702
+ </Text>
1703
+ <View style={styles.buttonRow}>
1704
+ {attachment.status === "failed" ? (
1705
+ <ActionButton
1706
+ label={labels.retryUpload}
1707
+ onPress={() => void attachments.retry(attachment.id)}
1708
+ theme={theme}
1709
+ variant="quiet"
1710
+ />
1711
+ ) : null}
1712
+ <ActionButton
1713
+ label={labels.removeAttachment}
1714
+ onPress={() => attachments.remove(attachment.id)}
1715
+ theme={theme}
1716
+ variant="quiet"
1717
+ />
1718
+ </View>
1719
+ </View>
1720
+ ))}
1721
+ {restoredResources.map((_resource, index) => (
1722
+ <View
1723
+ // oxlint-disable-next-line react/no-array-index-key -- restored refs are opaque and positional
1724
+ key={`restored-${index}`}
1725
+ style={[
1726
+ styles.attachmentChip,
1727
+ { backgroundColor: theme.colors.elevated, borderColor: theme.colors.border },
1728
+ ]}
1729
+ >
1730
+ <Text style={[styles.attachmentName, { color: theme.colors.text }]}>
1731
+ {labels.restoredAttachment}
1732
+ </Text>
1733
+ <ActionButton
1734
+ label={labels.removeAttachment}
1735
+ onPress={() => onRemoveRestoredResource?.(index)}
1736
+ theme={theme}
1737
+ variant="quiet"
1738
+ disabled={!onRemoveRestoredResource}
1739
+ />
1740
+ </View>
1741
+ ))}
1742
+ </ScrollView>
1743
+ );
1744
+ }
1745
+
1746
+ function Composer({
1747
+ accessory,
1748
+ controller,
1749
+ labels,
1750
+ renderActions,
1751
+ renderComposer,
1752
+ renderSurface,
1753
+ theme,
1754
+ }: {
1755
+ accessory?: ReactNode | undefined;
1756
+ controller: OpenGeniNativeSessionController;
1757
+ labels: OpenGeniNativeLabels;
1758
+ renderActions?: NativeComposerActionRenderer | undefined;
1759
+ renderComposer?: NativeComposerRenderer | undefined;
1760
+ renderSurface?: NativeComposerSurfaceRenderer | undefined;
1761
+ theme: OpenGeniNativeTheme;
1762
+ }) {
1763
+ const paused = controller.queue.effectiveControl?.state === "paused";
1764
+ const busy =
1765
+ controller.composer.sending || controller.composer.pausing || controller.composer.resuming;
1766
+ const actionContext: NativeComposerActionContext = {
1767
+ accessory,
1768
+ labels,
1769
+ theme,
1770
+ busy,
1771
+ paused,
1772
+ runActive: controller.runActive,
1773
+ canSubmit: controller.composer.canSend && !busy,
1774
+ draftSaving: controller.composer.draftSaving,
1775
+ submitLabel: labels.send,
1776
+ onAttachDocument: () => void controller.attachments.pickDocuments(),
1777
+ onAttachImage: () => void controller.attachments.pickImages(),
1778
+ onTogglePause: () => void (paused ? controller.composer.resume() : controller.composer.pause()),
1779
+ onSubmit: () => void controller.composer.send(),
1780
+ };
1781
+ const composerContext: NativeComposerRenderContext = {
1782
+ ...actionContext,
1783
+ value: controller.composer.value,
1784
+ onChangeText: controller.composer.setValue,
1785
+ placeholder: labels.composerPlaceholder,
1786
+ };
1787
+
1788
+ const content = (
1789
+ <>
1790
+ {controller.composer.draftConflict ? (
1791
+ <View style={[styles.draftConflict, { backgroundColor: theme.colors.warningSurface }]}>
1792
+ <Text style={[styles.secondaryText, { color: theme.colors.warning }]}>
1793
+ {labels.draftConflict}
1794
+ </Text>
1795
+ <View style={styles.buttonRow}>
1796
+ <ActionButton
1797
+ label={labels.useRemote}
1798
+ onPress={() => void controller.composer.resolveDraftConflict("use_remote")}
1799
+ theme={theme}
1800
+ variant="quiet"
1801
+ />
1802
+ <ActionButton
1803
+ label={labels.keepMine}
1804
+ onPress={() => void controller.composer.resolveDraftConflict("keep_mine")}
1805
+ theme={theme}
1806
+ variant="quiet"
1807
+ />
1808
+ </View>
1809
+ </View>
1810
+ ) : null}
1811
+ {renderComposer ? (
1812
+ renderComposer(composerContext)
1813
+ ) : (
1814
+ <View style={renderActions ? styles.composerRow : styles.composerStack}>
1815
+ <TextInput
1816
+ accessibilityLabel={labels.composerPlaceholder}
1817
+ blurOnSubmit={false}
1818
+ multiline
1819
+ onChangeText={controller.composer.setValue}
1820
+ placeholder={labels.composerPlaceholder}
1821
+ placeholderTextColor={theme.colors.mutedText}
1822
+ style={[styles.composerInput, { color: theme.colors.text }]}
1823
+ value={controller.composer.value}
1824
+ />
1825
+ {renderActions ? (
1826
+ renderActions(actionContext)
1827
+ ) : (
1828
+ <DefaultComposerActions {...actionContext} />
1829
+ )}
1830
+ </View>
1831
+ )}
1832
+ </>
1833
+ );
1834
+
1835
+ if (renderComposer) {
1836
+ return <>{content}</>;
1837
+ }
1838
+
1839
+ if (renderSurface) {
1840
+ return renderSurface({ children: content, style: styles.composerContainer });
1841
+ }
1842
+
1843
+ return (
1844
+ <View
1845
+ style={[
1846
+ styles.composerContainer,
1847
+ { backgroundColor: theme.colors.surface, borderColor: theme.colors.border },
1848
+ ]}
1849
+ >
1850
+ {content}
1851
+ </View>
1852
+ );
1853
+ }
1854
+
1855
+ function DefaultComposerActions(context: NativeComposerActionContext) {
1856
+ return (
1857
+ <View style={styles.composerToolbar}>
1858
+ <View style={styles.buttonWrap}>
1859
+ <ActionButton
1860
+ accessibilityLabel={context.labels.attachDocument}
1861
+ label="+"
1862
+ onPress={() => context.onAttachDocument()}
1863
+ theme={context.theme}
1864
+ variant="quiet"
1865
+ disabled={context.busy}
1866
+ />
1867
+ <ActionButton
1868
+ accessibilityLabel={context.labels.attachImage}
1869
+ label="▧"
1870
+ onPress={() => context.onAttachImage()}
1871
+ theme={context.theme}
1872
+ variant="quiet"
1873
+ disabled={context.busy}
1874
+ />
1875
+ {context.accessory}
1876
+ </View>
1877
+ <View style={styles.buttonWrap}>
1878
+ <ActionButton
1879
+ label={context.paused ? context.labels.resume : context.labels.pause}
1880
+ onPress={() => context.onTogglePause()}
1881
+ theme={context.theme}
1882
+ variant="quiet"
1883
+ disabled={context.busy}
1884
+ />
1885
+ <ActionButton
1886
+ label={context.submitLabel}
1887
+ onPress={() => context.onSubmit()}
1888
+ theme={context.theme}
1889
+ disabled={!context.canSubmit}
1890
+ />
1891
+ </View>
1892
+ </View>
1893
+ );
1894
+ }
1895
+
1896
+ function ActionButton({
1897
+ accessibilityLabel,
1898
+ compact = false,
1899
+ disabled = false,
1900
+ label,
1901
+ onPress,
1902
+ theme,
1903
+ variant = "primary",
1904
+ }: {
1905
+ accessibilityLabel?: string | undefined;
1906
+ compact?: boolean | undefined;
1907
+ disabled?: boolean | undefined;
1908
+ label: string;
1909
+ onPress: () => void;
1910
+ theme: OpenGeniNativeTheme;
1911
+ variant?: "primary" | "quiet" | "danger";
1912
+ }) {
1913
+ const colors =
1914
+ variant === "primary"
1915
+ ? {
1916
+ background: theme.colors.accent,
1917
+ foreground: theme.colors.accentForeground,
1918
+ border: theme.colors.accent,
1919
+ }
1920
+ : variant === "danger"
1921
+ ? {
1922
+ background: "transparent",
1923
+ foreground: theme.colors.danger,
1924
+ border: theme.colors.danger,
1925
+ }
1926
+ : {
1927
+ background: "transparent",
1928
+ foreground: theme.colors.secondaryText,
1929
+ border: theme.colors.border,
1930
+ };
1931
+ return (
1932
+ <Pressable
1933
+ accessibilityLabel={accessibilityLabel ?? label}
1934
+ accessibilityRole="button"
1935
+ accessibilityState={{ disabled }}
1936
+ disabled={disabled}
1937
+ onPress={onPress}
1938
+ style={({ pressed }) => [
1939
+ styles.button,
1940
+ compact && styles.compactButton,
1941
+ {
1942
+ backgroundColor: colors.background,
1943
+ borderColor: colors.border,
1944
+ opacity: disabled ? 0.42 : pressed ? 0.72 : 1,
1945
+ },
1946
+ ]}
1947
+ >
1948
+ <Text
1949
+ style={[
1950
+ styles.buttonText,
1951
+ compact && styles.compactButtonText,
1952
+ { color: colors.foreground },
1953
+ ]}
1954
+ >
1955
+ {label}
1956
+ </Text>
1957
+ </Pressable>
1958
+ );
1959
+ }
1960
+
1961
+ function formatBytes(value: number | null): string {
1962
+ if (value === null) return "";
1963
+ if (value < 1024) return `${value} B`;
1964
+ if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
1965
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
1966
+ }
1967
+
1968
+ const styles = StyleSheet.create({
1969
+ root: { flex: 1 },
1970
+ body: { flex: 1 },
1971
+ timelineColumn: { flex: 1, minWidth: 0 },
1972
+ timelineStage: { flex: 1, minHeight: 0 },
1973
+ timeline: { flex: 1 },
1974
+ timelineHidden: { flex: 0, height: 0, overflow: "hidden", opacity: 0 },
1975
+ timelineContent: {
1976
+ width: "100%",
1977
+ maxWidth: 820,
1978
+ alignSelf: "center",
1979
+ gap: 12,
1980
+ padding: 16,
1981
+ paddingBottom: 132,
1982
+ },
1983
+ timelineContentNativeComposer: {
1984
+ paddingBottom: 16,
1985
+ },
1986
+ emptyTimeline: { flexGrow: 1, justifyContent: "center" },
1987
+ emptyState: { alignItems: "center", gap: 8, paddingHorizontal: 32 },
1988
+ emptyTitle: { fontSize: 22, fontWeight: "700", textAlign: "center" },
1989
+ emptyDescription: { fontSize: 15, lineHeight: 22, textAlign: "center" },
1990
+ banner: {
1991
+ minHeight: 44,
1992
+ paddingHorizontal: 16,
1993
+ paddingVertical: 10,
1994
+ flexDirection: "row",
1995
+ alignItems: "center",
1996
+ gap: 12,
1997
+ },
1998
+ bannerText: { flex: 1, fontSize: 13, lineHeight: 18 },
1999
+ messageRow: { width: "100%" },
2000
+ userMessageRow: { alignItems: "flex-end" },
2001
+ agentMessageRow: { alignItems: "flex-start" },
2002
+ messageBubble: {
2003
+ gap: 8,
2004
+ },
2005
+ userMessageBubble: {
2006
+ maxWidth: "84%",
2007
+ borderRadius: 16,
2008
+ borderWidth: StyleSheet.hairlineWidth,
2009
+ paddingHorizontal: 14,
2010
+ paddingVertical: 11,
2011
+ },
2012
+ agentMessage: {
2013
+ width: "100%",
2014
+ paddingHorizontal: 4,
2015
+ paddingVertical: 5,
2016
+ },
2017
+ messageText: { fontSize: 16, lineHeight: 23 },
2018
+ timelineCard: { borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, padding: 12, gap: 7 },
2019
+ activityRail: { borderLeftWidth: 2, gap: 12, paddingLeft: 12 },
2020
+ step: { gap: 4 },
2021
+ stepLabel: { fontSize: 12, fontWeight: "600", lineHeight: 16 },
2022
+ disclosure: { gap: 4 },
2023
+ disclosureHeader: {
2024
+ minHeight: 44,
2025
+ borderRadius: 8,
2026
+ flexDirection: "row",
2027
+ alignItems: "center",
2028
+ gap: 7,
2029
+ paddingHorizontal: 6,
2030
+ paddingVertical: 7,
2031
+ },
2032
+ disclosureChevron: { width: 14, fontSize: 18, lineHeight: 20, textAlign: "center" },
2033
+ disclosureIcon: { width: 15, fontSize: 16, lineHeight: 18, textAlign: "center" },
2034
+ disclosureTitle: { flexShrink: 0, fontSize: 14, lineHeight: 19 },
2035
+ disclosurePreview: { minWidth: 0, flex: 1, fontSize: 13, lineHeight: 18 },
2036
+ disclosureBody: { marginLeft: 42, paddingBottom: 6 },
2037
+ activityDisclosure: { gap: 4 },
2038
+ activityDisclosureCard: {
2039
+ borderRadius: 12,
2040
+ borderWidth: StyleSheet.hairlineWidth,
2041
+ paddingHorizontal: 8,
2042
+ paddingVertical: 4,
2043
+ },
2044
+ activityDisclosureHeader: {
2045
+ minHeight: 44,
2046
+ flexDirection: "row",
2047
+ alignItems: "center",
2048
+ gap: 7,
2049
+ paddingVertical: 6,
2050
+ },
2051
+ activityDisclosureChevron: { width: 14, fontSize: 18, lineHeight: 20, textAlign: "center" },
2052
+ activityStatusDot: { width: 6, height: 6, borderRadius: 999 },
2053
+ activityDisclosureTitle: { minWidth: 0, flexShrink: 1, fontSize: 13, fontWeight: "600" },
2054
+ activityDisclosurePreview: { minWidth: 0, flex: 1, fontSize: 13, lineHeight: 18 },
2055
+ activityDisclosureStatus: { flexShrink: 0, fontSize: 11, fontWeight: "600", lineHeight: 15 },
2056
+ activityDisclosureBody: { marginLeft: 27, paddingBottom: 7, paddingRight: 6 },
2057
+ notice: {
2058
+ borderRadius: 10,
2059
+ borderWidth: StyleSheet.hairlineWidth,
2060
+ flexDirection: "row",
2061
+ alignItems: "flex-start",
2062
+ gap: 10,
2063
+ paddingHorizontal: 13,
2064
+ paddingVertical: 11,
2065
+ },
2066
+ noticeIcon: { width: 16, fontSize: 17, fontWeight: "800", lineHeight: 20, textAlign: "center" },
2067
+ noticeContent: { minWidth: 0, flex: 1, gap: 7 },
2068
+ noticeDetails: { gap: 6 },
2069
+ noticeAction: { fontSize: 12, fontWeight: "700", lineHeight: 17 },
2070
+ noticeLink: { minHeight: 44, justifyContent: "center", paddingHorizontal: 2 },
2071
+ sessionStatus: { flexDirection: "row", alignItems: "center", gap: 10 },
2072
+ sessionStatusRule: { height: StyleSheet.hairlineWidth, flex: 1 },
2073
+ sessionStatusLabel: { flexDirection: "row", alignItems: "center", gap: 6 },
2074
+ sessionStatusDot: { width: 5, height: 5, borderRadius: 999 },
2075
+ sessionStatusText: { fontSize: 11, lineHeight: 15 },
2076
+ turnGroup: { gap: 8 },
2077
+ turnSummary: {
2078
+ minHeight: 48,
2079
+ borderRadius: 999,
2080
+ borderWidth: StyleSheet.hairlineWidth,
2081
+ flexDirection: "row",
2082
+ alignItems: "center",
2083
+ gap: 7,
2084
+ paddingHorizontal: 12,
2085
+ },
2086
+ turnSummaryText: { flex: 1, fontSize: 13, fontWeight: "600" },
2087
+ turnChevron: { width: 13, fontSize: 19, fontWeight: "500", textAlign: "center" },
2088
+ turnOutcome: { width: 14, fontSize: 13, fontWeight: "800", textAlign: "center" },
2089
+ turnDuration: { fontSize: 12, fontVariant: ["tabular-nums"] },
2090
+ turnRail: { borderLeftWidth: 2, gap: 12, marginLeft: 8, paddingLeft: 12 },
2091
+ cardTitle: { fontSize: 14, fontWeight: "700", lineHeight: 19 },
2092
+ secondaryText: { fontSize: 14, lineHeight: 20 },
2093
+ helpText: { fontSize: 12, lineHeight: 17 },
2094
+ codeText: {
2095
+ fontFamily: Platform.select({ ios: "Menlo", android: "monospace" }),
2096
+ fontSize: 12,
2097
+ lineHeight: 17,
2098
+ },
2099
+ approvalOverlay: {
2100
+ position: "absolute",
2101
+ left: 0,
2102
+ right: 0,
2103
+ bottom: 12,
2104
+ alignItems: "center",
2105
+ paddingHorizontal: 16,
2106
+ },
2107
+ approvalSurface: {
2108
+ width: "100%",
2109
+ maxWidth: 760,
2110
+ borderRadius: 16,
2111
+ borderWidth: 1,
2112
+ padding: 12,
2113
+ gap: 10,
2114
+ elevation: 8,
2115
+ shadowColor: "#000000",
2116
+ shadowOffset: { width: 0, height: 10 },
2117
+ shadowOpacity: 0.16,
2118
+ shadowRadius: 18,
2119
+ },
2120
+ approvalItem: { gap: 8 },
2121
+ sessionSurfaces: { maxHeight: 300 },
2122
+ sessionSurfacesContent: { paddingVertical: 4 },
2123
+ humanInputWorkspaceCollapsed: { flexShrink: 0 },
2124
+ humanInputWorkspaceExpanded: { flex: 1, minHeight: 0 },
2125
+ humanInputDisclosure: {
2126
+ minHeight: 44,
2127
+ flexDirection: "row",
2128
+ alignItems: "center",
2129
+ gap: 7,
2130
+ paddingHorizontal: 12,
2131
+ paddingVertical: 7,
2132
+ },
2133
+ humanInputDisclosureTitle: { flexShrink: 0, fontSize: 14, fontWeight: "600", lineHeight: 19 },
2134
+ humanInputWorkspaceScroll: { flex: 1, minHeight: 0 },
2135
+ humanInputWorkspaceBody: { gap: 14, paddingHorizontal: 12, paddingBottom: 16 },
2136
+ actionSurfaces: { gap: 14, paddingHorizontal: 12, paddingVertical: 8 },
2137
+ actionGroup: { gap: 8 },
2138
+ actionTitle: { fontSize: 15, fontWeight: "700" },
2139
+ actionCard: { borderRadius: 14, borderWidth: 1, padding: 12, gap: 10 },
2140
+ queueSurface: {
2141
+ overflow: "hidden",
2142
+ borderRadius: 12,
2143
+ borderWidth: StyleSheet.hairlineWidth,
2144
+ },
2145
+ queueDisclosure: {
2146
+ minHeight: 44,
2147
+ flexDirection: "row",
2148
+ alignItems: "center",
2149
+ gap: 8,
2150
+ paddingHorizontal: 12,
2151
+ paddingVertical: 8,
2152
+ },
2153
+ queueChevron: { width: 12, fontSize: 18, lineHeight: 20, textAlign: "center" },
2154
+ queueTitle: { flexShrink: 0, fontSize: 13, fontWeight: "600" },
2155
+ queuePreview: { minWidth: 0, flex: 1, fontSize: 13 },
2156
+ queueRow: { borderTopWidth: StyleSheet.hairlineWidth, padding: 12, gap: 9 },
2157
+ queuePrompt: { fontSize: 14, lineHeight: 19 },
2158
+ queueActions: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 6 },
2159
+ buttonRow: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
2160
+ buttonWrap: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 6 },
2161
+ button: {
2162
+ minHeight: 48,
2163
+ minWidth: 48,
2164
+ borderRadius: 11,
2165
+ borderWidth: 1,
2166
+ alignItems: "center",
2167
+ justifyContent: "center",
2168
+ paddingHorizontal: 12,
2169
+ paddingVertical: 8,
2170
+ },
2171
+ buttonText: { fontSize: 13, fontWeight: "700" },
2172
+ compactButton: {
2173
+ minHeight: 36,
2174
+ minWidth: 0,
2175
+ borderRadius: 9,
2176
+ paddingHorizontal: 10,
2177
+ paddingVertical: 6,
2178
+ },
2179
+ compactButtonText: { fontSize: 12 },
2180
+ question: { gap: 8 },
2181
+ questionInput: {
2182
+ minHeight: 48,
2183
+ maxHeight: 144,
2184
+ borderWidth: 1,
2185
+ borderRadius: 11,
2186
+ paddingHorizontal: 12,
2187
+ paddingVertical: 10,
2188
+ fontSize: 15,
2189
+ textAlignVertical: "top",
2190
+ },
2191
+ optionList: { gap: 7 },
2192
+ option: {
2193
+ minHeight: 48,
2194
+ borderWidth: 1,
2195
+ borderRadius: 11,
2196
+ justifyContent: "center",
2197
+ paddingHorizontal: 12,
2198
+ paddingVertical: 9,
2199
+ },
2200
+ optionLabel: { fontSize: 14, fontWeight: "600" },
2201
+ otherOptionToggle: { minHeight: 48, justifyContent: "center" },
2202
+ otherInput: {
2203
+ minHeight: 48,
2204
+ borderWidth: 1,
2205
+ borderRadius: 9,
2206
+ marginTop: 7,
2207
+ paddingHorizontal: 10,
2208
+ paddingVertical: 8,
2209
+ fontSize: 14,
2210
+ },
2211
+ attachmentStrip: { gap: 8, paddingHorizontal: 12, paddingVertical: 6 },
2212
+ attachmentChip: {
2213
+ width: 180,
2214
+ minHeight: 64,
2215
+ borderWidth: 1,
2216
+ borderRadius: 12,
2217
+ padding: 9,
2218
+ gap: 4,
2219
+ },
2220
+ attachmentName: { fontSize: 13, fontWeight: "600" },
2221
+ composerDock: { paddingHorizontal: 12, paddingTop: 5, paddingBottom: 8, gap: 5 },
2222
+ composerContainer: {
2223
+ width: "100%",
2224
+ maxWidth: 820,
2225
+ alignSelf: "center",
2226
+ borderRadius: 28,
2227
+ borderWidth: 1,
2228
+ padding: 5,
2229
+ gap: 4,
2230
+ },
2231
+ composerRow: { minHeight: 40, flexDirection: "row", alignItems: "flex-end" },
2232
+ composerStack: { gap: 8 },
2233
+ composerInput: {
2234
+ minWidth: 0,
2235
+ minHeight: 40,
2236
+ maxHeight: 120,
2237
+ flex: 1,
2238
+ paddingHorizontal: 11,
2239
+ paddingVertical: 9,
2240
+ fontSize: 16,
2241
+ lineHeight: 22,
2242
+ textAlignVertical: "top",
2243
+ },
2244
+ composerToolbar: {
2245
+ flexDirection: "row",
2246
+ alignItems: "center",
2247
+ justifyContent: "space-between",
2248
+ gap: 8,
2249
+ },
2250
+ draftConflict: { borderRadius: 10, padding: 8, gap: 6 },
2251
+ });