@sentry/junior-dashboard 0.279.0 → 0.280.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 (147) hide show
  1. package/dist/app.js +100 -31
  2. package/dist/client-manifest.json +1 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +100 -31
  5. package/dist/public/chunks/AdminPage-YEKZOKH6.js +1 -0
  6. package/dist/public/chunks/AutomationPage-5GS3IUAK.js +3 -0
  7. package/dist/public/chunks/AutomationRunsPage-IPYEZU4L.js +1 -0
  8. package/dist/public/chunks/AutomationsPage-HSAYPC3C.js +1 -0
  9. package/dist/public/chunks/CodePage-IPFKXWFB.js +1 -0
  10. package/dist/public/chunks/ConnectionsPage-5XUEK6FS.js +1 -0
  11. package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
  12. package/dist/public/chunks/LocationDetailPage-27ETUGR6.js +1 -0
  13. package/dist/public/chunks/LocationsPage-WOKMQAGM.js +1 -0
  14. package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
  15. package/dist/public/chunks/PeoplePage-6YQLDOUL.js +1 -0
  16. package/dist/public/chunks/PersonProfilePage-GYMAZFIK.js +1 -0
  17. package/dist/public/chunks/PersonalTokensPage-4BCR744B.js +1 -0
  18. package/dist/public/chunks/PluginUserPage-IP6WL7MD.js +1 -0
  19. package/dist/public/chunks/RepositoryPage-TRD44LQ3.js +1 -0
  20. package/dist/public/chunks/SettingsPage-4E3SLQ36.js +1 -0
  21. package/dist/public/chunks/SystemPage-C3XBKXKW.js +1 -0
  22. package/dist/public/chunks/WorkspaceFormPage-2LS2X67Q.js +2 -0
  23. package/dist/public/chunks/WorkspacesPage-5WB3CUTX.js +1 -0
  24. package/dist/public/chunks/chunk-2BCS5AQL.js +1 -0
  25. package/dist/public/chunks/chunk-3Y3ICT6K.js +1 -0
  26. package/dist/public/chunks/chunk-4AKUZW6R.js +1 -0
  27. package/dist/public/chunks/chunk-4UGZNVJM.js +1 -0
  28. package/dist/public/chunks/chunk-564V7YHG.js +1 -0
  29. package/dist/public/chunks/chunk-5GZDBGAR.js +1 -0
  30. package/dist/public/chunks/chunk-5ISD3QSC.js +1 -0
  31. package/dist/public/chunks/chunk-5LNGNJ3P.js +1 -0
  32. package/dist/public/chunks/chunk-5ZA674OU.js +1 -0
  33. package/dist/public/chunks/chunk-6FSJ3W2W.js +1 -0
  34. package/dist/public/chunks/chunk-6GQL3EVJ.js +1 -0
  35. package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
  36. package/dist/public/chunks/chunk-A2QWWA7O.js +41 -0
  37. package/dist/public/chunks/chunk-AYXSBMWA.js +1 -0
  38. package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -0
  39. package/dist/public/chunks/chunk-B6HGRBBX.js +1 -0
  40. package/dist/public/chunks/chunk-BKOACADP.js +1 -0
  41. package/dist/public/chunks/chunk-CQY6BYUB.js +1 -0
  42. package/dist/public/chunks/chunk-CR2ICLSH.js +1 -0
  43. package/dist/public/chunks/chunk-DZB4LPI6.js +1 -0
  44. package/dist/public/chunks/chunk-EFEREKX4.js +1 -0
  45. package/dist/public/chunks/chunk-FE522UEA.js +1 -0
  46. package/dist/public/chunks/chunk-FVX42XAD.js +1 -0
  47. package/dist/public/chunks/chunk-G5NWYJTV.js +1 -0
  48. package/dist/public/chunks/chunk-GMZIELFU.js +1 -0
  49. package/dist/public/chunks/chunk-GO45RQDL.js +1 -0
  50. package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
  51. package/dist/public/chunks/chunk-GRMX2HX6.js +1 -0
  52. package/dist/public/chunks/chunk-GYRM5ZIV.js +1 -0
  53. package/dist/public/chunks/chunk-H2ZU34CP.js +1 -0
  54. package/dist/public/chunks/chunk-H76B43OQ.js +1 -0
  55. package/dist/public/chunks/chunk-HCLRG2EC.js +1 -0
  56. package/dist/public/chunks/chunk-IA6BJDKN.js +1 -0
  57. package/dist/public/chunks/chunk-IZWTFLCC.js +1 -0
  58. package/dist/public/chunks/chunk-J37MXUED.js +1 -0
  59. package/dist/public/chunks/chunk-JAZABOBA.js +0 -0
  60. package/dist/public/chunks/chunk-L2H3ZWZN.js +1 -0
  61. package/dist/public/chunks/chunk-LBELRO5P.js +1 -0
  62. package/dist/public/chunks/chunk-LKW7MULF.js +1 -0
  63. package/dist/public/chunks/chunk-MEHLQZDG.js +3 -0
  64. package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
  65. package/dist/public/chunks/chunk-N4AONKD3.js +1 -0
  66. package/dist/public/chunks/chunk-NQMDED2R.js +2 -0
  67. package/dist/public/chunks/chunk-NQOJAZFQ.js +1 -0
  68. package/dist/public/chunks/chunk-PEQNT5MO.js +1 -0
  69. package/dist/public/chunks/chunk-PQPAPPEI.js +1 -0
  70. package/dist/public/chunks/chunk-PXHLJIOT.js +1 -0
  71. package/dist/public/chunks/chunk-QNA7F7QB.js +1 -0
  72. package/dist/public/chunks/chunk-QOX5RMLM.js +1 -0
  73. package/dist/public/chunks/chunk-QPBLZSNX.js +1 -0
  74. package/dist/public/chunks/chunk-R4ORRE77.js +1 -0
  75. package/dist/public/chunks/chunk-RHIXHBS5.js +1 -0
  76. package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
  77. package/dist/public/chunks/chunk-SDKV3OID.js +1 -0
  78. package/dist/public/chunks/chunk-SIOW7OOW.js +1 -0
  79. package/dist/public/chunks/chunk-SUX4QBVF.js +1 -0
  80. package/dist/public/chunks/chunk-T4M53QQT.js +1 -0
  81. package/dist/public/chunks/chunk-TFJOEF3Q.js +1 -0
  82. package/dist/public/chunks/chunk-UAH6DWNS.js +1 -0
  83. package/dist/public/chunks/chunk-UJD3MBMB.js +1 -0
  84. package/dist/public/chunks/chunk-UK2V4UBT.js +1 -0
  85. package/dist/public/chunks/chunk-UT6HZPGL.js +1 -0
  86. package/dist/public/chunks/chunk-UZ44J2LH.js +1 -0
  87. package/dist/public/chunks/chunk-V5UDSIRA.js +63 -0
  88. package/dist/public/chunks/chunk-VC6IS564.js +1 -0
  89. package/dist/public/chunks/chunk-VFEBNOPX.js +1 -0
  90. package/dist/public/chunks/chunk-VXT3FIVL.js +55 -0
  91. package/dist/public/chunks/chunk-WJ3OXMVX.js +1 -0
  92. package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
  93. package/dist/public/chunks/chunk-WZVO3HG4.js +1 -0
  94. package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
  95. package/dist/public/chunks/chunk-YPMUHXYT.js +147 -0
  96. package/dist/public/chunks/chunk-YYNMZ4WC.js +1 -0
  97. package/dist/public/chunks/chunk-Z2JPRGFT.js +1 -0
  98. package/dist/public/chunks/chunk-ZIJ6SJDN.js +1 -0
  99. package/dist/public/chunks/code-highlighter-Q7WC7AEF.js +12 -0
  100. package/dist/public/client-TYC7JKRE.js +91 -0
  101. package/dist/tailwind.css +1 -1
  102. package/package.json +5 -4
  103. package/src/api/schema.ts +1 -1
  104. package/src/assets.ts +4 -1
  105. package/src/client/App.tsx +100 -23
  106. package/src/client/TracedRoutes.tsx +8 -4
  107. package/src/client/code-highlighter.ts +26 -0
  108. package/src/client/code.tsx +2 -22
  109. package/src/client/components/Drawer.tsx +12 -71
  110. package/src/client/components/DrawerContent.tsx +76 -0
  111. package/src/client/components/ProfileMenu.tsx +12 -3
  112. package/src/client/components/UserInputForm.tsx +28 -5
  113. package/src/client/conversations/ConversationCatchUp.tsx +107 -0
  114. package/src/client/conversations/ConversationContextTags.tsx +110 -0
  115. package/src/client/conversations/ConversationEventLog.tsx +213 -71
  116. package/src/client/conversations/ConversationHeader.tsx +35 -41
  117. package/src/client/conversations/ConversationHeaderActions.tsx +34 -38
  118. package/src/client/conversations/ConversationHomeList.tsx +41 -3
  119. package/src/client/conversations/ConversationMeta.tsx +6 -12
  120. package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
  121. package/src/client/conversations/ConversationPage.tsx +188 -150
  122. package/src/client/conversations/ShareConversationButton.tsx +5 -50
  123. package/src/client/conversations/SlackMark.tsx +16 -4
  124. package/src/client/conversations/TranscriptRows.tsx +24 -11
  125. package/src/client/conversations/TranscriptView.tsx +21 -13
  126. package/src/client/conversations/conversationArchive.ts +22 -0
  127. package/src/client/conversations/conversationCatchUpState.ts +96 -0
  128. package/src/client/conversations/conversationMemory.ts +8 -1
  129. package/src/client/conversations/queries.ts +11 -3
  130. package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
  131. package/src/client/conversations/transcriptBottomPinning.ts +27 -3
  132. package/src/client/conversations/transcriptReadPosition.ts +24 -2
  133. package/src/client/conversations/turnContext.ts +1 -1
  134. package/src/client/pages/admin/AdminPage.tsx +1 -1
  135. package/src/client/pages/admin/ServiceAccountsSection.tsx +1 -1
  136. package/src/client/pages/memory/memoryDashboard.ts +1 -1
  137. package/src/client/pages/memory/memoryRecord.ts +1 -1
  138. package/src/client/pages/settings/ConnectionsPage.tsx +1 -1
  139. package/src/client/pages/user/PluginUserPage.tsx +0 -6
  140. package/src/client/pages/user/pluginUserPageRoutes.ts +5 -0
  141. package/src/client/primaryNav.ts +1 -1
  142. package/src/client/review-desk/reviewItemFormat.ts +1 -1
  143. package/src/mock-reporting/fixtures.ts +43 -0
  144. package/src/mock-reporting/routes.ts +40 -3
  145. package/src/public-assets.ts +28 -20
  146. package/src/shell.ts +12 -2
  147. package/dist/public/client-ZCZXZBTL.js +0 -460
@@ -1,6 +1,10 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
 
3
3
  import { cn } from "../styles";
4
+ import {
5
+ ConversationCatchUpButton,
6
+ ConversationCatchUpCard,
7
+ } from "./ConversationCatchUp";
4
8
 
5
9
  /** Keep row padding and the gap between transcript entries in one place. */
6
10
  export function TranscriptRows({
@@ -20,17 +24,26 @@ export function TranscriptRows({
20
24
  */
21
25
  export function TranscriptUnreadMarker() {
22
26
  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" />
27
+ <div className="grid gap-2 py-1" data-transcript-unread-marker="">
28
+ {/* Equal side columns keep "New" centered when the catch-up offer shows. */}
29
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
30
+ <span
31
+ aria-label="New messages"
32
+ className="h-px bg-dashboard-accent/50"
33
+ role="separator"
34
+ />
35
+ <span
36
+ aria-hidden="true"
37
+ className="font-sans text-xs font-semibold text-dashboard-accent-text"
38
+ >
39
+ New
40
+ </span>
41
+ <div className="flex min-w-0 items-center gap-3">
42
+ <span className="h-px flex-1 bg-dashboard-accent/50" />
43
+ <ConversationCatchUpButton />
44
+ </div>
45
+ </div>
46
+ <ConversationCatchUpCard />
34
47
  </div>
35
48
  );
36
49
  }
@@ -12,6 +12,7 @@ import { cn } from "../styles";
12
12
  import { ConversationTranscriptView } from "./ConversationTranscript";
13
13
  import { ConversationEventLog } from "./ConversationEventLog";
14
14
  import {
15
+ TranscriptJumpToLatestContext,
15
16
  transcriptBottomVersion,
16
17
  transcriptJuniorMessageVersion,
17
18
  useAutoLoadHistoryPage,
@@ -23,7 +24,7 @@ import {
23
24
  type TranscriptUnreadRange,
24
25
  } from "./transcriptReadPosition";
25
26
  import { TranscriptSearchProvider } from "./transcriptSearch";
26
- import type { TranscriptHistory } from "./transcriptUnreadHistory";
27
+ import type { TranscriptHistory } from "./transcriptAroundHistory";
27
28
  import {
28
29
  TurnContextPanelProvider,
29
30
  type TurnContextPanel,
@@ -61,6 +62,8 @@ export function Transcript(props: {
61
62
  transcript?: ConversationTranscript;
62
63
  /** Show turn context in the owner's panel. Without it, a drawer opens. */
63
64
  turnContextPanel?: TurnContextPanel;
65
+ /** Open where a link shows its row instead of the latest message. */
66
+ startAtLink?: boolean;
64
67
  /** Open at the "New" marker instead of the latest message. */
65
68
  startAtUnread?: boolean;
66
69
  /** Rows that were unread when the Conversation opened get a marker. */
@@ -79,6 +82,7 @@ export function Transcript(props: {
79
82
  historyVersion: props.historyVersion ?? "empty",
80
83
  juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
81
84
  pinRequestVersion: props.pinRequestVersion,
85
+ startAtLink: props.startAtLink,
82
86
  startAtUnread: view === "rich" && props.startAtUnread,
83
87
  view,
84
88
  versions: {
@@ -114,6 +118,11 @@ export function Transcript(props: {
114
118
  historyVersion: props.historyVersion ?? "empty",
115
119
  onLoad: () => newer?.load(),
116
120
  });
121
+ const jumpToLatest = () => {
122
+ // The latest page is in the detail, so the jump needs no request.
123
+ newer?.showLatest();
124
+ bottomPinning.jumpToBottom();
125
+ };
117
126
 
118
127
  if (!props.transcript) {
119
128
  return (
@@ -162,17 +171,20 @@ export function Transcript(props: {
162
171
  conversation={props.transcript}
163
172
  onSearchChange={props.onSearchChange ?? setLocalSearch}
164
173
  onSelectedSeqChange={props.onSelectedEventSeqChange}
174
+ scrollAnchorKey={bottomPinning.scrollAnchorKey}
165
175
  selectedSeq={props.selectedEventSeq}
166
176
  />
167
177
  ) : (
168
178
  <TurnContextPanelProvider panel={props.turnContextPanel}>
169
- <ConversationTranscriptView
170
- onOpenSubagentTranscript={props.onOpenSubagentTranscript}
171
- conversation={props.transcript}
172
- pendingMessages={props.pendingMessages}
173
- responding={props.responding ?? props.live ?? false}
174
- unread={props.unread}
175
- />
179
+ <TranscriptJumpToLatestContext value={jumpToLatest}>
180
+ <ConversationTranscriptView
181
+ onOpenSubagentTranscript={props.onOpenSubagentTranscript}
182
+ conversation={props.transcript}
183
+ pendingMessages={props.pendingMessages}
184
+ responding={props.responding ?? props.live ?? false}
185
+ unread={props.unread}
186
+ />
187
+ </TranscriptJumpToLatestContext>
176
188
  </TurnContextPanelProvider>
177
189
  )}
178
190
  {newer ? (
@@ -206,11 +218,7 @@ export function Transcript(props: {
206
218
  />
207
219
  <JumpToLatestButton
208
220
  hasPendingUpdate={bottomPinning.hasPendingUpdate}
209
- onClick={() => {
210
- // The latest page is in the detail, so the jump needs no request.
211
- newer?.showLatest();
212
- bottomPinning.jumpToBottom();
213
- }}
221
+ onClick={jumpToLatest}
214
222
  visible={bottomPinning.showJumpToLatest}
215
223
  />
216
224
  </div>
@@ -11,6 +11,7 @@ import type {
11
11
  import { archiveConversationResponseSchema } from "@sentry/junior/api/schema";
12
12
 
13
13
  import { patch } from "../http";
14
+ import type { Conversation } from "../types";
14
15
  import {
15
16
  conversationDetailQueryKey,
16
17
  type ConversationSnapshot,
@@ -82,6 +83,27 @@ export function usePendingArchiveConversationUpdates() {
82
83
  });
83
84
  }
84
85
 
86
+ /**
87
+ * Router state for home after a conversation archives itself. Home reports
88
+ * the background archive result, because the conversation page is gone.
89
+ */
90
+ export type ArchivedFromConversationState = {
91
+ archivedConversation: Conversation;
92
+ };
93
+
94
+ /** Read the newest archive or restore status for one conversation. */
95
+ export function useLatestArchiveStatus(conversationId: string | undefined) {
96
+ const statuses = useMutationState({
97
+ filters: {
98
+ exact: true,
99
+ mutationKey: [...archiveConversationMutationKey, conversationId],
100
+ },
101
+ select: (mutation) => mutation.state.status,
102
+ });
103
+ // The mutation cache keeps insertion order, so the last entry is newest.
104
+ return conversationId ? statuses.at(-1) : undefined;
105
+ }
106
+
85
107
  /** Archive or restore one conversation with an immediate reversible cache update. */
86
108
  export function useArchiveConversation(
87
109
  conversationId: string,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Offer a catch-up when a reader comes back to a lot of unread activity. The
3
+ * reader asks for it, because each summary costs a model call and a short
4
+ * wait.
5
+ */
6
+ import { useMutation } from "@tanstack/react-query";
7
+ import { createContext, useContext } from "react";
8
+
9
+ import {
10
+ conversationCatchUpSchema,
11
+ type ConversationCatchUp,
12
+ type ConversationDetailReport,
13
+ } from "@sentry/junior/api/schema";
14
+ import { post } from "../http";
15
+ import type { TranscriptUnreadRange } from "./transcriptReadPosition";
16
+
17
+ /** Fewer unread Turns than this are quicker to read than a summary. */
18
+ const CATCH_UP_MIN_TURNS = 2;
19
+
20
+ /** The catch-up offer for the unread range of one Conversation visit. */
21
+ export type ConversationCatchUpState = {
22
+ data?: ConversationCatchUp;
23
+ error: boolean;
24
+ pending: boolean;
25
+ request(): void;
26
+ };
27
+
28
+ /**
29
+ * Count the Turns that finished in the unread range. When the range goes past
30
+ * the loaded rows, the reader has at least a page of unread rows.
31
+ */
32
+ function hasEnoughUnread(
33
+ events: ConversationDetailReport["events"],
34
+ unread: TranscriptUnreadRange,
35
+ pages: { hasNewerPage: boolean; hasPreviousPage: boolean },
36
+ ): boolean {
37
+ if (pages.hasNewerPage) return true;
38
+ const first = events[0];
39
+ if (pages.hasPreviousPage && first && first.seq > unread.afterSeq) {
40
+ return true;
41
+ }
42
+ let turns = 0;
43
+ for (const event of events) {
44
+ if (event.seq <= unread.afterSeq || event.seq > unread.throughSeq) continue;
45
+ if (
46
+ event.data.type === "turn_lifecycle" &&
47
+ event.data.state !== "started"
48
+ ) {
49
+ turns += 1;
50
+ }
51
+ }
52
+ return turns >= CATCH_UP_MIN_TURNS;
53
+ }
54
+
55
+ /** Offer a catch-up only when enough is unread. */
56
+ export function useConversationCatchUp(input: {
57
+ conversationId: string;
58
+ events?: ConversationDetailReport["events"];
59
+ hasNewerPage: boolean;
60
+ hasPreviousPage: boolean;
61
+ unread?: TranscriptUnreadRange;
62
+ }): ConversationCatchUpState | undefined {
63
+ const { unread } = input;
64
+ const mutation = useMutation({
65
+ mutationFn: (range: TranscriptUnreadRange) =>
66
+ post(
67
+ conversationCatchUpSchema,
68
+ `/api/conversations/${encodeURIComponent(input.conversationId)}/catch-up`,
69
+ range,
70
+ ),
71
+ });
72
+ // Once asked, keep the answer even if the loaded rows change.
73
+ const asked = !mutation.isIdle;
74
+ if (
75
+ !unread ||
76
+ (!asked && (!input.events || !hasEnoughUnread(input.events, unread, input)))
77
+ ) {
78
+ return undefined;
79
+ }
80
+ return {
81
+ data: mutation.data,
82
+ error: mutation.isError,
83
+ pending: mutation.isPending,
84
+ request: () => mutation.mutate(unread),
85
+ };
86
+ }
87
+
88
+ /** Only the Conversation page sets it, so nested transcripts offer nothing. */
89
+ export const ConversationCatchUpContext = createContext<
90
+ ConversationCatchUpState | undefined
91
+ >(undefined);
92
+
93
+ /** Read the catch-up offer of the current Conversation page. */
94
+ export function useConversationCatchUpState() {
95
+ return useContext(ConversationCatchUpContext);
96
+ }
@@ -1,6 +1,6 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
2
  import { createContext, useContext } from "react";
3
- import { z } from "zod";
3
+ import * as z from "zod";
4
4
 
5
5
  import { fetchDashboardJson } from "../http";
6
6
  import { conversationSideReadQueryKey } from "./queries";
@@ -11,6 +11,7 @@ const conversationMemoryListSchema = z
11
11
  z
12
12
  .object({
13
13
  memoryIds: z.array(z.string().min(1)).min(1),
14
+ seq: z.number().int().min(0),
14
15
  source: z.enum(["captured", "recalled"]),
15
16
  turnId: z.string().min(1).optional(),
16
17
  })
@@ -76,6 +77,8 @@ export function anchorMemories(
76
77
  /** One row of the Conversation memory list. */
77
78
  export type ConversationMemoryEntry = {
78
79
  memory: ConversationMemory;
80
+ /** The recall or capture event, where the transcript opens. */
81
+ seq: number;
79
82
  source: MemorySource;
80
83
  turnId?: string;
81
84
  };
@@ -99,6 +102,7 @@ export function conversationMemoryEntries(
99
102
  seen.add(key);
100
103
  entries.push({
101
104
  memory,
105
+ seq: event.seq,
102
106
  source: event.source,
103
107
  ...(event.turnId ? { turnId: event.turnId } : undefined),
104
108
  });
@@ -110,6 +114,9 @@ export function conversationMemoryEntries(
110
114
  /** The open memory popover, and the memory it highlights. */
111
115
  export type OpenMemoryPopover = MemoryAnchor & { memoryId?: string };
112
116
 
117
+ /** A memory chosen in details, and the event where the transcript opens. */
118
+ export type MemoryJump = OpenMemoryPopover & { seq: number };
119
+
113
120
  /** Shared memory popover state for one Conversation page. */
114
121
  export type ConversationMemoryState = {
115
122
  list?: ConversationMemoryList;
@@ -221,7 +221,9 @@ export function conversationDetailQueryOptions(
221
221
  /**
222
222
  * Read positions only move forward. A poll that started before a read report
223
223
  * finished must not put an older position back in the cache, nor the unread
224
- * cursor of that older position.
224
+ * cursor of that older position. The cache already holds the cursor for the
225
+ * newer position: a read does not change the detail version, so no later poll
226
+ * would bring it back.
225
227
  */
226
228
  function keepLatestReadPosition(
227
229
  previous: ConversationSnapshot,
@@ -230,8 +232,14 @@ function keepLatestReadPosition(
230
232
  const kept = previous.readPosition;
231
233
  if (!kept) return next;
232
234
  if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
233
- const { unreadCursor: _olderCursor, ...rest } = next;
234
- return { ...rest, readPosition: kept };
235
+ const { unreadCursor: _olderCursor, unreadPage: _olderPage, ...rest } = next;
236
+ return {
237
+ ...rest,
238
+ readPosition: kept,
239
+ ...(previous.unreadCursor
240
+ ? { unreadCursor: previous.unreadCursor, unreadPage: previous.unreadPage }
241
+ : undefined),
242
+ };
235
243
  }
236
244
 
237
245
  /** Create one dashboard conversation and refresh the personal feed. */
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Open a long Conversation at the reader's read position.
2
+ * Open a long Conversation away from its latest events: at the reader's read
3
+ * position, or at a linked event, Turn, or memory.
3
4
  *
4
5
  * The detail holds the latest page of events. When the read position is
5
6
  * older than that page, the transcript starts with the page around the read
6
7
  * position instead: the last read events and the unread events after them.
7
8
  * The first detail fetch of a visit brings that page, so it needs no second
8
- * request.
9
+ * request. A link to a row that the transcript does not hold starts it with
10
+ * the page around that row, which the server finds in one request.
9
11
  * Like earlier pages, newer pages load when the reader scrolls near them, so
10
12
  * the server reads only the pages that the reader reaches.
11
13
  *
@@ -54,23 +56,33 @@ export type TranscriptHistory = {
54
56
  };
55
57
 
56
58
  /**
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.
59
+ * The row that the transcript opens at: the `unreadCursor` of the read
60
+ * position, or a linked event seq or Turn id.
60
61
  */
61
- export function useUnreadHistory(input: {
62
+ export type HistoryStart =
63
+ | { around: string }
64
+ | { event: string }
65
+ | { turn: string };
66
+
67
+ /**
68
+ * Load the transcript from the page around `start`. Returns `undefined` when
69
+ * there is no start, or when the first page fails, so the caller shows the
70
+ * latest page instead.
71
+ */
72
+ export function useHistoryAround(input: {
62
73
  conversationId: string;
63
74
  /** The latest page, from the polled detail. */
64
75
  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`. */
76
+ start?: HistoryStart;
77
+ /** Skip the newer pages. The caller then passes no `start`. */
68
78
  onShowLatest: () => void;
69
- /** The page around `unreadCursor`, when the detail brought it. */
70
- unreadPage?: ConversationEventPage;
79
+ /** The page around `start` failed. */
80
+ onStartFailed?: () => void;
81
+ /** The page around `start`, when the detail brought it. */
82
+ startPage?: ConversationEventPage;
71
83
  }): (TranscriptHistory & { isPending: boolean }) | undefined {
72
- const { conversationId, detail, onShowLatest, unreadCursor, unreadPage } =
73
- input;
84
+ const { conversationId, detail, onShowLatest, onStartFailed, start } = input;
85
+ const startKey = start ? Object.entries(start)[0]?.join(":") : undefined;
74
86
  const latestEventsRef = useRef<ConversationReportEvent[]>([]);
75
87
  const latestEvents = useMemo(() => {
76
88
  const kept = keepLatestEvents(latestEventsRef.current, detail?.events);
@@ -78,28 +90,24 @@ export function useUnreadHistory(input: {
78
90
  return kept;
79
91
  }, [detail?.events]);
80
92
  const latestFirstSeq = latestEvents[0]?.seq;
81
- const joinedRef = useRef(false);
93
+ // The start whose newer pages joined the latest page.
94
+ const joinedRef = useRef<string>(undefined);
82
95
  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),
96
+ // Only the page around the start loads by itself. The data never goes
97
+ // stale, so a refetch cannot reload every newer page at once.
98
+ enabled: Boolean(start),
86
99
  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.
100
+ // Each visit starts again from the page around the start. Pages from an
101
+ // earlier visit can end at an old latest event, which would leave a gap
102
+ // before the latest page.
90
103
  gcTime: 0,
91
- queryKey: ["conversation", conversationId, "unread", unreadCursor, "newer"],
104
+ queryKey: ["conversation", conversationId, "around", startKey, "newer"],
92
105
  queryFn: ({ pageParam, signal }) =>
93
106
  readConversationEventsFrom(conversationId, pageParam, signal),
94
- initialPageParam: { around: unreadCursor ?? "" } as NewerPageParam,
107
+ initialPageParam: (start ?? { around: "" }) as NewerPageParam,
95
108
  // 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
- }
109
+ ...(input.startPage && start
110
+ ? { initialData: { pageParams: [start], pages: [input.startPage] } }
103
111
  : undefined),
104
112
  getNextPageParam: (page): NewerPageParam | undefined =>
105
113
  reachesLatest(page, latestFirstSeq) || !page.nextCursor
@@ -110,13 +118,7 @@ export function useUnreadHistory(input: {
110
118
  const firstNewerPage = newer.data?.pages[0];
111
119
  const earlier = useInfiniteQuery({
112
120
  enabled: false,
113
- queryKey: [
114
- "conversation",
115
- conversationId,
116
- "unread",
117
- unreadCursor,
118
- "earlier",
119
- ],
121
+ queryKey: ["conversation", conversationId, "around", startKey, "earlier"],
120
122
  queryFn: ({ pageParam, signal }) =>
121
123
  readConversationEvents(conversationId, pageParam, signal),
122
124
  initialPageParam: firstNewerPage?.previousCursor ?? "",
@@ -130,16 +132,20 @@ export function useUnreadHistory(input: {
130
132
  const reached = lastNewerPage
131
133
  ? reachesLatest(lastNewerPage, latestFirstSeq)
132
134
  : false;
133
- if (reached) joinedRef.current = true;
135
+ if (reached) joinedRef.current = startKey;
134
136
  // No newer page can fill the gap before the latest page, or the latest
135
137
  // events already on screen would leave until one does.
136
138
  const skipped =
137
139
  lastNewerPage !== undefined &&
138
140
  !reached &&
139
- (joinedRef.current || !lastNewerPage.nextCursor);
141
+ (joinedRef.current === startKey || !lastNewerPage.nextCursor);
140
142
  useEffect(() => {
141
143
  if (skipped) onShowLatest();
142
144
  }, [onShowLatest, skipped]);
145
+ const startFailed = Boolean(start && newer.error && !newerPages);
146
+ useEffect(() => {
147
+ if (startFailed) onStartFailed?.();
148
+ }, [onStartFailed, startFailed]);
143
149
  const pages = useMemo(
144
150
  () => [...(earlierPages ?? []), ...(newerPages ?? [])],
145
151
  [earlierPages, newerPages],
@@ -156,7 +162,7 @@ export function useUnreadHistory(input: {
156
162
  [detail, latestEvents, newerPages, pages, reached],
157
163
  );
158
164
 
159
- if (!unreadCursor || skipped || (newer.error && !newerPages)) {
165
+ if (!start || skipped || startFailed) {
160
166
  return undefined;
161
167
  }
162
168
 
@@ -225,10 +231,10 @@ function reachesLatest(
225
231
  return lastSeq + 1 >= latestFirstSeq;
226
232
  }
227
233
 
228
- /** The first page is around the read position. The rest follow it. */
229
- type NewerPageParam = { around: string } | { after: string };
234
+ /** The first page is around the start. The rest follow it. */
235
+ type NewerPageParam = HistoryStart | { after: string };
230
236
 
231
- /** Read one bounded page of events around or after a history cursor. */
237
+ /** Read one bounded page of events around a start or after a cursor. */
232
238
  function readConversationEventsFrom(
233
239
  conversationId: string,
234
240
  param: NewerPageParam,
@@ -1,4 +1,5 @@
1
1
  import {
2
+ createContext,
2
3
  useCallback,
3
4
  useEffect,
4
5
  useLayoutEffect,
@@ -52,6 +53,8 @@ type BottomPinResult = {
52
53
  contentRef: RefCallback<HTMLDivElement>;
53
54
  hasPendingUpdate: boolean;
54
55
  jumpToBottom: () => void;
56
+ /** The `data-scroll-anchor` key of the row that holds the reader's place. */
57
+ scrollAnchorKey: () => string | undefined;
55
58
  showJumpToLatest: boolean;
56
59
  };
57
60
 
@@ -319,6 +322,8 @@ export function usePinnedTranscriptBottom(input: {
319
322
  historyVersion: string;
320
323
  juniorMessageVersion: string;
321
324
  pinRequestVersion?: number;
325
+ /** Open where a link shows its row, not at the latest message. */
326
+ startAtLink?: boolean;
322
327
  /** Open at the "New" marker when the transcript has one. */
323
328
  startAtUnread?: boolean;
324
329
  view: TranscriptViewMode;
@@ -331,7 +336,9 @@ export function usePinnedTranscriptBottom(input: {
331
336
  endsAtLatestRef.current = input.endsAtLatest ?? true;
332
337
  const followingRef = useRef(false);
333
338
  const initializedRef = useRef(false);
334
- const initializedConversationRef = useRef<string | null>(null);
339
+ // The Conversation and start that the transcript opened with. A link at
340
+ // open that fails becomes a normal start, so the transcript opens again.
341
+ const startedRef = useRef<string | null>(null);
335
342
  const previousScrollTopRef = useRef<number | null>(null);
336
343
  // The scroll position that the last restore set. Its scroll event is not
337
344
  // a reader scroll, so it must not change follow intent.
@@ -546,13 +553,20 @@ export function usePinnedTranscriptBottom(input: {
546
553
 
547
554
  useBrowserLayoutEffect(() => {
548
555
  if (!contentElement || !input.conversationId) return;
549
- if (initializedConversationRef.current === input.conversationId) return;
556
+ const start = `${input.conversationId}:${input.startAtLink ? "link" : "start"}`;
557
+ if (startedRef.current === start) return;
550
558
 
551
559
  const root = scrollRootFor(contentElement);
552
560
  if (!root) return;
553
561
 
554
- initializedConversationRef.current = input.conversationId;
562
+ startedRef.current = start;
555
563
  setHasPendingUpdate(false);
564
+ // The link scrolls to its row, maybe before this runs. Do not move away.
565
+ if (input.startAtLink) {
566
+ previousScrollTopRef.current = scrollSnapshot(root).scrollTop;
567
+ setFollowingIntent(false);
568
+ return;
569
+ }
556
570
  if (input.startAtUnread && openAtUnreadMarker()) return;
557
571
 
558
572
  setScrollTop(root, scrollSnapshot(root).scrollHeight);
@@ -565,6 +579,7 @@ export function usePinnedTranscriptBottom(input: {
565
579
  }, [
566
580
  contentElement,
567
581
  input.conversationId,
582
+ input.startAtLink,
568
583
  input.startAtUnread,
569
584
  openAtUnreadMarker,
570
585
  setFollowingIntent,
@@ -761,6 +776,8 @@ export function usePinnedTranscriptBottom(input: {
761
776
  scrollToBottom("auto");
762
777
  }, [input.pinRequestVersion, scrollToBottom, setFollowingIntent]);
763
778
 
779
+ const scrollAnchorKey = useCallback(() => scrollAnchorRef.current?.key, []);
780
+
764
781
  return useMemo(
765
782
  () => ({
766
783
  anchorRef,
@@ -768,6 +785,7 @@ export function usePinnedTranscriptBottom(input: {
768
785
  contentRef,
769
786
  hasPendingUpdate,
770
787
  jumpToBottom,
788
+ scrollAnchorKey,
771
789
  showJumpToLatest: shouldShowJumpToLatest({
772
790
  enabled: input.enabled,
773
791
  endsAtLatest: input.endsAtLatest,
@@ -785,10 +803,16 @@ export function usePinnedTranscriptBottom(input: {
785
803
  input.endsAtLatest,
786
804
  jumpToBottom,
787
805
  openedAtUnread,
806
+ scrollAnchorKey,
788
807
  ],
789
808
  );
790
809
  }
791
810
 
811
+ /** Jump to the latest row from inside the transcript, like the floating pill. */
812
+ export const TranscriptJumpToLatestContext = createContext<
813
+ (() => void) | undefined
814
+ >(undefined);
815
+
792
816
  /**
793
817
  * Load the next history page when the reader scrolls near the edge where it
794
818
  * goes: the top for earlier pages, the bottom for newer pages.
@@ -4,7 +4,8 @@
4
4
  * Rows from stored events carry `data-transcript-seq`. While the page is
5
5
  * visible, the reporter sends the sequence of the last row that has reached
6
6
  * the viewport. The server keeps the highest value, so every client of the
7
- * same user shares one position. Detail polls bring it back to other clients.
7
+ * same user shares one position. A write does not change the detail version,
8
+ * so other clients get it with the next detail change or a reload.
8
9
  *
9
10
  * The page reads the position once when a Conversation opens. That value
10
11
  * places the "New" marker and, by the reader's preference, where the
@@ -139,6 +140,27 @@ function readPositionAtOpen(
139
140
  return { lastReadSeq, unread: { afterSeq: lastReadSeq, throughSeq } };
140
141
  }
141
142
 
143
+ /**
144
+ * Put a stored read position in the cached detail. Polls answer 304 after a
145
+ * read, so the cache must also drop the unread cursor once the reader reaches
146
+ * the latest page, as the server would. Otherwise the next visit in this
147
+ * session opens at rows the reader has already read.
148
+ */
149
+ function withReadPosition(
150
+ detail: ConversationDetailReport,
151
+ readPosition: ConversationReadPosition,
152
+ ): ConversationDetailReport {
153
+ const firstSeq = detail.events[0]?.seq;
154
+ if (
155
+ !detail.unreadCursor ||
156
+ (firstSeq !== undefined && readPosition.lastReadSeq + 1 < firstSeq)
157
+ ) {
158
+ return { ...detail, readPosition };
159
+ }
160
+ const { unreadCursor: _cursor, unreadPage: _page, ...rest } = detail;
161
+ return { ...rest, readPosition };
162
+ }
163
+
142
164
  /**
143
165
  * Send read positions for one Conversation. A position at or before the one
144
166
  * in the cached detail is not sent, because the server already has it. The
@@ -157,7 +179,7 @@ export function useMarkConversationRead(conversationId: string) {
157
179
  onSuccess: (readPosition) => {
158
180
  queryClient.setQueryData<ConversationDetailReport>(
159
181
  conversationDetailQueryKey(conversationId),
160
- (detail) => (detail ? { ...detail, readPosition } : detail),
182
+ (detail) => detail && withReadPosition(detail, readPosition),
161
183
  );
162
184
  },
163
185
  });
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import * as z from "zod";
2
2
 
3
3
  import type { TranscriptViewTurnContext } from "../types";
4
4
 
@@ -5,7 +5,7 @@ import type { PluginUserPageLink } from "@sentry/junior-plugin-api";
5
5
  import { Card } from "../../components/layout/Card";
6
6
  import { PageHeader } from "../../components/layout/PageHeader";
7
7
  import { dashboardContainerClass } from "../../styles";
8
- import { pluginUserPagePath } from "../user/PluginUserPage";
8
+ import { pluginUserPagePath } from "../user/pluginUserPageRoutes";
9
9
  import {
10
10
  ServiceAccountsSection,
11
11
  useServiceAccounts,
@@ -5,7 +5,7 @@ import {
5
5
  type UseQueryResult,
6
6
  } from "@tanstack/react-query";
7
7
  import { KeyRound } from "lucide-react";
8
- import type { z } from "zod";
8
+ import type * as z from "zod";
9
9
  import {
10
10
  disconnectServiceAccountResponseSchema,
11
11
  serviceAccountListSchema,