@sentry/junior-dashboard 0.269.0 → 0.271.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.
- package/dist/app.js +2 -2
- package/dist/client.js +78 -75
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/client/App.tsx +103 -65
- package/src/client/components/AGENTS.md +30 -59
- package/src/client/components/AutomationCard.tsx +2 -1
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +143 -39
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/AGENTS.md +12 -37
- package/src/client/conversations/ConversationBrief.tsx +55 -36
- package/src/client/conversations/ConversationComposer.tsx +27 -8
- package/src/client/conversations/ConversationEventLog.tsx +1 -0
- package/src/client/conversations/ConversationHomeList.tsx +43 -26
- package/src/client/conversations/ConversationPage.tsx +137 -40
- package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
- package/src/client/conversations/ConversationSidebar.tsx +55 -25
- package/src/client/conversations/ConversationTranscript.tsx +3 -2
- package/src/client/conversations/ConversationWatches.tsx +4 -1
- package/src/client/conversations/ConversationWorkspace.tsx +43 -25
- package/src/client/conversations/EventDetails.tsx +6 -9
- package/src/client/conversations/PendingMailboxStack.tsx +5 -2
- package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
- package/src/client/conversations/TranscriptEventView.tsx +9 -3
- package/src/client/conversations/TranscriptMessageView.tsx +6 -5
- package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
- package/src/client/conversations/TranscriptRows.tsx +6 -1
- package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
- package/src/client/conversations/TranscriptToolView.tsx +2 -1
- package/src/client/conversations/TranscriptView.tsx +6 -6
- package/src/client/conversations/conversationOutbox.ts +13 -3
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/conversations/conversationSections.ts +36 -8
- package/src/client/conversations/eventTranscript.ts +1 -0
- package/src/client/conversations/transcriptBottomPinning.ts +171 -81
- package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
- package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
- package/src/client/display-text/DisplayText.tsx +68 -0
- package/src/client/display-text/emoji.ts +54 -0
- package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
- package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
- package/src/client/display-text/mentions.tsx +148 -0
- package/src/client/display-text/plainText.ts +29 -0
- package/src/client/pages/automations/AGENTS.md +6 -25
- package/src/client/pages/automations/AutomationFilters.tsx +6 -6
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
- package/src/client/pages/code/RepositoryTabs.tsx +6 -1
- package/src/client/pages/memory/MemoryDetails.tsx +5 -13
- package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
- package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
- package/src/client/review-desk/ReviewItemActions.tsx +6 -2
- package/src/client/review-desk/ReviewItemCard.tsx +58 -17
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/queries.ts +4 -2
- package/src/client/review-desk/reviewItemFormat.ts +83 -1
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/conversations/TranscriptText.tsx +0 -47
- package/src/client/conversations/conversationActivityPreview.ts +0 -16
- package/src/client/conversations/transcriptMentions.tsx +0 -134
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/system/AGENTS.md +0 -12
- package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
+
}
|