@sentry/junior-dashboard 0.276.0 → 0.278.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 (73) hide show
  1. package/dist/app.js +196 -72
  2. package/dist/client.js +94 -89
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +191 -70
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/App.tsx +9 -4
  11. package/src/client/components/DetailsPanel.tsx +1 -1
  12. package/src/client/components/FormNotice.tsx +11 -14
  13. package/src/client/components/ProfileMenu.tsx +5 -6
  14. package/src/client/components/ui/resizable.tsx +50 -0
  15. package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
  16. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  17. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  18. package/src/client/conversations/ConversationDetails.tsx +6 -3
  19. package/src/client/conversations/ConversationHeader.tsx +15 -2
  20. package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
  21. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  22. package/src/client/conversations/ConversationMemories.tsx +64 -67
  23. package/src/client/conversations/ConversationMeta.tsx +2 -7
  24. package/src/client/conversations/ConversationPage.tsx +175 -79
  25. package/src/client/conversations/ConversationSidebar.tsx +82 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  27. package/src/client/conversations/ConversationWatches.tsx +15 -21
  28. package/src/client/conversations/ConversationWorkspace.tsx +132 -44
  29. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  30. package/src/client/conversations/DetailsList.tsx +8 -14
  31. package/src/client/conversations/EventDetails.tsx +2 -62
  32. package/src/client/conversations/MemoryIcons.tsx +23 -0
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  34. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  36. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  37. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  38. package/src/client/conversations/TranscriptView.tsx +89 -20
  39. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  40. package/src/client/conversations/conversationArchive.ts +255 -0
  41. package/src/client/conversations/conversationMemory.ts +2 -1
  42. package/src/client/conversations/conversationSections.ts +24 -18
  43. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  44. package/src/client/conversations/eventLog.ts +2 -31
  45. package/src/client/conversations/eventTranscript.ts +9 -42
  46. package/src/client/conversations/queries.ts +121 -250
  47. package/src/client/conversations/transcript.ts +46 -16
  48. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  49. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  50. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  51. package/src/client/conversations/transcriptSearch.tsx +3 -11
  52. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  53. package/src/client/conversations/usePinConversation.ts +88 -0
  54. package/src/client/display-text/DisplayText.tsx +7 -2
  55. package/src/client/display-text/codeBlocks.ts +92 -0
  56. package/src/client/format.ts +3 -67
  57. package/src/client/http.ts +5 -5
  58. package/src/client/markdownExport.ts +32 -42
  59. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  60. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  61. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  62. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  63. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  64. package/src/client/pages/memory/memoryRecord.ts +1 -6
  65. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  66. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  67. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  68. package/src/client/review-desk/queries.ts +2 -1
  69. package/src/client/types.ts +19 -33
  70. package/src/conversation-cache.ts +7 -9
  71. package/src/mock-reporting/fixtures.ts +126 -60
  72. package/src/mock-reporting/routes.ts +50 -1
  73. package/src/tailwind.css +30 -0
@@ -20,7 +20,7 @@ import {
20
20
  } from "./transcriptScrollAnchor";
21
21
 
22
22
  const BOTTOM_PROXIMITY_PX = 96;
23
- const PREVIOUS_PAGE_PROXIMITY_PX = 600;
23
+ const HISTORY_PAGE_PROXIMITY_PX = 600;
24
24
  const USER_SCROLL_DELTA_PX = 2;
25
25
  // Leave a little earlier context above the "New" marker.
26
26
  const UNREAD_START_OFFSET_PX = 48;
@@ -133,8 +133,7 @@ function changesVisibleTranscript(event: ConversationReportEvent): boolean {
133
133
  data.type === "subagent" ||
134
134
  data.type === "structured_event" ||
135
135
  data.type === "attachments_delivered" ||
136
- data.type === "compaction" ||
137
- data.type === "handoff"
136
+ data.type === "compaction"
138
137
  );
139
138
  }
140
139
 
@@ -208,18 +207,6 @@ function eventTailVersion(event: ConversationReportEvent | undefined): string {
208
207
  ].join(":");
209
208
  case "compaction":
210
209
  return [data.type, data.summary?.length ?? 0, event.createdAt].join(":");
211
- case "handoff":
212
- return [
213
- data.type,
214
- data.modelProfile,
215
- data.modelId,
216
- data.summary?.length ?? 0,
217
- event.createdAt,
218
- ].join(":");
219
- case "turn_routed":
220
- return [data.type, data.turnId, data.modelProfile, data.modelId].join(
221
- ":",
222
- );
223
210
  case "guardian_action_reviewed":
224
211
  return [data.type, data.turnId, data.toolCallId, data.decision].join(":");
225
212
  case "turn_context":
@@ -262,18 +249,22 @@ export function terminalReplyPinState(input: {
262
249
 
263
250
  /**
264
251
  * Show the jump control only when the reader left the bottom and a newer tail
265
- * arrived, or when the transcript opened at unread rows. Staying pinned, or
266
- * intentionally following, must not flash it.
252
+ * arrived, when the transcript opened at unread rows, or when it does not end
253
+ * at the latest event. Staying pinned, or intentionally following, must not
254
+ * flash it.
267
255
  */
268
256
  export function shouldShowJumpToLatest(input: {
269
257
  enabled: boolean;
258
+ endsAtLatest?: boolean;
270
259
  following: boolean;
271
260
  hasPendingUpdate: boolean;
272
261
  openedAtUnread?: boolean;
273
262
  }): boolean {
274
263
  return (
275
264
  !input.following &&
276
- (Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate))
265
+ (input.endsAtLatest === false ||
266
+ Boolean(input.openedAtUnread) ||
267
+ (input.enabled && input.hasPendingUpdate))
277
268
  );
278
269
  }
279
270
 
@@ -319,6 +310,12 @@ export function programmaticSettleScrollAction(input: {
319
310
  export function usePinnedTranscriptBottom(input: {
320
311
  conversationId?: string;
321
312
  enabled: boolean;
313
+ /**
314
+ * False while newer pages are not loaded. The end of the transcript is
315
+ * then not the latest event, so the reader cannot follow it, and a page
316
+ * that loads below them does not pull them down.
317
+ */
318
+ endsAtLatest?: boolean;
322
319
  historyVersion: string;
323
320
  juniorMessageVersion: string;
324
321
  pinRequestVersion?: number;
@@ -330,6 +327,8 @@ export function usePinnedTranscriptBottom(input: {
330
327
  const anchorRef = useRef<HTMLDivElement | null>(null);
331
328
  const contentElementRef = useRef<HTMLDivElement | null>(null);
332
329
  const enabledRef = useRef(input.enabled);
330
+ const endsAtLatestRef = useRef(true);
331
+ endsAtLatestRef.current = input.endsAtLatest ?? true;
333
332
  const followingRef = useRef(false);
334
333
  const initializedRef = useRef(false);
335
334
  const initializedConversationRef = useRef<string | null>(null);
@@ -447,6 +446,7 @@ export function usePinnedTranscriptBottom(input: {
447
446
  }
448
447
 
449
448
  if (intent === "follow") {
449
+ if (!endsAtLatestRef.current) return;
450
450
  setFollowingIntent(true);
451
451
  setHasPendingUpdate(false);
452
452
  return;
@@ -537,7 +537,7 @@ export function usePinnedTranscriptBottom(input: {
537
537
  const snapshot = scrollSnapshot(root);
538
538
  previousScrollTopRef.current = snapshot.scrollTop;
539
539
  // Unread rows that fit above the bottom leave the reader at the latest.
540
- const atBottom = isNearScrollBottom(snapshot);
540
+ const atBottom = endsAtLatestRef.current && isNearScrollBottom(snapshot);
541
541
  setFollowingIntent(atBottom);
542
542
  setOpenedAtUnread(!atBottom);
543
543
  rememberPlace();
@@ -574,8 +574,13 @@ export function usePinnedTranscriptBottom(input: {
574
574
  // reader moved. Then open at the marker, as if it was there at the start.
575
575
  const followLatest = useCallback(() => {
576
576
  if (awaitingUnreadMarkerRef.current && openAtUnreadMarker()) return;
577
+ if (!endsAtLatestRef.current) {
578
+ setFollowingIntent(false);
579
+ restorePlace();
580
+ return;
581
+ }
577
582
  scrollToBottom("auto");
578
- }, [openAtUnreadMarker, scrollToBottom]);
583
+ }, [openAtUnreadMarker, restorePlace, scrollToBottom, setFollowingIntent]);
579
584
 
580
585
  const syncAfterLayoutChange = useCallback(() => {
581
586
  if (followingRef.current) {
@@ -749,6 +754,8 @@ export function usePinnedTranscriptBottom(input: {
749
754
  const version = input.pinRequestVersion ?? 0;
750
755
  if (version === pinRequestVersionRef.current) return;
751
756
  pinRequestVersionRef.current = version;
757
+ // The reader asked for the latest event, not the "New" marker.
758
+ awaitingUnreadMarkerRef.current = false;
752
759
  setFollowingIntent(true);
753
760
  setHasPendingUpdate(false);
754
761
  scrollToBottom("auto");
@@ -763,6 +770,7 @@ export function usePinnedTranscriptBottom(input: {
763
770
  jumpToBottom,
764
771
  showJumpToLatest: shouldShowJumpToLatest({
765
772
  enabled: input.enabled,
773
+ endsAtLatest: input.endsAtLatest,
766
774
  following,
767
775
  hasPendingUpdate,
768
776
  openedAtUnread,
@@ -774,14 +782,19 @@ export function usePinnedTranscriptBottom(input: {
774
782
  following,
775
783
  hasPendingUpdate,
776
784
  input.enabled,
785
+ input.endsAtLatest,
777
786
  jumpToBottom,
778
787
  openedAtUnread,
779
788
  ],
780
789
  );
781
790
  }
782
791
 
783
- /** Load the previous history page when the reader scrolls near the top. */
784
- export function useAutoLoadPreviousPage(input: {
792
+ /**
793
+ * Load the next history page when the reader scrolls near the edge where it
794
+ * goes: the top for earlier pages, the bottom for newer pages.
795
+ */
796
+ export function useAutoLoadHistoryPage(input: {
797
+ edge: "bottom" | "top";
785
798
  enabled: boolean;
786
799
  historyVersion: string;
787
800
  onLoad: () => void;
@@ -803,12 +816,15 @@ export function useAutoLoadPreviousPage(input: {
803
816
  },
804
817
  {
805
818
  root: root && !isWindowRoot(root) ? root : null,
806
- rootMargin: `${PREVIOUS_PAGE_PROXIMITY_PX}px 0px 0px 0px`,
819
+ rootMargin:
820
+ input.edge === "top"
821
+ ? `${HISTORY_PAGE_PROXIMITY_PX}px 0px 0px 0px`
822
+ : `0px 0px ${HISTORY_PAGE_PROXIMITY_PX}px 0px`,
807
823
  },
808
824
  );
809
825
  observer.observe(sentinel);
810
826
  return () => observer.disconnect();
811
- }, [input.enabled, input.historyVersion, sentinel]);
827
+ }, [input.edge, input.enabled, input.historyVersion, sentinel]);
812
828
 
813
829
  return setSentinel;
814
830
  }
@@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
17
17
  import {
18
18
  conversationReadPositionSchema,
19
19
  type ConversationDetailReport,
20
+ type ConversationEventPage,
20
21
  type ConversationReadPosition,
21
22
  } from "@sentry/junior/api/schema";
22
23
  import { put } from "../http";
@@ -42,6 +43,10 @@ export type TranscriptUnreadRange = { afterSeq: number; throughSeq: number };
42
43
  export type ReadPositionAtOpen = {
43
44
  lastReadSeq?: number;
44
45
  unread?: TranscriptUnreadRange;
46
+ /** Set when unread events start before the latest page. */
47
+ unreadCursor?: string;
48
+ /** The page around `unreadCursor`, when the fetch brought it. */
49
+ unreadPage?: ConversationEventPage;
45
50
  };
46
51
 
47
52
  /** Edit the browser-only preference for where Conversations open. */
@@ -74,7 +79,9 @@ export function readConversationStartPosition(): ConversationStartPosition {
74
79
  * The read position comes from the first detail, which can be a cached copy.
75
80
  * The last unread row comes from the first detail fetched in this visit, so a
76
81
  * stale cache cannot hide messages that arrived while the reader was away,
77
- * and later polls cannot add rows that arrived after open.
82
+ * and later polls cannot add rows that arrived after open. `unreadCursor`
83
+ * and its page come from that fetch too: a cache from a visit that read to
84
+ * the end has no cursor, even when more than a page arrived after that visit.
78
85
  */
79
86
  export function useReadPositionAtOpen(
80
87
  detail: DetailAtOpen | undefined,
@@ -83,24 +90,41 @@ export function useReadPositionAtOpen(
83
90
  ): ReadPositionAtOpen {
84
91
  // Store derived state during render so the first transcript render has it.
85
92
  const [opened, setOpened] = useState<{ lastReadSeq?: number }>();
86
- const [fetchedEnd, setFetchedEnd] = useState<{ throughSeq?: number }>();
93
+ const [fetchedEnd, setFetchedEnd] = useState<{
94
+ throughSeq?: number;
95
+ unreadCursor?: string;
96
+ unreadPage?: ConversationEventPage;
97
+ }>();
87
98
  const read =
88
99
  opened ??
89
100
  (detail ? { lastReadSeq: detail.readPosition?.lastReadSeq } : undefined);
90
101
  if (!opened && read) setOpened(read);
91
102
  const end =
92
103
  fetchedEnd ??
93
- (fetched ? { throughSeq: fetched.events.at(-1)?.seq } : undefined);
104
+ (fetched
105
+ ? {
106
+ throughSeq: fetched.events.at(-1)?.seq,
107
+ unreadCursor: fetched.unreadCursor,
108
+ unreadPage: fetched.unreadPage,
109
+ }
110
+ : undefined);
94
111
  if (!fetchedEnd && end) setFetchedEnd(end);
95
- return readPositionAtOpen(
96
- read?.lastReadSeq,
97
- end ? end.throughSeq : detail?.events.at(-1)?.seq,
98
- );
112
+ return {
113
+ ...readPositionAtOpen(
114
+ read?.lastReadSeq,
115
+ end ? end.throughSeq : detail?.events.at(-1)?.seq,
116
+ ),
117
+ ...(end?.unreadCursor
118
+ ? { unreadCursor: end.unreadCursor, unreadPage: end.unreadPage }
119
+ : undefined),
120
+ };
99
121
  }
100
122
 
101
123
  type DetailAtOpen = {
102
124
  events: readonly { seq: number }[];
103
125
  readPosition?: ConversationReadPosition;
126
+ unreadCursor?: string;
127
+ unreadPage?: ConversationEventPage;
104
128
  };
105
129
 
106
130
  /** Find the rows after the read position, through the last row at open. */
@@ -234,20 +234,7 @@ export function messageRawText(message: TranscriptViewMessage): string {
234
234
  .map((attachment) => attachment.filename)
235
235
  .join("\n");
236
236
  }
237
- if (part.event.type !== "handoff") {
238
- return ["context compacted", part.event.summary]
239
- .filter((line): line is string => line !== undefined)
240
- .join("\n");
241
- }
242
- return [
243
- "model handoff",
244
- `model ${part.event.modelId}`,
245
- `profile ${part.event.modelProfile}`,
246
- part.event.summary,
247
- part.event.reasoningLevel
248
- ? `reasoning ${part.event.reasoningLevel}`
249
- : undefined,
250
- ]
237
+ return ["context compacted", part.event.summary]
251
238
  .filter((line): line is string => line !== undefined)
252
239
  .join("\n");
253
240
  })
@@ -194,17 +194,9 @@ export function entryMatchesSearch(
194
194
 
195
195
  if (entry.kind === "context") {
196
196
  const event = entry.part.event;
197
- return event.type === "handoff"
198
- ? [
199
- "model handoff",
200
- event.modelProfile,
201
- event.modelId,
202
- event.reasoningLevel,
203
- event.summary,
204
- ].some((value) => textContains(value, normalizedQuery))
205
- : ["context compacted", event.summary].some((value) =>
206
- textContains(value, normalizedQuery),
207
- );
197
+ return ["context compacted", event.summary].some((value) =>
198
+ textContains(value, normalizedQuery),
199
+ );
208
200
  }
209
201
 
210
202
  return false;
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Open a long Conversation at the reader's read position.
3
+ *
4
+ * The detail holds the latest page of events. When the read position is
5
+ * older than that page, the transcript starts with the page around the read
6
+ * position instead: the last read events and the unread events after them.
7
+ * The first detail fetch of a visit brings that page, so it needs no second
8
+ * request.
9
+ * Like earlier pages, newer pages load when the reader scrolls near them, so
10
+ * the server reads only the pages that the reader reaches.
11
+ *
12
+ * The latest page joins the transcript only after the newer pages reach it,
13
+ * so the transcript never has a gap. Until then the transcript ends with a
14
+ * row that loads the next newer page. A reader who jumps to the latest skips
15
+ * the newer pages: the transcript shows the latest page from the detail at
16
+ * once, and earlier pages load above it as the reader scrolls up. When a
17
+ * poll skips past the newer pages after they join the latest page, or past a
18
+ * newer page that has nothing after it, the transcript shows the latest page
19
+ * in the same way, so it never drops the skipped events.
20
+ */
21
+ import { useEffect, useMemo, useRef } from "react";
22
+ import { useInfiniteQuery } from "@tanstack/react-query";
23
+ import {
24
+ conversationEventPageSchema,
25
+ type ConversationDetailReport,
26
+ type ConversationEventPage,
27
+ type ConversationReportEvent,
28
+ } from "@sentry/junior/api/schema";
29
+
30
+ import { fetchDashboardJson } from "../http";
31
+ import { readConversationEvents } from "./queries";
32
+ import {
33
+ buildUnreadConversationTranscript,
34
+ conversationHistoryVersion,
35
+ } from "./transcript";
36
+
37
+ /** The transcript to show and how to load more of it. */
38
+ export type TranscriptHistory = {
39
+ data: ConversationDetailReport | undefined;
40
+ hasPreviousPage: boolean;
41
+ historyError: Error | null;
42
+ historyVersion: string;
43
+ isLoadingPreviousPage: boolean;
44
+ loadPreviousPage: () => void;
45
+ /** Set while newer pages have not reached the latest page. */
46
+ newer?: {
47
+ error: Error | null;
48
+ /** Load the next newer page, or retry a failed one. */
49
+ load: () => void;
50
+ loading: boolean;
51
+ /** Skip the newer pages and show the latest page. */
52
+ showLatest: () => void;
53
+ };
54
+ };
55
+
56
+ /**
57
+ * Load the transcript from `unreadCursor`. Returns `undefined` when there is
58
+ * no cursor, or when the first page fails, so the caller shows the latest
59
+ * page instead.
60
+ */
61
+ export function useUnreadHistory(input: {
62
+ conversationId: string;
63
+ /** The latest page, from the polled detail. */
64
+ detail: ConversationDetailReport | undefined;
65
+ /** `unreadCursor` from the detail when the Conversation opened. */
66
+ unreadCursor: string | undefined;
67
+ /** Skip the newer pages. The caller then passes no `unreadCursor`. */
68
+ onShowLatest: () => void;
69
+ /** The page around `unreadCursor`, when the detail brought it. */
70
+ unreadPage?: ConversationEventPage;
71
+ }): (TranscriptHistory & { isPending: boolean }) | undefined {
72
+ const { conversationId, detail, onShowLatest, unreadCursor, unreadPage } =
73
+ input;
74
+ const latestEventsRef = useRef<ConversationReportEvent[]>([]);
75
+ const latestEvents = useMemo(() => {
76
+ const kept = keepLatestEvents(latestEventsRef.current, detail?.events);
77
+ latestEventsRef.current = kept;
78
+ return kept;
79
+ }, [detail?.events]);
80
+ const latestFirstSeq = latestEvents[0]?.seq;
81
+ const joinedRef = useRef(false);
82
+ const newer = useInfiniteQuery({
83
+ // Only the page around the read position loads by itself. The data never
84
+ // goes stale, so a refetch cannot reload every newer page at once.
85
+ enabled: Boolean(unreadCursor),
86
+ staleTime: Infinity,
87
+ // Each visit starts again from the page around the read position. Pages
88
+ // from an earlier visit can end at an old latest event, which would
89
+ // leave a gap before the latest page.
90
+ gcTime: 0,
91
+ queryKey: ["conversation", conversationId, "unread", unreadCursor, "newer"],
92
+ queryFn: ({ pageParam, signal }) =>
93
+ readConversationEventsFrom(conversationId, pageParam, signal),
94
+ initialPageParam: { around: unreadCursor ?? "" } as NewerPageParam,
95
+ // The page that came with the detail. Without it, the query reads it.
96
+ ...(unreadPage
97
+ ? {
98
+ initialData: {
99
+ pageParams: [{ around: unreadCursor ?? "" }],
100
+ pages: [unreadPage],
101
+ },
102
+ }
103
+ : undefined),
104
+ getNextPageParam: (page): NewerPageParam | undefined =>
105
+ reachesLatest(page, latestFirstSeq) || !page.nextCursor
106
+ ? undefined
107
+ : { after: page.nextCursor },
108
+ retry: false,
109
+ });
110
+ const firstNewerPage = newer.data?.pages[0];
111
+ const earlier = useInfiniteQuery({
112
+ enabled: false,
113
+ queryKey: [
114
+ "conversation",
115
+ conversationId,
116
+ "unread",
117
+ unreadCursor,
118
+ "earlier",
119
+ ],
120
+ queryFn: ({ pageParam, signal }) =>
121
+ readConversationEvents(conversationId, pageParam, signal),
122
+ initialPageParam: firstNewerPage?.previousCursor ?? "",
123
+ getNextPageParam: (page) => page.previousCursor,
124
+ retry: false,
125
+ });
126
+
127
+ const newerPages = newer.data?.pages;
128
+ const earlierPages = earlier.data?.pages;
129
+ const lastNewerPage = newerPages?.at(-1);
130
+ const reached = lastNewerPage
131
+ ? reachesLatest(lastNewerPage, latestFirstSeq)
132
+ : false;
133
+ if (reached) joinedRef.current = true;
134
+ // No newer page can fill the gap before the latest page, or the latest
135
+ // events already on screen would leave until one does.
136
+ const skipped =
137
+ lastNewerPage !== undefined &&
138
+ !reached &&
139
+ (joinedRef.current || !lastNewerPage.nextCursor);
140
+ useEffect(() => {
141
+ if (skipped) onShowLatest();
142
+ }, [onShowLatest, skipped]);
143
+ const pages = useMemo(
144
+ () => [...(earlierPages ?? []), ...(newerPages ?? [])],
145
+ [earlierPages, newerPages],
146
+ );
147
+ const data = useMemo(
148
+ () =>
149
+ detail && newerPages
150
+ ? buildUnreadConversationTranscript(
151
+ detail,
152
+ pages,
153
+ reached ? latestEvents : [],
154
+ )
155
+ : undefined,
156
+ [detail, latestEvents, newerPages, pages, reached],
157
+ );
158
+
159
+ if (!unreadCursor || skipped || (newer.error && !newerPages)) {
160
+ return undefined;
161
+ }
162
+
163
+ const hasPreviousPage = earlier.data
164
+ ? earlier.hasNextPage
165
+ : Boolean(firstNewerPage?.previousCursor);
166
+ // `cancelRefetch: false` lets a second call join the page read in flight.
167
+ // By default it cancels that read and starts the same read again.
168
+ return {
169
+ data,
170
+ hasPreviousPage,
171
+ historyError: earlier.error,
172
+ historyVersion: conversationHistoryVersion(pages),
173
+ isLoadingPreviousPage: earlier.isFetchingNextPage,
174
+ isPending: !newerPages,
175
+ loadPreviousPage: () => {
176
+ if (hasPreviousPage) void earlier.fetchNextPage({ cancelRefetch: false });
177
+ },
178
+ ...(reached
179
+ ? undefined
180
+ : {
181
+ newer: {
182
+ error: newer.error,
183
+ load: () => {
184
+ if (newer.hasNextPage) {
185
+ void newer.fetchNextPage({ cancelRefetch: false });
186
+ }
187
+ },
188
+ loading: newer.isFetchingNextPage,
189
+ showLatest: onShowLatest,
190
+ },
191
+ }),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Keep the events that new events push out of the latest page. Without them,
197
+ * the latest page would start after the newer pages again, and the transcript
198
+ * would drop its latest events until one more page loads. A poll that skips
199
+ * past the kept events starts over from its own events.
200
+ */
201
+ function keepLatestEvents(
202
+ kept: ConversationReportEvent[],
203
+ events: ConversationReportEvent[] | undefined,
204
+ ): ConversationReportEvent[] {
205
+ const firstSeq = events?.[0]?.seq;
206
+ const keptLastSeq = kept.at(-1)?.seq;
207
+ if (!events || firstSeq === undefined) return kept;
208
+ if (keptLastSeq === undefined || firstSeq > keptLastSeq + 1) return events;
209
+ const slid = kept.filter((event) => event.seq < firstSeq);
210
+ return slid.length ? [...slid, ...events] : events;
211
+ }
212
+
213
+ /**
214
+ * The newer pages reach the latest page when the latest page starts right
215
+ * after them. Without latest events, they reach it when nothing follows them.
216
+ */
217
+ function reachesLatest(
218
+ page: ConversationEventPage,
219
+ latestFirstSeq: number | undefined,
220
+ ): boolean {
221
+ const lastSeq = page.events.at(-1)?.seq;
222
+ if (lastSeq === undefined || latestFirstSeq === undefined) {
223
+ return !page.nextCursor;
224
+ }
225
+ return lastSeq + 1 >= latestFirstSeq;
226
+ }
227
+
228
+ /** The first page is around the read position. The rest follow it. */
229
+ type NewerPageParam = { around: string } | { after: string };
230
+
231
+ /** Read one bounded page of events around or after a history cursor. */
232
+ function readConversationEventsFrom(
233
+ conversationId: string,
234
+ param: NewerPageParam,
235
+ signal?: AbortSignal,
236
+ ): Promise<ConversationEventPage> {
237
+ const query = new URLSearchParams(param);
238
+ return fetchDashboardJson(
239
+ conversationEventPageSchema,
240
+ `/api/conversations/${encodeURIComponent(conversationId)}/events?${query}`,
241
+ signal,
242
+ ).then((page) => {
243
+ if ("after" in param && page.nextCursor === param.after) {
244
+ throw new Error("Conversation history cursor did not advance");
245
+ }
246
+ return page;
247
+ });
248
+ }
@@ -0,0 +1,88 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import type {
3
+ ConversationDetailReport,
4
+ ConversationFeed,
5
+ } from "@sentry/junior/api/schema";
6
+ import { pinConversationResponseSchema } from "@sentry/junior/api/schema";
7
+
8
+ import { patch } from "../http";
9
+ import { conversationDetailQueryKey } from "./queries";
10
+
11
+ type DetailCache = ConversationDetailReport & { detailEtag?: string };
12
+
13
+ /**
14
+ * Pin or unpin one conversation for the viewer. The feed and detail caches
15
+ * change at once, so the row moves to or from the Pinned section before the
16
+ * request returns. A failed request puts the old caches back.
17
+ */
18
+ export function usePinConversation(
19
+ conversationId: string,
20
+ options?: { onError?(pinned: boolean): void; onSuccess?(): void },
21
+ ) {
22
+ const queryClient = useQueryClient();
23
+ const feeds = { queryKey: ["dashboard", "conversations"] };
24
+ const detailQueryKey = conversationDetailQueryKey(conversationId);
25
+ const setPinnedAt = (pinnedAt: string | null) => {
26
+ // Match the API: a pin also restores an archived conversation.
27
+ const viewerMarks = pinnedAt
28
+ ? { archivedAt: null, pinnedAt }
29
+ : { pinnedAt };
30
+ queryClient.setQueriesData<ConversationFeed>(feeds, (feed) =>
31
+ feed
32
+ ? {
33
+ ...feed,
34
+ conversations: feed.conversations.map((conversation) =>
35
+ conversation.conversationId === conversationId
36
+ ? { ...conversation, ...viewerMarks }
37
+ : conversation,
38
+ ),
39
+ }
40
+ : feed,
41
+ );
42
+ // Clear the etag so the next read gets a full body, not a 304 that keeps
43
+ // this local value.
44
+ queryClient.setQueryData<DetailCache>(detailQueryKey, (detail) =>
45
+ detail ? { ...detail, ...viewerMarks, detailEtag: undefined } : detail,
46
+ );
47
+ };
48
+ return useMutation({
49
+ mutationFn: (pinned: boolean) =>
50
+ patch(
51
+ pinConversationResponseSchema,
52
+ `/api/conversations/${encodeURIComponent(conversationId)}/pin`,
53
+ { pinned },
54
+ ),
55
+ onMutate: async (pinned) => {
56
+ await Promise.all([
57
+ queryClient.cancelQueries(feeds),
58
+ queryClient.cancelQueries({ queryKey: detailQueryKey }),
59
+ ]);
60
+ const previous = {
61
+ detail: queryClient.getQueryData<DetailCache>(detailQueryKey),
62
+ feeds: queryClient.getQueriesData<ConversationFeed>(feeds),
63
+ };
64
+ setPinnedAt(pinned ? new Date().toISOString() : null);
65
+ return previous;
66
+ },
67
+ onError: (_error, pinned, previous) => {
68
+ previous?.feeds.forEach(([queryKey, feed]) => {
69
+ queryClient.setQueryData(queryKey, feed);
70
+ });
71
+ queryClient.setQueryData(detailQueryKey, previous?.detail);
72
+ options?.onError?.(pinned);
73
+ },
74
+ onSuccess: (result) => {
75
+ setPinnedAt(result.pinnedAt);
76
+ options?.onSuccess?.();
77
+ },
78
+ // A reader's first pin adds the conversation to their feed.
79
+ onSettled: () =>
80
+ Promise.all([
81
+ queryClient.invalidateQueries(feeds),
82
+ queryClient.invalidateQueries({
83
+ exact: true,
84
+ queryKey: detailQueryKey,
85
+ }),
86
+ ]),
87
+ });
88
+ }
@@ -2,7 +2,8 @@ import { memo } from "react";
2
2
  import type { ActorIdentity } from "@sentry/junior/api/schema";
3
3
 
4
4
  import { HighlightedCode } from "../code";
5
- import { parseMarkdownBlocks } from "../format";
5
+ import { CopyButton } from "../components/CopyButton";
6
+ import { parseMarkdownBlocks } from "./codeBlocks";
6
7
  import { MarkdownText } from "./markdown";
7
8
  import { MentionPeopleProvider } from "./mentions";
8
9
 
@@ -56,10 +57,14 @@ function DisplayTextBlocks(props: { chat?: boolean; text: string }) {
56
57
 
57
58
  return (
58
59
  <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
+ className="group/code relative min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel py-2.5 pl-3 pr-10"
60
61
  key={index}
61
62
  >
62
63
  <HighlightedCode code={block.code} language={block.language} />
64
+ {/* Touch screens cannot hover, so keep the button visible there. */}
65
+ <div className="absolute right-0.5 top-0.5 transition-opacity [@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover/code:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within/code:opacity-100">
66
+ <CopyButton getText={() => block.code} label="Copy code" />
67
+ </div>
63
68
  </div>
64
69
  );
65
70
  })}