@sentry/junior-dashboard 0.271.0 → 0.272.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 (30) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +67 -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/conversations/ConversationPage.tsx +11 -0
  8. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  9. package/src/client/conversations/TranscriptRows.tsx +23 -0
  10. package/src/client/conversations/TranscriptView.tsx +22 -0
  11. package/src/client/conversations/queries.ts +30 -5
  12. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  13. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  14. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  15. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  16. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  17. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  18. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  19. package/src/client/pages/settings/SettingsPage.tsx +62 -0
  20. package/src/client/review-desk/ReviewDeskPage.tsx +26 -44
  21. package/src/client/review-desk/ReviewDeskSidebar.tsx +29 -23
  22. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  23. package/src/client/review-desk/queries.ts +3 -5
  24. package/src/client/review-desk/reviewItemFormat.ts +3 -5
  25. package/src/mock-reporting/read-positions.ts +39 -0
  26. package/src/mock-reporting/routes.ts +26 -1
  27. package/src/tailwind.css +1 -1
  28. package/src/client/components/AGENTS.md +0 -36
  29. package/src/client/conversations/AGENTS.md +0 -17
  30. package/src/client/pages/automations/AGENTS.md +0 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.271.0",
3
+ "version": "0.272.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,10 +39,10 @@
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "4.1.0",
41
41
  "zod": "^4.5.4",
42
- "@sentry/junior-plugin-api": "0.271.0"
42
+ "@sentry/junior-plugin-api": "0.272.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@sentry/junior": "0.271.0"
45
+ "@sentry/junior": "0.272.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@oxlint/plugins": "1.79.0",
@@ -63,7 +63,7 @@
63
63
  "tw-animate-css": "^1.4.0",
64
64
  "typescript": "^7.0.2",
65
65
  "vitest": "^4.1.12",
66
- "@sentry/junior": "0.271.0"
66
+ "@sentry/junior": "0.272.0"
67
67
  },
68
68
  "scripts": {
69
69
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
package/src/app.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from "./auth";
37
37
  import { isAuthPath, type AuthenticatedRoute } from "./authenticated-routes";
38
38
  import { resetMockConversationArchiveState } from "./mock-reporting/fixtures";
39
+ import { resetMockReadPositions } from "./mock-reporting/read-positions";
39
40
  import { createMockReportingApi } from "./mock-reporting/routes";
40
41
  import {
41
42
  DASHBOARD_AVATAR_HEADER_PATH,
@@ -71,6 +72,7 @@ const mockDisplayNamesByEmail = new Map<string, string>();
71
72
  export function resetMockDashboardState(): void {
72
73
  mockDisplayNamesByEmail.clear();
73
74
  resetMockConversationArchiveState();
75
+ resetMockReadPositions();
74
76
  }
75
77
 
76
78
  export interface JuniorDashboardOptions {
@@ -98,6 +98,11 @@ import {
98
98
  } from "../review-desk/reviewItemFormat";
99
99
  import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
100
100
  import { Transcript } from "./TranscriptView";
101
+ import {
102
+ readConversationStartPosition,
103
+ useMarkConversationRead,
104
+ useReadPositionAtOpen,
105
+ } from "./transcriptReadPosition";
101
106
  import { TranscriptLoading } from "./TranscriptLoading";
102
107
  import type { TranscriptViewMode } from "./transcriptRenderModel";
103
108
  import {
@@ -166,6 +171,8 @@ export function ConversationPage(props: {
166
171
  const conversations = buildConversations(summaries);
167
172
  const detail = useConversationData(conversationId);
168
173
  const archive = useArchiveConversation(conversationId);
174
+ const markRead = useMarkConversationRead(conversationId);
175
+ const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
169
176
  const feedConversation = conversations.find(
170
177
  (item) => item.id === conversationId,
171
178
  );
@@ -484,9 +491,11 @@ export function ConversationPage(props: {
484
491
  hasPreviousPage={detail.hasPreviousPage}
485
492
  historyError={detail.historyError}
486
493
  historyVersion={detail.historyVersion}
494
+ lastReadSeq={readAtOpen.lastReadSeq}
487
495
  live={live}
488
496
  loadingPreviousPage={detail.isLoadingPreviousPage}
489
497
  onLoadPreviousPage={detail.loadPreviousPage}
498
+ onRead={markRead}
490
499
  onSelectedEventSeqChange={setSelectedEventSeq}
491
500
  pendingMessages={detail.inlineMessages}
492
501
  pinRequestVersion={pinRequestVersion}
@@ -497,8 +506,10 @@ export function ConversationPage(props: {
497
506
  search={search}
498
507
  onSearchChange={setSearch}
499
508
  selectedEventSeq={selectedEventSeq}
509
+ startAtUnread={readConversationStartPosition() === "unread"}
500
510
  transcript={transcript}
501
511
  turnContextPanel={turnContextPanel}
512
+ unread={readAtOpen.unread}
502
513
  view={view}
503
514
  />
504
515
  </>
@@ -49,8 +49,13 @@ import {
49
49
  type RenderedTranscriptEntry,
50
50
  } from "./transcriptRenderModel";
51
51
  import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch";
52
- import { TranscriptRow, TranscriptRows } from "./TranscriptRows";
52
+ import {
53
+ TranscriptRow,
54
+ TranscriptRows,
55
+ TranscriptUnreadMarker,
56
+ } from "./TranscriptRows";
53
57
  import { TranscriptTimestampProvider } from "./TranscriptTimestamp";
58
+ import type { TranscriptUnreadRange } from "./transcriptReadPosition";
54
59
 
55
60
  type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
56
61
  type TranscriptContextEntry = Extract<TranscriptEntry, { kind: "context" }>;
@@ -85,6 +90,8 @@ export const ConversationTranscriptView = memo(
85
90
  /** Sends that history does not have yet. History wins on message id. */
86
91
  pendingMessages?: readonly ConversationMailboxMessage[];
87
92
  responding?: boolean;
93
+ /** Show the "New" marker before the first row in this range. */
94
+ unread?: TranscriptUnreadRange;
88
95
  }) {
89
96
  // Cards can change while their Message events stay unchanged.
90
97
  const { events, annotations } = props.conversation;
@@ -106,6 +113,7 @@ export const ConversationTranscriptView = memo(
106
113
  conversation={props.conversation}
107
114
  messages={messages}
108
115
  responding={props.responding}
116
+ unread={props.unread}
109
117
  />
110
118
  </section>
111
119
  </TranscriptTimestampProvider>
@@ -121,6 +129,7 @@ function SegmentEvents(props: {
121
129
  conversation: ConversationTranscript;
122
130
  messages: TranscriptViewMessage[];
123
131
  responding?: boolean;
132
+ unread?: TranscriptUnreadRange;
124
133
  }) {
125
134
  return (
126
135
  <TranscriptRows>
@@ -134,6 +143,7 @@ function SegmentEvents(props: {
134
143
  onOpenSubagentTranscript={props.onOpenSubagentTranscript}
135
144
  transcript={props.messages}
136
145
  conversation={props.conversation}
146
+ unread={props.unread}
137
147
  />
138
148
  ) : props.conversation.eventHistory.status === "redacted" &&
139
149
  props.messages.length > 0 ? (
@@ -205,6 +215,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
205
215
  }) => void;
206
216
  transcript: TranscriptViewMessage[];
207
217
  conversation: ConversationTranscript;
218
+ unread?: TranscriptUnreadRange;
208
219
  }) {
209
220
  const entries = useMemo(
210
221
  () => groupTranscriptMessages(props.transcript),
@@ -214,6 +225,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
214
225
  <TranscriptEntryList
215
226
  entries={entries}
216
227
  keyPrefix={props.conversation.conversationId}
228
+ unread={props.unread}
217
229
  renderContext={(entry) => (
218
230
  <TranscriptRailEvent
219
231
  kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
@@ -314,11 +326,26 @@ function TranscriptEntryList(props: {
314
326
  renderReasoning: (entry: TranscriptReasoningEntry) => ReactNode;
315
327
  renderSubagent: (entry: TranscriptSubagentEntry) => ReactNode;
316
328
  renderTool: (entry: TranscriptToolEntry) => ReactNode;
329
+ unread?: TranscriptUnreadRange;
317
330
  }) {
318
331
  const search = useTranscriptSearch();
319
332
  const activityKeys = useRef(new Map<string, string>());
320
333
  const claimedActivityKeys = new Set<string>();
321
334
  const rows: ReactNode[] = [];
335
+ // Search results are not a reading order, so they get no marker.
336
+ const markerSeq = search.active
337
+ ? undefined
338
+ : unreadMarkerSeq(props.entries, props.unread);
339
+ let unreadMarkerPending = markerSeq !== undefined;
340
+ // Entries are in seq order, so the first row that reaches the marker seq
341
+ // gets the marker.
342
+ const pushRow = (seq: number | undefined, row: ReactNode) => {
343
+ if (unreadMarkerPending && seq !== undefined && seq >= markerSeq!) {
344
+ unreadMarkerPending = false;
345
+ rows.push(<TranscriptUnreadMarker key="unread-marker" />);
346
+ }
347
+ rows.push(row);
348
+ };
322
349
 
323
350
  const renderEntry = (entry: TranscriptEntry): ReactNode => {
324
351
  if (entry.kind === "subagent") return props.renderSubagent(entry);
@@ -359,8 +386,16 @@ function TranscriptEntryList(props: {
359
386
  keyPrefix: props.keyPrefix,
360
387
  knownKeys: activityKeys.current,
361
388
  });
362
- rows.push(
363
- <TranscriptRow anchorKey={activityKey} indent key={activityKey}>
389
+ const lastSeq = lastEntrySeq(activityEntries);
390
+ // The marker goes before the group when any of it is unread.
391
+ pushRow(
392
+ lastSeq,
393
+ <TranscriptRow
394
+ anchorKey={activityKey}
395
+ indent
396
+ key={activityKey}
397
+ seq={lastSeq}
398
+ >
364
399
  <TranscriptActivityGroup
365
400
  entries={visibleEntries}
366
401
  renderEntry={renderEntry}
@@ -373,8 +408,9 @@ function TranscriptEntryList(props: {
373
408
 
374
409
  if (!search.active || entryMatchesSearch(entry, search.normalizedQuery)) {
375
410
  const rowKey = `${props.keyPrefix}:${entry.key}`;
376
- rows.push(
377
- <TranscriptRow anchorKey={rowKey} key={rowKey}>
411
+ pushRow(
412
+ entry.seq,
413
+ <TranscriptRow anchorKey={rowKey} key={rowKey} seq={entry.seq}>
378
414
  {renderEntry(entry)}
379
415
  </TranscriptRow>,
380
416
  );
@@ -391,6 +427,39 @@ function TranscriptEntryList(props: {
391
427
  return rows;
392
428
  }
393
429
 
430
+ /**
431
+ * Find the seq of the first unread row. Entries are in seq order, and pending
432
+ * sends without a seq come last, so a binary search finds the first seq after
433
+ * the read position. Rows after the range arrived after open, so they get no
434
+ * marker.
435
+ */
436
+ function unreadMarkerSeq(
437
+ entries: readonly TranscriptEntry[],
438
+ unread: TranscriptUnreadRange | undefined,
439
+ ): number | undefined {
440
+ if (!unread) return undefined;
441
+ let low = 0;
442
+ let high = entries.length;
443
+ while (low < high) {
444
+ const middle = (low + high) >> 1;
445
+ const seq = entries[middle]!.seq;
446
+ if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1;
447
+ else high = middle;
448
+ }
449
+ const seq = entries[low]?.seq;
450
+ return seq !== undefined && seq <= unread.throughSeq ? seq : undefined;
451
+ }
452
+
453
+ function lastEntrySeq(
454
+ entries: readonly RenderedTranscriptEntry[],
455
+ ): number | undefined {
456
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
457
+ const seq = entries[index]!.seq;
458
+ if (seq !== undefined) return seq;
459
+ }
460
+ return undefined;
461
+ }
462
+
394
463
  /** Keep one activity group mounted while new or historical events extend either edge. */
395
464
  function stableActivityGroupKey(args: {
396
465
  claimedKeys: Set<string>;
@@ -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>
@@ -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
  )}
@@ -178,9 +178,12 @@ export function conversationDetailQueryOptions(
178
178
  structuralSharing: (previous, next) => {
179
179
  if (!next || typeof next !== "object") return next;
180
180
  if (!previous || typeof previous !== "object") return next;
181
- return reuseConversationEventReferences(
181
+ return keepLatestReadPosition(
182
182
  previous as ConversationSnapshot,
183
- next as ConversationSnapshot,
183
+ reuseConversationEventReferences(
184
+ previous as ConversationSnapshot,
185
+ next as ConversationSnapshot,
186
+ ),
184
187
  );
185
188
  },
186
189
  refetchInterval: (query) =>
@@ -196,6 +199,20 @@ export function conversationDetailQueryOptions(
196
199
  });
197
200
  }
198
201
 
202
+ /**
203
+ * Read positions only move forward. A poll that started before a read report
204
+ * finished must not put an older position back in the cache.
205
+ */
206
+ function keepLatestReadPosition(
207
+ previous: ConversationSnapshot,
208
+ next: ConversationSnapshot,
209
+ ): ConversationSnapshot {
210
+ const kept = previous.readPosition;
211
+ if (!kept) return next;
212
+ if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
213
+ return { ...next, readPosition: kept };
214
+ }
215
+
199
216
  function outboxActorIdentity(
200
217
  queryClient: QueryClient,
201
218
  ): ActorIdentity | undefined {
@@ -704,14 +721,17 @@ export function useConversationData(conversationId: string | undefined) {
704
721
  0,
705
722
  ...awaitingRows.map((message) => Date.parse(message.createdAt)),
706
723
  );
707
- const detail = useQuery(
708
- conversationDetailQueryOptions(conversationId, {
724
+ const detail = useQuery({
725
+ ...conversationDetailQueryOptions(conversationId, {
709
726
  awaitingMessage: awaitingRows.length > 0,
710
727
  fastPollUntilMs: latestSendAtMs
711
728
  ? latestSendAtMs + FAST_POLL_WINDOW_MS
712
729
  : undefined,
713
730
  }),
714
- );
731
+ // A reopened Conversation shows the cached copy at once, but it can miss
732
+ // messages that arrived while the reader was away. Fetch it each visit.
733
+ refetchOnMount: "always",
734
+ });
715
735
  // Defer the whole server snapshot, not history alone. Queue removal and the
716
736
  // matching transcript must paint together while composer input stays urgent.
717
737
  const deferredDetail = useDeferredValue(detail.data);
@@ -893,6 +913,11 @@ export function useConversationData(conversationId: string | undefined) {
893
913
  ? history.hasNextPage
894
914
  : Boolean(detailData?.previousCursor),
895
915
  isPending: detail.isPending,
916
+ /** The latest detail after a fetch in this visit, before it is deferred. */
917
+ fetchedDetail:
918
+ detail.isFetchedAfterMount && !detail.isFetching
919
+ ? detail.data
920
+ : undefined,
896
921
  isLoadingPreviousPage,
897
922
  pendingAuthorization: mailbox?.authorization,
898
923
  pendingGeneratedAt: mailbox?.generatedAt,
@@ -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;