@opengeni/react-native 4.2.1-canary.39642758903001 → 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 (37) 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-YTT4WSJZ.js → chunk-QFV5DISV.js} +3 -3
  9. package/dist/{chunk-YTT4WSJZ.js.map → chunk-QFV5DISV.js.map} +1 -1
  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/annotations.d.ts +81 -0
  14. package/dist/timeline/icon-nodes.generated.d.ts +5 -0
  15. package/dist/timeline/index.d.ts +2 -0
  16. package/dist/timeline/index.js +1186 -197
  17. package/dist/timeline/index.js.map +1 -1
  18. package/dist/timeline/markdown.js +5 -2
  19. package/dist/timeline/markdown.js.map +1 -1
  20. package/dist/timeline/message-timeline.d.ts +20 -2
  21. package/dist/timeline/previews.js +2 -2
  22. package/dist/timeline/queue-dock.d.ts +4 -1
  23. package/dist/timeline/text-selection.d.ts +44 -0
  24. package/expo-module.config.json +5 -2
  25. package/ios/OpenGeniTextSelectionModule.swift +560 -0
  26. package/package.json +6 -4
  27. package/src/timeline/activity.tsx +24 -15
  28. package/src/timeline/annotations.tsx +877 -0
  29. package/src/timeline/icon-nodes.generated.ts +14 -0
  30. package/src/timeline/index.ts +2 -0
  31. package/src/timeline/markdown.tsx +7 -0
  32. package/src/timeline/message-timeline.tsx +340 -174
  33. package/src/timeline/queue-dock.tsx +36 -0
  34. package/src/timeline/session-screen.tsx +122 -4
  35. package/src/timeline/sheet.tsx +3 -2
  36. package/src/timeline/text-selection.ts +115 -0
  37. package/dist/chunk-2W74OY6I.js.map +0 -1
@@ -966,6 +966,20 @@ export const iconNodes = {
966
966
  },
967
967
  ],
968
968
  ],
969
+ quote: [
970
+ [
971
+ "path",
972
+ {
973
+ d: "M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z",
974
+ },
975
+ ],
976
+ [
977
+ "path",
978
+ {
979
+ d: "M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z",
980
+ },
981
+ ],
982
+ ],
969
983
  search: [
970
984
  [
971
985
  "path",
@@ -47,6 +47,8 @@ export type { NativeTimelineMessages } from "./messages";
47
47
  export { ComposerPill, SessionComposer } from "./composer";
48
48
  export { ModelPickerSheet } from "./model-picker";
49
49
  export { BottomSheet } from "./sheet";
50
+ export { AnnotationsChip, AnnotationsSheet } from "./annotations";
51
+ export type { AnnotationReveal, NativeAnnotationLike } from "./annotations";
50
52
  export { SessionActionsButton } from "./session-actions";
51
53
  export type { SessionAction, SessionActionsClient, SessionActionsProps } from "./session-actions";
52
54
  export type { ModelPickerSheetProps } from "./model-picker";
@@ -469,6 +469,13 @@ function webRules(
469
469
  const line = tone === "muted" ? 24 : 28;
470
470
  return {
471
471
  ...renderRules,
472
+ // Every run of text is selectable, as on the web: a long press selects
473
+ // words (and, inside a timeline message, offers Add note).
474
+ textgroup: (node, children, _parent, styles) => (
475
+ <Text key={node.key} selectable style={styles.textgroup}>
476
+ {children}
477
+ </Text>
478
+ ),
472
479
  // Web lists use a disc marker in fg-subtle; the library's iOS default is a middle dot.
473
480
  list_item: (node, children, parent, styles, inheritedStyles = {}) => {
474
481
  if (parent.some((entry: { type: string }) => entry.type === "bullet_list")) {
@@ -1,10 +1,16 @@
1
- import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
1
+ import type {
2
+ DraftTimelineAnnotation,
3
+ SessionEvent,
4
+ SessionStatus,
5
+ TimelineAnnotationSource,
6
+ } from "@opengeni/sdk";
2
7
  import {
3
8
  buildTimeline,
4
9
  groupTimeline,
5
10
  type AgentMessageItem,
6
11
  type HumanInputItem,
7
12
  type TimelineGroup,
13
+ type TimelineAnnotationSourceDescriptor,
8
14
  type TimelineItem,
9
15
  type UserMessageItem,
10
16
  } from "@opengeni/react/session";
@@ -35,10 +41,12 @@ import {
35
41
  useRef,
36
42
  useState,
37
43
  type ComponentRef,
44
+ type MutableRefObject,
38
45
  type ReactNode,
39
46
  } from "react";
40
47
  import {
41
48
  ActivityIndicator,
49
+ Keyboard,
42
50
  Pressable,
43
51
  ScrollView,
44
52
  Text,
@@ -70,6 +78,16 @@ import { useOpenGeniText, type OpenGeniText } from "@opengeni/react/messages";
70
78
  import { TurnStepsLoader } from "./turn-steps";
71
79
  import { useNativeTimelineMessages } from "./messages";
72
80
  import { collectLiveNotes, foldedLiveNotes, liveFoldShift } from "./live-notes";
81
+ import {
82
+ AnnotationSourcesContext,
83
+ AnnotationSourceView,
84
+ SentAnnotationCards,
85
+ draftMarkersBySource,
86
+ type AnnotationFlash,
87
+ type AnnotationReveal,
88
+ type AnnotationSourceNode,
89
+ } from "./annotations";
90
+ import { measureQuoteAnchors, useNativeTextAnnotation } from "./text-selection";
73
91
 
74
92
  /* ----------------------------------------------------------------------------
75
93
  Native MessageTimeline: the web MessageTimeline (components/message-timeline)
@@ -125,9 +143,39 @@ export interface NativeMessageTimelineProps extends NativeActivityOptions {
125
143
  contentInsetBottom?: number | undefined;
126
144
  /** Height of host chrome floating over the timeline's bottom edge (a floating composer). */
127
145
  overlayInsetBottom?: number | undefined;
146
+ /**
147
+ * Changes whenever the reader sends: the timeline resumes following the
148
+ * latest row, so a message sent while reading (or annotating) older
149
+ * history comes into view.
150
+ */
151
+ followLatestSignal?: number | undefined;
152
+ /**
153
+ * Add note on selected message text (iOS): receives the quote as a draft
154
+ * annotation, as the web timeline's selection popover does.
155
+ */
156
+ onAnnotate?: ((annotation: DraftTimelineAnnotation) => void) | undefined;
157
+ /** The draft's annotations, marked on the messages they quote. */
158
+ draftAnnotations?: readonly DraftTimelineAnnotation[] | undefined;
159
+ /** A draft marker was tapped: review that annotation. */
160
+ onDraftAnnotationSelect?: ((id: string) => void) | undefined;
161
+ /** Receives a function that scrolls a quoted message into view and highlights it. */
162
+ annotationRevealRef?: MutableRefObject<AnnotationReveal | null> | undefined;
128
163
  style?: StyleProp<ViewStyle>;
129
164
  }
130
165
 
166
+ /** Each message's annotation source, by source key (its durable event id). */
167
+ function annotationSources(
168
+ items: readonly TimelineItem[],
169
+ ): Map<string, TimelineAnnotationSourceDescriptor> {
170
+ const sources = new Map<string, TimelineAnnotationSourceDescriptor>();
171
+ for (const item of items) {
172
+ if (item.kind !== "user-message" && item.kind !== "agent-message") continue;
173
+ const source = item.annotationSource;
174
+ if (source) sources.set(source.eventId, source);
175
+ }
176
+ return sources;
177
+ }
178
+
131
179
  export function MessageTimeline(props: NativeMessageTimelineProps) {
132
180
  const theme = useNativeTimelineTheme();
133
181
  const m = useNativeTimelineMessages();
@@ -137,6 +185,8 @@ export function MessageTimeline(props: NativeMessageTimelineProps) {
137
185
  [t, props.items, props.events],
138
186
  );
139
187
  const groups = useMemo(() => groupTimeline(t, items, { readableTurns: true }), [t, items]);
188
+ const sources = useMemo(() => annotationSources(items), [items]);
189
+ useNativeTextAnnotation(sources, props.onAnnotate);
140
190
  const foldMemory = useRef(new Map<string, "open" | "closed">()).current;
141
191
  const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);
142
192
  // Opening on a moment starts away from the tip; the landing decides where.
@@ -151,6 +201,86 @@ export function MessageTimeline(props: NativeMessageTimelineProps) {
151
201
  // Expanded outer turn headers, for the pinned copy (web: sticky in its section).
152
202
  const stickyHeaders = useRef(new Map<string, StickyWorkHeader>()).current;
153
203
  const scrollY = useRef(0);
204
+ const sourceNodes = useRef(new Map<string, AnnotationSourceNode>()).current;
205
+ const [flash, setFlash] = useState<AnnotationFlash | null>(null);
206
+ // Read when a reveal lands, after the keyboard has gone and the composer settled.
207
+ const overlayInsetBottom = useRef(0);
208
+ overlayInsetBottom.current = props.overlayInsetBottom ?? 0;
209
+ const registerSource = useCallback(
210
+ (sourceKey: string, node: AnnotationSourceNode | null) => {
211
+ if (node) sourceNodes.set(sourceKey, node);
212
+ else sourceNodes.delete(sourceKey);
213
+ },
214
+ [sourceNodes],
215
+ );
216
+ const revealSource = useCallback<AnnotationReveal>(
217
+ (source: TimelineAnnotationSource, quote) => {
218
+ const node = sourceNodes.get(source.eventId);
219
+ const scroller = scrollRef.current?.getNativeScrollRef?.();
220
+ if (!node || !scroller) return false;
221
+ following.current = false;
222
+ // Reading the source, not writing: the keyboard would hide it.
223
+ const keyboardGone = Keyboard.isVisible()
224
+ ? new Promise<void>((resolve) => {
225
+ const done = () => {
226
+ subscription.remove();
227
+ clearTimeout(timer);
228
+ // The composer re-measures as it drops back down.
229
+ setTimeout(resolve, 60);
230
+ };
231
+ const subscription = Keyboard.addListener("keyboardDidHide", done);
232
+ const timer = setTimeout(done, 600);
233
+ Keyboard.dismiss();
234
+ })
235
+ : Promise.resolve();
236
+ const located = quote
237
+ ? measureQuoteAnchors(source.eventId, [quote]).then(([anchor]) => anchor ?? null)
238
+ : Promise.resolve(null);
239
+ void Promise.all([located, keyboardGone]).then(([anchor]) => {
240
+ scroller.measureInWindow((_scrollX, scrollTop) => {
241
+ node.measureInWindow((_x, top, _width, height) => {
242
+ // Center the message in the space the composer leaves; a message
243
+ // taller than that centers the quote instead, or shows its top.
244
+ const y = scrollY.current + top - scrollTop;
245
+ const visible = (viewportHeight.current || 600) - overlayInsetBottom.current;
246
+ const first = anchor?.boxes[0];
247
+ const last = anchor?.boxes[anchor.boxes.length - 1];
248
+ const span =
249
+ height < visible - 32 || !first || !last
250
+ ? { top: y, height }
251
+ : { top: y + first.y, height: last.y + last.height - first.y };
252
+ const target =
253
+ span.height < visible - 32 ? span.top - (visible - span.height) / 2 : span.top - 16;
254
+ const end = Math.max(0, contentHeight.current - (viewportHeight.current || 0));
255
+ scrollY.current = Math.min(Math.max(0, target), end || Number.POSITIVE_INFINITY);
256
+ scrollRef.current?.scrollTo({ y: scrollY.current, animated: true });
257
+ // Flash once the scroll is under way, so the reader sees it land.
258
+ setFlash((current) => ({
259
+ sourceKey: source.eventId,
260
+ token: (current?.token ?? 0) + 1,
261
+ ...(quote ? { quote } : {}),
262
+ }));
263
+ });
264
+ });
265
+ });
266
+ return true;
267
+ },
268
+ [sourceNodes],
269
+ );
270
+ if (props.annotationRevealRef) props.annotationRevealRef.current = revealSource;
271
+ const draftMarkers = useMemo(
272
+ () => draftMarkersBySource(props.draftAnnotations),
273
+ [props.draftAnnotations],
274
+ );
275
+ const annotationSourcesValue = useMemo(
276
+ () => ({
277
+ register: registerSource,
278
+ flash,
279
+ drafts: draftMarkers,
280
+ onSelectDraft: props.onDraftAnnotationSelect,
281
+ }),
282
+ [draftMarkers, flash, props.onDraftAnnotationSelect, registerSource],
283
+ );
154
284
  const [pinned, setPinned] = useState<{ key: string; offset: number; height: number } | null>(
155
285
  null,
156
286
  );
@@ -318,6 +448,13 @@ export function MessageTimeline(props: NativeMessageTimelineProps) {
318
448
  },
319
449
  [requestOlder],
320
450
  );
451
+ const followLatestSignal = props.followLatestSignal ?? 0;
452
+ useEffect(() => {
453
+ if (!followLatestSignal) return;
454
+ following.current = true;
455
+ setShowJump(false);
456
+ scrollRef.current?.scrollToEnd({ animated: true });
457
+ }, [followLatestSignal]);
321
458
  // Landing on a moment: each group's offset, read as it lays out.
322
459
  const groupTops = useRef(new Map<string, number>()).current;
323
460
  const landed = useRef<number | null>(null);
@@ -371,155 +508,161 @@ export function MessageTimeline(props: NativeMessageTimelineProps) {
371
508
  renderUserAttachments: props.renderUserAttachments,
372
509
  onCopy: props.onCopy,
373
510
  reportLiveFold,
511
+ onRevealAnnotation: revealSource,
374
512
  };
375
513
  return (
376
- <NativeActivityOptionsProvider value={activityOptions}>
377
- <FoldMemoryProvider value={foldMemory}>
378
- <StickyWorkHeaderProvider value={registerSticky}>
379
- <ReleaseFollowProvider value={releaseFollow}>
380
- <View style={[{ flex: 1, backgroundColor: theme.colors.bg }, props.style]}>
381
- <ScrollView
382
- ref={scrollRef}
383
- onScroll={onScroll}
384
- onScrollBeginDrag={() => {
385
- readerScrolling.current = true;
386
- // The reader takes over: a live turn re-lays out the content many
387
- // times a second, and snapping to the end on each pass would undo
388
- // the drag before it got far. Follow is re-decided when it ends.
389
- following.current = false;
390
- }}
391
- onScrollEndDrag={onScrollEnd}
392
- onMomentumScrollEnd={onScrollEnd}
393
- onLayout={(event: LayoutChangeEvent) => {
394
- viewportHeight.current = event.nativeEvent.layout.height;
395
- if (following.current) scrollRef.current?.scrollToEnd({ animated: false });
396
- }}
397
- scrollEventThrottle={32}
398
- onContentSizeChange={onContentSizeChange}
399
- keyboardDismissMode="interactive"
400
- keyboardShouldPersistTaps="handled"
401
- contentContainerStyle={{
402
- paddingTop: props.contentInsetTop ?? 16,
403
- paddingHorizontal: 16,
404
- paddingBottom: props.contentInsetBottom ?? 24,
405
- gap: ROW_GAP_PX,
406
- flexGrow: 1,
407
- }}
408
- >
409
- <View>
410
- {props.hasOlder ? (
411
- <OlderHistoryRow loading={props.loadingOlder === true} onPress={requestOlder} />
412
- ) : null}
413
- {props.header}
414
- </View>
415
- {groups.length === 0 && props.emptyState ? props.emptyState : null}
416
- {groups.map((group, index) => {
417
- const key = groupKey(group);
418
- const prompt = group.kind === "item" && group.item.kind === "user-message";
419
- // Agent messages keep one wrapper so a progress note can fold
420
- // (stay mounted, out of layout) without remounting.
421
- const noteId =
422
- group.kind === "item" && group.item.kind === "agent-message"
423
- ? group.item.id
424
- : null;
425
- const liveNote = noteId !== null && liveNotes.notes.has(noteId);
426
- const folded = noteId !== null && foldedNotes.has(noteId);
427
- // The landing row keeps one wrapper for its life, so landing never remounts it.
428
- const landing = focusSequence !== null && index === focusIndex;
429
- return prompt || landing || noteId !== null ? (
430
- <View
431
- key={key}
432
- style={folded ? { display: "none" } : undefined}
433
- onLayout={(event) => {
434
- const { y, height } = event.nativeEvent.layout;
435
- if (prompt) promptFrames.set(key, { top: y, bottom: y + height });
436
- if (liveNote && !folded && height > 0) {
437
- liveNoteHeights.set(noteId, height);
438
- }
439
- if (landing && landed.current !== focusSequence) {
440
- groupTops.set(key, y);
441
- setLayoutPass((pass) => pass + 1);
442
- }
443
- }}
444
- >
445
- <TimelineGroupView group={group} context={context} />
446
- </View>
447
- ) : (
448
- <TimelineGroupView key={key} group={group} context={context} />
449
- );
450
- })}
451
- {props.trailing}
452
- </ScrollView>
453
- {pinned && stickyHeaders.get(pinned.key) ? (
454
- <View
455
- style={{
456
- position: "absolute",
457
- top: 0,
458
- left: 0,
459
- right: 0,
514
+ <AnnotationSourcesContext.Provider value={annotationSourcesValue}>
515
+ <NativeActivityOptionsProvider value={activityOptions}>
516
+ <FoldMemoryProvider value={foldMemory}>
517
+ <StickyWorkHeaderProvider value={registerSticky}>
518
+ <ReleaseFollowProvider value={releaseFollow}>
519
+ <View style={[{ flex: 1, backgroundColor: theme.colors.bg }, props.style]}>
520
+ <ScrollView
521
+ ref={scrollRef}
522
+ onScroll={onScroll}
523
+ onScrollBeginDrag={() => {
524
+ readerScrolling.current = true;
525
+ // The reader takes over: a live turn re-lays out the content many
526
+ // times a second, and snapping to the end on each pass would undo
527
+ // the drag before it got far. Follow is re-decided when it ends.
528
+ following.current = false;
529
+ }}
530
+ onScrollEndDrag={onScrollEnd}
531
+ onMomentumScrollEnd={onScrollEnd}
532
+ onLayout={(event: LayoutChangeEvent) => {
533
+ viewportHeight.current = event.nativeEvent.layout.height;
534
+ if (following.current) scrollRef.current?.scrollToEnd({ animated: false });
535
+ }}
536
+ scrollEventThrottle={32}
537
+ onContentSizeChange={onContentSizeChange}
538
+ keyboardDismissMode="interactive"
539
+ keyboardShouldPersistTaps="handled"
540
+ contentContainerStyle={{
541
+ paddingTop: props.contentInsetTop ?? 16,
460
542
  paddingHorizontal: 16,
461
- backgroundColor: theme.colors.bg,
462
- transform: [{ translateY: pinned.offset }],
543
+ paddingBottom: props.contentInsetBottom ?? 24,
544
+ gap: ROW_GAP_PX,
545
+ flexGrow: 1,
463
546
  }}
464
547
  >
465
- {stickyHeaders.get(pinned.key)!.render()}
466
- </View>
467
- ) : null}
468
- {/* Web timeline navigation: two identical small round arrow buttons at
548
+ <View>
549
+ {props.hasOlder ? (
550
+ <OlderHistoryRow
551
+ loading={props.loadingOlder === true}
552
+ onPress={requestOlder}
553
+ />
554
+ ) : null}
555
+ {props.header}
556
+ </View>
557
+ {groups.length === 0 && props.emptyState ? props.emptyState : null}
558
+ {groups.map((group, index) => {
559
+ const key = groupKey(group);
560
+ const prompt = group.kind === "item" && group.item.kind === "user-message";
561
+ // Agent messages keep one wrapper so a progress note can fold
562
+ // (stay mounted, out of layout) without remounting.
563
+ const noteId =
564
+ group.kind === "item" && group.item.kind === "agent-message"
565
+ ? group.item.id
566
+ : null;
567
+ const liveNote = noteId !== null && liveNotes.notes.has(noteId);
568
+ const folded = noteId !== null && foldedNotes.has(noteId);
569
+ // The landing row keeps one wrapper for its life, so landing never remounts it.
570
+ const landing = focusSequence !== null && index === focusIndex;
571
+ return prompt || landing || noteId !== null ? (
572
+ <View
573
+ key={key}
574
+ style={folded ? { display: "none" } : undefined}
575
+ onLayout={(event) => {
576
+ const { y, height } = event.nativeEvent.layout;
577
+ if (prompt) promptFrames.set(key, { top: y, bottom: y + height });
578
+ if (liveNote && !folded && height > 0) {
579
+ liveNoteHeights.set(noteId, height);
580
+ }
581
+ if (landing && landed.current !== focusSequence) {
582
+ groupTops.set(key, y);
583
+ setLayoutPass((pass) => pass + 1);
584
+ }
585
+ }}
586
+ >
587
+ <TimelineGroupView group={group} context={context} />
588
+ </View>
589
+ ) : (
590
+ <TimelineGroupView key={key} group={group} context={context} />
591
+ );
592
+ })}
593
+ {props.trailing}
594
+ </ScrollView>
595
+ {pinned && stickyHeaders.get(pinned.key) ? (
596
+ <View
597
+ style={{
598
+ position: "absolute",
599
+ top: 0,
600
+ left: 0,
601
+ right: 0,
602
+ paddingHorizontal: 16,
603
+ backgroundColor: theme.colors.bg,
604
+ transform: [{ translateY: pinned.offset }],
605
+ }}
606
+ >
607
+ {stickyHeaders.get(pinned.key)!.render()}
608
+ </View>
609
+ ) : null}
610
+ {/* Web timeline navigation: two identical small round arrow buttons at
469
611
  fixed, centered spots that never dodge the content beneath them. */}
470
- {questionNav ? (
471
- <View
472
- pointerEvents="box-none"
473
- style={{
474
- position: "absolute",
475
- // Below a pinned work header so that strip stays a full-width target.
476
- top: pinned ? pinned.height + pinned.offset + 8 : 12,
477
- left: 0,
478
- right: 0,
479
- alignItems: "center",
480
- }}
481
- >
482
- <NavButton
483
- icon="arrow-up"
484
- label={m.backToYourMessage}
485
- onPress={() => {
486
- const frame = promptFrames.get(questionNav);
487
- if (!frame) return;
488
- following.current = false;
489
- scrollRef.current?.scrollTo({
490
- y: Math.max(0, frame.top - QUESTION_NAV_MARGIN_PX),
491
- animated: true,
492
- });
612
+ {questionNav ? (
613
+ <View
614
+ pointerEvents="box-none"
615
+ style={{
616
+ position: "absolute",
617
+ // Below a pinned work header so that strip stays a full-width target.
618
+ top: pinned ? pinned.height + pinned.offset + 8 : 12,
619
+ left: 0,
620
+ right: 0,
621
+ alignItems: "center",
493
622
  }}
494
- />
495
- </View>
496
- ) : null}
497
- {showJump ? (
498
- <View
499
- pointerEvents="box-none"
500
- style={{
501
- position: "absolute",
502
- bottom: (props.overlayInsetBottom ?? 0) + 12,
503
- left: 0,
504
- right: 0,
505
- alignItems: "center",
506
- }}
507
- >
508
- <NavButton
509
- icon="arrow-down"
510
- label={m.jumpToLatest}
511
- onPress={() => {
512
- following.current = true;
513
- scrollRef.current?.scrollToEnd({ animated: true });
623
+ >
624
+ <NavButton
625
+ icon="arrow-up"
626
+ label={m.backToYourMessage}
627
+ onPress={() => {
628
+ const frame = promptFrames.get(questionNav);
629
+ if (!frame) return;
630
+ following.current = false;
631
+ scrollRef.current?.scrollTo({
632
+ y: Math.max(0, frame.top - QUESTION_NAV_MARGIN_PX),
633
+ animated: true,
634
+ });
635
+ }}
636
+ />
637
+ </View>
638
+ ) : null}
639
+ {showJump ? (
640
+ <View
641
+ pointerEvents="box-none"
642
+ style={{
643
+ position: "absolute",
644
+ bottom: (props.overlayInsetBottom ?? 0) + 12,
645
+ left: 0,
646
+ right: 0,
647
+ alignItems: "center",
514
648
  }}
515
- />
516
- </View>
517
- ) : null}
518
- </View>
519
- </ReleaseFollowProvider>
520
- </StickyWorkHeaderProvider>
521
- </FoldMemoryProvider>
522
- </NativeActivityOptionsProvider>
649
+ >
650
+ <NavButton
651
+ icon="arrow-down"
652
+ label={m.jumpToLatest}
653
+ onPress={() => {
654
+ following.current = true;
655
+ scrollRef.current?.scrollToEnd({ animated: true });
656
+ }}
657
+ />
658
+ </View>
659
+ ) : null}
660
+ </View>
661
+ </ReleaseFollowProvider>
662
+ </StickyWorkHeaderProvider>
663
+ </FoldMemoryProvider>
664
+ </NativeActivityOptionsProvider>
665
+ </AnnotationSourcesContext.Provider>
523
666
  );
524
667
  }
525
668
 
@@ -570,6 +713,7 @@ type GroupContext = {
570
713
  onCopy?: ((text: string) => void) | undefined;
571
714
  /** A live work row reports its open state, so its progress notes above fold. */
572
715
  reportLiveFold?: ((workId: string, open: boolean) => void) | undefined;
716
+ onRevealAnnotation?: AnnotationReveal | undefined;
573
717
  };
574
718
 
575
719
  /** A wait's one quiet secondary line under its work row. */
@@ -1108,13 +1252,15 @@ function UserMessageRow({ item, context }: { item: UserMessageItem; context: Gro
1108
1252
  const theme = useNativeTimelineTheme();
1109
1253
  const m = useNativeTimelineMessages();
1110
1254
  const failed = item.delivery?.state === "failed";
1255
+ const annotations = item.annotations ?? [];
1256
+ const sourceKey = item.annotationSource?.eventId;
1111
1257
  return (
1112
1258
  <Animated.View entering={FadeIn.duration(180)} style={{ alignItems: "flex-end" }}>
1113
1259
  <View style={{ maxWidth: "85%", alignItems: "flex-end", gap: 6 }}>
1114
1260
  {item.resources.some((resource) => resource.kind === "file")
1115
1261
  ? context.renderUserAttachments?.(item)
1116
1262
  : null}
1117
- {item.text ? (
1263
+ {item.text || annotations.length > 0 ? (
1118
1264
  <View
1119
1265
  style={{
1120
1266
  backgroundColor: theme.colors["surface-2"],
@@ -1128,23 +1274,38 @@ function UserMessageRow({ item, context }: { item: UserMessageItem; context: Gro
1128
1274
  paddingVertical: 10,
1129
1275
  }}
1130
1276
  >
1131
- <CollapsibleUserText>
1132
- {context.renderMarkdown ? (
1133
- context.renderMarkdown(item.text, { tone: "body" })
1134
- ) : (
1135
- <Text
1136
- selectable
1137
- style={{
1138
- ...fontStyle(theme),
1139
- fontSize: theme.size.md,
1140
- lineHeight: 28,
1141
- color: theme.colors.fg,
1142
- }}
1277
+ {item.text ? (
1278
+ <CollapsibleUserText>
1279
+ <AnnotationSourceView
1280
+ sourceKey={sourceKey}
1281
+ style={{ marginHorizontal: -6, paddingHorizontal: 6 }}
1143
1282
  >
1144
- {item.text}
1145
- </Text>
1146
- )}
1147
- </CollapsibleUserText>
1283
+ {context.renderMarkdown ? (
1284
+ context.renderMarkdown(item.text, { tone: "body" })
1285
+ ) : (
1286
+ <Text
1287
+ selectable
1288
+ style={{
1289
+ ...fontStyle(theme),
1290
+ fontSize: theme.size.md,
1291
+ lineHeight: 28,
1292
+ color: theme.colors.fg,
1293
+ }}
1294
+ >
1295
+ {item.text}
1296
+ </Text>
1297
+ )}
1298
+ </AnnotationSourceView>
1299
+ </CollapsibleUserText>
1300
+ ) : null}
1301
+ {annotations.length > 0 ? (
1302
+ <View style={item.text ? { marginTop: 10, marginBottom: 2 } : { paddingVertical: 2 }}>
1303
+ <SentAnnotationCards
1304
+ annotations={annotations}
1305
+ onReveal={context.onRevealAnnotation}
1306
+ />
1307
+ </View>
1308
+ ) : null}
1148
1309
  </View>
1149
1310
  ) : null}
1150
1311
  <MessageFooter
@@ -1193,21 +1354,26 @@ function AgentMessageRow({ item, context }: { item: AgentMessageItem; context: G
1193
1354
  const theme = useNativeTimelineTheme();
1194
1355
  return (
1195
1356
  <Animated.View entering={FadeIn.duration(180)} style={{ minWidth: 0 }}>
1196
- {context.renderMarkdown ? (
1197
- context.renderMarkdown(item.text, { tone: "body", streaming: item.streaming })
1198
- ) : (
1199
- <Text
1200
- selectable
1201
- style={{
1202
- ...fontStyle(theme),
1203
- fontSize: theme.size.md,
1204
- lineHeight: 28,
1205
- color: theme.colors.fg,
1206
- }}
1207
- >
1208
- {item.text}
1209
- </Text>
1210
- )}
1357
+ <AnnotationSourceView
1358
+ sourceKey={item.annotationSource?.eventId}
1359
+ style={{ marginHorizontal: -8, paddingHorizontal: 8 }}
1360
+ >
1361
+ {context.renderMarkdown ? (
1362
+ context.renderMarkdown(item.text, { tone: "body", streaming: item.streaming })
1363
+ ) : (
1364
+ <Text
1365
+ selectable
1366
+ style={{
1367
+ ...fontStyle(theme),
1368
+ fontSize: theme.size.md,
1369
+ lineHeight: 28,
1370
+ color: theme.colors.fg,
1371
+ }}
1372
+ >
1373
+ {item.text}
1374
+ </Text>
1375
+ )}
1376
+ </AnnotationSourceView>
1211
1377
  {item.streaming ? null : (
1212
1378
  <MessageFooter
1213
1379
  text={item.text}