@sentry/junior-dashboard 0.274.0 → 0.276.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 (52) hide show
  1. package/dist/app.js +28 -9
  2. package/dist/client.js +115 -92
  3. package/dist/index.js +28 -9
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +8 -3
  7. package/src/client/App.tsx +11 -0
  8. package/src/client/components/Metric.tsx +0 -32
  9. package/src/client/components/ProfileMenu.tsx +56 -4
  10. package/src/client/components/layout/DashboardChrome.tsx +3 -3
  11. package/src/client/components/layout/DashboardHeader.tsx +41 -4
  12. package/src/client/components/layout/NavRail.tsx +43 -40
  13. package/src/client/conversations/ConversationComposer.tsx +42 -26
  14. package/src/client/conversations/ConversationDetails.tsx +149 -166
  15. package/src/client/conversations/ConversationHeader.tsx +126 -132
  16. package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
  17. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  18. package/src/client/conversations/ConversationMemories.tsx +118 -78
  19. package/src/client/conversations/ConversationMeta.tsx +94 -84
  20. package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
  21. package/src/client/conversations/ConversationPage.tsx +197 -422
  22. package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
  23. package/src/client/conversations/ConversationSidebar.tsx +5 -7
  24. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  25. package/src/client/conversations/ConversationWatches.tsx +37 -15
  26. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  27. package/src/client/conversations/QuickQuestion.tsx +476 -0
  28. package/src/client/conversations/TelemetryMetrics.tsx +2 -15
  29. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  30. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  31. package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
  32. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  33. package/src/client/conversations/TranscriptRows.tsx +9 -2
  34. package/src/client/conversations/TranscriptView.tsx +15 -32
  35. package/src/client/conversations/UsageCard.tsx +4 -18
  36. package/src/client/conversations/conversationMemory.ts +138 -0
  37. package/src/client/conversations/conversationSections.ts +18 -14
  38. package/src/client/conversations/eventTranscript.ts +8 -0
  39. package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
  40. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  41. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  42. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  43. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  44. package/src/client/sentry.ts +77 -0
  45. package/src/client/types.ts +6 -0
  46. package/src/client.tsx +3 -0
  47. package/src/mock-reporting/watches.ts +2 -0
  48. package/src/shell.ts +27 -2
  49. package/src/tailwind.css +12 -19
  50. package/src/client/components/ActiveIndicator.tsx +0 -14
  51. package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
  52. package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
@@ -0,0 +1,342 @@
1
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
2
+ import type {
3
+ ConversationDetailReport,
4
+ ConversationPendingMessagesReport,
5
+ InputImage,
6
+ } from "@sentry/junior/api/schema";
7
+
8
+ import { useDashboardCoreData } from "../api";
9
+ import { useReviewItemData } from "../review-desk/queries";
10
+ import { reviewItemWaitsForAnswer } from "../review-desk/reviewItemFormat";
11
+ import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
12
+ import { readStoredComposerImages } from "./ComposerImages";
13
+ import { ComposerDock } from "./ComposerDock";
14
+ import {
15
+ ConversationComposer,
16
+ restoreStoredDraftText,
17
+ type ConversationComposerHandle,
18
+ } from "./ConversationComposer";
19
+ import type { ConversationMailboxMessage } from "./conversationOutbox";
20
+ import { PendingAuthorization } from "./ConversationMeta";
21
+ import { PendingMailboxStack } from "./PendingMailboxStack";
22
+ import {
23
+ useAppendConversationMessage,
24
+ useCancelConversationPendingMessages,
25
+ useSteerConversationPendingMessages,
26
+ useStopConversationTurn,
27
+ } from "./queries";
28
+
29
+ /**
30
+ * Own mutation state and mailbox chrome outside the page tree that re-renders
31
+ * on every live transcript poll. Keeps composer props stable while typing.
32
+ *
33
+ * Memoized so metadata-only polls that keep mailbox identity stable skip the
34
+ * footer tree. Fast chat UIs isolate the composer the same way.
35
+ */
36
+ export const ConversationReplyFooter = memo(ReplyFooter);
37
+
38
+ function ReplyFooter(props: {
39
+ authors?: ConversationDetailReport["authors"];
40
+ committedMessageIds: readonly string[];
41
+ conversationId: string;
42
+ onPinRequest: () => void;
43
+ pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
44
+ pendingGeneratedAtRef: { current: string | undefined };
45
+ pendingMessages: readonly ConversationMailboxMessage[];
46
+ /** The last Review item that Junior added in the transcript. */
47
+ reviewItemId?: string;
48
+ /**
49
+ * True when history is live and no Turn has started, so the next Turn will
50
+ * read a new send at once.
51
+ */
52
+ sendsInline: boolean;
53
+ /** True while the conversation has a Turn that a stop can end. */
54
+ stoppable: boolean;
55
+ }) {
56
+ const appendMessage = useAppendConversationMessage(props.conversationId);
57
+ // A message closes the review items that it answers. Ask before it drops
58
+ // answers that the reader did not send.
59
+ const unsentAnswers = useConfirmUnsentAnswers();
60
+ const stopTurn = useStopConversationTurn(props.conversationId);
61
+ const stopTurnRef = useRef(stopTurn);
62
+ stopTurnRef.current = stopTurn;
63
+ const cancelPendingMessages = useCancelConversationPendingMessages(
64
+ props.conversationId,
65
+ );
66
+ const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
67
+ useState<string>();
68
+ const [queueActionError, setQueueActionError] = useState<string>();
69
+ // While Junior waits for an answer, hide the message box and the queued
70
+ // messages, so the reader answers in the question and does not type the
71
+ // answer as a new message.
72
+ // Dismiss on the question closes the item and shows the message box again.
73
+ // The message box stays mounted, so its text and images stay, and it does
74
+ // not hide while it has focus.
75
+ const reviewItem = useReviewItemData(props.reviewItemId).data;
76
+ const [composerShownForItemId, setComposerShownForItemId] =
77
+ useState<string>();
78
+ const [composerFocused, setComposerFocused] = useState(false);
79
+ const question =
80
+ reviewItem &&
81
+ !props.stoppable &&
82
+ !composerFocused &&
83
+ reviewItemWaitsForAnswer(reviewItem) &&
84
+ composerShownForItemId !== reviewItem.id
85
+ ? reviewItem
86
+ : undefined;
87
+ // The message box can show only because it has focus. Using the queued
88
+ // messages moves focus out of it, so keep them shown for this item.
89
+ const keepComposerShown = () => {
90
+ if (composerFocused && reviewItem && reviewItemWaitsForAnswer(reviewItem)) {
91
+ setComposerShownForItemId(reviewItem.id);
92
+ }
93
+ };
94
+ // Keep submit identity stable across mutation status flips so the memoized
95
+ // composer does not re-render while the reader is still typing.
96
+ const appendMessageRef = useRef(appendMessage);
97
+ appendMessageRef.current = appendMessage;
98
+ const cancelPendingMessagesRef = useRef(cancelPendingMessages);
99
+ cancelPendingMessagesRef.current = cancelPendingMessages;
100
+ const onPinRequestRef = useRef(props.onPinRequest);
101
+ onPinRequestRef.current = props.onPinRequest;
102
+ const pendingMessageVersion = props.pendingMessages
103
+ .map((message) =>
104
+ [
105
+ message.inboundMessageId,
106
+ message.messageId,
107
+ message.clientStatus,
108
+ message.delivery,
109
+ ].join(":"),
110
+ )
111
+ .join("|");
112
+ const pendingMessageVersionRef = useRef(pendingMessageVersion);
113
+ useEffect(() => {
114
+ if (pendingMessageVersionRef.current === pendingMessageVersion) return;
115
+ pendingMessageVersionRef.current = pendingMessageVersion;
116
+ if (!window.matchMedia("(max-width: 767px)").matches) return;
117
+ onPinRequestRef.current();
118
+ }, [pendingMessageVersion]);
119
+ // No Turn runs and nothing waits in the queue, so the next send would
120
+ // leave the queue almost at once. Show it in the transcript instead. A
121
+ // conversation can be active before its Turn starts; that still counts.
122
+ const committedIds = new Set(props.committedMessageIds);
123
+ const sendInlineRef = useRef(false);
124
+ sendInlineRef.current =
125
+ props.sendsInline &&
126
+ !props.pendingMessages.some(
127
+ (message) => !committedIds.has(message.messageId),
128
+ );
129
+ const onSubmit = useCallback(
130
+ async (message: string, idempotencyKey: string, images?: InputImage[]) => {
131
+ await appendMessageRef.current.mutateAsync({
132
+ idempotencyKey,
133
+ inline: sendInlineRef.current,
134
+ message,
135
+ images,
136
+ });
137
+ },
138
+ [],
139
+ );
140
+ const onRetry = useCallback((message: ConversationMailboxMessage) => {
141
+ if (!message.idempotencyKey || (!message.text && !message.images?.length))
142
+ return;
143
+ void appendMessageRef.current.mutateAsync({
144
+ idempotencyKey: message.idempotencyKey,
145
+ message: message.text ?? "",
146
+ images: message.images,
147
+ });
148
+ }, []);
149
+ const onSubmitStart = useCallback(() => {
150
+ // Keep an in-flight remove intact so optimistic cache rollback stays coherent.
151
+ if (!cancelPendingMessagesRef.current.isPending) {
152
+ cancelPendingMessagesRef.current.reset();
153
+ }
154
+ if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
155
+ setQueueActionError(undefined);
156
+ onPinRequestRef.current();
157
+ }, []);
158
+ const cancellableMessageIds = props.pendingMessages
159
+ .filter((message) => message.clientStatus === undefined)
160
+ .map((message) => message.inboundMessageId);
161
+ const onCancelMessage = useCallback(
162
+ (message: ConversationMailboxMessage) => {
163
+ const receivedBefore = props.pendingGeneratedAtRef.current;
164
+ if (!receivedBefore) return;
165
+ cancelPendingMessagesRef.current.mutate({
166
+ inboundMessageIds: [message.inboundMessageId],
167
+ receivedBefore,
168
+ });
169
+ },
170
+ [props.pendingGeneratedAtRef],
171
+ );
172
+ const cancelTargetInboundMessageId =
173
+ cancelPendingMessages.variables?.inboundMessageIds[0];
174
+ const cancelError = Boolean(
175
+ cancelPendingMessages.error &&
176
+ cancelPendingMessages.variables?.inboundMessageIds.some((id) =>
177
+ cancellableMessageIds.includes(id),
178
+ ),
179
+ );
180
+
181
+ // Edit uses its own cancel mutation so remove errors stay on remove.
182
+ const editCancel = useCancelConversationPendingMessages(props.conversationId);
183
+ const editCancelRef = useRef(editCancel);
184
+ editCancelRef.current = editCancel;
185
+ const editingRef = useRef(false);
186
+ const composerRef = useRef<ConversationComposerHandle>(null);
187
+ const viewerEmail = useDashboardCoreData().data?.me.user.email;
188
+ const onEditMessage = useCallback(
189
+ async (message: ConversationMailboxMessage) => {
190
+ const receivedBefore = props.pendingGeneratedAtRef.current;
191
+ // The image read runs before the cancel starts, so isPending alone does
192
+ // not stop a second click.
193
+ if (!receivedBefore || editingRef.current) return;
194
+ editingRef.current = true;
195
+ const inboundMessageId = message.inboundMessageId;
196
+ setEditTargetInboundMessageId(inboundMessageId);
197
+ setQueueActionError(undefined);
198
+ try {
199
+ // Read images first. A failed read must leave the message queued.
200
+ const images = await readStoredComposerImages(
201
+ props.conversationId,
202
+ message.attachments,
203
+ );
204
+ // The reader left during the read. Keep the message in the queue.
205
+ if (!composerRef.current) return;
206
+ // Check before the cancel. After it, images that do not fit are lost.
207
+ if (!composerRef.current.canRestoreImages(images)) {
208
+ setQueueActionError("Remove some draft images to edit that message.");
209
+ return;
210
+ }
211
+ const result = await editCancelRef.current.mutateAsync({
212
+ inboundMessageIds: [inboundMessageId],
213
+ receivedBefore,
214
+ });
215
+ // Junior can read the message before the cancel lands. Then it is
216
+ // already in the Conversation and there is nothing to edit.
217
+ if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) {
218
+ setQueueActionError("Junior already read that message.");
219
+ return;
220
+ }
221
+ const restored = { images, text: message.text ?? "" };
222
+ if (composerRef.current) {
223
+ composerRef.current.restoreMessage(restored);
224
+ } else {
225
+ // The reader left during the cancel. Keep the text in the saved
226
+ // draft so it is there when they come back.
227
+ restoreStoredDraftText(props.conversationId, restored.text);
228
+ }
229
+ } catch {
230
+ setQueueActionError("Could not edit the queued message. Try again.");
231
+ } finally {
232
+ editingRef.current = false;
233
+ setEditTargetInboundMessageId(undefined);
234
+ }
235
+ },
236
+ [props.conversationId, props.pendingGeneratedAtRef],
237
+ );
238
+
239
+ const steerPendingMessages = useSteerConversationPendingMessages(
240
+ props.conversationId,
241
+ );
242
+ const steerPendingMessagesRef = useRef(steerPendingMessages);
243
+ steerPendingMessagesRef.current = steerPendingMessages;
244
+ const onSteerMessage = useCallback((message: ConversationMailboxMessage) => {
245
+ if (steerPendingMessagesRef.current.isPending) return;
246
+ setQueueActionError(undefined);
247
+ steerPendingMessagesRef.current.mutate(
248
+ { inboundMessageIds: [message.inboundMessageId] },
249
+ {
250
+ onError: () =>
251
+ setQueueActionError(
252
+ "Could not send the queued message now. Try again.",
253
+ ),
254
+ },
255
+ );
256
+ }, []);
257
+ const steerTargetInboundMessageId = steerPendingMessages.isPending
258
+ ? steerPendingMessages.variables?.inboundMessageIds[0]
259
+ : undefined;
260
+
261
+ const onStop = useCallback(async () => {
262
+ const result = await stopTurnRef.current.mutateAsync();
263
+ const restored = result.stoppedMessages.map((message) => message.text);
264
+ return restored.length ? restored.join("\n") : undefined;
265
+ }, []);
266
+
267
+ const onMailboxLayoutChange = useCallback(() => {
268
+ if (!window.matchMedia("(max-width: 767px)").matches) return;
269
+ onPinRequestRef.current();
270
+ }, []);
271
+ // Mobile keyboard open shrinks the transcript. Focus must re-pin the latest
272
+ // message so the reader does not stay stuck above the docked composer.
273
+ const onComposerFocus = useCallback(() => {
274
+ if (!window.matchMedia("(max-width: 767px)").matches) return;
275
+ onPinRequestRef.current();
276
+ }, []);
277
+
278
+ return (
279
+ <ComposerDock
280
+ above={
281
+ <>
282
+ {props.pendingAuthorization ? (
283
+ <PendingAuthorization authorization={props.pendingAuthorization} />
284
+ ) : null}
285
+ {/* Queued messages hide with the message box, so the question is
286
+ the last thing above the dock. */}
287
+ <div
288
+ hidden={Boolean(question)}
289
+ onFocus={keepComposerShown}
290
+ onPointerDown={keepComposerShown}
291
+ >
292
+ <PendingMailboxStack
293
+ authors={props.authors}
294
+ cancelError={cancelError}
295
+ cancelPending={cancelPendingMessages.isPending}
296
+ cancelTargetInboundMessageId={cancelTargetInboundMessageId}
297
+ committedMessageIds={props.committedMessageIds}
298
+ conversationId={props.conversationId}
299
+ editTargetInboundMessageId={editTargetInboundMessageId}
300
+ messages={props.pendingMessages}
301
+ onCancelMessage={onCancelMessage}
302
+ onEditMessage={onEditMessage}
303
+ onLayoutChange={onMailboxLayoutChange}
304
+ onRetry={onRetry}
305
+ onSteerMessage={props.stoppable ? onSteerMessage : undefined}
306
+ steerTargetInboundMessageId={steerTargetInboundMessageId}
307
+ viewerEmail={viewerEmail}
308
+ />
309
+ </div>
310
+ {unsentAnswers.dialog}
311
+ </>
312
+ }
313
+ >
314
+ <div
315
+ hidden={Boolean(question)}
316
+ onBlur={(event) => {
317
+ if (!event.currentTarget.contains(event.relatedTarget)) {
318
+ setComposerFocused(false);
319
+ }
320
+ }}
321
+ onFocus={() => setComposerFocused(true)}
322
+ >
323
+ <ConversationComposer
324
+ confirmSubmit={unsentAnswers.confirmSubmit}
325
+ draftId={props.conversationId}
326
+ error={
327
+ queueActionError ??
328
+ (stopTurn.error ? "Could not stop Junior. Try again." : undefined)
329
+ }
330
+ ref={composerRef}
331
+ label="Continue this conversation"
332
+ submitLabel="Send"
333
+ onFocus={onComposerFocus}
334
+ onStop={props.stoppable ? onStop : undefined}
335
+ stopPending={stopTurn.isPending}
336
+ onSubmitStart={onSubmitStart}
337
+ onSubmit={onSubmit}
338
+ />
339
+ </div>
340
+ </ComposerDock>
341
+ );
342
+ }
@@ -36,7 +36,7 @@ import {
36
36
  TooltipContent,
37
37
  TooltipTrigger,
38
38
  } from "../components/ui/tooltip";
39
- import { ShimmerText } from "../components/ShimmerText";
39
+ import { Spinner } from "../components/ui/spinner";
40
40
  import type { WaitingConversation } from "../review-desk/queries";
41
41
 
42
42
  type ConversationSidebarEntry =
@@ -443,12 +443,10 @@ function ConversationRowState(props: {
443
443
  }) {
444
444
  if (props.status === "active") {
445
445
  return (
446
- <ShimmerText
447
- active
448
- className="font-sans text-xs text-dashboard-text-muted"
449
- >
450
- Running
451
- </ShimmerText>
446
+ <Spinner
447
+ aria-label="Running"
448
+ className="size-3.5 text-dashboard-text-muted"
449
+ />
452
450
  );
453
451
  }
454
452
  if (props.status === "failed") {
@@ -28,6 +28,11 @@ import { TranscriptToolView } from "./TranscriptToolView";
28
28
  import { TranscriptReasoningView } from "./TranscriptReasoningView";
29
29
  import { TranscriptAttachmentsDeliveredView } from "./TranscriptAttachmentsDeliveredView";
30
30
  import { TranscriptStructuredEventView } from "./TranscriptStructuredEventView";
31
+ import {
32
+ TranscriptCapturedMemories,
33
+ TranscriptRecalledMemories,
34
+ } from "./TranscriptMemoryPopover";
35
+ import { isMemoryCaptureEvent } from "./conversationMemory";
31
36
  import { TranscriptFailureView } from "./TranscriptFailureView";
32
37
  import {
33
38
  structuredEventIcon,
@@ -248,16 +253,23 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
248
253
  renderMessage={(entry) =>
249
254
  props.conversation.sourceTask &&
250
255
  isAutomationTriggerMessage(props.conversation, entry.message) ? (
251
- <TranscriptAutomationTriggerView
252
- message={entry.message}
253
- sourceTask={props.conversation.sourceTask}
254
- />
256
+ <div className="grid min-w-0 gap-2">
257
+ <TranscriptAutomationTriggerView
258
+ message={entry.message}
259
+ sourceTask={props.conversation.sourceTask}
260
+ />
261
+ <TranscriptRecalledMemories message={entry.message} />
262
+ </div>
255
263
  ) : entry.message.eventType ? (
256
264
  <TranscriptRailEvent
257
265
  kind="event"
258
266
  objectType={entry.message.eventObjectType}
259
267
  >
260
268
  <TranscriptEventView message={entry.message} />
269
+ <TranscriptRecalledMemories
270
+ className="mt-1"
271
+ message={entry.message}
272
+ />
261
273
  </TranscriptRailEvent>
262
274
  ) : entry.message.context ? (
263
275
  <TranscriptRailEvent kind="message_context">
@@ -265,6 +277,10 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
265
277
  message={entry.message}
266
278
  conversation={props.conversation}
267
279
  />
280
+ <TranscriptRecalledMemories
281
+ className="mt-2"
282
+ message={entry.message}
283
+ />
268
284
  </TranscriptRailEvent>
269
285
  ) : (
270
286
  <TranscriptMessageView
@@ -285,10 +301,23 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
285
301
  icon={structuredEventIcon(entry.part.presentation.icon)}
286
302
  kind="structured_event"
287
303
  >
288
- <TranscriptStructuredEventView
289
- part={entry.part}
290
- timestamp={entry.timestamp}
291
- />
304
+ {isMemoryCaptureEvent(entry.part) ? (
305
+ <TranscriptCapturedMemories
306
+ fallback={
307
+ <TranscriptStructuredEventView
308
+ part={entry.part}
309
+ timestamp={entry.timestamp}
310
+ />
311
+ }
312
+ timestamp={entry.timestamp}
313
+ turnId={entry.part.turnId}
314
+ />
315
+ ) : (
316
+ <TranscriptStructuredEventView
317
+ part={entry.part}
318
+ timestamp={entry.timestamp}
319
+ />
320
+ )}
292
321
  </TranscriptRailEvent>
293
322
  )}
294
323
  renderSubagent={(entry) => (
@@ -69,9 +69,10 @@ const resourceIcons: Partial<Record<string, LucideIcon>> = {
69
69
  };
70
70
 
71
71
  /**
72
- * Show one Watch as a single line with its resource. Clicking the line shows
73
- * why the Watch exists and which events wake the Conversation. The expiry is
74
- * an internal limit, so the row does not show it.
72
+ * Show one Watch as a single line with its resource. When the resource has a
73
+ * web page, the line opens it and a chevron shows the details. Otherwise the
74
+ * line itself shows why the Watch exists and which events wake the
75
+ * Conversation. The expiry is an internal limit, so the row does not show it.
75
76
  */
76
77
  function WatchRow(props: { watch: ConversationWatch }) {
77
78
  const { watch } = props;
@@ -83,20 +84,41 @@ function WatchRow(props: { watch: ConversationWatch }) {
83
84
  // A timer has one event, and its fire time already says when it wakes.
84
85
  const events = watch.firesAt ? [] : watch.events;
85
86
  const filters = Object.entries(watch.match ?? {});
86
- const row = (
87
+ const icon = <Icon aria-hidden="true" className="size-3.5" />;
88
+ const title = watch.label ?? `Private ${resource}`;
89
+ const chevron = (
90
+ <span className="grid size-5 place-items-center">
91
+ <ChevronRight
92
+ aria-hidden="true"
93
+ className={dashboardDisclosureChevronClass}
94
+ />
95
+ </span>
96
+ );
97
+ const toggle = () => setExpanded((value) => !value);
98
+ const row = watch.url ? (
99
+ // The row opens the watched resource, like a linked work row. The
100
+ // chevron beside it shows the details.
101
+ <div className="flex min-w-0 items-start gap-0.5">
102
+ <div className="min-w-0 flex-1">
103
+ <DetailsRow href={watch.url} icon={icon} title={title} />
104
+ </div>
105
+ <button
106
+ aria-expanded={expanded}
107
+ aria-label={`Details for ${title}`}
108
+ className="group grid size-8 shrink-0 place-items-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-surface-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
109
+ onClick={toggle}
110
+ type="button"
111
+ >
112
+ {chevron}
113
+ </button>
114
+ </div>
115
+ ) : (
87
116
  <DetailsRow
88
117
  expanded={expanded}
89
- icon={<Icon aria-hidden="true" className="size-3.5" />}
90
- meta={
91
- <span className="grid size-5 place-items-center">
92
- <ChevronRight
93
- aria-hidden="true"
94
- className={dashboardDisclosureChevronClass}
95
- />
96
- </span>
97
- }
98
- onToggle={() => setExpanded((value) => !value)}
99
- title={watch.label ?? `Private ${resource}`}
118
+ icon={icon}
119
+ meta={chevron}
120
+ onToggle={toggle}
121
+ title={title}
100
122
  />
101
123
  );
102
124
  if (!expanded) return row;
@@ -42,6 +42,7 @@ import {
42
42
  } from "./TranscriptHeadingRow";
43
43
  import { SlackMark } from "./SlackMark";
44
44
  import { DisplayText } from "../display-text/DisplayText";
45
+ import { ReviewItemChangeLabel } from "../review-desk/ReviewItemChangeRow";
45
46
  import { displayPlainText } from "../display-text/plainText";
46
47
 
47
48
  // Rows are one line each, so the stack can show five before it collapses.
@@ -349,7 +350,16 @@ function PendingRow(props: {
349
350
  className="m-0 min-w-0 truncate font-sans text-sm leading-snug text-dashboard-text-subtle"
350
351
  title={displayPlainText(text, mentions)}
351
352
  >
352
- <DisplayText inline mentions={mentions} text={text} />
353
+ {props.message.reviewItemChange ? (
354
+ <ReviewItemChangeLabel
355
+ fallback={
356
+ <DisplayText inline mentions={mentions} text={text} />
357
+ }
358
+ record={props.message.reviewItemChange}
359
+ />
360
+ ) : (
361
+ <DisplayText inline mentions={mentions} text={text} />
362
+ )}
353
363
  </p>
354
364
  ) : null}
355
365
  </>