@opengeni/react-native 4.2.1-canary.39593846423001 → 4.3.1

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 (41) hide show
  1. package/android/build.gradle +15 -0
  2. package/android/src/main/AndroidManifest.xml +1 -0
  3. package/android/src/main/java/dev/opengeni/textselection/OpenGeniTextSelectionModule.kt +274 -0
  4. package/dist/{chunk-D3MFBDVR.js → chunk-2RNMGRTB.js} +3 -3
  5. package/dist/{chunk-D3MFBDVR.js.map → chunk-2RNMGRTB.js.map} +1 -1
  6. package/dist/{chunk-2W74OY6I.js → chunk-FHPMRXIG.js} +16 -2
  7. package/dist/chunk-FHPMRXIG.js.map +1 -0
  8. package/dist/{chunk-63PZ52YJ.js → chunk-QFV5DISV.js} +38 -5
  9. package/dist/chunk-QFV5DISV.js.map +1 -0
  10. package/dist/{chunk-U5L2CUMJ.js → chunk-ZZNZBZWV.js} +3 -3
  11. package/dist/{chunk-U5L2CUMJ.js.map → chunk-ZZNZBZWV.js.map} +1 -1
  12. package/dist/index.js +2 -2
  13. package/dist/timeline/activity.d.ts +3 -1
  14. package/dist/timeline/annotations.d.ts +81 -0
  15. package/dist/timeline/icon-nodes.generated.d.ts +5 -0
  16. package/dist/timeline/index.d.ts +2 -0
  17. package/dist/timeline/index.js +1223 -203
  18. package/dist/timeline/index.js.map +1 -1
  19. package/dist/timeline/markdown.js +5 -2
  20. package/dist/timeline/markdown.js.map +1 -1
  21. package/dist/timeline/message-timeline.d.ts +20 -2
  22. package/dist/timeline/previews.js +2 -2
  23. package/dist/timeline/primitives.d.ts +7 -1
  24. package/dist/timeline/queue-dock.d.ts +4 -1
  25. package/dist/timeline/text-selection.d.ts +44 -0
  26. package/expo-module.config.json +5 -2
  27. package/ios/OpenGeniTextSelectionModule.swift +560 -0
  28. package/package.json +6 -4
  29. package/src/timeline/activity.tsx +76 -21
  30. package/src/timeline/annotations.tsx +877 -0
  31. package/src/timeline/icon-nodes.generated.ts +14 -0
  32. package/src/timeline/index.ts +2 -0
  33. package/src/timeline/markdown.tsx +7 -0
  34. package/src/timeline/message-timeline.tsx +340 -174
  35. package/src/timeline/primitives.tsx +50 -1
  36. package/src/timeline/queue-dock.tsx +36 -0
  37. package/src/timeline/session-screen.tsx +122 -4
  38. package/src/timeline/sheet.tsx +3 -2
  39. package/src/timeline/text-selection.ts +115 -0
  40. package/dist/chunk-2W74OY6I.js.map +0 -1
  41. package/dist/chunk-63PZ52YJ.js.map +0 -1
@@ -13,6 +13,7 @@ import Animated, {
13
13
  import { Icon, type NativeIconName } from "./icon";
14
14
  import { fontStyle, useNativeTimelineTheme, type NativeTimelineTheme } from "./theme";
15
15
  import { useOpenGeniText } from "@opengeni/react/messages";
16
+ import type { ToolOutputPreview } from "@opengeni/react/session";
16
17
 
17
18
  /**
18
19
  * A reader opening or closing a disclosure owns the scroll position: the
@@ -160,6 +161,11 @@ export interface ActivityDisclosureProps {
160
161
  /** Compact reel rendering for the rolling status (no chevron, no body). */
161
162
  compact?: boolean | undefined;
162
163
  accessibilityLabel?: string | undefined;
164
+ /**
165
+ * Set while a tool row's output is a bounded preview: opening the row reads
166
+ * the exact output, and the body waits for it (or says it is a preview).
167
+ */
168
+ outputPreview?: ToolOutputPreview | null | undefined;
163
169
  children?: ReactNode | undefined;
164
170
  }
165
171
 
@@ -179,10 +185,19 @@ export function ActivityDisclosure({
179
185
  defaultOpen,
180
186
  compact,
181
187
  accessibilityLabel,
188
+ outputPreview,
182
189
  children,
183
190
  }: ActivityDisclosureProps) {
184
191
  const theme = useNativeTimelineTheme();
192
+ const t = useOpenGeniText();
185
193
  const [open, setOpen] = useState(defaultOpen ?? false);
194
+ // A previewed output reads its exact output when the row opens (once: a
195
+ // failed read waits for Retry).
196
+ const loadOutput = outputPreview?.load;
197
+ const outputPending = outputPreview?.state === "preview";
198
+ useEffect(() => {
199
+ if (open && outputPending && !compact) loadOutput?.();
200
+ }, [open, outputPending, loadOutput, compact]);
186
201
  const releaseFollow = useReleaseFollow();
187
202
  const iconTone = failed && iconToneProp === "muted" ? "failed" : iconToneProp;
188
203
  const chip =
@@ -327,7 +342,41 @@ export function ActivityDisclosure({
327
342
  {row}
328
343
  </Pressable>
329
344
  {open ? (
330
- <View style={{ marginLeft: 28, marginTop: 6, marginBottom: 8, gap: 8 }}>{children}</View>
345
+ <View style={{ marginLeft: 28, marginTop: 6, marginBottom: 8, gap: 8 }}>
346
+ {outputPreview && loadOutput && outputPreview.state !== "error" ? (
347
+ // Never show a preview's body as if it were the whole output.
348
+ <BodyNote>{t("timeline.shared.loadingFullOutput")}</BodyNote>
349
+ ) : outputPreview ? (
350
+ <>
351
+ <View
352
+ style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}
353
+ >
354
+ <BodyNote>
355
+ {loadOutput
356
+ ? t("timeline.shared.outputPreviewFailed")
357
+ : t("timeline.shared.outputPreviewOnly")}
358
+ </BodyNote>
359
+ {loadOutput ? (
360
+ <Pressable accessibilityRole="button" onPress={loadOutput} hitSlop={8}>
361
+ <Text
362
+ style={{
363
+ ...fontStyle(theme, 500),
364
+ fontSize: theme.size.sm,
365
+ lineHeight: 20,
366
+ color: theme.colors["fg-muted"],
367
+ }}
368
+ >
369
+ {t("timeline.shared.retryFullOutput")}
370
+ </Text>
371
+ </Pressable>
372
+ ) : null}
373
+ </View>
374
+ {children}
375
+ </>
376
+ ) : (
377
+ children
378
+ )}
379
+ </View>
331
380
  ) : null}
332
381
  </View>
333
382
  );
@@ -17,6 +17,7 @@ import { blendOver, withAlpha } from "./primitives";
17
17
  import { CompactSignalsContext } from "./session-signals";
18
18
  import { fontStyle, useNativeTimelineTheme } from "./theme";
19
19
  import { useNativeTimelineMessages } from "./messages";
20
+ import type { NativeAnnotationLike } from "./annotations";
20
21
 
21
22
  /* ----------------------------------------------------------------------------
22
23
  The web session-chrome queue dock above the composer: a "N queued · Steer"
@@ -40,12 +41,15 @@ export function QueueDock({
40
41
  queue,
41
42
  composer,
42
43
  onComposerFocus,
44
+ onReviewAnnotations,
43
45
  leading,
44
46
  singleSend = false,
45
47
  }: {
46
48
  queue: TurnQueue;
47
49
  composer?: QueueDockComposer | undefined;
48
50
  onComposerFocus?: (() => void) | undefined;
51
+ /** Show a waiting message's quotes and notes. */
52
+ onReviewAnnotations?: ((annotations: readonly NativeAnnotationLike[]) => void) | undefined;
49
53
  /** Chips sharing the queue chip's row (goal, agents, commands). */
50
54
  leading?: ReactNode;
51
55
  /** One Send and nothing to choose: no Steer on waiting messages. */
@@ -215,6 +219,38 @@ export function QueueDock({
215
219
  >
216
220
  {label}
217
221
  </Text>
222
+ {annotationCount > 0 && onReviewAnnotations ? (
223
+ <Pressable
224
+ accessibilityRole="button"
225
+ accessibilityLabel={t("timelineAnnotations.reviewCountlabel", {
226
+ countLabel: t("counts.annotations", { count: annotationCount }),
227
+ })}
228
+ hitSlop={8}
229
+ onPress={() => onReviewAnnotations(turn.annotations ?? [])}
230
+ style={({ pressed }) => ({
231
+ flexDirection: "row",
232
+ alignItems: "center",
233
+ gap: 3,
234
+ height: 22,
235
+ paddingHorizontal: 7,
236
+ borderRadius: 11,
237
+ backgroundColor: c["accent-soft"],
238
+ opacity: pressed ? 0.6 : 1,
239
+ })}
240
+ >
241
+ <Icon name="quote" size={10} color={c["fg-muted"]} />
242
+ <Text
243
+ style={{
244
+ ...fontStyle(theme, 500),
245
+ fontSize: 11,
246
+ color: c["fg-muted"],
247
+ fontVariant: ["tabular-nums"],
248
+ }}
249
+ >
250
+ {annotationCount}
251
+ </Text>
252
+ </Pressable>
253
+ ) : null}
218
254
  {turns.length > 1 ? (
219
255
  <>
220
256
  <RowAction
@@ -13,7 +13,16 @@ import {
13
13
  type ComponentRef,
14
14
  type ReactNode,
15
15
  } from "react";
16
- import { ActivityIndicator, Image, Pressable, Text, View, type TextInput } from "react-native";
16
+ import {
17
+ ActivityIndicator,
18
+ Alert,
19
+ Image,
20
+ Pressable,
21
+ Text,
22
+ View,
23
+ type TextInput,
24
+ } from "react-native";
25
+ import { annotationHasNote } from "@opengeni/react/timeline-model";
17
26
  import {
18
27
  defaultChatComposerMessages,
19
28
  type ChatComposerMessages,
@@ -33,6 +42,12 @@ import {
33
42
  } from "./decisions";
34
43
  import { NativeMessageAttachments } from "./message-attachments";
35
44
  import { QueueDock } from "./queue-dock";
45
+ import {
46
+ AnnotationsChip,
47
+ AnnotationsSheet,
48
+ type AnnotationReveal,
49
+ type NativeAnnotationLike,
50
+ } from "./annotations";
36
51
  import { SessionCommandsList, SessionSignals } from "./session-signals";
37
52
  import type { ClientModel, OpenGeniClient, SessionBackgroundCommand } from "@opengeni/sdk";
38
53
  import { MessageTimeline, type NativeMessageTimelineProps } from "./message-timeline";
@@ -156,9 +171,29 @@ function SessionScreenBody({
156
171
  ...timelineProps
157
172
  }: Omit<NativeSessionScreenProps, "timelineMessages">) {
158
173
  const theme = useNativeTimelineTheme();
174
+ const t = useOpenGeniText();
159
175
  const m = useNativeTimelineMessages();
160
176
  const { composer, queue, humanInput, approvals, control, attachments } = controller;
161
177
  const composerInput = useRef<ComponentRef<typeof TextInput>>(null);
178
+ const annotationReveal = useRef<AnnotationReveal | null>(null);
179
+ const draftAnnotations = composer.annotations ?? [];
180
+ const annotationsIncomplete = draftAnnotations.some(
181
+ (annotation) => annotation.note.trim().length === 0,
182
+ );
183
+ const reviewTargetId = composer.annotationReviewTargetId ?? null;
184
+ const [reviewOpen, setReviewOpen] = useState(false);
185
+ // A new quote, a marker tap or a send with an empty note opens its note.
186
+ useEffect(() => {
187
+ if (reviewTargetId) setReviewOpen(true);
188
+ }, [reviewTargetId]);
189
+ const closeReview = useCallback(() => {
190
+ setReviewOpen(false);
191
+ composer.clearAnnotationReviewTarget?.();
192
+ }, [composer]);
193
+ const [queuedReview, setQueuedReview] = useState<readonly NativeAnnotationLike[] | null>(null);
194
+ // Each send brings the conversation back to its latest row.
195
+ const [followLatestSignal, setFollowLatestSignal] = useState(0);
196
+ const followLatest = useCallback(() => setFollowLatestSignal((value) => value + 1), []);
162
197
  // The composer floats over the conversation; its occupied height insets the timeline.
163
198
  const [composerHeight, setComposerHeight] = useState(120);
164
199
  const items = useMemo(
@@ -201,6 +236,11 @@ function SessionScreenBody({
201
236
  <View style={{ flex: 1, backgroundColor: theme.colors.bg }}>
202
237
  {topBar}
203
238
  <MessageTimeline
239
+ onAnnotate={composer.addAnnotation}
240
+ draftAnnotations={composer.annotations}
241
+ onDraftAnnotationSelect={composer.requestAnnotationReview}
242
+ annotationRevealRef={annotationReveal}
243
+ followLatestSignal={followLatestSignal}
204
244
  {...timelineProps}
205
245
  hasOlder={controller.hasOlder}
206
246
  loadingOlder={controller.loadingOlder}
@@ -276,14 +316,31 @@ function SessionScreenBody({
276
316
  onActionFeedback: composerSlots?.onActionFeedback,
277
317
  value: composer.value,
278
318
  onChangeText: composer.setValue,
279
- onSend: () => void composer.send(),
319
+ // A quote without its note cannot be sent: Send opens that note instead
320
+ // of staying disabled with no reason given.
321
+ onSend: () => {
322
+ if (annotationsIncomplete) composer.requestAnnotationReview?.();
323
+ else {
324
+ followLatest();
325
+ void composer.send();
326
+ }
327
+ },
280
328
  // The other choice beside Send while the agent works: Queue when
281
329
  // Send steers, Steer when Send queues.
282
330
  alternateSend: composer.alternateSend ?? null,
283
331
  ...(composer.sendAlternate
284
- ? { onAlternateSend: () => void composer.sendAlternate?.() }
332
+ ? {
333
+ onAlternateSend: () => {
334
+ if (annotationsIncomplete) {
335
+ composer.requestAnnotationReview?.();
336
+ return;
337
+ }
338
+ followLatest();
339
+ void composer.sendAlternate?.();
340
+ },
341
+ }
285
342
  : {}),
286
- canSend: composer.canSend && !busy,
343
+ canSend: (composer.canSend || (annotationsIncomplete && !composer.sending)) && !busy,
287
344
  sending: composer.sending,
288
345
  running: controller.runActive,
289
346
  paused: paused,
@@ -311,6 +368,44 @@ function SessionScreenBody({
311
368
  <>
312
369
  {composerSlots?.header}
313
370
  <AttachmentChips attachments={attachments} />
371
+ {draftAnnotations.length > 0 && composer.removeAnnotation ? (
372
+ <View style={{ paddingHorizontal: 12, paddingTop: 10 }}>
373
+ <AnnotationsChip
374
+ annotations={draftAnnotations}
375
+ onPress={() => composer.requestAnnotationReview?.()}
376
+ onClear={() => {
377
+ const removeAll = () => {
378
+ for (const annotation of draftAnnotations) {
379
+ composer.removeAnnotation?.(annotation.id);
380
+ }
381
+ };
382
+ // Bare quotes go at once; written notes are worth one confirm,
383
+ // since the × sits right beside the chip.
384
+ if (!draftAnnotations.some((item) => annotationHasNote(item.note))) {
385
+ removeAll();
386
+ return;
387
+ }
388
+ Alert.alert(
389
+ t("native.timeline.annotations.removeAllTitle", {
390
+ count: draftAnnotations.length,
391
+ }),
392
+ t("native.timeline.annotations.removeAllMessage", {
393
+ count: draftAnnotations.filter((item) => annotationHasNote(item.note))
394
+ .length,
395
+ }),
396
+ [
397
+ { text: t("native.timeline.annotations.cancel"), style: "cancel" },
398
+ {
399
+ text: t("native.timeline.annotations.removeAllConfirm"),
400
+ style: "destructive",
401
+ onPress: removeAll,
402
+ },
403
+ ],
404
+ );
405
+ }}
406
+ />
407
+ </View>
408
+ ) : null}
314
409
  </>
315
410
  ),
316
411
  above: (
@@ -320,6 +415,7 @@ function SessionScreenBody({
320
415
  composer={composer}
321
416
  singleSend={composer.singleSend === true}
322
417
  onComposerFocus={() => composerInput.current?.focus()}
418
+ onReviewAnnotations={setQueuedReview}
323
419
  leading={
324
420
  <SessionSignals
325
421
  goal={controller.goal}
@@ -354,6 +450,28 @@ function SessionScreenBody({
354
450
  </>
355
451
  ),
356
452
  })}
453
+ <AnnotationsSheet
454
+ open={reviewOpen}
455
+ onClose={closeReview}
456
+ annotations={draftAnnotations}
457
+ editable={Boolean(composer.updateAnnotation)}
458
+ focusId={reviewTargetId}
459
+ onFocusConsumed={composer.clearAnnotationReviewTarget}
460
+ onUpdate={composer.updateAnnotation}
461
+ onRemove={composer.removeAnnotation}
462
+ onReveal={(source, quote) => annotationReveal.current?.(source, quote) ?? false}
463
+ onCommit={() => {
464
+ closeReview();
465
+ setTimeout(() => composerInput.current?.focus(), 250);
466
+ }}
467
+ />
468
+ <AnnotationsSheet
469
+ open={queuedReview !== null}
470
+ onClose={() => setQueuedReview(null)}
471
+ annotations={queuedReview ?? []}
472
+ editable={false}
473
+ onReveal={(source, quote) => annotationReveal.current?.(source, quote) ?? false}
474
+ />
357
475
  </View>
358
476
  );
359
477
  }
@@ -6,7 +6,6 @@ import {
6
6
  KeyboardAvoidingView,
7
7
  Modal,
8
8
  PanResponder,
9
- Platform,
10
9
  Pressable,
11
10
  View,
12
11
  useWindowDimensions,
@@ -96,7 +95,9 @@ export function BottomSheet(props: {
96
95
  onRequestClose={props.onClose}
97
96
  >
98
97
  <KeyboardAvoidingView
99
- behavior={Platform.OS === "ios" ? "padding" : undefined}
98
+ // Edge-to-edge Android doesn't resize a modal's window for the
99
+ // keyboard either, so the sheet lifts itself on both platforms.
100
+ behavior="padding"
100
101
  style={{ flex: 1, justifyContent: "flex-end" }}
101
102
  >
102
103
  <Animated.View
@@ -0,0 +1,115 @@
1
+ import type { DraftTimelineAnnotation } from "@opengeni/sdk";
2
+ import type { TimelineAnnotationSourceDescriptor } from "@opengeni/react/session";
3
+ import { annotationFromSelection } from "@opengeni/react/timeline-model";
4
+ import { useOpenGeniText } from "@opengeni/react/messages";
5
+ import { useEffect, useRef } from "react";
6
+ import { Platform } from "react-native";
7
+
8
+ /**
9
+ * Text selection inside timeline messages. A message body sits in a view with
10
+ * this test id prefix plus its annotation source key (empty when it cannot be
11
+ * annotated). On iOS the package's native module then gives its text the
12
+ * system selection (handles, Copy, Look Up, Share) instead of React Native's
13
+ * whole-paragraph Copy; on both platforms it adds Add note to the selection
14
+ * menu when a host takes annotations.
15
+ */
16
+ export const ANNOTATION_SOURCE_TEST_ID_PREFIX = "og-annotation-source:";
17
+
18
+ export function annotationSourceTestId(sourceKey: string | undefined): string {
19
+ return `${ANNOTATION_SOURCE_TEST_ID_PREFIX}${sourceKey ?? ""}`;
20
+ }
21
+
22
+ type AnnotateEvent = { sourceKey: string; quote: string; before: string; after: string };
23
+
24
+ export type QuoteBox = { x: number; y: number; width: number; height: number };
25
+
26
+ /**
27
+ * Where a quote is drawn in its source view: the end of its last glyph, the
28
+ * top of that line's text, and a text box for each line it spans.
29
+ */
30
+ export type QuoteAnchor = { x: number; top: number; boxes: QuoteBox[] };
31
+
32
+ export type QuoteAnchorQuery = { quote: string; before: string; after: string };
33
+
34
+ type TextSelectionModule = {
35
+ addListener(
36
+ eventName: "onAnnotate",
37
+ listener: (event: AnnotateEvent) => void,
38
+ ): { remove(): void };
39
+ setAnnotateTitle(title: string | null): void;
40
+ quoteAnchors?(
41
+ sourceKey: string,
42
+ queries: QuoteAnchorQuery[],
43
+ ): Promise<({ x?: number; top?: number; rects?: number[] } | null)[]>;
44
+ };
45
+
46
+ function textSelectionModule(): TextSelectionModule | null {
47
+ if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
48
+ const registry = (globalThis as { expo?: { modules?: Record<string, unknown> } }).expo?.modules;
49
+ const module = registry?.OpenGeniTextSelection as TextSelectionModule | undefined;
50
+ return module && typeof module.addListener === "function" ? module : null;
51
+ }
52
+
53
+ /** Whether this build can turn a text selection into an annotation. */
54
+ export function nativeTextAnnotationAvailable(): boolean {
55
+ return textSelectionModule() !== null;
56
+ }
57
+
58
+ /**
59
+ * Measures where each quote ends inside the source drawn for `sourceKey`;
60
+ * null for a quote that is not drawn there (or without native measuring).
61
+ */
62
+ export async function measureQuoteAnchors(
63
+ sourceKey: string,
64
+ queries: QuoteAnchorQuery[],
65
+ ): Promise<(QuoteAnchor | null)[]> {
66
+ const module = textSelectionModule();
67
+ if (!module?.quoteAnchors || queries.length === 0) return queries.map(() => null);
68
+ try {
69
+ const anchors = await module.quoteAnchors(sourceKey, queries);
70
+ return queries.map((_, index) => {
71
+ const anchor = anchors[index];
72
+ if (typeof anchor?.x !== "number" || typeof anchor.top !== "number") return null;
73
+ const flat = anchor.rects ?? [];
74
+ const boxes: QuoteBox[] = [];
75
+ for (let at = 0; at + 3 < flat.length; at += 4) {
76
+ boxes.push({ x: flat[at]!, y: flat[at + 1]!, width: flat[at + 2]!, height: flat[at + 3]! });
77
+ }
78
+ return { x: anchor.x, top: anchor.top, boxes };
79
+ });
80
+ } catch {
81
+ return queries.map(() => null);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Turns Add note on a selection into a draft annotation for `onAnnotate`.
87
+ * `sources` maps each message's annotation source key to its source.
88
+ */
89
+ export function useNativeTextAnnotation(
90
+ sources: ReadonlyMap<string, TimelineAnnotationSourceDescriptor>,
91
+ onAnnotate: ((annotation: DraftTimelineAnnotation) => void) | undefined,
92
+ ): void {
93
+ const t = useOpenGeniText();
94
+ const latest = useRef({ sources, onAnnotate });
95
+ latest.current = { sources, onAnnotate };
96
+ const enabled = Boolean(onAnnotate);
97
+ const title = t("timelineAnnotationSelection.addNote");
98
+ useEffect(() => {
99
+ const module = textSelectionModule();
100
+ if (!module || !enabled) return;
101
+ // Add note shows while some timeline listens (the module tracks listeners).
102
+ module.setAnnotateTitle(title);
103
+ const subscription = module.addListener("onAnnotate", (event) => {
104
+ const source = latest.current.sources.get(event.sourceKey);
105
+ if (!source) return;
106
+ const annotation = annotationFromSelection(source, {
107
+ text: event.quote,
108
+ textBefore: event.before,
109
+ textAfter: event.after,
110
+ });
111
+ if (annotation) latest.current.onAnnotate?.(annotation);
112
+ });
113
+ return () => subscription.remove();
114
+ }, [enabled, title]);
115
+ }