@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
@@ -15,6 +15,7 @@ import {
15
15
  } from "./eventLog";
16
16
  import { ConversationEventDetails } from "./ConversationEventDetails";
17
17
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
18
+ import { useVirtualRows } from "./virtualRows";
18
19
 
19
20
  const rowClass =
20
21
  "grid grid-cols-[12ch_minmax(0,1fr)] items-baseline gap-x-3 px-4 @min-[32rem]:grid-cols-[12ch_17ch_minmax(0,1fr)]";
@@ -28,6 +29,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
28
29
  conversation: ConversationTranscript;
29
30
  onSearchChange(value: string): void;
30
31
  onSelectedSeqChange?(seq: number | undefined): void;
32
+ /** The row that holds the reader's place, from the scroll owner. */
33
+ scrollAnchorKey?: () => string | undefined;
31
34
  selectedSeq?: number;
32
35
  }) {
33
36
  const { conversation } = props;
@@ -50,17 +53,46 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
50
53
  );
51
54
  // Resolve the selection from current data so polling cannot leave stale details.
52
55
  const selected = events.find((event) => event.seq === selectedSeq);
56
+ // An arrow key can select a row that mounts only on the next render.
57
+ const focusSeqRef = useRef<number>(undefined);
58
+ const focusedSeqRef = useRef<number>(undefined);
59
+ const indexBySeq = useMemo(
60
+ () => new Map(visibleRows.map((row, index) => [row.event.seq, index])),
61
+ [visibleRows],
62
+ );
63
+ const indexOf = (seq: number | undefined) =>
64
+ seq === undefined ? undefined : indexBySeq.get(seq);
65
+ // The open entry, the focused row, and the row that holds the reader's
66
+ // place stay mounted.
67
+ const virtualizer = useVirtualRows({
68
+ count: visibleRows.length,
69
+ defaultSize: (sizeClass) => (sizeClass === "metadata" ? 56 : 40),
70
+ getItemKey: (index) => visibleRows[index]?.event.seq ?? index,
71
+ keptIndexes: [
72
+ indexOf(selectedSeq),
73
+ indexOf(focusedSeqRef.current),
74
+ indexOf(anchorSeq(props.scrollAnchorKey?.())),
75
+ ],
76
+ listRef,
77
+ sizeClass: (index) => (visibleRows[index]?.metadata ? "metadata" : "plain"),
78
+ });
79
+ const virtualRows = virtualizer.getVirtualItems();
53
80
  useEffect(() => {
54
- if (selectedSeq === undefined || revealedSeqRef.current === selectedSeq) {
55
- return;
56
- }
81
+ if (selectedSeq === undefined) return;
57
82
  const row = listRef.current?.querySelector(
58
83
  `[data-event-seq="${selectedSeq}"]`,
59
84
  );
60
85
  if (!row) return;
86
+ if (focusSeqRef.current === selectedSeq) {
87
+ focusSeqRef.current = undefined;
88
+ row
89
+ .querySelector<HTMLButtonElement>("button")
90
+ ?.focus({ preventScroll: true });
91
+ }
92
+ if (revealedSeqRef.current === selectedSeq) return;
61
93
  revealedSeqRef.current = selectedSeq;
62
94
  row.scrollIntoView({ block: "nearest" });
63
- }, [selectedSeq, visibleRows]);
95
+ }, [selectedSeq, virtualRows]);
64
96
 
65
97
  if (conversation.eventHistory.status === "expired") {
66
98
  return (
@@ -101,84 +133,112 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
101
133
  <EventLogColumns />
102
134
  </div>
103
135
  <div className="font-mono text-xs">
136
+ {/* Only rows near the viewport mount. Each row is placed at its
137
+ measured offset, so the list keeps its full height. */}
104
138
  <ol
105
139
  aria-label="Events"
106
- className="m-0 list-none divide-y divide-dashboard-border-subtle p-0"
140
+ className="relative m-0 list-none p-0"
107
141
  ref={listRef}
142
+ style={{ height: virtualizer.getTotalSize() }}
108
143
  >
109
- {visibleRows.map(({ event, summary, metadata }, index) => (
110
- <li
111
- className="scroll-mt-32 scroll-mb-2"
112
- data-event-seq={event.seq}
113
- data-scroll-anchor={`event:${event.seq}`}
114
- key={event.seq}
115
- >
116
- <button
117
- aria-expanded={selectedSeq === event.seq}
118
- aria-label={`Event ${event.seq}: ${event.data.type}`}
144
+ {virtualRows.map((virtualRow) => {
145
+ const { event, summary, metadata } = visibleRows[virtualRow.index]!;
146
+ const index = virtualRow.index;
147
+ return (
148
+ <li
119
149
  className={cn(
120
- rowClass,
121
- "relative min-h-11 w-full cursor-pointer border-0 bg-transparent py-2 text-left leading-5 hover:bg-dashboard-fill-hover active:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus @min-[32rem]:min-h-9 @min-[32rem]:py-1.5",
122
- selectedSeq === event.seq &&
123
- "bg-dashboard-fill-strong before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-dashboard-accent before:content-['']",
150
+ "absolute left-0 top-0 w-full scroll-mt-32 scroll-mb-2",
151
+ index > 0 && "border-t border-dashboard-border-subtle",
124
152
  )}
125
- onClick={() => {
126
- revealedSeqRef.current = event.seq;
127
- setSelectedSeq(event.seq);
153
+ data-event-seq={event.seq}
154
+ data-index={index}
155
+ data-scroll-anchor={`event:${event.seq}`}
156
+ key={virtualRow.key}
157
+ onBlur={() => {
158
+ if (focusedSeqRef.current === event.seq) {
159
+ focusedSeqRef.current = undefined;
160
+ }
128
161
  }}
129
- onKeyDown={(keyEvent) => {
130
- if (
131
- keyEvent.altKey ||
132
- keyEvent.ctrlKey ||
133
- keyEvent.metaKey ||
134
- keyEvent.shiftKey ||
135
- (keyEvent.key !== "ArrowUp" && keyEvent.key !== "ArrowDown")
136
- )
137
- return;
138
- keyEvent.preventDefault();
139
- const next =
140
- visibleRows[
141
- index + (keyEvent.key === "ArrowDown" ? 1 : -1)
142
- ];
143
- if (!next) return;
144
- setSelectedSeq(next.event.seq);
145
- listRef.current
146
- ?.querySelector<HTMLButtonElement>(
147
- `[data-event-seq="${next.event.seq}"] button`,
148
- )
149
- ?.focus({ preventScroll: true });
162
+ onFocus={() => {
163
+ focusedSeqRef.current = event.seq;
164
+ }}
165
+ ref={virtualizer.measureElement}
166
+ style={{
167
+ transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`,
150
168
  }}
151
- type="button"
152
169
  >
153
- <time
154
- className="tabular-nums text-dashboard-text-muted"
155
- dateTime={event.createdAt}
156
- title={time.formatTimestamp(event.createdAt)}
157
- >
158
- {time.formatClock(event.createdAt)}
159
- </time>
160
- <span
161
- className={cn("truncate", eventLogTone(event.data))}
162
- title={event.data.type}
170
+ <button
171
+ aria-expanded={selectedSeq === event.seq}
172
+ aria-label={`Event ${event.seq}: ${event.data.type}`}
173
+ className={cn(
174
+ rowClass,
175
+ "relative min-h-11 w-full cursor-pointer border-0 bg-transparent py-2 text-left leading-5 hover:bg-dashboard-fill-hover active:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus @min-[32rem]:min-h-9 @min-[32rem]:py-1.5",
176
+ selectedSeq === event.seq &&
177
+ "bg-dashboard-fill-strong before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-dashboard-accent before:content-['']",
178
+ )}
179
+ onClick={() => {
180
+ revealedSeqRef.current = event.seq;
181
+ setSelectedSeq(event.seq);
182
+ }}
183
+ onKeyDown={(keyEvent) => {
184
+ if (
185
+ keyEvent.altKey ||
186
+ keyEvent.ctrlKey ||
187
+ keyEvent.metaKey ||
188
+ keyEvent.shiftKey ||
189
+ (keyEvent.key !== "ArrowUp" &&
190
+ keyEvent.key !== "ArrowDown")
191
+ )
192
+ return;
193
+ keyEvent.preventDefault();
194
+ const next =
195
+ visibleRows[
196
+ index + (keyEvent.key === "ArrowDown" ? 1 : -1)
197
+ ];
198
+ if (!next) return;
199
+ setSelectedSeq(next.event.seq);
200
+ // Focus a mounted row now, so the next key press reaches
201
+ // it. A row past the mounted range gets focus after it
202
+ // mounts.
203
+ const nextButton =
204
+ listRef.current?.querySelector<HTMLButtonElement>(
205
+ `[data-event-seq="${next.event.seq}"] button`,
206
+ );
207
+ if (nextButton) nextButton.focus({ preventScroll: true });
208
+ else focusSeqRef.current = next.event.seq;
209
+ }}
210
+ type="button"
163
211
  >
164
- <HighlightText text={event.data.type} />
165
- </span>
166
- <span className="col-span-2 row-start-2 grid min-w-0 gap-0.5 text-dashboard-text @min-[32rem]:col-span-1 @min-[32rem]:row-auto">
167
- <span className="truncate" title={summary}>
168
- <HighlightText text={summary} />
212
+ <time
213
+ className="tabular-nums text-dashboard-text-muted"
214
+ dateTime={event.createdAt}
215
+ title={time.formatTimestamp(event.createdAt)}
216
+ >
217
+ {time.formatClock(event.createdAt)}
218
+ </time>
219
+ <span
220
+ className={cn("truncate", eventLogTone(event.data))}
221
+ title={event.data.type}
222
+ >
223
+ <HighlightText text={event.data.type} />
169
224
  </span>
170
- {metadata ? (
171
- <span
172
- className="truncate text-dashboard-text-muted"
173
- title={metadata}
174
- >
175
- <HighlightText text={metadata} />
225
+ <span className="col-span-2 row-start-2 grid min-w-0 gap-0.5 text-dashboard-text @min-[32rem]:col-span-1 @min-[32rem]:row-auto">
226
+ <span className="truncate" title={summary}>
227
+ <HighlightText text={summary} />
176
228
  </span>
177
- ) : null}
178
- </span>
179
- </button>
180
- </li>
181
- ))}
229
+ {metadata ? (
230
+ <span
231
+ className="truncate text-dashboard-text-muted"
232
+ title={metadata}
233
+ >
234
+ <HighlightText text={metadata} />
235
+ </span>
236
+ ) : null}
237
+ </span>
238
+ </button>
239
+ </li>
240
+ );
241
+ })}
182
242
  </ol>
183
243
  {visibleRows.length === 0 ? (
184
244
  <p className="m-0 px-3 py-6 text-sm text-dashboard-text-muted">
@@ -205,6 +265,11 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
205
265
  );
206
266
  });
207
267
 
268
+ /** Read the event seq from a `data-scroll-anchor` key. */
269
+ function anchorSeq(key: string | undefined): number | undefined {
270
+ return key?.startsWith("event:") ? Number(key.slice(6)) : undefined;
271
+ }
272
+
208
273
  const loadingRowWidths = ["w-3/5", "w-2/5", "w-4/5", "w-1/2", "w-2/3", "w-1/3"];
209
274
 
210
275
  /** Match the event log toolbar, columns, and rows while the transcript loads. */
@@ -1,8 +1,9 @@
1
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
- import { Menu, PanelRight, ScrollText, X } from "lucide-react";
2
+ import { Menu, PanelRight } from "lucide-react";
3
3
 
4
4
  import { SearchInput } from "../components/SearchInput";
5
5
  import { Button } from "../components/ui/button";
6
+ import { Spinner } from "../components/ui/spinner";
6
7
  import { cn } from "../styles";
7
8
  import {
8
9
  MobileHeaderActionsPortal,
@@ -17,32 +18,29 @@ import {
17
18
  type ConversationPinAction,
18
19
  } from "./ConversationHeaderActions";
19
20
  import { ConversationOverflowMenu } from "./ConversationOverflowMenu";
20
- import { ShareConversationButton } from "./ShareConversationButton";
21
21
  import type { TranscriptViewMode } from "./transcriptRenderModel";
22
22
 
23
23
  /**
24
- * Show the conversation title bar: where it runs, the title, its cost, and
25
- * actions. Other run facts, linked work, and the Brief live in Conversation
26
- * details.
24
+ * Keep the conversation title and frequently used controls in the header.
25
+ * Run facts and the Brief live in Conversation details.
27
26
  */
28
27
  export function ConversationHeader(props: {
29
28
  /** Parent chain of a child Conversation. Root Conversations omit it. */
30
29
  agentTree?: ReactNode;
30
+ contextTags?: ReactNode;
31
31
  /** A `menu` layout copy action for the `⋯` menu. */
32
32
  copyAction?: ReactNode;
33
- /** Estimated cost. Desktop only; mobile shows it in Conversation details. */
34
- cost?: ReactNode;
35
33
  archive: ConversationArchiveAction;
36
34
  conversationId: string;
35
+ /** Estimated cost. Desktop only; mobile shows it in Conversation details. */
36
+ cost?: ReactNode;
37
37
  detailsOpen: boolean;
38
38
  onDetailsClick(): void;
39
39
  /** Links to the fork source and to forks of this conversation. */
40
40
  forks?: ReactNode;
41
- /** Mobile only. Desktop shows linked work in Conversation details. */
41
+ /** Linked work links below the title on mobile. */
42
42
  linkedWork?: ReactNode;
43
43
  live: boolean;
44
- /** Where the conversation runs, such as a Slack channel. */
45
- location?: ReactNode;
46
44
  onSearchChange(value: string): void;
47
45
  onViewChange(value: TranscriptViewMode): void;
48
46
  pin: ConversationPinAction;
@@ -121,16 +119,18 @@ export function ConversationHeader(props: {
121
119
  searchOpenVisible ||
122
120
  props.archive.error ||
123
121
  props.pin.error ||
122
+ Boolean(props.contextTags) ||
124
123
  Boolean(props.linkedWork) ||
125
124
  Boolean(props.forks) ||
126
125
  Boolean(props.agentTree);
127
126
 
128
127
  const liveIndicator = props.live ? (
129
- <span
130
- aria-label="Conversation is live"
131
- className="inline-flex size-2 shrink-0 rounded-full bg-emerald-300"
132
- title="Live"
133
- />
128
+ <span className="inline-flex shrink-0" title="Working">
129
+ <Spinner
130
+ aria-label="Working"
131
+ className="size-3.5 text-dashboard-text-muted motion-safe:[animation-duration:1.25s]"
132
+ />
133
+ </span>
134
134
  ) : null;
135
135
 
136
136
  return (
@@ -166,28 +166,53 @@ export function ConversationHeader(props: {
166
166
  !showMobileHeader && "hidden md:block",
167
167
  )}
168
168
  >
169
- <div className="hidden h-13 min-w-0 items-center justify-between gap-4 px-4 md:flex">
170
- <div className="flex min-w-0 items-center gap-2">
169
+ <div className="hidden h-13 min-w-0 items-center justify-between gap-1.5 px-1 md:flex">
170
+ <div className="flex min-w-0 items-center gap-1.5">
171
171
  {props.sidebarToggle ? (
172
172
  <div className="shrink-0">{props.sidebarToggle}</div>
173
173
  ) : null}
174
- {props.agentTree ? (
175
- <div className="min-w-0 shrink-[2]">{props.agentTree}</div>
176
- ) : props.location ? (
177
- <span className="shrink-0 text-sm text-dashboard-text-muted">
178
- {props.location}
179
- </span>
174
+ {props.view === "raw" ? (
175
+ // Event log is a debug view reached from the `⋯` menu. The
176
+ // title leads back to the Conversation.
177
+ <>
178
+ <h2 className="m-0 min-w-0 font-display text-base font-medium leading-6 tracking-tight">
179
+ <button
180
+ className="block max-w-full cursor-pointer truncate text-left text-dashboard-text-muted hover:text-dashboard-text hover:underline"
181
+ onClick={() => props.onViewChange("rich")}
182
+ title="Back to conversation"
183
+ type="button"
184
+ >
185
+ {props.title}
186
+ </button>
187
+ </h2>
188
+ <span aria-hidden="true" className="text-dashboard-text-faint">
189
+ ›
190
+ </span>
191
+ <span
192
+ aria-current="page"
193
+ className="shrink-0 font-display text-base font-medium leading-6 tracking-tight"
194
+ >
195
+ Event log
196
+ </span>
197
+ </>
198
+ ) : (
199
+ <h2 className="m-0 min-w-0 truncate font-display text-base font-medium leading-6 tracking-tight">
200
+ {props.title}
201
+ </h2>
202
+ )}
203
+ {liveIndicator}
204
+ {props.privacy ? (
205
+ <span className="shrink-0">{props.privacy}</span>
180
206
  ) : null}
181
- {props.agentTree || props.location ? (
182
- <span aria-hidden="true" className="text-dashboard-text-faint">
183
- ›
184
- </span>
207
+ {props.agentTree ? (
208
+ <div className="min-w-0 max-w-1/3 shrink-[2] border-l border-dashboard-border-subtle pl-2 text-xs text-dashboard-text-muted [&_a]:text-dashboard-text-muted [&_a:hover]:text-dashboard-text">
209
+ {props.agentTree}
210
+ </div>
211
+ ) : props.contextTags ? (
212
+ <div className="flex h-6 min-w-0 max-w-[min(19rem,60%)] shrink-[2] items-center">
213
+ {props.contextTags}
214
+ </div>
185
215
  ) : null}
186
- <h2 className="m-0 line-clamp-1 min-w-0 font-display text-base font-medium leading-tight tracking-tight">
187
- {props.title}
188
- </h2>
189
- {liveIndicator}
190
- <span className="shrink-0">{props.privacy}</span>
191
216
  </div>
192
217
  <div className="flex shrink-0 items-center gap-0.5">
193
218
  {props.cost ? (
@@ -195,41 +220,25 @@ export function ConversationHeader(props: {
195
220
  {props.cost}
196
221
  </span>
197
222
  ) : null}
198
- {props.view === "raw" ? (
199
- // Event log is a debug view reached from the menu. Say which
200
- // view this is, and give one click back.
201
- <Button
202
- aria-label="Close event log"
203
- className="mr-1 h-8 px-2.5"
204
- onClick={() => props.onViewChange("rich")}
205
- variant="quiet"
206
- >
207
- <ScrollText aria-hidden="true" size={14} strokeWidth={2} />
208
- Event log
209
- <X aria-hidden="true" size={14} strokeWidth={2} />
210
- </Button>
211
- ) : (
223
+ {props.view === "raw" ? null : (
212
224
  <TranscriptSearchToggle
213
225
  onClick={toggleSearch}
214
226
  open={searchOpenVisible}
215
227
  />
216
228
  )}
217
- <ShareConversationButton
218
- key={props.conversationId}
219
- conversationId={props.conversationId}
220
- layout="icon"
221
- />
222
229
  <ConversationOverflowMenu>
223
230
  {(close) => menuActions("desktop", close)}
224
231
  </ConversationOverflowMenu>
225
- <HeaderIconButton
226
- ref={detailsButtonRef}
227
- expanded={detailsOpen}
228
- label="Conversation details"
229
- onClick={props.onDetailsClick}
230
- >
231
- <PanelRight aria-hidden="true" size={15} strokeWidth={2} />
232
- </HeaderIconButton>
232
+ {detailsOpen ? null : (
233
+ <HeaderIconButton
234
+ ref={detailsButtonRef}
235
+ expanded={false}
236
+ label="Conversation details"
237
+ onClick={props.onDetailsClick}
238
+ >
239
+ <PanelRight aria-hidden="true" size={15} strokeWidth={2} />
240
+ </HeaderIconButton>
241
+ )}
233
242
  </div>
234
243
  </div>
235
244
  {props.forks ? (
@@ -241,12 +250,12 @@ export function ConversationHeader(props: {
241
250
  {props.forks ? (
242
251
  <div className="px-4 py-2 md:hidden">{props.forks}</div>
243
252
  ) : null}
244
- {props.linkedWork ? (
245
- <div
246
- aria-label="Linked work"
247
- className="border-t border-dashboard-border-subtle px-4 py-2.5 md:hidden"
248
- >
249
- {props.linkedWork}
253
+ {props.contextTags || props.linkedWork ? (
254
+ <div className="grid min-w-0 gap-2 px-3 pb-2 pt-1.5 md:hidden">
255
+ {props.contextTags}
256
+ {props.linkedWork ? (
257
+ <div aria-label="Linked work">{props.linkedWork}</div>
258
+ ) : null}
250
259
  </div>
251
260
  ) : null}
252
261
  {searchOpenVisible ? (
@@ -12,6 +12,7 @@ import {
12
12
  } from "lucide-react";
13
13
 
14
14
  import { Button, buttonVariants } from "../components/ui/button";
15
+ import { Separator } from "../components/ui/separator";
15
16
  import {
16
17
  Tooltip,
17
18
  TooltipContent,
@@ -38,8 +39,8 @@ export type ConversationPinAction = {
38
39
  };
39
40
 
40
41
  /**
41
- * Show conversation actions in the `⋯` menu. On desktop the title bar shows
42
- * search, share, and details, so the menu leaves them out.
42
+ * Keep secondary conversation actions in the menu, with search and details
43
+ * available there on mobile too.
43
44
  */
44
45
  export function ConversationHeaderActions(props: {
45
46
  archive: ConversationArchiveAction;
@@ -56,9 +57,10 @@ export function ConversationHeaderActions(props: {
56
57
  view: TranscriptViewMode;
57
58
  }) {
58
59
  const mobile = props.placement === "mobile";
60
+ const eventLogOpen = props.view === "raw";
59
61
  return (
60
62
  <div className="grid gap-0.5">
61
- {mobile && props.view === "rich" ? (
63
+ {mobile && !eventLogOpen ? (
62
64
  <MenuActionButton
63
65
  label={props.searchOpen ? "Hide search" : "Search transcript"}
64
66
  onClick={props.onSearchClick}
@@ -71,40 +73,32 @@ export function ConversationHeaderActions(props: {
71
73
  )}
72
74
  </MenuActionButton>
73
75
  ) : null}
74
- <MenuActionButton
75
- label="Conversation"
76
- onClick={() => props.onViewChange("rich")}
77
- pressed={props.view === "rich"}
78
- >
79
- <MessagesSquare aria-hidden="true" size={16} strokeWidth={2} />
80
- </MenuActionButton>
81
- <MenuActionButton
82
- label="Event log"
83
- onClick={() => props.onViewChange("raw")}
84
- pressed={props.view === "raw"}
85
- >
86
- <ScrollText aria-hidden="true" size={16} strokeWidth={2} />
87
- </MenuActionButton>
88
76
  {mobile ? (
89
- <ShareConversationButton
90
- key={props.conversationId}
91
- conversationId={props.conversationId}
92
- layout="menu"
93
- />
77
+ <MenuActionButton
78
+ label="Conversation details"
79
+ onClick={props.onDetailsClick}
80
+ pressed={props.detailsOpen}
81
+ >
82
+ <Info aria-hidden="true" size={16} strokeWidth={2} />
83
+ </MenuActionButton>
94
84
  ) : null}
85
+ {/* The copy action is a sibling keyed by the same id. Unique keys keep
86
+ React from duplicating menu rows on re-render. */}
87
+ <ShareConversationButton
88
+ key={`share:${props.conversationId}`}
89
+ conversationId={props.conversationId}
90
+ />
95
91
  {props.copyAction}
96
92
  <MenuActionButton
97
- disabled={props.pin.disabled}
98
- label={props.pin.pinned ? "Unpin" : "Pin"}
99
- onClick={props.pin.onClick}
93
+ label={eventLogOpen ? "Back to conversation" : "Open event log"}
94
+ onClick={() => props.onViewChange(eventLogOpen ? "rich" : "raw")}
100
95
  >
101
- {props.pin.pinned ? (
102
- <PinOff aria-hidden="true" size={16} strokeWidth={2} />
96
+ {eventLogOpen ? (
97
+ <MessagesSquare aria-hidden="true" size={16} strokeWidth={2} />
103
98
  ) : (
104
- <Pin aria-hidden="true" size={16} strokeWidth={2} />
99
+ <ScrollText aria-hidden="true" size={16} strokeWidth={2} />
105
100
  )}
106
101
  </MenuActionButton>
107
- <ArchiveConversationButton {...props.archive} />
108
102
  {props.sentryConversationUrl ? (
109
103
  <a
110
104
  className={buttonVariants({ size: "menu" })}
@@ -116,15 +110,19 @@ export function ConversationHeaderActions(props: {
116
110
  Open in Sentry
117
111
  </a>
118
112
  ) : null}
119
- {mobile ? (
120
- <MenuActionButton
121
- label="Conversation details"
122
- onClick={props.onDetailsClick}
123
- pressed={props.detailsOpen}
124
- >
125
- <Info aria-hidden="true" size={16} strokeWidth={2} />
126
- </MenuActionButton>
127
- ) : null}
113
+ <Separator className="my-1 bg-dashboard-border-subtle" />
114
+ <MenuActionButton
115
+ disabled={props.pin.disabled}
116
+ label={props.pin.pinned ? "Unpin" : "Pin"}
117
+ onClick={props.pin.onClick}
118
+ >
119
+ {props.pin.pinned ? (
120
+ <PinOff aria-hidden="true" size={16} strokeWidth={2} />
121
+ ) : (
122
+ <Pin aria-hidden="true" size={16} strokeWidth={2} />
123
+ )}
124
+ </MenuActionButton>
125
+ <ArchiveConversationButton {...props.archive} />
128
126
  </div>
129
127
  );
130
128
  }
@@ -1,6 +1,6 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback, useEffect, useState } from "react";
2
2
  import { Archive, ArchiveRestore } from "lucide-react";
3
- import { Link } from "react-router";
3
+ import { Link, useLocation, useNavigate } from "react-router";
4
4
 
5
5
  import {
6
6
  conversationDisplayTitle,
@@ -20,7 +20,11 @@ import { ConversationSidebarAnnotations } from "./ConversationMeta";
20
20
  import { ConversationArchiveNotices } from "./ConversationArchiveNotices";
21
21
  import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
22
22
  import { conversationPath } from "./conversationRoutes";
23
- import { useArchiveConversation } from "./conversationArchive";
23
+ import {
24
+ useArchiveConversation,
25
+ useLatestArchiveStatus,
26
+ type ArchivedFromConversationState,
27
+ } from "./conversationArchive";
24
28
  import { DisplayText } from "../display-text/DisplayText";
25
29
  import {
26
30
  attentionConversationIds,
@@ -60,6 +64,7 @@ export function ConversationHomeList(props: {
60
64
  );
61
65
  setArchivedConversation(conversation);
62
66
  }, []);
67
+ useConversationPageArchiveResult(handleArchived, handleArchiveError);
63
68
  const notices = (
64
69
  <ConversationArchiveNotices
65
70
  archivedConversation={archivedConversation}
@@ -116,6 +121,39 @@ export function ConversationHomeList(props: {
116
121
  );
117
122
  }
118
123
 
124
+ /**
125
+ * Report an archive started on the conversation page, which navigated home
126
+ * before the request finished.
127
+ */
128
+ function useConversationPageArchiveResult(
129
+ onArchived: (conversation: Conversation) => void,
130
+ onArchiveError: (conversation: Conversation, wasArchiving: boolean) => void,
131
+ ) {
132
+ const location = useLocation();
133
+ const navigate = useNavigate();
134
+ const conversation = (location.state as ArchivedFromConversationState | null)
135
+ ?.archivedConversation;
136
+ const status = useLatestArchiveStatus(conversation?.id);
137
+ useEffect(() => {
138
+ if (!conversation || (status !== "success" && status !== "error")) return;
139
+ if (status === "success") onArchived(conversation);
140
+ else onArchiveError(conversation, true);
141
+ // Clear the state so a reload or a later restore does not report again.
142
+ navigate(
143
+ { hash: location.hash, search: location.search },
144
+ { replace: true, state: null },
145
+ );
146
+ }, [
147
+ conversation,
148
+ location.hash,
149
+ location.search,
150
+ navigate,
151
+ onArchiveError,
152
+ onArchived,
153
+ status,
154
+ ]);
155
+ }
156
+
119
157
  /** Match the grouped card list while its first feed request is pending. */
120
158
  export function ConversationHomeListLoading(props: { label?: string }) {
121
159
  return (