@sentry/junior-dashboard 0.275.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 (37) 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/conversations/ConversationComposer.tsx +42 -26
  8. package/src/client/conversations/ConversationDetails.tsx +63 -35
  9. package/src/client/conversations/ConversationHeader.tsx +10 -2
  10. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  11. package/src/client/conversations/ConversationMemories.tsx +118 -78
  12. package/src/client/conversations/ConversationMeta.tsx +47 -42
  13. package/src/client/conversations/ConversationPage.tsx +168 -58
  14. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  15. package/src/client/conversations/ConversationWatches.tsx +37 -15
  16. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  17. package/src/client/conversations/QuickQuestion.tsx +476 -0
  18. package/src/client/conversations/TelemetryMetrics.tsx +2 -1
  19. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  20. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  21. package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
  22. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  23. package/src/client/conversations/TranscriptRows.tsx +9 -2
  24. package/src/client/conversations/UsageCard.tsx +3 -3
  25. package/src/client/conversations/conversationMemory.ts +138 -0
  26. package/src/client/conversations/eventTranscript.ts +8 -0
  27. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  28. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  29. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  30. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  31. package/src/client/sentry.ts +77 -0
  32. package/src/client/types.ts +6 -0
  33. package/src/client.tsx +3 -0
  34. package/src/mock-reporting/watches.ts +2 -0
  35. package/src/shell.ts +27 -2
  36. package/src/tailwind.css +8 -19
  37. package/src/client/components/ActiveIndicator.tsx +0 -14
@@ -6,7 +6,6 @@ import {
6
6
  CircleX,
7
7
  GitMerge,
8
8
  GitPullRequest,
9
- Globe2,
10
9
  LockKeyhole,
11
10
  TriangleAlert,
12
11
  } from "lucide-react";
@@ -72,35 +71,24 @@ export function PendingAuthorization(props: {
72
71
  );
73
72
  }
74
73
 
75
- /** Show whether the selected conversation is public or private. */
74
+ /**
75
+ * Mark a private conversation with a lock icon. Public is the default, so it
76
+ * gets no marker, which keeps the title bar short.
77
+ */
76
78
  export function ConversationPrivacyChip(props: {
77
79
  visibility: Conversation["visibility"];
78
80
  }) {
79
- if (!props.visibility) return null;
80
- const isPublic = props.visibility === "public";
81
- const Icon = isPublic ? Globe2 : LockKeyhole;
82
- const shortLabel = isPublic ? "Public" : "Private";
83
- const fullLabel = isPublic ? "Public conversation" : "Private conversation";
84
- const detail = isPublic
85
- ? "Anyone in this workspace can see this transcript."
86
- : "Only members of this conversation can see this transcript.";
81
+ if (props.visibility !== "private") return null;
82
+ const label =
83
+ "Private conversation. Only members of this conversation can see this transcript.";
87
84
  return (
88
85
  <span
89
- className={
90
- isPublic
91
- ? "inline-flex max-w-full items-center gap-1 rounded-full border border-dashboard-border-emphasis bg-dashboard-fill-soft px-2 py-0.5 font-sans text-2xs font-medium text-dashboard-text-muted"
92
- : "inline-flex max-w-full items-center gap-1 rounded-full border border-dashboard-border-strong bg-dashboard-fill-soft px-2 py-0.5 font-sans text-2xs font-medium text-dashboard-text-muted"
93
- }
86
+ className="inline-flex items-center text-dashboard-text-muted"
94
87
  role="note"
95
- title={`${fullLabel}. ${detail}`}
88
+ title={label}
96
89
  >
97
- <Icon aria-hidden="true" className="size-3 shrink-0" />
98
- <span aria-hidden="true" className="truncate">
99
- {shortLabel}
100
- </span>
101
- <span className="sr-only">
102
- {fullLabel}. {detail}
103
- </span>
90
+ <LockKeyhole aria-hidden="true" className="size-3.5 shrink-0" />
91
+ <span className="sr-only">{label}</span>
104
92
  </span>
105
93
  );
106
94
  }
@@ -568,6 +556,37 @@ export function conversationLocation(
568
556
  );
569
557
  }
570
558
 
559
+ /**
560
+ * Show the estimated cost of a conversation with its breakdown tooltip.
561
+ * Returns null until there is a cost or a live run to show.
562
+ */
563
+ export function conversationCost(
564
+ conversation: Conversation | undefined,
565
+ detail: ConversationDetailReport | undefined,
566
+ tooltipPlacement?: "above" | "below",
567
+ ): ReactNode {
568
+ if (!conversation) return null;
569
+ const costSummary = summarizeCost(
570
+ detail?.cumulativeUsage ?? conversation.cumulativeUsage,
571
+ );
572
+ const auxiliaryCosts = detail?.auxiliaryCosts ?? conversation.auxiliaryCosts;
573
+ const live = (detail?.status ?? conversation.status) === "active";
574
+ if (!costSummary && !auxiliaryCosts && !live) return null;
575
+ return (
576
+ <CostMetric
577
+ auxiliaryCosts={auxiliaryCosts}
578
+ childAgentTotals={
579
+ detail?.childAgentTotals ?? conversation.childAgentTotals
580
+ }
581
+ live={live}
582
+ liveModelId={liveModelId(detail)}
583
+ modelUsage={detail?.modelUsage}
584
+ summary={costSummary}
585
+ tooltipPlacement={tooltipPlacement}
586
+ />
587
+ );
588
+ }
589
+
571
590
  function conversationStatItems(props: {
572
591
  conversation: Conversation | undefined;
573
592
  detail?: ConversationDetailReport;
@@ -580,7 +599,6 @@ function conversationStatItems(props: {
580
599
  props.detail?.cumulativeUsage ?? props.conversation.cumulativeUsage;
581
600
  const tokenSummary =
582
601
  summarizeModelUsage(props.detail?.modelUsage) ?? summarizeUsage(usage);
583
- const costSummary = summarizeCost(usage);
584
602
  const durationLabel = formatConversationDuration(props.conversation);
585
603
  const live = (props.detail?.status ?? props.conversation.status) === "active";
586
604
  const activeModelId = liveModelId(props.detail);
@@ -589,8 +607,9 @@ function conversationStatItems(props: {
589
607
  props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
590
608
  const lastActivityAt = props.conversation.lastSeenAt;
591
609
  const location = conversationLocation(props.conversation, props.detail);
610
+ const cost = conversationCost(props.conversation, props.detail);
592
611
  const rawStats: Array<ConversationStatItem | undefined> = [
593
- // The desktop title bar shows the location. Mobile hides that bar.
612
+ // The desktop title bar shows the location and cost. Mobile hides that bar.
594
613
  location
595
614
  ? {
596
615
  content: location,
@@ -660,26 +679,12 @@ function conversationStatItems(props: {
660
679
  label: "Tokens",
661
680
  }
662
681
  : undefined,
663
- costSummary ||
664
- props.detail?.auxiliaryCosts ||
665
- props.conversation.auxiliaryCosts ||
666
- live
682
+ cost
667
683
  ? {
668
- content: (
669
- <CostMetric
670
- auxiliaryCosts={
671
- props.detail?.auxiliaryCosts ??
672
- props.conversation.auxiliaryCosts
673
- }
674
- childAgentTotals={childAgentTotals}
675
- live={live}
676
- liveModelId={activeModelId}
677
- modelUsage={props.detail?.modelUsage}
678
- summary={costSummary}
679
- />
680
- ),
684
+ content: cost,
681
685
  key: "cost",
682
686
  label: "Cost",
687
+ mobileOnly: true,
683
688
  }
684
689
  : undefined,
685
690
  childAgentTotals
@@ -31,6 +31,14 @@ import {
31
31
  import { ConversationHeader } from "./ConversationHeader";
32
32
  import { ConversationReplyFooter } from "./ConversationReplyFooter";
33
33
  import { ConversationDetails } from "./ConversationDetails";
34
+ import { turnPrompts } from "./ConversationMemories";
35
+ import {
36
+ ConversationMemoryContext,
37
+ memoryAnchorKey,
38
+ useConversationMemories,
39
+ type ConversationMemoryState,
40
+ type OpenMemoryPopover,
41
+ } from "./conversationMemory";
34
42
  import { ConversationEventDetails } from "./ConversationEventDetails";
35
43
  import { ConversationEventLogLoading } from "./ConversationEventLog";
36
44
  import {
@@ -50,6 +58,7 @@ import { ConversationAgentTree } from "./ConversationAgentTree";
50
58
  import { conversationLinks } from "./ConversationLinkedWork";
51
59
  import {
52
60
  ConversationAnnotations,
61
+ conversationCost,
53
62
  conversationLocation,
54
63
  ConversationPrivacyChip,
55
64
  ConversationStats,
@@ -74,6 +83,11 @@ import {
74
83
  ParticipantAvatarStack,
75
84
  } from "../components/ParticipantAvatarStack";
76
85
  import { ChatLayout } from "./ChatLayout";
86
+ import {
87
+ QuickQuestionPanel,
88
+ QuickQuestionSelectionButton,
89
+ useQuickQuestionThread,
90
+ } from "./QuickQuestion";
77
91
  import { latestReviewItemId } from "../review-desk/reviewItemFormat";
78
92
  import { Transcript } from "./TranscriptView";
79
93
  import {
@@ -155,7 +169,24 @@ export function ConversationPage(props: {
155
169
  );
156
170
  }, [setSearchParams]);
157
171
  const [turnContext, setTurnContext] = useState<TurnContextSelection>();
158
- const showDetails = detailsOpen && !eventOpen && !turnContext;
172
+ const [memoryPopover, setMemoryPopover] = useState<OpenMemoryPopover>();
173
+ // A memory chosen in details waits here until its transcript row renders.
174
+ const [memoryJump, setMemoryJump] = useState<OpenMemoryPopover>();
175
+ // The page stays mounted across Conversations. Memory state belongs to one.
176
+ const [memoryConversationId, setMemoryConversationId] = useState(
177
+ props.conversationId,
178
+ );
179
+ if (memoryConversationId !== props.conversationId) {
180
+ setMemoryConversationId(props.conversationId);
181
+ setMemoryPopover(undefined);
182
+ setMemoryJump(undefined);
183
+ }
184
+ const quickQuestion = useQuickQuestionThread(props.conversationId);
185
+ // Event details and turn context also show over a Quick question.
186
+ const showQuickQuestion =
187
+ Boolean(quickQuestion.thread) && !eventOpen && !turnContext;
188
+ const showDetails =
189
+ detailsOpen && !eventOpen && !turnContext && !quickQuestion.thread;
159
190
  const [viewState, setView] = useState<TranscriptViewMode>(() =>
160
191
  eventOpen ? "raw" : "rich",
161
192
  );
@@ -167,6 +198,15 @@ export function ConversationPage(props: {
167
198
  const summaries = props.data?.conversations.conversations ?? [];
168
199
  const conversations = buildConversations(summaries);
169
200
  const detail = useConversationData(conversationId);
201
+ const memoryList = useConversationMemories(conversationId).data;
202
+ const memoryState = useMemo<ConversationMemoryState>(
203
+ () => ({
204
+ list: memoryList,
205
+ open: memoryPopover,
206
+ setOpen: setMemoryPopover,
207
+ }),
208
+ [memoryList, memoryPopover],
209
+ );
170
210
  const archive = useArchiveConversation(conversationId);
171
211
  const markRead = useMarkConversationRead(conversationId);
172
212
  const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
@@ -288,6 +328,38 @@ export function ConversationPage(props: {
288
328
  if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
289
329
  else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
290
330
  }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
331
+ // Show a chosen memory at its transcript row. Wait for the first page, then
332
+ // load earlier pages until the row renders, or stop when history ends.
333
+ const memoryPrompts = useMemo(
334
+ () => turnPrompts(transcript?.events),
335
+ [transcript?.events],
336
+ );
337
+ useEffect(() => {
338
+ if (!memoryJump || !transcript) return;
339
+ const anchor = pageRef.current?.querySelector(
340
+ `[data-memory-anchor="${CSS.escape(memoryAnchorKey(memoryJump))}"]`,
341
+ );
342
+ if (anchor) {
343
+ anchor.scrollIntoView({ block: "center" });
344
+ setMemoryPopover(memoryJump);
345
+ setMemoryJump(undefined);
346
+ } else if (
347
+ detail.hasPreviousPage &&
348
+ !detail.isLoadingPreviousPage &&
349
+ !detail.historyError
350
+ ) {
351
+ loadPreviousPageRef.current();
352
+ } else if (!detail.isLoadingPreviousPage) {
353
+ setMemoryJump(undefined);
354
+ }
355
+ }, [
356
+ detail.hasPreviousPage,
357
+ detail.historyError,
358
+ detail.isLoadingPreviousPage,
359
+ memoryJump,
360
+ transcript,
361
+ view,
362
+ ]);
291
363
  // Keep the panel while the transcript or a linked event loads, and for the
292
364
  // render before a found Turn link becomes an event link.
293
365
  const eventLoading =
@@ -362,7 +434,7 @@ export function ConversationPage(props: {
362
434
  ref={pageRef}
363
435
  className={cn(
364
436
  "grid h-full min-h-0 min-w-0",
365
- (showDetails || showEvent || turnContext) &&
437
+ (showDetails || showEvent || turnContext || showQuickQuestion) &&
366
438
  "xl:grid-cols-[minmax(0,1fr)_25rem]",
367
439
  )}
368
440
  >
@@ -373,10 +445,12 @@ export function ConversationPage(props: {
373
445
  agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
374
446
  }
375
447
  conversationId={conversationId}
448
+ cost={conversationCost(conversation, detail.data, "below")}
376
449
  detailsOpen={showDetails}
377
450
  onDetailsClick={() => {
378
451
  if (eventOpen) setSelectedEventSeq(undefined);
379
452
  setTurnContext(undefined);
453
+ quickQuestion.close();
380
454
  if (showDetails) closeDetails();
381
455
  else setDetailsChoice(true);
382
456
  }}
@@ -470,31 +544,35 @@ export function ConversationPage(props: {
470
544
  data.
471
545
  </div>
472
546
  ) : null}
473
- <Transcript
474
- hasPreviousPage={detail.hasPreviousPage}
475
- historyError={detail.historyError}
476
- historyVersion={detail.historyVersion}
477
- lastReadSeq={readAtOpen.lastReadSeq}
478
- live={live}
479
- loadingPreviousPage={detail.isLoadingPreviousPage}
480
- onLoadPreviousPage={detail.loadPreviousPage}
481
- onRead={markRead}
482
- onSelectedEventSeqChange={setSelectedEventSeq}
483
- pendingMessages={detail.inlineMessages}
484
- pinRequestVersion={pinRequestVersion}
485
- responding={
486
- !detail.error && conversationIsResponding(transcript)
487
- }
488
- onOpenSubagentTranscript={onOpenSubagentTranscript}
489
- search={search}
490
- onSearchChange={setSearch}
491
- selectedEventSeq={selectedEventSeq}
492
- startAtUnread={readConversationStartPosition() === "unread"}
493
- transcript={transcript}
494
- turnContextPanel={turnContextPanel}
495
- unread={readAtOpen.unread}
496
- view={view}
497
- />
547
+ <ConversationMemoryContext value={memoryState}>
548
+ <Transcript
549
+ hasPreviousPage={detail.hasPreviousPage}
550
+ historyError={detail.historyError}
551
+ historyVersion={detail.historyVersion}
552
+ lastReadSeq={readAtOpen.lastReadSeq}
553
+ live={live}
554
+ loadingPreviousPage={detail.isLoadingPreviousPage}
555
+ onLoadPreviousPage={detail.loadPreviousPage}
556
+ onRead={markRead}
557
+ onSelectedEventSeqChange={setSelectedEventSeq}
558
+ pendingMessages={detail.inlineMessages}
559
+ pinRequestVersion={pinRequestVersion}
560
+ responding={
561
+ !detail.error && conversationIsResponding(transcript)
562
+ }
563
+ onOpenSubagentTranscript={onOpenSubagentTranscript}
564
+ search={search}
565
+ onSearchChange={setSearch}
566
+ selectedEventSeq={selectedEventSeq}
567
+ startAtUnread={
568
+ readConversationStartPosition() === "unread"
569
+ }
570
+ transcript={transcript}
571
+ turnContextPanel={turnContextPanel}
572
+ unread={readAtOpen.unread}
573
+ view={view}
574
+ />
575
+ </ConversationMemoryContext>
498
576
  </>
499
577
  )}
500
578
  </div>
@@ -532,37 +610,51 @@ export function ConversationPage(props: {
532
610
  }
533
611
  />
534
612
  {showDetails ? (
535
- <ConversationDetails
536
- brief={detail.data?.brief}
537
- conversationId={conversationId}
538
- highlightReview={reviewOpen}
539
- identity={identity}
540
- onClose={closeDetails}
541
- onOpenSubagent={setSubagentTarget}
542
- links={conversationLinks(detail.data)}
543
- overview={
544
- hasConversationStats({
545
- conversation,
546
- detail: detail.data,
547
- }) ? (
548
- <ConversationStats
549
- conversation={conversation}
550
- detail={detail.data}
551
- />
552
- ) : null
553
- }
554
- usage={
555
- hasConversationUsageBreakdown({
556
- conversation,
557
- detail: detail.data,
558
- }) ? (
559
- <ConversationUsageBreakdown
560
- conversation={conversation}
561
- detail={detail.data}
562
- />
563
- ) : null
564
- }
565
- />
613
+ <ConversationMemoryContext value={memoryState}>
614
+ <ConversationDetails
615
+ brief={detail.data?.brief}
616
+ conversationId={conversationId}
617
+ highlightReview={reviewOpen}
618
+ identity={identity}
619
+ onClose={closeDetails}
620
+ onOpenSubagent={setSubagentTarget}
621
+ memoryPrompts={memoryPrompts}
622
+ onSelectMemory={(entry) => {
623
+ // Memory rows show only in the Conversation view, and search
624
+ // can hide them.
625
+ if (view !== "rich") onViewChange("rich");
626
+ setSearch("");
627
+ setMemoryJump({
628
+ memoryId: entry.memory.id,
629
+ source: entry.source,
630
+ turnId: entry.turnId,
631
+ });
632
+ }}
633
+ links={conversationLinks(detail.data)}
634
+ overview={
635
+ hasConversationStats({
636
+ conversation,
637
+ detail: detail.data,
638
+ }) ? (
639
+ <ConversationStats
640
+ conversation={conversation}
641
+ detail={detail.data}
642
+ />
643
+ ) : null
644
+ }
645
+ usage={
646
+ hasConversationUsageBreakdown({
647
+ conversation,
648
+ detail: detail.data,
649
+ }) ? (
650
+ <ConversationUsageBreakdown
651
+ conversation={conversation}
652
+ detail={detail.data}
653
+ />
654
+ ) : null
655
+ }
656
+ />
657
+ </ConversationMemoryContext>
566
658
  ) : null}
567
659
  {showEvent ? (
568
660
  <ConversationEventDetails
@@ -584,6 +676,24 @@ export function ConversationPage(props: {
584
676
  />
585
677
  </TranscriptSearchProvider>
586
678
  ) : null}
679
+ {showQuickQuestion && quickQuestion.thread ? (
680
+ <QuickQuestionPanel
681
+ conversationId={conversationId}
682
+ error={quickQuestion.error}
683
+ onClose={quickQuestion.close}
684
+ onSubmit={quickQuestion.submit}
685
+ pending={quickQuestion.pending}
686
+ thread={quickQuestion.thread}
687
+ />
688
+ ) : null}
689
+ <QuickQuestionSelectionButton
690
+ onAsk={(selection) => {
691
+ if (eventOpen) setSelectedEventSeq(undefined);
692
+ setTurnContext(undefined);
693
+ quickQuestion.open(selection);
694
+ }}
695
+ rootRef={pageRef}
696
+ />
587
697
  <SubagentTranscriptDrawer
588
698
  onClose={() => setSubagentTarget(undefined)}
589
699
  target={subagentTarget}
@@ -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
  </>