@sentry/junior-dashboard 0.279.0 → 0.281.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 (164) hide show
  1. package/dist/app.js +105 -43
  2. package/dist/client-manifest.json +1 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +105 -43
  5. package/dist/public/chunks/AdminPage-M5RBVZFN.js +1 -0
  6. package/dist/public/chunks/AutomationPage-WALCREDT.js +3 -0
  7. package/dist/public/chunks/AutomationRunsPage-CV47E4UX.js +1 -0
  8. package/dist/public/chunks/AutomationsPage-57L3AQVJ.js +1 -0
  9. package/dist/public/chunks/CodePage-XJUYMFJH.js +1 -0
  10. package/dist/public/chunks/ConnectionsPage-3QQAGYWF.js +1 -0
  11. package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
  12. package/dist/public/chunks/LocationDetailPage-4IL5HQML.js +1 -0
  13. package/dist/public/chunks/LocationsPage-D2DUQAHJ.js +1 -0
  14. package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
  15. package/dist/public/chunks/PeoplePage-6F5GPQ7C.js +1 -0
  16. package/dist/public/chunks/PersonProfilePage-SPAYQPTD.js +1 -0
  17. package/dist/public/chunks/PersonalTokensPage-SHAUJ74M.js +1 -0
  18. package/dist/public/chunks/PluginUserPage-64FIWD7M.js +1 -0
  19. package/dist/public/chunks/RepositoryPage-L7IHOXSN.js +1 -0
  20. package/dist/public/chunks/SettingsPage-U7WG4FHE.js +1 -0
  21. package/dist/public/chunks/SystemPage-ZBC43OJH.js +1 -0
  22. package/dist/public/chunks/WorkspaceFormPage-3NVIIZ4P.js +2 -0
  23. package/dist/public/chunks/WorkspacesPage-G73HPBDD.js +1 -0
  24. package/dist/public/chunks/chunk-2BCS5AQL.js +1 -0
  25. package/dist/public/chunks/chunk-2J3LGF5Q.js +1 -0
  26. package/dist/public/chunks/chunk-2NNKHSZE.js +1 -0
  27. package/dist/public/chunks/chunk-3Y3ICT6K.js +1 -0
  28. package/dist/public/chunks/chunk-4AKUZW6R.js +1 -0
  29. package/dist/public/chunks/chunk-4TQSTCIN.js +13 -0
  30. package/dist/public/chunks/chunk-4UGZNVJM.js +1 -0
  31. package/dist/public/chunks/chunk-564V7YHG.js +1 -0
  32. package/dist/public/chunks/chunk-5ISD3QSC.js +1 -0
  33. package/dist/public/chunks/chunk-5LNGNJ3P.js +1 -0
  34. package/dist/public/chunks/chunk-5ZA674OU.js +1 -0
  35. package/dist/public/chunks/chunk-64GPEGGE.js +1 -0
  36. package/dist/public/chunks/chunk-6FHY65EQ.js +1 -0
  37. package/dist/public/chunks/chunk-6FSJ3W2W.js +1 -0
  38. package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
  39. package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -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-DEEO4D4N.js +1 -0
  44. package/dist/public/chunks/chunk-DZB4LPI6.js +1 -0
  45. package/dist/public/chunks/chunk-FE522UEA.js +1 -0
  46. package/dist/public/chunks/chunk-FSGVY7HB.js +1 -0
  47. package/dist/public/chunks/chunk-FVX42XAD.js +1 -0
  48. package/dist/public/chunks/chunk-G5NWYJTV.js +1 -0
  49. package/dist/public/chunks/chunk-GMZIELFU.js +1 -0
  50. package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
  51. package/dist/public/chunks/chunk-GYRM5ZIV.js +1 -0
  52. package/dist/public/chunks/chunk-GZBMHYYU.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-II4NXLSQ.js +2 -0
  57. package/dist/public/chunks/chunk-ILH47V7O.js +1 -0
  58. package/dist/public/chunks/chunk-IZWTFLCC.js +1 -0
  59. package/dist/public/chunks/chunk-J37MXUED.js +1 -0
  60. package/dist/public/chunks/chunk-JAZABOBA.js +0 -0
  61. package/dist/public/chunks/chunk-JPL2NRWK.js +1 -0
  62. package/dist/public/chunks/chunk-JVJ5UWIK.js +1 -0
  63. package/dist/public/chunks/chunk-K4TYMAA3.js +63 -0
  64. package/dist/public/chunks/chunk-KDUTK357.js +1 -0
  65. package/dist/public/chunks/chunk-L2H3ZWZN.js +1 -0
  66. package/dist/public/chunks/chunk-LBELRO5P.js +1 -0
  67. package/dist/public/chunks/chunk-LKW7MULF.js +1 -0
  68. package/dist/public/chunks/chunk-LZYAHJVQ.js +1 -0
  69. package/dist/public/chunks/chunk-MEHLQZDG.js +3 -0
  70. package/dist/public/chunks/chunk-MKKFXRYO.js +1 -0
  71. package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
  72. package/dist/public/chunks/chunk-NGQRNS3C.js +1 -0
  73. package/dist/public/chunks/chunk-ONDKE72P.js +1 -0
  74. package/dist/public/chunks/chunk-PG7M7L24.js +1 -0
  75. package/dist/public/chunks/chunk-PQPAPPEI.js +1 -0
  76. package/dist/public/chunks/chunk-PXHLJIOT.js +1 -0
  77. package/dist/public/chunks/chunk-QOX5RMLM.js +1 -0
  78. package/dist/public/chunks/chunk-QPBLZSNX.js +1 -0
  79. package/dist/public/chunks/chunk-QQUFROWT.js +1 -0
  80. package/dist/public/chunks/chunk-RB5FBDEY.js +1 -0
  81. package/dist/public/chunks/chunk-RGXXD4DM.js +41 -0
  82. package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
  83. package/dist/public/chunks/chunk-SIOW7OOW.js +1 -0
  84. package/dist/public/chunks/chunk-SUX4QBVF.js +1 -0
  85. package/dist/public/chunks/chunk-T4M53QQT.js +1 -0
  86. package/dist/public/chunks/chunk-TC7TU4GK.js +1 -0
  87. package/dist/public/chunks/chunk-TFJOEF3Q.js +1 -0
  88. package/dist/public/chunks/chunk-UJD3MBMB.js +1 -0
  89. package/dist/public/chunks/chunk-UK2V4UBT.js +1 -0
  90. package/dist/public/chunks/chunk-UT6HZPGL.js +1 -0
  91. package/dist/public/chunks/chunk-UZ44J2LH.js +1 -0
  92. package/dist/public/chunks/chunk-VC6IS564.js +1 -0
  93. package/dist/public/chunks/chunk-WLIOZO3W.js +1 -0
  94. package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
  95. package/dist/public/chunks/chunk-XDUSY26N.js +55 -0
  96. package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
  97. package/dist/public/chunks/chunk-YPMUHXYT.js +147 -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-4OAAOHF4.js +68 -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/BottomSheet.tsx +1 -1
  110. package/src/client/components/DetailsPanel.tsx +2 -1
  111. package/src/client/components/Drawer.tsx +12 -71
  112. package/src/client/components/DrawerContent.tsx +76 -0
  113. package/src/client/components/ProfileMenu.tsx +12 -3
  114. package/src/client/components/UserInputForm.tsx +28 -5
  115. package/src/client/conversations/ConversationBrief.tsx +129 -65
  116. package/src/client/conversations/ConversationCatchUp.tsx +107 -0
  117. package/src/client/conversations/ConversationContextTags.tsx +110 -0
  118. package/src/client/conversations/ConversationDetails.tsx +13 -14
  119. package/src/client/conversations/ConversationEventLog.tsx +135 -70
  120. package/src/client/conversations/ConversationHeader.tsx +74 -65
  121. package/src/client/conversations/ConversationHeaderActions.tsx +36 -38
  122. package/src/client/conversations/ConversationHomeList.tsx +41 -3
  123. package/src/client/conversations/ConversationLinkedWork.tsx +30 -10
  124. package/src/client/conversations/ConversationMeta.tsx +21 -27
  125. package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
  126. package/src/client/conversations/ConversationPage.tsx +219 -157
  127. package/src/client/conversations/ConversationTranscript.tsx +234 -186
  128. package/src/client/conversations/ConversationUsageBreakdown.tsx +142 -64
  129. package/src/client/conversations/DetailsList.tsx +7 -1
  130. package/src/client/conversations/PendingMailboxStack.tsx +88 -44
  131. package/src/client/conversations/ShareConversationButton.tsx +5 -50
  132. package/src/client/conversations/SlackMark.tsx +16 -4
  133. package/src/client/conversations/TranscriptActivityGroup.tsx +7 -14
  134. package/src/client/conversations/TranscriptEventView.tsx +7 -3
  135. package/src/client/conversations/TranscriptReasoningView.tsx +4 -2
  136. package/src/client/conversations/TranscriptRows.tsx +61 -16
  137. package/src/client/conversations/TranscriptView.tsx +25 -13
  138. package/src/client/conversations/conversationArchive.ts +22 -0
  139. package/src/client/conversations/conversationCatchUpState.ts +96 -0
  140. package/src/client/conversations/conversationMemory.ts +8 -1
  141. package/src/client/conversations/queries.ts +85 -46
  142. package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
  143. package/src/client/conversations/transcriptBottomPinning.ts +40 -8
  144. package/src/client/conversations/transcriptListRows.ts +215 -0
  145. package/src/client/conversations/transcriptReadPosition.ts +24 -2
  146. package/src/client/conversations/turnContext.ts +1 -1
  147. package/src/client/conversations/virtualRows.ts +168 -0
  148. package/src/client/http.ts +6 -5
  149. package/src/client/pages/admin/AdminPage.tsx +1 -1
  150. package/src/client/pages/admin/ServiceAccountsSection.tsx +1 -1
  151. package/src/client/pages/memory/memoryDashboard.ts +1 -1
  152. package/src/client/pages/memory/memoryRecord.ts +1 -1
  153. package/src/client/pages/settings/ConnectionsPage.tsx +1 -1
  154. package/src/client/pages/user/PluginUserPage.tsx +0 -6
  155. package/src/client/pages/user/pluginUserPageRoutes.ts +5 -0
  156. package/src/client/primaryNav.ts +1 -1
  157. package/src/client/review-desk/reviewItemFormat.ts +1 -1
  158. package/src/client/sentry.ts +53 -3
  159. package/src/conversation-cache.ts +8 -10
  160. package/src/mock-reporting/fixtures.ts +43 -8
  161. package/src/mock-reporting/routes.ts +40 -3
  162. package/src/public-assets.ts +28 -20
  163. package/src/shell.ts +12 -2
  164. package/dist/public/client-ZCZXZBTL.js +0 -460
@@ -1,4 +1,4 @@
1
- import { Fragment, useState, type ReactNode } from "react";
1
+ import { Fragment, type ReactNode } from "react";
2
2
  import { ChevronRight } from "lucide-react";
3
3
 
4
4
  import { ShimmerText } from "../components/ShimmerText";
@@ -15,6 +15,7 @@ import type { RenderedTranscriptEntry } from "./transcriptRenderModel";
15
15
  import { cn } from "../styles";
16
16
  import { useTranscriptSearch } from "./transcriptSearch";
17
17
  import { TranscriptActivityNesting } from "./TranscriptRailEvent";
18
+ import { useTranscriptRowOpen } from "./TranscriptRows";
18
19
  import { TranscriptSummaryTrigger } from "./TranscriptSummary";
19
20
 
20
21
  function countLabel(count: number, singular: string, plural: string): string {
@@ -175,18 +176,16 @@ function hasLiveActivity(entries: RenderedTranscriptEntry[]): boolean {
175
176
  );
176
177
  }
177
178
 
178
- /** Keep activity collapsed until the reader explicitly opens it. */
179
- export function activityGroupOpen(userOpen: boolean | null): boolean {
180
- return userOpen ?? false;
181
- }
182
-
183
179
  /** Collapse completed non-message activity so chat messages stay primary. */
184
180
  export function TranscriptActivityGroup(props: {
185
181
  entries: RenderedTranscriptEntry[];
182
+ /** Keeps the group open while it unmounts outside the viewport. */
183
+ openKey?: string;
186
184
  renderEntry: (entry: RenderedTranscriptEntry) => ReactNode;
187
185
  }) {
188
186
  const { active: searchActive } = useTranscriptSearch();
189
- const [userOpen, setUserOpen] = useState<boolean | null>(null);
187
+ // Activity stays collapsed until the reader opens it.
188
+ const [open, setOpen] = useTranscriptRowOpen(props.openKey);
190
189
  const rows = props.entries.map((entry) => (
191
190
  <Fragment key={entry.key}>{props.renderEntry(entry)}</Fragment>
192
191
  ));
@@ -202,14 +201,8 @@ export function TranscriptActivityGroup(props: {
202
201
  );
203
202
  }
204
203
 
205
- const open = activityGroupOpen(userOpen);
206
-
207
204
  return (
208
- <Collapsible
209
- className="min-w-0 py-0.5"
210
- onOpenChange={setUserOpen}
211
- open={open}
212
- >
205
+ <Collapsible className="min-w-0 py-0.5" onOpenChange={setOpen} open={open}>
213
206
  <TranscriptSummaryTrigger
214
207
  flush
215
208
  className={cn(
@@ -1,5 +1,4 @@
1
1
  import { ChevronRight } from "lucide-react";
2
- import { useState } from "react";
3
2
 
4
3
  import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
5
4
  import { cn, dashboardDisclosureChevronClass } from "../styles";
@@ -9,6 +8,7 @@ import { messageRawText } from "./transcriptRenderModel";
9
8
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
10
9
  import { RedactedMarker } from "./TranscriptRedacted";
11
10
  import type { TranscriptViewMessage } from "../types";
11
+ import { useTranscriptRowOpen } from "./TranscriptRows";
12
12
  import { TranscriptSummaryTrigger } from "./TranscriptSummary";
13
13
 
14
14
  function EventTypeChip(props: { eventType: string }) {
@@ -28,9 +28,13 @@ function batchSummary(events: readonly { label?: string }[]): string {
28
28
  }
29
29
 
30
30
  /** Render an Event Message whose heading expands its full text. */
31
- export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
31
+ export function TranscriptEventView(props: {
32
+ message: TranscriptViewMessage;
33
+ /** Keeps the text open while the row unmounts outside the viewport. */
34
+ openKey?: string;
35
+ }) {
32
36
  const { active: searchActive } = useTranscriptSearch();
33
- const [open, setOpen] = useState(false);
37
+ const [open, setOpen] = useTranscriptRowOpen(props.openKey);
34
38
  const text = messageRawText(props.message);
35
39
  const redacted = props.message.parts.some(
36
40
  (part) => part.type === "text" && part.redacted,
@@ -1,4 +1,3 @@
1
- import { useState } from "react";
2
1
  import { Lightbulb } from "lucide-react";
3
2
 
4
3
  import {
@@ -13,15 +12,18 @@ import {
13
12
  TranscriptHeadingMeta,
14
13
  TranscriptHeadingRow,
15
14
  } from "./TranscriptHeadingRow";
15
+ import { useTranscriptRowOpen } from "./TranscriptRows";
16
16
  import { useTranscriptSearch } from "./transcriptSearch";
17
17
 
18
18
  /** Render reasoning collapsed with a short preview until expanded or searched. */
19
19
  export function TranscriptReasoningView(props: {
20
+ /** Keeps the entry open while it unmounts outside the viewport. */
21
+ openKey?: string;
20
22
  part: TranscriptViewReasoningPart;
21
23
  timestamp?: number;
22
24
  }) {
23
25
  const { active: searchActive } = useTranscriptSearch();
24
- const [open, setOpen] = useState(false);
26
+ const [open, setOpen] = useTranscriptRowOpen(props.openKey);
25
27
  const expanded = searchActive || open;
26
28
  const rendered = props.part.redacted ? "Reasoning redacted" : props.part.text;
27
29
  const timestamp = formatMessageTimestamp(props.timestamp);
@@ -1,6 +1,16 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ type ComponentPropsWithoutRef,
6
+ type ReactNode,
7
+ } from "react";
2
8
 
3
9
  import { cn } from "../styles";
10
+ import {
11
+ ConversationCatchUpButton,
12
+ ConversationCatchUpCard,
13
+ } from "./ConversationCatchUp";
4
14
 
5
15
  /** Keep row padding and the gap between transcript entries in one place. */
6
16
  export function TranscriptRows({
@@ -20,28 +30,63 @@ export function TranscriptRows({
20
30
  */
21
31
  export function TranscriptUnreadMarker() {
22
32
  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" />
33
+ <div className="grid gap-2 py-1" data-transcript-unread-marker="">
34
+ {/* Equal side columns keep "New" centered when the catch-up offer shows. */}
35
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
36
+ <span
37
+ aria-label="New messages"
38
+ className="h-px bg-dashboard-accent/50"
39
+ role="separator"
40
+ />
41
+ <span
42
+ aria-hidden="true"
43
+ className="font-sans text-xs font-semibold text-dashboard-accent-text"
44
+ >
45
+ New
46
+ </span>
47
+ <div className="flex min-w-0 items-center gap-3">
48
+ <span className="h-px flex-1 bg-dashboard-accent/50" />
49
+ <ConversationCatchUpButton />
50
+ </div>
51
+ </div>
52
+ <ConversationCatchUpCard />
34
53
  </div>
35
54
  );
36
55
  }
37
56
 
57
+ /**
58
+ * Open rows by key, for the rows of one transcript list. Rows far from the
59
+ * viewport unmount, so a row that opens again reads its state here.
60
+ */
61
+ export const TranscriptOpenRows = createContext<Map<string, boolean> | null>(
62
+ null,
63
+ );
64
+
65
+ /**
66
+ * Hold whether a transcript row part is open. With a key, the state stays
67
+ * while the row unmounts outside the viewport.
68
+ */
69
+ export function useTranscriptRowOpen(
70
+ key: string | undefined,
71
+ ): [boolean, (open: boolean) => void] {
72
+ const openRows = useContext(TranscriptOpenRows);
73
+ const [open, setOpenState] = useState(
74
+ () => (key !== undefined && openRows?.get(key)) || false,
75
+ );
76
+ const setOpen = (value: boolean) => {
77
+ setOpenState(value);
78
+ if (key !== undefined) openRows?.set(key, value);
79
+ };
80
+ return [open, setOpen];
81
+ }
82
+
38
83
  /**
39
84
  * Lay out one transcript row at its real size.
40
85
  *
41
- * Rows do not use `content-visibility: auto`. A skipped row has an estimated
42
- * size, and it changes size when the reader scrolls near it. Above the
43
- * reader, each change needs a scroll correction while they scroll, and on
44
- * phones those corrections make the transcript jump.
86
+ * Rows do not use `content-visibility: auto`. The list mounts only the rows
87
+ * near the viewport, and a row that mounts above the reader can measure a
88
+ * different size than its estimate. The scroll owner corrects the scroll
89
+ * position for that, so keep estimates close and overscan large.
45
90
  */
46
91
  export function TranscriptRow(props: {
47
92
  /** Stable row id that holds the reader's place while layout changes. */
@@ -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,
@@ -51,6 +52,8 @@ export function Transcript(props: {
51
52
  /** The reader's own sends to show at the end until history has them. */
52
53
  pendingMessages?: readonly ConversationMailboxMessage[];
53
54
  responding?: boolean;
55
+ /** Keep the rows around this event seq mounted, so the page can show it. */
56
+ revealSeq?: number;
54
57
  search?: string;
55
58
  onSearchChange?(value: string): void;
56
59
  selectedEventSeq?: number;
@@ -61,6 +64,8 @@ export function Transcript(props: {
61
64
  transcript?: ConversationTranscript;
62
65
  /** Show turn context in the owner's panel. Without it, a drawer opens. */
63
66
  turnContextPanel?: TurnContextPanel;
67
+ /** Open where a link shows its row instead of the latest message. */
68
+ startAtLink?: boolean;
64
69
  /** Open at the "New" marker instead of the latest message. */
65
70
  startAtUnread?: boolean;
66
71
  /** Rows that were unread when the Conversation opened get a marker. */
@@ -79,6 +84,7 @@ export function Transcript(props: {
79
84
  historyVersion: props.historyVersion ?? "empty",
80
85
  juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
81
86
  pinRequestVersion: props.pinRequestVersion,
87
+ startAtLink: props.startAtLink,
82
88
  startAtUnread: view === "rich" && props.startAtUnread,
83
89
  view,
84
90
  versions: {
@@ -114,6 +120,11 @@ export function Transcript(props: {
114
120
  historyVersion: props.historyVersion ?? "empty",
115
121
  onLoad: () => newer?.load(),
116
122
  });
123
+ const jumpToLatest = () => {
124
+ // The latest page is in the detail, so the jump needs no request.
125
+ newer?.showLatest();
126
+ bottomPinning.jumpToBottom();
127
+ };
117
128
 
118
129
  if (!props.transcript) {
119
130
  return (
@@ -162,17 +173,22 @@ export function Transcript(props: {
162
173
  conversation={props.transcript}
163
174
  onSearchChange={props.onSearchChange ?? setLocalSearch}
164
175
  onSelectedSeqChange={props.onSelectedEventSeqChange}
176
+ scrollAnchorKey={bottomPinning.scrollAnchorKey}
165
177
  selectedSeq={props.selectedEventSeq}
166
178
  />
167
179
  ) : (
168
180
  <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
- />
181
+ <TranscriptJumpToLatestContext value={jumpToLatest}>
182
+ <ConversationTranscriptView
183
+ onOpenSubagentTranscript={props.onOpenSubagentTranscript}
184
+ conversation={props.transcript}
185
+ pendingMessages={props.pendingMessages}
186
+ responding={props.responding ?? props.live ?? false}
187
+ revealSeq={props.revealSeq}
188
+ scrollAnchorKey={bottomPinning.scrollAnchorKey}
189
+ unread={props.unread}
190
+ />
191
+ </TranscriptJumpToLatestContext>
176
192
  </TurnContextPanelProvider>
177
193
  )}
178
194
  {newer ? (
@@ -206,11 +222,7 @@ export function Transcript(props: {
206
222
  />
207
223
  <JumpToLatestButton
208
224
  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
- }}
225
+ onClick={jumpToLatest}
214
226
  visible={bottomPinning.showJumpToLatest}
215
227
  />
216
228
  </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;
@@ -1,4 +1,5 @@
1
1
  import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
2
+ import * as z from "zod";
2
3
  import {
3
4
  type QueryClient,
4
5
  queryOptions,
@@ -22,13 +23,14 @@ import {
22
23
  conversationDetailReportSchema,
23
24
  conversationDetailWithMailboxReportSchema,
24
25
  conversationEventPageSchema,
25
- conversationPendingMessagesReportSchema,
26
+ conversationUnchangedDetailWithMailboxSchema,
26
27
  forkConversationResponseSchema,
27
28
  steerConversationPendingMessagesResponseSchema,
28
29
  stopConversationTurnResponseSchema,
29
30
  } from "@sentry/junior/api/schema";
30
31
 
31
32
  import { REVIEW_ITEMS_QUERY_KEY } from "../review-desk/queries";
33
+ import { readResponseJson } from "../sentry";
32
34
  import {
33
35
  DashboardApiError,
34
36
  del,
@@ -102,29 +104,6 @@ function isOpenPrefetch(snapshot: ConversationSnapshot | undefined) {
102
104
  );
103
105
  }
104
106
 
105
- /** Read the mailbox before history so acknowledged input is already committed. */
106
- async function readConversationSnapshot(
107
- conversationId: string,
108
- signal?: AbortSignal,
109
- previous?: ConversationSnapshot,
110
- unreadPage?: boolean,
111
- ): Promise<ConversationSnapshot> {
112
- // With no cached copy, the transcript waits for this read. The server reads
113
- // both in the same order in one request, which saves a round trip. Polls
114
- // read the mailbox apart, so the detail can still answer 304.
115
- if (!previous) {
116
- return readConversationData(conversationId, signal, undefined, {
117
- mailbox: true,
118
- unreadPage,
119
- });
120
- }
121
- const mailbox = await readConversationPendingMessages(conversationId, signal);
122
- const detail = await readConversationData(conversationId, signal, previous, {
123
- unreadPage,
124
- });
125
- return { ...detail, mailbox };
126
- }
127
-
128
107
  const CONVERSATION_SIDE_READS = ["agents", "memories", "watches"] as const;
129
108
 
130
109
  /**
@@ -185,11 +164,13 @@ export function conversationDetailQueryOptions(
185
164
  const previous = client.getQueryData<ConversationSnapshot>(
186
165
  conversationDetailQueryKey(conversationId),
187
166
  );
188
- const next = await readConversationSnapshot(
167
+ // The server reads the mailbox before history, so acknowledged input
168
+ // is in at least one of them.
169
+ const next = await readConversationData(
189
170
  conversationId!,
190
171
  signal,
191
172
  previous,
192
- options?.unreadPage,
173
+ { mailbox: true, unreadPage: options?.unreadPage },
193
174
  );
194
175
  refreshSideReadsOnChange(client, conversationId!, previous, next);
195
176
  return next;
@@ -221,7 +202,9 @@ export function conversationDetailQueryOptions(
221
202
  /**
222
203
  * Read positions only move forward. A poll that started before a read report
223
204
  * finished must not put an older position back in the cache, nor the unread
224
- * cursor of that older position.
205
+ * cursor of that older position. The cache already holds the cursor for the
206
+ * newer position: a read does not change the detail version, so no later poll
207
+ * would bring it back.
225
208
  */
226
209
  function keepLatestReadPosition(
227
210
  previous: ConversationSnapshot,
@@ -230,8 +213,14 @@ function keepLatestReadPosition(
230
213
  const kept = previous.readPosition;
231
214
  if (!kept) return next;
232
215
  if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next;
233
- const { unreadCursor: _olderCursor, ...rest } = next;
234
- return { ...rest, readPosition: kept };
216
+ const { unreadCursor: _olderCursor, unreadPage: _olderPage, ...rest } = next;
217
+ return {
218
+ ...rest,
219
+ readPosition: kept,
220
+ ...(previous.unreadCursor
221
+ ? { unreadCursor: previous.unreadCursor, unreadPage: previous.unreadPage }
222
+ : undefined),
223
+ };
235
224
  }
236
225
 
237
226
  /** Create one dashboard conversation and refresh the personal feed. */
@@ -858,6 +847,10 @@ export async function readConversationData(
858
847
  const query = new URLSearchParams();
859
848
  if (options?.unreadPage) query.set("unreadPage", "true");
860
849
  if (options?.mailbox) query.set("mailbox", "true");
850
+ // A poll asks only for the events after the cached ones. The unread page
851
+ // needs the latest page, so a read that asks for it reads that page.
852
+ const after = options?.unreadPage ? undefined : previous?.appendCursor;
853
+ if (after) query.set("after", after);
861
854
  const search = query.size ? `?${query}` : "";
862
855
  const response = await readDashboardResponse(
863
856
  `/api/conversations/${encodeURIComponent(conversationId)}${search}`,
@@ -867,11 +860,69 @@ export async function readConversationData(
867
860
  : undefined,
868
861
  );
869
862
  if (response.status === 304 && previous) return previous;
870
- const report = (
871
- options?.mailbox
872
- ? conversationDetailWithMailboxReportSchema
873
- : conversationDetailReportSchema
874
- ).parse(await response.json());
863
+ if (!options?.mailbox) {
864
+ return withDetailEtag(
865
+ withCachedEvents(
866
+ await readResponseJson(response, conversationDetailReportSchema),
867
+ previous,
868
+ ),
869
+ response,
870
+ );
871
+ }
872
+ const report = await readResponseJson(
873
+ response,
874
+ z.union([
875
+ conversationUnchangedDetailWithMailboxSchema,
876
+ conversationDetailWithMailboxReportSchema,
877
+ ]),
878
+ );
879
+ if (!("unchanged" in report)) {
880
+ return withDetailEtag(withCachedEvents(report, previous), response);
881
+ }
882
+ // Only a request that sent a version can get this answer.
883
+ if (!previous) throw new Error("Unchanged detail without a cached copy");
884
+ return { ...previous, mailbox: report.mailbox };
885
+ }
886
+
887
+ /**
888
+ * Put the cached events before the newer events of an `appended` report.
889
+ * The cached detail keeps its cursors: they still point before its events.
890
+ */
891
+ function withCachedEvents(
892
+ report: ConversationSnapshot,
893
+ previous: ConversationSnapshot | undefined,
894
+ ): ConversationSnapshot {
895
+ if (!report.appended) return report;
896
+ // Only a request that sent an `appendCursor` can get this answer.
897
+ if (!previous) throw new Error("Appended detail without a cached copy");
898
+ const { appended: _appended, ...rest } = report;
899
+ const lastSeq = previous.events.at(-1)?.seq ?? -1;
900
+ const firstSeq = previous.events[0]?.seq;
901
+ // Like the server, keep the unread cursor while the reader is behind the
902
+ // first event.
903
+ const behind =
904
+ rest.readPosition !== undefined &&
905
+ firstSeq !== undefined &&
906
+ rest.readPosition.lastReadSeq + 1 < firstSeq;
907
+ return {
908
+ ...rest,
909
+ events: [
910
+ ...previous.events,
911
+ ...report.events.filter((event) => event.seq > lastSeq),
912
+ ],
913
+ ...(previous.previousCursor
914
+ ? { previousCursor: previous.previousCursor }
915
+ : undefined),
916
+ ...(behind && previous.unreadCursor
917
+ ? { unreadCursor: previous.unreadCursor, unreadPage: previous.unreadPage }
918
+ : undefined),
919
+ };
920
+ }
921
+
922
+ function withDetailEtag<T extends ConversationDetailReport>(
923
+ report: T,
924
+ response: Response,
925
+ ): T & { detailEtag?: string } {
875
926
  return {
876
927
  ...report,
877
928
  detailEtag:
@@ -879,18 +930,6 @@ export async function readConversationData(
879
930
  };
880
931
  }
881
932
 
882
- /** Read accepted mailbox messages that have not reached durable history yet. */
883
- export function readConversationPendingMessages(
884
- conversationId: string,
885
- signal?: AbortSignal,
886
- ): Promise<ConversationPendingMessagesReport> {
887
- return fetchDashboardJson(
888
- conversationPendingMessagesReportSchema,
889
- `/api/conversations/${encodeURIComponent(conversationId)}/pending-messages`,
890
- signal,
891
- );
892
- }
893
-
894
933
  /** Read one bounded page of events before the supplied history cursor. */
895
934
  export function readConversationEvents(
896
935
  conversationId: string,