@sentry/junior-dashboard 0.268.0 → 0.270.0

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 (81) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +12 -9
  3. package/dist/client.js +81 -81
  4. package/dist/index.js +12 -9
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +2 -1
  8. package/src/app.ts +11 -7
  9. package/src/client/App.tsx +15 -9
  10. package/src/client/api.ts +4 -19
  11. package/src/client/components/AGENTS.md +65 -0
  12. package/src/client/components/ActorAvatar.tsx +1 -1
  13. package/src/client/components/AutomationCard.tsx +2 -1
  14. package/src/client/components/ParticipantAvatarStack.tsx +1 -0
  15. package/src/client/components/ProfileMenu.tsx +88 -29
  16. package/src/client/components/UserInputForm.tsx +110 -37
  17. package/src/client/components/charts/AGENTS.md +15 -0
  18. package/src/client/conversations/AGENTS.md +38 -0
  19. package/src/client/conversations/ConversationBrief.tsx +55 -36
  20. package/src/client/conversations/ConversationComposer.tsx +27 -8
  21. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  22. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  23. package/src/client/conversations/ConversationPage.tsx +90 -56
  24. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  25. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  27. package/src/client/conversations/ConversationWatches.tsx +4 -1
  28. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  29. package/src/client/conversations/EventDetails.tsx +6 -9
  30. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  31. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  32. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  33. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  34. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  35. package/src/client/conversations/TranscriptRows.tsx +6 -1
  36. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  37. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  38. package/src/client/conversations/TranscriptView.tsx +6 -6
  39. package/src/client/conversations/conversationOutbox.ts +13 -3
  40. package/src/client/conversations/conversationSections.ts +36 -8
  41. package/src/client/conversations/eventTranscript.ts +1 -0
  42. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  43. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  44. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  45. package/src/client/display-text/DisplayText.tsx +68 -0
  46. package/src/client/display-text/emoji.ts +54 -0
  47. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  48. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  49. package/src/client/display-text/mentions.tsx +148 -0
  50. package/src/client/display-text/plainText.ts +29 -0
  51. package/src/client/format.ts +7 -23
  52. package/src/client/pages/automations/AGENTS.md +34 -0
  53. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  54. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  55. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  56. package/src/client/pages/automations/AutomationOverview.tsx +5 -5
  57. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  58. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  59. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  60. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  61. package/src/client/pages/system/AGENTS.md +12 -0
  62. package/src/client/review-desk/ConversationReviewBar.tsx +14 -9
  63. package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
  64. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  65. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  66. package/src/client/review-desk/ReviewItemDetail.tsx +17 -9
  67. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  68. package/src/client/review-desk/queries.ts +25 -16
  69. package/src/client/review-desk/reviewItemFormat.ts +29 -1
  70. package/src/mock-reporting/routes.ts +1 -1
  71. package/src/tailwind.css +1 -1
  72. package/src/client/components/README.md +0 -219
  73. package/src/client/components/charts/README.md +0 -38
  74. package/src/client/conversations/README.md +0 -159
  75. package/src/client/conversations/TranscriptText.tsx +0 -47
  76. package/src/client/conversations/UserInputRequestCard.tsx +0 -39
  77. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  78. package/src/client/conversations/transcriptMentions.tsx +0 -134
  79. package/src/client/conversations/userInputRequest.ts +0 -56
  80. package/src/client/pages/automations/README.md +0 -97
  81. package/src/client/pages/system/README.md +0 -23
@@ -13,10 +13,25 @@ import type { ConversationReportEvent } from "@sentry/junior/api/schema";
13
13
 
14
14
  import type { ConversationTranscript } from "../types";
15
15
  import type { TranscriptViewMode } from "./transcriptRenderModel";
16
+ import {
17
+ captureScrollAnchor,
18
+ scrollAnchorShift,
19
+ type ScrollAnchor,
20
+ } from "./transcriptScrollAnchor";
16
21
 
17
22
  const BOTTOM_PROXIMITY_PX = 96;
18
23
  const PREVIOUS_PAGE_PROXIMITY_PX = 600;
19
24
  const USER_SCROLL_DELTA_PX = 2;
25
+ const PIN_START_ATTRIBUTE = "data-transcript-pin-start";
26
+ const PIN_START_MARGIN_PX = 16;
27
+
28
+ /**
29
+ * Mark a tall block whose start the reader must see first, such as a Review
30
+ * item card. The value is a stable id, so each block moves the view only once.
31
+ */
32
+ export function transcriptPinStartProps(id: string) {
33
+ return { [PIN_START_ATTRIBUTE]: id };
34
+ }
20
35
 
21
36
  type ScrollRoot = HTMLElement | Window;
22
37
  type PositionMeasureSource = "measure" | "scroll";
@@ -34,17 +49,9 @@ type BottomPinResult = {
34
49
  contentRef: RefCallback<HTMLDivElement>;
35
50
  hasPendingUpdate: boolean;
36
51
  jumpToBottom: () => void;
37
- preserveViewportForPrepend: () => void;
38
52
  showJumpToLatest: boolean;
39
53
  };
40
54
 
41
- type PrependSnapshot = {
42
- historyVersion: string;
43
- root: ScrollRoot;
44
- scrollHeight: number;
45
- scrollTop: number;
46
- };
47
-
48
55
  const useBrowserLayoutEffect =
49
56
  typeof window === "undefined" ? useEffect : useLayoutEffect;
50
57
 
@@ -300,26 +307,12 @@ export function programmaticSettleScrollAction(input: {
300
307
  return "ignore";
301
308
  }
302
309
 
303
- /** Decide when a requested history prepend can restore or discard its viewport snapshot. */
304
- export function prependViewportIntent(input: {
305
- currentHistoryVersion: string;
306
- loadingPreviousPage: boolean;
307
- snapshotHistoryVersion: string;
308
- }): "discard" | "restore" | "wait" {
309
- if (input.loadingPreviousPage) return "wait";
310
- if (input.currentHistoryVersion !== input.snapshotHistoryVersion) {
311
- return "restore";
312
- }
313
- return "discard";
314
- }
315
-
316
310
  /** Keep live transcript updates visually pinned only while the reader intends to follow them. */
317
311
  export function usePinnedTranscriptBottom(input: {
318
312
  conversationId?: string;
319
313
  enabled: boolean;
320
314
  historyVersion: string;
321
315
  juniorMessageVersion: string;
322
- loadingPreviousPage: boolean;
323
316
  pinRequestVersion?: number;
324
317
  view: TranscriptViewMode;
325
318
  versions: Record<TranscriptViewMode, string>;
@@ -331,13 +324,17 @@ export function usePinnedTranscriptBottom(input: {
331
324
  const initializedRef = useRef(false);
332
325
  const initializedConversationRef = useRef<string | null>(null);
333
326
  const previousScrollTopRef = useRef<number | null>(null);
334
- const prependSnapshotRef = useRef<PrependSnapshot | null>(null);
327
+ // The scroll position that the last restore set. Its scroll event is not
328
+ // a reader scroll, so it must not change follow intent.
329
+ const restoredScrollTopRef = useRef<number | null>(null);
330
+ const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
335
331
  const pinRequestVersionRef = useRef(input.pinRequestVersion ?? 0);
336
332
  const juniorMessageVersionRef = useRef(input.juniorMessageVersion);
337
333
  const terminalEnabledRef = useRef(input.enabled);
338
334
  const terminalPinPendingRef = useRef(false);
339
335
  const versionsRef = useRef(input.versions);
340
336
  const programmaticScrollGenerationRef = useRef(0);
337
+ const seenPinStartsRef = useRef(new Set<string>());
341
338
  const [following, setFollowing] = useState(false);
342
339
  const [hasPendingUpdate, setHasPendingUpdate] = useState(false);
343
340
  const [contentElement, setContentElement] = useState<HTMLDivElement | null>(
@@ -359,15 +356,64 @@ export function usePinnedTranscriptBottom(input: {
359
356
  setFollowing(value);
360
357
  }, []);
361
358
 
359
+ const rememberPlace = useCallback(() => {
360
+ const content = contentElementRef.current;
361
+ const root = scrollRootFor(content);
362
+ if (!content || !root) return;
363
+ scrollAnchorRef.current = captureScrollAnchor(
364
+ content,
365
+ viewportBounds(root),
366
+ );
367
+ }, []);
368
+
369
+ // Move by the distance the anchor row moved, so content that grows or
370
+ // shrinks above the reader does not move what they are reading.
371
+ const restorePlace = useCallback(() => {
372
+ const content = contentElementRef.current;
373
+ const root = scrollRootFor(content);
374
+ const anchor = scrollAnchorRef.current;
375
+ if (!content || !root) return;
376
+ const shift = anchor
377
+ ? scrollAnchorShift(content, viewportBounds(root), anchor)
378
+ : null;
379
+ if (shift !== null && Math.abs(shift) >= 1) {
380
+ const scrollTop = scrollSnapshot(root).scrollTop + shift;
381
+ setScrollTop(root, scrollTop);
382
+ const restored = scrollSnapshot(root).scrollTop;
383
+ previousScrollTopRef.current = restored;
384
+ restoredScrollTopRef.current = restored;
385
+ }
386
+ rememberPlace();
387
+ }, [rememberPlace]);
388
+
389
+ const leaveBottom = useCallback(() => {
390
+ if (!followingRef.current) return;
391
+ const root = scrollRootFor(contentElementRef.current);
392
+ // Content that fits the viewport has nothing above to read.
393
+ if (!root || scrollSnapshot(root).scrollTop <= 0) return;
394
+ programmaticScrollGenerationRef.current = 0;
395
+ setFollowingIntent(false);
396
+ }, [setFollowingIntent]);
397
+
362
398
  const measurePosition = useCallback(
363
399
  (source: PositionMeasureSource) => {
364
400
  const root = scrollRootFor(contentElementRef.current);
365
401
  if (!root) return;
366
402
 
403
+ if (source === "scroll") rememberPlace();
367
404
  const snapshot = scrollSnapshot(root);
368
405
  const previousScrollTop = previousScrollTopRef.current;
369
406
  previousScrollTopRef.current = snapshot.scrollTop;
370
407
 
408
+ if (source === "scroll") {
409
+ const restored = restoredScrollTopRef.current;
410
+ restoredScrollTopRef.current = null;
411
+ // A restore near the bottom must not turn follow mode back on.
412
+ if (restored !== null && Math.abs(snapshot.scrollTop - restored) < 1) {
413
+ return;
414
+ }
415
+ }
416
+
371
417
  const intent = transcriptFollowIntent({
372
418
  previousScrollTop,
373
419
  snapshot,
@@ -394,7 +440,7 @@ export function usePinnedTranscriptBottom(input: {
394
440
  setFollowingIntent(false);
395
441
  }
396
442
  },
397
- [setFollowingIntent],
443
+ [rememberPlace, setFollowingIntent],
398
444
  );
399
445
 
400
446
  const scrollToBottom = useCallback(
@@ -402,13 +448,26 @@ export function usePinnedTranscriptBottom(input: {
402
448
  const root = scrollRootFor(contentElementRef.current);
403
449
  if (!root) return;
404
450
 
451
+ // A new block that does not fit shows its start, not its end. Stop
452
+ // following, so later layout does not move the start out of view.
453
+ const startTop = newPinStartScrollTop(
454
+ root,
455
+ contentElementRef.current,
456
+ seenPinStartsRef.current,
457
+ );
458
+ if (startTop !== null) setFollowingIntent(false);
459
+
405
460
  // Only suppress pin-settle noise while still following. After the reader
406
461
  // leaves the bottom, do not open a window that can ignore their scroll.
407
- const suppressSettleNoise = followingRef.current;
462
+ const suppressSettleNoise = startTop !== null || followingRef.current;
408
463
  const generation = suppressSettleNoise
409
464
  ? ++programmaticScrollGenerationRef.current
410
465
  : 0;
411
- setScrollTop(root, scrollSnapshot(root).scrollHeight, behavior);
466
+ setScrollTop(
467
+ root,
468
+ startTop ?? scrollSnapshot(root).scrollHeight,
469
+ behavior,
470
+ );
412
471
 
413
472
  const settleProgrammaticScroll = () => {
414
473
  if (
@@ -443,18 +502,6 @@ export function usePinnedTranscriptBottom(input: {
443
502
  [setFollowingIntent],
444
503
  );
445
504
 
446
- const preserveViewportForPrepend = useCallback(() => {
447
- const root = scrollRootFor(contentElementRef.current);
448
- if (!root) return;
449
- const snapshot = scrollSnapshot(root);
450
- prependSnapshotRef.current = {
451
- historyVersion: input.historyVersion,
452
- root,
453
- scrollHeight: snapshot.scrollHeight,
454
- scrollTop: snapshot.scrollTop,
455
- };
456
- }, [input.historyVersion]);
457
-
458
505
  useBrowserLayoutEffect(() => {
459
506
  if (!contentElement || !input.conversationId) return;
460
507
  if (initializedConversationRef.current === input.conversationId) return;
@@ -472,36 +519,21 @@ export function usePinnedTranscriptBottom(input: {
472
519
  setHasPendingUpdate(false);
473
520
  }, [contentElement, input.conversationId, setFollowingIntent]);
474
521
 
475
- useBrowserLayoutEffect(() => {
476
- const previous = prependSnapshotRef.current;
477
- if (!previous) return;
478
-
479
- const intent = prependViewportIntent({
480
- currentHistoryVersion: input.historyVersion,
481
- loadingPreviousPage: input.loadingPreviousPage,
482
- snapshotHistoryVersion: previous.historyVersion,
483
- });
484
- if (intent === "restore") {
485
- const current = scrollSnapshot(previous.root);
486
- setScrollTop(
487
- previous.root,
488
- scrollTopAfterPrepend(previous, current.scrollHeight),
489
- );
490
- prependSnapshotRef.current = null;
491
- return;
492
- }
493
-
494
- if (intent === "discard") prependSnapshotRef.current = null;
495
- }, [input.historyVersion, input.loadingPreviousPage]);
496
-
497
522
  const syncAfterLayoutChange = useCallback(() => {
498
523
  if (followingRef.current) {
499
524
  scrollToBottom("auto");
500
525
  return;
501
526
  }
502
527
 
528
+ restorePlace();
503
529
  measurePosition("measure");
504
- }, [measurePosition, scrollToBottom]);
530
+ }, [measurePosition, restorePlace, scrollToBottom]);
531
+
532
+ // An earlier page renders above the reader. Hold their place before paint;
533
+ // a reader at the bottom stays at the latest message.
534
+ useBrowserLayoutEffect(() => {
535
+ syncAfterLayoutChange();
536
+ }, [input.historyVersion, syncAfterLayoutChange]);
505
537
 
506
538
  useBrowserLayoutEffect(() => {
507
539
  // Compare the same view across snapshots, not one view's tail to another.
@@ -554,22 +586,58 @@ export function usePinnedTranscriptBottom(input: {
554
586
  ]);
555
587
 
556
588
  useEffect(() => {
557
- if (typeof window === "undefined") return;
589
+ if (typeof window === "undefined" || !contentElement) return;
558
590
 
559
591
  const root = scrollRootFor(contentElement);
560
592
  if (!root) return;
561
593
 
562
- const target: HTMLElement | Window = root === window ? window : root;
594
+ // Both a scrolling element and the window send these events.
595
+ const target: GlobalEventHandlers = root;
563
596
  const onScroll = () => measurePosition("scroll");
597
+ // Wheel and touch show intent before the scroll event. A live update in
598
+ // that gap must not pull a reader who starts to scroll up back down.
599
+ const onWheel = (event: WheelEvent) => {
600
+ if (event.deltaY < 0) leaveBottom();
601
+ };
602
+ // Compare with the highest finger point in this drag, so small steps
603
+ // still add up to a drag down.
604
+ let touchTopY: number | null = null;
605
+ const onTouchStart = (event: TouchEvent) => {
606
+ touchTopY = event.touches[0]?.clientY ?? null;
607
+ };
608
+ const onTouchMove = (event: TouchEvent) => {
609
+ const y = event.touches[0]?.clientY;
610
+ if (y === undefined || touchTopY === null) return;
611
+ if (y > touchTopY + USER_SCROLL_DELTA_PX) leaveBottom();
612
+ touchTopY = Math.min(touchTopY, y);
613
+ };
564
614
 
565
615
  measurePosition("measure");
616
+ rememberPlace();
617
+ // Listen on the scroll root, so gestures outside transcript rows count.
566
618
  target.addEventListener("scroll", onScroll, { passive: true });
619
+ target.addEventListener("wheel", onWheel, { passive: true });
620
+ target.addEventListener("touchstart", onTouchStart, {
621
+ passive: true,
622
+ });
623
+ target.addEventListener("touchmove", onTouchMove, {
624
+ passive: true,
625
+ });
567
626
  window.addEventListener("resize", syncAfterLayoutChange);
568
627
  return () => {
569
628
  target.removeEventListener("scroll", onScroll);
629
+ target.removeEventListener("wheel", onWheel);
630
+ target.removeEventListener("touchstart", onTouchStart);
631
+ target.removeEventListener("touchmove", onTouchMove);
570
632
  window.removeEventListener("resize", syncAfterLayoutChange);
571
633
  };
572
- }, [contentElement, measurePosition, syncAfterLayoutChange]);
634
+ }, [
635
+ contentElement,
636
+ leaveBottom,
637
+ measurePosition,
638
+ rememberPlace,
639
+ syncAfterLayoutChange,
640
+ ]);
573
641
 
574
642
  useEffect(() => {
575
643
  if (typeof ResizeObserver === "undefined") return;
@@ -633,21 +701,13 @@ export function usePinnedTranscriptBottom(input: {
633
701
  contentRef,
634
702
  hasPendingUpdate,
635
703
  jumpToBottom,
636
- preserveViewportForPrepend,
637
704
  showJumpToLatest: shouldShowJumpToLatest({
638
705
  enabled: input.enabled,
639
706
  following,
640
707
  hasPendingUpdate,
641
708
  }),
642
709
  }),
643
- [
644
- contentRef,
645
- following,
646
- hasPendingUpdate,
647
- input.enabled,
648
- jumpToBottom,
649
- preserveViewportForPrepend,
650
- ],
710
+ [contentRef, following, hasPendingUpdate, input.enabled, jumpToBottom],
651
711
  );
652
712
  }
653
713
 
@@ -684,14 +744,6 @@ export function useAutoLoadPreviousPage(input: {
684
744
  return setSentinel;
685
745
  }
686
746
 
687
- /** Keep the previously visible content at the same viewport offset after a prepend. */
688
- export function scrollTopAfterPrepend(
689
- previous: Pick<ScrollSnapshot, "scrollHeight" | "scrollTop">,
690
- scrollHeight: number,
691
- ): number {
692
- return previous.scrollTop + scrollHeight - previous.scrollHeight;
693
- }
694
-
695
747
  function scrollRootFor(element: HTMLElement | null): ScrollRoot | null {
696
748
  if (typeof window === "undefined") return null;
697
749
  if (!element) return window;
@@ -706,6 +758,38 @@ function scrollRootFor(element: HTMLElement | null): ScrollRoot | null {
706
758
  return window;
707
759
  }
708
760
 
761
+ /**
762
+ * Return the scroll position that shows the start of the last pin-start block,
763
+ * when a bottom pin would cut off its start. Each block counts once.
764
+ */
765
+ function newPinStartScrollTop(
766
+ root: ScrollRoot,
767
+ content: HTMLElement | null,
768
+ seen: Set<string>,
769
+ ): number | null {
770
+ const block = content
771
+ ? Array.from(
772
+ content.querySelectorAll<HTMLElement>(`[${PIN_START_ATTRIBUTE}]`),
773
+ ).at(-1)
774
+ : undefined;
775
+ const id = block?.getAttribute(PIN_START_ATTRIBUTE);
776
+ if (!block || !id || seen.has(id)) return null;
777
+ const rect = block.getBoundingClientRect();
778
+ // The block has no size until its data loads. Check it again then.
779
+ if (rect.height === 0) return null;
780
+ seen.add(id);
781
+
782
+ const snapshot = scrollSnapshot(root);
783
+ const offset =
784
+ snapshot.scrollTop -
785
+ (isWindowRoot(root) ? 0 : root.getBoundingClientRect().top);
786
+ const startTop = rect.top + offset - PIN_START_MARGIN_PX;
787
+ const bottomTop = snapshot.scrollHeight - snapshot.clientHeight;
788
+ // Keep the bottom pin when it shows the start, or when the block is above it.
789
+ if (startTop >= bottomTop || rect.bottom + offset <= bottomTop) return null;
790
+ return Math.max(0, startTop);
791
+ }
792
+
709
793
  function scrollSnapshot(root: ScrollRoot): ScrollSnapshot {
710
794
  if (isWindowRoot(root)) {
711
795
  const element = document.scrollingElement ?? document.documentElement;
@@ -735,6 +819,12 @@ function setScrollTop(
735
819
  root.scrollTo({ behavior, top: scrollTop });
736
820
  }
737
821
 
822
+ function viewportBounds(root: ScrollRoot): { bottom: number; top: number } {
823
+ if (isWindowRoot(root)) return { bottom: window.innerHeight, top: 0 };
824
+ const top = root.getBoundingClientRect().top;
825
+ return { bottom: top + root.clientHeight, top };
826
+ }
827
+
738
828
  function isWindowRoot(root: ScrollRoot): root is Window {
739
829
  return root === window;
740
830
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Keep the reader's place while transcript layout changes.
3
+ *
4
+ * Rows mark themselves with `data-scroll-anchor`. The anchor is the first row
5
+ * that starts inside the viewport. When no row starts there, it is the row that
6
+ * covers the viewport top. After a layout change, the scroll owner moves the
7
+ * scroll position by the distance that the anchor moved. This covers earlier
8
+ * pages, late images, and deferred rendering above the reader.
9
+ *
10
+ * Transcript content turns off browser scroll anchoring. Safari does not
11
+ * support it, so one rule keeps every browser the same.
12
+ */
13
+
14
+ const SCROLL_ANCHOR_ATTRIBUTE = "data-scroll-anchor";
15
+
16
+ /** A row and its offset from the viewport top when the reader last moved. */
17
+ export type ScrollAnchor = {
18
+ element: HTMLElement;
19
+ key: string;
20
+ offset: number;
21
+ };
22
+
23
+ type VerticalBounds = { bottom: number; top: number };
24
+
25
+ /**
26
+ * Find the anchor row in rows that are in document order.
27
+ *
28
+ * Rows do not overlap, so a binary search finds the first row below the
29
+ * viewport top with a few layout reads.
30
+ */
31
+ export function scrollAnchorIndex(
32
+ count: number,
33
+ rowAt: (index: number) => VerticalBounds,
34
+ viewport: VerticalBounds,
35
+ ): number | null {
36
+ let low = 0;
37
+ let high = count;
38
+ while (low < high) {
39
+ const middle = (low + high) >> 1;
40
+ if (rowAt(middle).bottom <= viewport.top) low = middle + 1;
41
+ else high = middle;
42
+ }
43
+ if (low >= count) return null;
44
+ if (rowAt(low).top >= viewport.top) return low;
45
+ if (low + 1 < count && rowAt(low + 1).top < viewport.bottom) return low + 1;
46
+ return low;
47
+ }
48
+
49
+ /** Record which row holds the reader's place inside `viewport`. */
50
+ export function captureScrollAnchor(
51
+ content: HTMLElement,
52
+ viewport: VerticalBounds,
53
+ ): ScrollAnchor | null {
54
+ const rows = content.querySelectorAll<HTMLElement>(
55
+ `[${SCROLL_ANCHOR_ATTRIBUTE}]`,
56
+ );
57
+ const bounds = new Map<number, DOMRect>();
58
+ const rowAt = (index: number) => {
59
+ let rect = bounds.get(index);
60
+ if (!rect) {
61
+ rect = rows[index]!.getBoundingClientRect();
62
+ bounds.set(index, rect);
63
+ }
64
+ return rect;
65
+ };
66
+ const index = scrollAnchorIndex(rows.length, rowAt, viewport);
67
+ if (index === null) return null;
68
+ const element = rows[index]!;
69
+ return {
70
+ element,
71
+ key: element.getAttribute(SCROLL_ANCHOR_ATTRIBUTE) ?? "",
72
+ offset: rowAt(index).top - viewport.top,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Measure how far the anchor moved since it was recorded.
78
+ *
79
+ * Returns `null` when the anchor row is gone, for example after a search
80
+ * filter or view change. The caller then records a new anchor.
81
+ */
82
+ export function scrollAnchorShift(
83
+ content: HTMLElement,
84
+ viewport: VerticalBounds,
85
+ anchor: ScrollAnchor,
86
+ ): number | null {
87
+ const element = anchor.element.isConnected
88
+ ? anchor.element
89
+ : findAnchorRow(content, anchor.key);
90
+ if (!element) return null;
91
+ return element.getBoundingClientRect().top - viewport.top - anchor.offset;
92
+ }
93
+
94
+ function findAnchorRow(content: HTMLElement, key: string): HTMLElement | null {
95
+ for (const row of content.querySelectorAll<HTMLElement>(
96
+ `[${SCROLL_ANCHOR_ATTRIBUTE}]`,
97
+ )) {
98
+ if (row.getAttribute(SCROLL_ANCHOR_ATTRIBUTE) === key) return row;
99
+ }
100
+ return null;
101
+ }
@@ -0,0 +1,48 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ const STORAGE_KEY = "junior:conversation-collapsed-sections";
4
+
5
+ type CollapsedConversationSections = {
6
+ collapsedKeys: ReadonlySet<string>;
7
+ toggle(key: string): void;
8
+ };
9
+
10
+ /** Keep the collapsed conversation sections of this browser. */
11
+ export function useCollapsedConversationSections(): CollapsedConversationSections {
12
+ const [collapsedKeys, setCollapsedKeys] = useState(readCollapsedKeys);
13
+ const toggle = useCallback((key: string) => {
14
+ setCollapsedKeys((current) => {
15
+ const next = new Set(current);
16
+ if (!next.delete(key)) next.add(key);
17
+ writeCollapsedKeys(next);
18
+ return next;
19
+ });
20
+ }, []);
21
+ return { collapsedKeys, toggle };
22
+ }
23
+
24
+ function readCollapsedKeys(): ReadonlySet<string> {
25
+ if (typeof window === "undefined") return new Set();
26
+
27
+ try {
28
+ const value: unknown = JSON.parse(
29
+ window.localStorage.getItem(STORAGE_KEY) ?? "[]",
30
+ );
31
+ if (!Array.isArray(value)) return new Set();
32
+ return new Set(
33
+ value.filter((key): key is string => typeof key === "string"),
34
+ );
35
+ } catch {
36
+ return new Set();
37
+ }
38
+ }
39
+
40
+ function writeCollapsedKeys(keys: ReadonlySet<string>): void {
41
+ if (typeof window === "undefined") return;
42
+
43
+ try {
44
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...keys]));
45
+ } catch {
46
+ // Keep in-memory collapse state usable when storage is unavailable.
47
+ }
48
+ }
@@ -0,0 +1,68 @@
1
+ import { memo } from "react";
2
+ import type { ActorIdentity } from "@sentry/junior/api/schema";
3
+
4
+ import { HighlightedCode } from "../code";
5
+ import { parseMarkdownBlocks } from "../format";
6
+ import { MarkdownText } from "./markdown";
7
+ import { MentionPeopleProvider } from "./mentions";
8
+
9
+ const NO_MENTIONS: readonly ActorIdentity[] = [];
10
+
11
+ /**
12
+ * Render display text from the API, such as a message, summary, or preview.
13
+ *
14
+ * Every dashboard surface that shows this text uses this component, so
15
+ * Markdown, Slack mentions, Slack links, Slack emoji codes, and search
16
+ * highlights render the same way everywhere. Block mode renders full
17
+ * Markdown and highlights fenced code. Inline mode keeps only inline
18
+ * formatting, for one-line previews, headings, and text inside buttons.
19
+ */
20
+ export const DisplayText = memo(function DisplayText(props: {
21
+ /** Use the larger chat type of user and assistant messages. */
22
+ chat?: boolean;
23
+ inline?: boolean;
24
+ /**
25
+ * Link people to their page. Set false inside a button, which cannot hold
26
+ * a link. Inline mode never links other URLs.
27
+ */
28
+ links?: boolean;
29
+ /** People that the API resolved for this text. */
30
+ mentions?: readonly ActorIdentity[];
31
+ text: string;
32
+ }) {
33
+ return (
34
+ <MentionPeopleProvider
35
+ links={props.links ?? true}
36
+ mentions={props.mentions ?? NO_MENTIONS}
37
+ >
38
+ {props.inline ? (
39
+ <MarkdownText inline text={props.text} />
40
+ ) : (
41
+ <DisplayTextBlocks chat={props.chat} text={props.text} />
42
+ )}
43
+ </MentionPeopleProvider>
44
+ );
45
+ });
46
+
47
+ function DisplayTextBlocks(props: { chat?: boolean; text: string }) {
48
+ return (
49
+ <div className="grid min-w-0 gap-2">
50
+ {parseMarkdownBlocks(props.text).map((block, index) => {
51
+ if (block.language === "markdown" && !block.fenced) {
52
+ return (
53
+ <MarkdownText chat={props.chat} key={index} text={block.code} />
54
+ );
55
+ }
56
+
57
+ return (
58
+ <div
59
+ className="min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5"
60
+ key={index}
61
+ >
62
+ <HighlightedCode code={block.code} language={block.language} />
63
+ </div>
64
+ );
65
+ })}
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,54 @@
1
+ // Slack names its emoji with the iamcal dataset, so `:thinking_face:` and
2
+ // `:man-shrugging:` resolve the same way that Slack shows them.
3
+ import iamcalShortcodes from "emojibase-data/en/shortcodes/iamcal.json";
4
+
5
+ const SHORTCODES: Readonly<Record<string, string | readonly string[]>> =
6
+ iamcalShortcodes;
7
+
8
+ // `:name:` with an optional Slack skin tone, such as `:wave::skin-tone-3:`.
9
+ const SLACK_EMOJI_RE = /:([a-z0-9_+-]+):(?::(skin-tone-[2-6]):)?/g;
10
+
11
+ const VARIATION_SELECTOR = 0xfe0f;
12
+
13
+ function codePoints(hexcode: string): number[] {
14
+ return hexcode.split("-").map((hex) => Number.parseInt(hex, 16));
15
+ }
16
+
17
+ const EMOJI_BY_NAME = new Map<string, number[]>();
18
+ for (const [hexcode, names] of Object.entries(SHORTCODES)) {
19
+ const points = codePoints(hexcode);
20
+ for (const name of typeof names === "string" ? [names] : names) {
21
+ EMOJI_BY_NAME.set(name, points);
22
+ }
23
+ }
24
+
25
+ function emojiText(name: string, skinTone: string | undefined): string {
26
+ const points = EMOJI_BY_NAME.get(name);
27
+ if (!points) return "";
28
+ const [base, ...rest] = points;
29
+ const tone = skinTone ? EMOJI_BY_NAME.get(skinTone)?.[0] : undefined;
30
+ if (tone !== undefined) {
31
+ // The tone follows the base code point and replaces its text/emoji
32
+ // selector, as in `👋🏼` and `🤷🏼‍♂️`.
33
+ const tail = rest[0] === VARIATION_SELECTOR ? rest.slice(1) : rest;
34
+ return String.fromCodePoint(base!, tone, ...tail);
35
+ }
36
+ // A lone symbol such as `❤` shows as text unless it asks for emoji style.
37
+ if (rest.length === 0 && base! < 0x1f000) {
38
+ return String.fromCodePoint(base!, VARIATION_SELECTOR);
39
+ }
40
+ return String.fromCodePoint(...points);
41
+ }
42
+
43
+ /**
44
+ * Replace Slack `:name:` emoji codes with the emoji. Unknown names, such as
45
+ * custom workspace emoji, stay as text.
46
+ */
47
+ export function replaceSlackEmoji(text: string): string {
48
+ if (!text.includes(":")) return text;
49
+ return text.replace(
50
+ SLACK_EMOJI_RE,
51
+ (match, name: string, skinTone: string | undefined) =>
52
+ emojiText(name, skinTone) || match,
53
+ );
54
+ }