@sentry/junior-dashboard 0.271.0 → 0.273.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 (46) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +69 -67
  3. package/dist/index.js +40 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/app.ts +2 -0
  7. package/src/client/App.tsx +440 -430
  8. package/src/client/components/JuniorLogo.tsx +1 -1
  9. package/src/client/components/ProfileMenu.tsx +4 -41
  10. package/src/client/components/layout/DashboardChrome.tsx +9 -4
  11. package/src/client/components/layout/DashboardHeader.tsx +11 -51
  12. package/src/client/components/layout/NavRail.tsx +156 -0
  13. package/src/client/components/ui/sidebar.tsx +336 -0
  14. package/src/client/conversations/ConversationPage.tsx +14 -0
  15. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  16. package/src/client/conversations/PendingMailboxStack.tsx +22 -9
  17. package/src/client/conversations/TranscriptMessageShell.tsx +19 -8
  18. package/src/client/conversations/TranscriptMessageView.tsx +8 -1
  19. package/src/client/conversations/TranscriptRows.tsx +23 -0
  20. package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
  21. package/src/client/conversations/TranscriptView.tsx +22 -0
  22. package/src/client/conversations/conversationOutbox.ts +19 -0
  23. package/src/client/conversations/eventTranscript.ts +6 -0
  24. package/src/client/conversations/queries.ts +29 -20
  25. package/src/client/conversations/transcript.ts +12 -2
  26. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  27. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  28. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  29. package/src/client/format.ts +8 -1
  30. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  31. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  32. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  33. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  34. package/src/client/pages/settings/SettingsPage.tsx +62 -0
  35. package/src/client/review-desk/ReviewDeskPage.tsx +26 -40
  36. package/src/client/review-desk/ReviewDeskSidebar.tsx +29 -23
  37. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  38. package/src/client/review-desk/queries.ts +6 -5
  39. package/src/client/review-desk/reviewItemFormat.ts +3 -5
  40. package/src/client/types.ts +2 -0
  41. package/src/mock-reporting/read-positions.ts +39 -0
  42. package/src/mock-reporting/routes.ts +26 -1
  43. package/src/tailwind.css +1 -1
  44. package/src/client/components/AGENTS.md +0 -36
  45. package/src/client/conversations/AGENTS.md +0 -17
  46. package/src/client/pages/automations/AGENTS.md +0 -15
@@ -40,6 +40,9 @@ export const TranscriptMessageView = memo(
40
40
  <TranscriptMessageShell
41
41
  role={props.message.role}
42
42
  actor={transcriptMessageActorLabel(props.conversation, props.message)}
43
+ avatarUrl={
44
+ transcriptMessageActor(props.conversation, props.message)?.avatarUrl
45
+ }
43
46
  onCopy={(event) => {
44
47
  const selection = event.currentTarget.ownerDocument.getSelection();
45
48
  if (
@@ -97,7 +100,8 @@ export const TranscriptMessageView = memo(
97
100
  previous.conversation.conversationId === next.conversation.conversationId &&
98
101
  previous.conversation.surface === next.conversation.surface &&
99
102
  previous.conversation.canFork === next.conversation.canFork &&
100
- previous.conversation.actorIdentity === next.conversation.actorIdentity,
103
+ previous.conversation.actorIdentity === next.conversation.actorIdentity &&
104
+ previous.conversation.authors === next.conversation.authors,
101
105
  );
102
106
 
103
107
  /** Render a redacted primary chat message without exposing body content. */
@@ -109,6 +113,9 @@ export function RedactedMessageView(props: {
109
113
  <TranscriptMessageShell
110
114
  role={props.message.role}
111
115
  actor={transcriptMessageActorLabel(props.conversation, props.message)}
116
+ avatarUrl={
117
+ transcriptMessageActor(props.conversation, props.message)?.avatarUrl
118
+ }
112
119
  >
113
120
  <TranscriptMessageHeader
114
121
  message={props.message}
@@ -15,17 +15,40 @@ export function TranscriptRows({
15
15
  );
16
16
  }
17
17
 
18
+ /**
19
+ * Mark where unread rows start. The pinning hook can open the transcript here.
20
+ */
21
+ export function TranscriptUnreadMarker() {
22
+ return (
23
+ <div
24
+ className="flex items-center gap-3 py-1"
25
+ data-transcript-unread-marker=""
26
+ role="separator"
27
+ aria-label="New messages"
28
+ >
29
+ <span className="h-px flex-1 bg-dashboard-accent/50" />
30
+ <span className="shrink-0 font-sans text-xs font-semibold text-dashboard-accent-text">
31
+ New
32
+ </span>
33
+ <span className="h-px flex-1 bg-dashboard-accent/50" />
34
+ </div>
35
+ );
36
+ }
37
+
18
38
  /** Leave room for control outlines inside the mobile row's paint bounds. */
19
39
  export function TranscriptRow(props: {
20
40
  /** Stable row id that holds the reader's place while layout changes. */
21
41
  anchorKey?: string;
22
42
  children: ReactNode;
23
43
  indent?: boolean;
44
+ /** Last event sequence in the row. Read positions use it. */
45
+ seq?: number;
24
46
  }) {
25
47
  return (
26
48
  <div
27
49
  className="mobile-transcript-row -mx-2 px-2 py-1.5"
28
50
  data-scroll-anchor={props.anchorKey}
51
+ data-transcript-seq={props.seq}
29
52
  >
30
53
  {props.indent ? (
31
54
  <div className="pl-11">{props.children}</div>
@@ -203,7 +203,7 @@ function TurnContextActor(props: { actor: ActorIdentity; timestamp?: number }) {
203
203
  aria-label="Speaker"
204
204
  className="flex min-w-0 items-center gap-3 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
205
205
  >
206
- <ActorAvatar name={name} size="detail" />
206
+ <ActorAvatar imageUrl={props.actor.avatarUrl} name={name} size="detail" />
207
207
  <div className="min-w-0 flex-1">
208
208
  <div className="flex min-w-0 items-baseline justify-between gap-3">
209
209
  {email ? (
@@ -22,6 +22,10 @@ import {
22
22
  usePinnedTranscriptBottom,
23
23
  } from "./transcriptBottomPinning";
24
24
  import type { TranscriptViewMode } from "./transcriptRenderModel";
25
+ import {
26
+ useTranscriptReadReporter,
27
+ type TranscriptUnreadRange,
28
+ } from "./transcriptReadPosition";
25
29
  import { TranscriptSearchProvider } from "./transcriptSearch";
26
30
  import {
27
31
  TurnContextPanelProvider,
@@ -34,9 +38,13 @@ export function Transcript(props: {
34
38
  historyError?: Error | null;
35
39
  historyVersion?: string;
36
40
  live?: boolean;
41
+ /** The read position when the Conversation opened. */
42
+ lastReadSeq?: number;
37
43
  loadingPreviousPage?: boolean;
38
44
  pinRequestVersion?: number;
39
45
  onLoadPreviousPage?: () => void;
46
+ /** Receive the last event sequence the reader saw in the transcript. */
47
+ onRead?: (seq: number) => void;
40
48
  onSelectedEventSeqChange?(seq: number | undefined): void;
41
49
  /** The reader's own sends to show at the end until history has them. */
42
50
  pendingMessages?: readonly ConversationMailboxMessage[];
@@ -51,6 +59,10 @@ export function Transcript(props: {
51
59
  transcript?: ConversationTranscript;
52
60
  /** Show turn context in the owner's panel. Without it, a drawer opens. */
53
61
  turnContextPanel?: TurnContextPanel;
62
+ /** Open at the "New" marker instead of the latest message. */
63
+ startAtUnread?: boolean;
64
+ /** Rows that were unread when the Conversation opened get a marker. */
65
+ unread?: TranscriptUnreadRange;
54
66
  view?: TranscriptViewMode;
55
67
  }) {
56
68
  const view = props.view ?? "rich";
@@ -64,6 +76,7 @@ export function Transcript(props: {
64
76
  historyVersion: props.historyVersion ?? "empty",
65
77
  juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
66
78
  pinRequestVersion: props.pinRequestVersion,
79
+ startAtUnread: view === "rich" && props.startAtUnread,
67
80
  view,
68
81
  versions: {
69
82
  rich: transcriptBottomVersion(props.transcript, "rich"),
@@ -71,6 +84,14 @@ export function Transcript(props: {
71
84
  },
72
85
  });
73
86
 
87
+ // Read positions follow the transcript rows, not the event log. Search
88
+ // results skip rows, so they do not move the read position.
89
+ useTranscriptReadReporter({
90
+ content: bottomPinning.contentElement,
91
+ lastReadSeq: props.lastReadSeq,
92
+ onRead: props.onRead,
93
+ paused: view !== "rich" || search.trim().length > 0,
94
+ });
74
95
  const loadPreviousPage = () => props.onLoadPreviousPage?.();
75
96
  const previousPageRef = useAutoLoadPreviousPage({
76
97
  enabled: Boolean(
@@ -144,6 +165,7 @@ export function Transcript(props: {
144
165
  conversation={props.transcript}
145
166
  pendingMessages={props.pendingMessages}
146
167
  responding={props.responding ?? props.live ?? false}
168
+ unread={props.unread}
147
169
  />
148
170
  </TurnContextPanelProvider>
149
171
  )}
@@ -1,8 +1,10 @@
1
+ import type { QueryClient } from "@tanstack/react-query";
1
2
  import type {
2
3
  ActorIdentity,
3
4
  ConversationPendingMessage,
4
5
  InputImage,
5
6
  } from "@sentry/junior/api/schema";
7
+ import type { DashboardCoreData } from "../types";
6
8
 
7
9
  /** Client-owned mailbox row waiting on accept, server visibility, or retry. */
8
10
  export type ConversationOutboxMessage = {
@@ -27,6 +29,22 @@ export type ConversationMailboxMessage = ConversationPendingMessage & {
27
29
  idempotencyKey?: string;
28
30
  };
29
31
 
32
+ /** Identity of the viewer, for the outbox rows that they send. */
33
+ export function outboxActorIdentity(
34
+ queryClient: QueryClient,
35
+ ): ActorIdentity | undefined {
36
+ const user = queryClient.getQueryData<DashboardCoreData>([
37
+ "dashboard",
38
+ "core",
39
+ ])?.me.user;
40
+ if (!user?.email) return undefined;
41
+ return {
42
+ ...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : undefined),
43
+ email: user.email,
44
+ fullName: user.name || undefined,
45
+ };
46
+ }
47
+
30
48
  /** Stable React Query key for one conversation's local send outbox. */
31
49
  export function conversationOutboxQueryKey(conversationId: string | undefined) {
32
50
  return ["conversation", conversationId, "outbox"] as const;
@@ -163,6 +181,7 @@ function sameActorIdentity(
163
181
  if (left === right) return true;
164
182
  if (!left || !right) return false;
165
183
  return (
184
+ left.avatarUrl === right.avatarUrl &&
166
185
  left.email === right.email &&
167
186
  left.fullName === right.fullName &&
168
187
  left.slackUserId === right.slackUserId &&
@@ -93,6 +93,9 @@ export function pendingTranscriptMessage(
93
93
  ...(message.actorIdentity
94
94
  ? { actorIdentity: message.actorIdentity }
95
95
  : undefined),
96
+ ...(message.actorIdentityId
97
+ ? { actorIdentityId: message.actorIdentityId }
98
+ : undefined),
96
99
  ...(message.mentions ? { mentions: message.mentions } : undefined),
97
100
  };
98
101
  }
@@ -222,6 +225,9 @@ export function transcriptMessagesFromEvents(
222
225
  ...(data.actorIdentity
223
226
  ? { actorIdentity: data.actorIdentity }
224
227
  : undefined),
228
+ ...(data.actorIdentityId
229
+ ? { actorIdentityId: data.actorIdentityId }
230
+ : undefined),
225
231
  ...(data.agentInvocationId
226
232
  ? { agentInvocationId: data.agentInvocationId }
227
233
  : undefined),
@@ -1,7 +1,6 @@
1
1
  import { useDeferredValue, useEffect, useMemo, useRef } from "react";
2
2
  import {
3
3
  queryOptions,
4
- type QueryClient,
5
4
  useInfiniteQuery,
6
5
  useMutation,
7
6
  useMutationState,
@@ -9,7 +8,6 @@ import {
9
8
  useQueryClient,
10
9
  } from "@tanstack/react-query";
11
10
  import type {
12
- ActorIdentity,
13
11
  ConversationDetailReport,
14
12
  ConversationEventPage,
15
13
  ConversationFeed,
@@ -30,7 +28,6 @@ import {
30
28
  stopConversationTurnResponseSchema,
31
29
  } from "@sentry/junior/api/schema";
32
30
 
33
- import type { DashboardCoreData } from "../types";
34
31
  import {
35
32
  DashboardApiError,
36
33
  del,
@@ -45,6 +42,7 @@ import {
45
42
  failConversationOutboxMessage,
46
43
  isInlineOutboxMessage,
47
44
  mergeConversationMailboxMessages,
45
+ outboxActorIdentity,
48
46
  acceptConversationOutboxMessage,
49
47
  reuseConversationMailboxMessages,
50
48
  splitInlineMailboxMessages,
@@ -178,9 +176,12 @@ export function conversationDetailQueryOptions(
178
176
  structuralSharing: (previous, next) => {
179
177
  if (!next || typeof next !== "object") return next;
180
178
  if (!previous || typeof previous !== "object") return next;
181
- return reuseConversationEventReferences(
179
+ return keepLatestReadPosition(
182
180
  previous as ConversationSnapshot,
183
- next as ConversationSnapshot,
181
+ reuseConversationEventReferences(
182
+ previous as ConversationSnapshot,
183
+ next as ConversationSnapshot,
184
+ ),
184
185
  );
185
186
  },
186
187
  refetchInterval: (query) =>
@@ -196,18 +197,18 @@ export function conversationDetailQueryOptions(
196
197
  });
197
198
  }
198
199
 
199
- function outboxActorIdentity(
200
- queryClient: QueryClient,
201
- ): ActorIdentity | undefined {
202
- const user = queryClient.getQueryData<DashboardCoreData>([
203
- "dashboard",
204
- "core",
205
- ])?.me.user;
206
- if (!user?.email) return undefined;
207
- return {
208
- email: user.email,
209
- fullName: user.name || undefined,
210
- };
200
+ /**
201
+ * Read positions only move forward. A poll that started before a read report
202
+ * finished must not put an older position back in the cache.
203
+ */
204
+ function keepLatestReadPosition(
205
+ previous: ConversationSnapshot,
206
+ next: ConversationSnapshot,
207
+ ): ConversationSnapshot {
208
+ const kept = previous.readPosition;
209
+ if (!kept) return next;
210
+ if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
211
+ return { ...next, readPosition: kept };
211
212
  }
212
213
 
213
214
  /** Create one dashboard conversation and refresh the personal feed. */
@@ -704,14 +705,17 @@ export function useConversationData(conversationId: string | undefined) {
704
705
  0,
705
706
  ...awaitingRows.map((message) => Date.parse(message.createdAt)),
706
707
  );
707
- const detail = useQuery(
708
- conversationDetailQueryOptions(conversationId, {
708
+ const detail = useQuery({
709
+ ...conversationDetailQueryOptions(conversationId, {
709
710
  awaitingMessage: awaitingRows.length > 0,
710
711
  fastPollUntilMs: latestSendAtMs
711
712
  ? latestSendAtMs + FAST_POLL_WINDOW_MS
712
713
  : undefined,
713
714
  }),
714
- );
715
+ // A reopened Conversation shows the cached copy at once, but it can miss
716
+ // messages that arrived while the reader was away. Fetch it each visit.
717
+ refetchOnMount: "always",
718
+ });
715
719
  // Defer the whole server snapshot, not history alone. Queue removal and the
716
720
  // matching transcript must paint together while composer input stays urgent.
717
721
  const deferredDetail = useDeferredValue(detail.data);
@@ -893,6 +897,11 @@ export function useConversationData(conversationId: string | undefined) {
893
897
  ? history.hasNextPage
894
898
  : Boolean(detailData?.previousCursor),
895
899
  isPending: detail.isPending,
900
+ /** The latest detail after a fetch in this visit, before it is deferred. */
901
+ fetchedDetail:
902
+ detail.isFetchedAfterMount && !detail.isFetching
903
+ ? detail.data
904
+ : undefined,
896
905
  isLoadingPreviousPage,
897
906
  pendingAuthorization: mailbox?.authorization,
898
907
  pendingGeneratedAt: mailbox?.generatedAt,
@@ -223,8 +223,18 @@ export function reuseConversationEventReferences(
223
223
  JSON.stringify(previous.annotations) === JSON.stringify(next.annotations)
224
224
  ? previous.annotations
225
225
  : next.annotations;
226
- if (events === next.events && annotations === next.annotations) return next;
227
- return { ...next, events, annotations };
226
+ const authors =
227
+ JSON.stringify(previous.authors) === JSON.stringify(next.authors)
228
+ ? previous.authors
229
+ : next.authors;
230
+ if (
231
+ events === next.events &&
232
+ annotations === next.annotations &&
233
+ authors === next.authors
234
+ ) {
235
+ return next;
236
+ }
237
+ return { ...next, events, annotations, authors };
228
238
  }
229
239
 
230
240
  function sameConversationEventVersion(
@@ -22,6 +22,8 @@ import {
22
22
  const BOTTOM_PROXIMITY_PX = 96;
23
23
  const PREVIOUS_PAGE_PROXIMITY_PX = 600;
24
24
  const USER_SCROLL_DELTA_PX = 2;
25
+ // Leave a little earlier context above the "New" marker.
26
+ const UNREAD_START_OFFSET_PX = 48;
25
27
  const PIN_START_ATTRIBUTE = "data-transcript-pin-start";
26
28
  const PIN_START_MARGIN_PX = 16;
27
29
 
@@ -46,6 +48,7 @@ export type ScrollSnapshot = {
46
48
 
47
49
  type BottomPinResult = {
48
50
  anchorRef: RefObject<HTMLDivElement | null>;
51
+ contentElement: HTMLDivElement | null;
49
52
  contentRef: RefCallback<HTMLDivElement>;
50
53
  hasPendingUpdate: boolean;
51
54
  jumpToBottom: () => void;
@@ -259,14 +262,19 @@ export function terminalReplyPinState(input: {
259
262
 
260
263
  /**
261
264
  * Show the jump control only when the reader left the bottom and a newer tail
262
- * arrived. Staying pinned, or intentionally following, must not flash it.
265
+ * arrived, or when the transcript opened at unread rows. Staying pinned, or
266
+ * intentionally following, must not flash it.
263
267
  */
264
268
  export function shouldShowJumpToLatest(input: {
265
269
  enabled: boolean;
266
270
  following: boolean;
267
271
  hasPendingUpdate: boolean;
272
+ openedAtUnread?: boolean;
268
273
  }): boolean {
269
- return input.enabled && !input.following && input.hasPendingUpdate;
274
+ return (
275
+ !input.following &&
276
+ (Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate))
277
+ );
270
278
  }
271
279
 
272
280
  /** Resolve scroll intent with user upward movement taking precedence over bottom slack. */
@@ -314,6 +322,8 @@ export function usePinnedTranscriptBottom(input: {
314
322
  historyVersion: string;
315
323
  juniorMessageVersion: string;
316
324
  pinRequestVersion?: number;
325
+ /** Open at the "New" marker when the transcript has one. */
326
+ startAtUnread?: boolean;
317
327
  view: TranscriptViewMode;
318
328
  versions: Record<TranscriptViewMode, string>;
319
329
  }): BottomPinResult {
@@ -337,6 +347,7 @@ export function usePinnedTranscriptBottom(input: {
337
347
  const seenPinStartsRef = useRef(new Set<string>());
338
348
  const [following, setFollowing] = useState(false);
339
349
  const [hasPendingUpdate, setHasPendingUpdate] = useState(false);
350
+ const [openedAtUnread, setOpenedAtUnread] = useState(false);
340
351
  const [contentElement, setContentElement] = useState<HTMLDivElement | null>(
341
352
  null,
342
353
  );
@@ -351,9 +362,14 @@ export function usePinnedTranscriptBottom(input: {
351
362
  if (!input.enabled) setHasPendingUpdate(false);
352
363
  }, [input.enabled]);
353
364
 
365
+ // The transcript opened at the latest message, but the reader prefers the
366
+ // "New" marker. The marker can appear when a fetch replaces a cached detail.
367
+ const awaitingUnreadMarkerRef = useRef(false);
354
368
  const setFollowingIntent = useCallback((value: boolean) => {
355
369
  followingRef.current = value;
356
370
  setFollowing(value);
371
+ if (value) setOpenedAtUnread(false);
372
+ else awaitingUnreadMarkerRef.current = false;
357
373
  }, []);
358
374
 
359
375
  const rememberPlace = useCallback(() => {
@@ -502,6 +518,32 @@ export function usePinnedTranscriptBottom(input: {
502
518
  [setFollowingIntent],
503
519
  );
504
520
 
521
+ /** Scroll to the "New" marker, if the transcript has one. */
522
+ const openAtUnreadMarker = useCallback((): boolean => {
523
+ const content = contentElementRef.current;
524
+ const root = scrollRootFor(content);
525
+ const marker = content?.querySelector("[data-transcript-unread-marker]");
526
+ if (!root || !marker) return false;
527
+ awaitingUnreadMarkerRef.current = false;
528
+ const offset =
529
+ marker.getBoundingClientRect().top - viewportBounds(root).top;
530
+ setScrollTop(
531
+ root,
532
+ Math.max(
533
+ 0,
534
+ scrollSnapshot(root).scrollTop + offset - UNREAD_START_OFFSET_PX,
535
+ ),
536
+ );
537
+ const snapshot = scrollSnapshot(root);
538
+ previousScrollTopRef.current = snapshot.scrollTop;
539
+ // Unread rows that fit above the bottom leave the reader at the latest.
540
+ const atBottom = isNearScrollBottom(snapshot);
541
+ setFollowingIntent(atBottom);
542
+ setOpenedAtUnread(!atBottom);
543
+ rememberPlace();
544
+ return true;
545
+ }, [rememberPlace, setFollowingIntent]);
546
+
505
547
  useBrowserLayoutEffect(() => {
506
548
  if (!contentElement || !input.conversationId) return;
507
549
  if (initializedConversationRef.current === input.conversationId) return;
@@ -510,24 +552,40 @@ export function usePinnedTranscriptBottom(input: {
510
552
  if (!root) return;
511
553
 
512
554
  initializedConversationRef.current = input.conversationId;
555
+ setHasPendingUpdate(false);
556
+ if (input.startAtUnread && openAtUnreadMarker()) return;
557
+
513
558
  setScrollTop(root, scrollSnapshot(root).scrollHeight);
514
559
  previousScrollTopRef.current = scrollSnapshot(root).scrollTop;
515
- // Start every conversation at its latest message. Keep following until the
516
- // reader scrolls up so late content and footer layout cannot expose stale
517
- // space below the transcript.
560
+ // Without unread rows, start at the latest message. Keep following until
561
+ // the reader scrolls up so late content and footer layout cannot expose
562
+ // stale space below the transcript.
518
563
  setFollowingIntent(true);
519
- setHasPendingUpdate(false);
520
- }, [contentElement, input.conversationId, setFollowingIntent]);
564
+ awaitingUnreadMarkerRef.current = Boolean(input.startAtUnread);
565
+ }, [
566
+ contentElement,
567
+ input.conversationId,
568
+ input.startAtUnread,
569
+ openAtUnreadMarker,
570
+ setFollowingIntent,
571
+ ]);
572
+
573
+ // Follow the latest message, unless the "New" marker appeared before the
574
+ // reader moved. Then open at the marker, as if it was there at the start.
575
+ const followLatest = useCallback(() => {
576
+ if (awaitingUnreadMarkerRef.current && openAtUnreadMarker()) return;
577
+ scrollToBottom("auto");
578
+ }, [openAtUnreadMarker, scrollToBottom]);
521
579
 
522
580
  const syncAfterLayoutChange = useCallback(() => {
523
581
  if (followingRef.current) {
524
- scrollToBottom("auto");
582
+ followLatest();
525
583
  return;
526
584
  }
527
585
 
528
586
  restorePlace();
529
587
  measurePosition("measure");
530
- }, [measurePosition, restorePlace, scrollToBottom]);
588
+ }, [followLatest, measurePosition, restorePlace]);
531
589
 
532
590
  // An earlier page renders above the reader. Hold their place before paint;
533
591
  // a reader at the bottom stays at the latest message.
@@ -568,7 +626,7 @@ export function usePinnedTranscriptBottom(input: {
568
626
  following: followingRef.current,
569
627
  })
570
628
  ) {
571
- scrollToBottom("auto");
629
+ followLatest();
572
630
  setHasPendingUpdate(false);
573
631
  return;
574
632
  }
@@ -581,7 +639,7 @@ export function usePinnedTranscriptBottom(input: {
581
639
  input.view,
582
640
  input.versions.rich,
583
641
  input.versions.raw,
584
- scrollToBottom,
642
+ followLatest,
585
643
  setFollowingIntent,
586
644
  ]);
587
645
 
@@ -655,6 +713,7 @@ export function usePinnedTranscriptBottom(input: {
655
713
  }, [contentElement, syncAfterLayoutChange]);
656
714
 
657
715
  const jumpToBottom = useCallback(() => {
716
+ awaitingUnreadMarkerRef.current = false;
658
717
  setFollowingIntent(true);
659
718
  setHasPendingUpdate(false);
660
719
  scrollToBottom(preferredExplicitScrollBehavior());
@@ -698,6 +757,7 @@ export function usePinnedTranscriptBottom(input: {
698
757
  return useMemo(
699
758
  () => ({
700
759
  anchorRef,
760
+ contentElement,
701
761
  contentRef,
702
762
  hasPendingUpdate,
703
763
  jumpToBottom,
@@ -705,9 +765,18 @@ export function usePinnedTranscriptBottom(input: {
705
765
  enabled: input.enabled,
706
766
  following,
707
767
  hasPendingUpdate,
768
+ openedAtUnread,
708
769
  }),
709
770
  }),
710
- [contentRef, following, hasPendingUpdate, input.enabled, jumpToBottom],
771
+ [
772
+ contentElement,
773
+ contentRef,
774
+ following,
775
+ hasPendingUpdate,
776
+ input.enabled,
777
+ jumpToBottom,
778
+ openedAtUnread,
779
+ ],
711
780
  );
712
781
  }
713
782
 
@@ -744,7 +813,8 @@ export function useAutoLoadPreviousPage(input: {
744
813
  return setSentinel;
745
814
  }
746
815
 
747
- function scrollRootFor(element: HTMLElement | null): ScrollRoot | null {
816
+ /** Find the nearest element that scrolls the transcript, or the window. */
817
+ export function scrollRootFor(element: HTMLElement | null): ScrollRoot | null {
748
818
  if (typeof window === "undefined") return null;
749
819
  if (!element) return window;
750
820
 
@@ -819,6 +889,7 @@ function setScrollTop(
819
889
  root.scrollTo({ behavior, top: scrollTop });
820
890
  }
821
891
 
892
+ /** Read the visible band of a scroll root in client coordinates. */
822
893
  function viewportBounds(root: ScrollRoot): { bottom: number; top: number } {
823
894
  if (isWindowRoot(root)) return { bottom: window.innerHeight, top: 0 };
824
895
  const top = root.getBoundingClientRect().top;