@sentry/junior-dashboard 0.266.0 → 0.268.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 (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -19,6 +19,7 @@ import {
19
19
  useAppendConversationMessage,
20
20
  useArchiveConversation,
21
21
  useCancelConversationPendingMessages,
22
+ useSteerConversationPendingMessages,
22
23
  useConversationData,
23
24
  useStopConversationTurn,
24
25
  type PendingArchiveConversationUpdate,
@@ -32,7 +33,13 @@ import {
32
33
  CONVERSATION_EVENT_PARAM,
33
34
  parseConversationEventSeq,
34
35
  } from "./conversationRoutes";
35
- import { ConversationComposer } from "./ConversationComposer";
36
+ import {
37
+ ConversationComposer,
38
+ restoreStoredDraftText,
39
+ type ConversationComposerHandle,
40
+ } from "./ConversationComposer";
41
+ import { readStoredComposerImages } from "./ComposerImages";
42
+ import { useDashboardCoreData } from "../api";
36
43
  import { ConversationHeader } from "./ConversationHeader";
37
44
  import { ConversationDetails } from "./ConversationDetails";
38
45
  import { ConversationEventDetails } from "./ConversationEventDetails";
@@ -66,6 +73,11 @@ import {
66
73
  hasConversationUsageBreakdown,
67
74
  } from "./ConversationUsageBreakdown";
68
75
  import { PendingMailboxStack } from "./PendingMailboxStack";
76
+ import { UserInputRequestCard } from "./UserInputRequestCard";
77
+ import {
78
+ openUserInputRequest,
79
+ type OpenUserInputRequest,
80
+ } from "./userInputRequest";
69
81
  import {
70
82
  buildConversations,
71
83
  conversationDetailTitle,
@@ -81,6 +93,7 @@ import {
81
93
  } from "../components/ParticipantAvatarStack";
82
94
  import { ChatLayout } from "./ChatLayout";
83
95
  import { ComposerDock } from "./ComposerDock";
96
+ import { ConversationReviewBar } from "../review-desk/ConversationReviewBar";
84
97
  import { Transcript } from "./TranscriptView";
85
98
  import { TranscriptLoading } from "./TranscriptLoading";
86
99
  import type { TranscriptViewMode } from "./transcriptRenderModel";
@@ -184,6 +197,10 @@ export function ConversationPage(props: {
184
197
  () => committedMessageIds(detail.data?.events),
185
198
  [detail.data?.events],
186
199
  );
200
+ const userInputRequest = useMemo(
201
+ () => openUserInputRequest(detail.data?.events),
202
+ [detail.data?.events],
203
+ );
187
204
  // Cancel needs the latest mailbox watermark, but not as a render prop. Live
188
205
  // polls refresh generatedAt every 2s even when the queue is unchanged.
189
206
  const pendingGeneratedAtRef = useRef(detail.pendingGeneratedAt);
@@ -434,6 +451,7 @@ export function ConversationPage(props: {
434
451
  loadingPreviousPage={detail.isLoadingPreviousPage}
435
452
  onLoadPreviousPage={detail.loadPreviousPage}
436
453
  onSelectedEventSeqChange={setSelectedEventSeq}
454
+ pendingMessages={detail.inlineMessages}
437
455
  pinRequestVersion={pinRequestVersion}
438
456
  responding={
439
457
  !detail.error && conversationIsResponding(transcript)
@@ -466,7 +484,17 @@ export function ConversationPage(props: {
466
484
  // every 2s; a prop would bust footer memo while the reader types.
467
485
  pendingGeneratedAtRef={pendingGeneratedAtRef}
468
486
  pendingMessages={detail.pendingMessages}
487
+ // Without live history, an inline send would never leave the
488
+ // transcript tail. A started Turn reads a new send only later.
489
+ // Keep those sends in the queue. An earlier inline send that
490
+ // history does not have yet can start that Turn first.
491
+ sendsInline={
492
+ detail.data.eventHistory.status === "available" &&
493
+ !conversationIsResponding(detail.data) &&
494
+ detail.inlineMessages.length === 0
495
+ }
469
496
  stoppable={live}
497
+ userInputRequest={userInputRequest}
470
498
  />
471
499
  ) : undefined
472
500
  }
@@ -520,9 +548,11 @@ export function ConversationPage(props: {
520
548
  {turnContext ? (
521
549
  <TranscriptSearchProvider query={search}>
522
550
  <TurnContextDetails
551
+ actor={turnContext.actor}
523
552
  contexts={turnContext.contexts}
524
553
  key={turnContext.id}
525
554
  onClose={closeTurnContext}
555
+ timestamp={turnContext.timestamp}
526
556
  />
527
557
  </TranscriptSearchProvider>
528
558
  ) : null}
@@ -548,8 +578,15 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
548
578
  pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
549
579
  pendingGeneratedAtRef: { current: string | undefined };
550
580
  pendingMessages: readonly ConversationMailboxMessage[];
581
+ /**
582
+ * True when history is live and no Turn has started, so the next Turn will
583
+ * read a new send at once.
584
+ */
585
+ sendsInline: boolean;
551
586
  /** True while the conversation has a Turn that a stop can end. */
552
587
  stoppable: boolean;
588
+ /** Junior's open question, if no user message answers it yet. */
589
+ userInputRequest?: OpenUserInputRequest;
553
590
  }) {
554
591
  const appendMessage = useAppendConversationMessage(props.conversationId);
555
592
  const stopTurn = useStopConversationTurn(props.conversationId);
@@ -558,6 +595,9 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
558
595
  const cancelPendingMessages = useCancelConversationPendingMessages(
559
596
  props.conversationId,
560
597
  );
598
+ const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
599
+ useState<string>();
600
+ const [queueActionError, setQueueActionError] = useState<string>();
561
601
  // Keep submit identity stable across mutation status flips so the memoized
562
602
  // composer does not re-render while the reader is still typing.
563
603
  const appendMessageRef = useRef(appendMessage);
@@ -583,10 +623,21 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
583
623
  if (!window.matchMedia("(max-width: 767px)").matches) return;
584
624
  onPinRequestRef.current();
585
625
  }, [pendingMessageVersion]);
626
+ // No Turn runs and nothing waits in the queue, so the next send would
627
+ // leave the queue almost at once. Show it in the transcript instead. A
628
+ // conversation can be active before its Turn starts; that still counts.
629
+ const committedIds = new Set(props.committedMessageIds);
630
+ const sendInlineRef = useRef(false);
631
+ sendInlineRef.current =
632
+ props.sendsInline &&
633
+ !props.pendingMessages.some(
634
+ (message) => !committedIds.has(message.messageId),
635
+ );
586
636
  const onSubmit = useCallback(
587
637
  async (message: string, idempotencyKey: string, images?: InputImage[]) => {
588
638
  await appendMessageRef.current.mutateAsync({
589
639
  idempotencyKey,
640
+ inline: sendInlineRef.current,
590
641
  message,
591
642
  images,
592
643
  });
@@ -608,11 +659,19 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
608
659
  cancelPendingMessagesRef.current.reset();
609
660
  }
610
661
  if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
662
+ setQueueActionError(undefined);
611
663
  onPinRequestRef.current();
612
664
  }, []);
613
665
  const cancellableMessageIds = props.pendingMessages
614
666
  .filter((message) => message.clientStatus === undefined)
615
667
  .map((message) => message.inboundMessageId);
668
+ const onAnswerUserInput = useCallback(async (answer: string) => {
669
+ onPinRequestRef.current();
670
+ await appendMessageRef.current.mutateAsync({
671
+ idempotencyKey: crypto.randomUUID(),
672
+ message: answer,
673
+ });
674
+ }, []);
616
675
  const onCancelMessage = useCallback(
617
676
  (message: ConversationMailboxMessage) => {
618
677
  const receivedBefore = props.pendingGeneratedAtRef.current;
@@ -633,6 +692,86 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
633
692
  ),
634
693
  );
635
694
 
695
+ // Edit uses its own cancel mutation so remove errors stay on remove.
696
+ const editCancel = useCancelConversationPendingMessages(props.conversationId);
697
+ const editCancelRef = useRef(editCancel);
698
+ editCancelRef.current = editCancel;
699
+ const editingRef = useRef(false);
700
+ const composerRef = useRef<ConversationComposerHandle>(null);
701
+ const viewerEmail = useDashboardCoreData().data?.me.user.email;
702
+ const onEditMessage = useCallback(
703
+ async (message: ConversationMailboxMessage) => {
704
+ const receivedBefore = props.pendingGeneratedAtRef.current;
705
+ // The image read runs before the cancel starts, so isPending alone does
706
+ // not stop a second click.
707
+ if (!receivedBefore || editingRef.current) return;
708
+ editingRef.current = true;
709
+ const inboundMessageId = message.inboundMessageId;
710
+ setEditTargetInboundMessageId(inboundMessageId);
711
+ setQueueActionError(undefined);
712
+ try {
713
+ // Read images first. A failed read must leave the message queued.
714
+ const images = await readStoredComposerImages(
715
+ props.conversationId,
716
+ message.attachments,
717
+ );
718
+ // The reader left during the read. Keep the message in the queue.
719
+ if (!composerRef.current) return;
720
+ // Check before the cancel. After it, images that do not fit are lost.
721
+ if (!composerRef.current.canRestoreImages(images)) {
722
+ setQueueActionError("Remove some draft images to edit that message.");
723
+ return;
724
+ }
725
+ const result = await editCancelRef.current.mutateAsync({
726
+ inboundMessageIds: [inboundMessageId],
727
+ receivedBefore,
728
+ });
729
+ // Junior can read the message before the cancel lands. Then it is
730
+ // already in the Conversation and there is nothing to edit.
731
+ if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) {
732
+ setQueueActionError("Junior already read that message.");
733
+ return;
734
+ }
735
+ const restored = { images, text: message.text ?? "" };
736
+ if (composerRef.current) {
737
+ composerRef.current.restoreMessage(restored);
738
+ } else {
739
+ // The reader left during the cancel. Keep the text in the saved
740
+ // draft so it is there when they come back.
741
+ restoreStoredDraftText(props.conversationId, restored.text);
742
+ }
743
+ } catch {
744
+ setQueueActionError("Could not edit the queued message. Try again.");
745
+ } finally {
746
+ editingRef.current = false;
747
+ setEditTargetInboundMessageId(undefined);
748
+ }
749
+ },
750
+ [props.conversationId, props.pendingGeneratedAtRef],
751
+ );
752
+
753
+ const steerPendingMessages = useSteerConversationPendingMessages(
754
+ props.conversationId,
755
+ );
756
+ const steerPendingMessagesRef = useRef(steerPendingMessages);
757
+ steerPendingMessagesRef.current = steerPendingMessages;
758
+ const onSteerMessage = useCallback((message: ConversationMailboxMessage) => {
759
+ if (steerPendingMessagesRef.current.isPending) return;
760
+ setQueueActionError(undefined);
761
+ steerPendingMessagesRef.current.mutate(
762
+ { inboundMessageIds: [message.inboundMessageId] },
763
+ {
764
+ onError: () =>
765
+ setQueueActionError(
766
+ "Could not send the queued message now. Try again.",
767
+ ),
768
+ },
769
+ );
770
+ }, []);
771
+ const steerTargetInboundMessageId = steerPendingMessages.isPending
772
+ ? steerPendingMessages.variables?.inboundMessageIds[0]
773
+ : undefined;
774
+
636
775
  const onStop = useCallback(async () => {
637
776
  const result = await stopTurnRef.current.mutateAsync();
638
777
  const restored = result.stoppedMessages.map((message) => message.text);
@@ -657,22 +796,41 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
657
796
  {props.pendingAuthorization ? (
658
797
  <PendingAuthorization authorization={props.pendingAuthorization} />
659
798
  ) : null}
799
+ {/* A queued message already answers the question. */}
800
+ {props.userInputRequest && props.pendingMessages.length === 0 ? (
801
+ <UserInputRequestCard
802
+ key={props.userInputRequest.toolCallId}
803
+ onAnswer={onAnswerUserInput}
804
+ request={props.userInputRequest}
805
+ />
806
+ ) : null}
807
+ <ConversationReviewBar conversationId={props.conversationId} />
660
808
  <PendingMailboxStack
661
809
  cancelError={cancelError}
662
810
  cancelPending={cancelPendingMessages.isPending}
663
811
  cancelTargetInboundMessageId={cancelTargetInboundMessageId}
664
812
  committedMessageIds={props.committedMessageIds}
813
+ conversationId={props.conversationId}
814
+ editTargetInboundMessageId={editTargetInboundMessageId}
665
815
  messages={props.pendingMessages}
666
816
  onCancelMessage={onCancelMessage}
817
+ onEditMessage={onEditMessage}
667
818
  onLayoutChange={onMailboxLayoutChange}
668
819
  onRetry={onRetry}
820
+ onSteerMessage={props.stoppable ? onSteerMessage : undefined}
821
+ steerTargetInboundMessageId={steerTargetInboundMessageId}
822
+ viewerEmail={viewerEmail}
669
823
  />
670
824
  </>
671
825
  }
672
826
  >
673
827
  <ConversationComposer
674
828
  draftId={props.conversationId}
675
- error={stopTurn.error ? "Could not stop Junior. Try again." : undefined}
829
+ error={
830
+ queueActionError ??
831
+ (stopTurn.error ? "Could not stop Junior. Try again." : undefined)
832
+ }
833
+ ref={composerRef}
676
834
  label="Continue this conversation"
677
835
  submitLabel="Send"
678
836
  onFocus={onComposerFocus}
@@ -48,6 +48,7 @@ export function ConversationSidebar(props: {
48
48
  query: string;
49
49
  selectedId?: string;
50
50
  timeZone: string;
51
+ waitingConversationIds: ReadonlySet<string>;
51
52
  /**
52
53
  * `panel` = split-pane dock with internal scroll.
53
54
  * `landing` = flow nav under create compose (parent owns scroll).
@@ -186,6 +187,9 @@ export function ConversationSidebar(props: {
186
187
  onArchiveError={handleArchiveError}
187
188
  onArchived={handleArchived}
188
189
  selected={entry.conversation.id === props.selectedId}
190
+ waitingOnViewer={props.waitingConversationIds.has(
191
+ entry.conversation.id,
192
+ )}
189
193
  />
190
194
  )
191
195
  }
@@ -271,6 +275,7 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
271
275
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
272
276
  onArchived(conversation: Conversation): void;
273
277
  selected: boolean;
278
+ waitingOnViewer: boolean;
274
279
  }) {
275
280
  const archive = useArchiveConversation(props.conversation.id, {
276
281
  // Read the attempted direction from the mutation call, not the (possibly
@@ -316,6 +321,7 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
316
321
  finishedSinceSeen={props.finishedSinceSeen}
317
322
  isPrivate={isPrivate}
318
323
  status={status}
324
+ waitingOnViewer={props.waitingOnViewer}
319
325
  />
320
326
  </div>
321
327
  <div className="col-start-2 row-start-1 min-w-0 truncate font-sans text-sm font-medium leading-snug text-dashboard-text">
@@ -1,7 +1,16 @@
1
1
  import { memo, useMemo, useRef, type ReactNode } from "react";
2
+ import { History } from "lucide-react";
3
+ import type { ConversationMailboxMessage } from "./conversationOutbox";
2
4
 
5
+ import { EmptyTelemetry } from "../components/EmptyTelemetry";
6
+ import {
7
+ Empty,
8
+ EmptyDescription,
9
+ EmptyMedia,
10
+ EmptyTitle,
11
+ } from "../components/ui/empty";
3
12
  import { Card } from "../components/layout/Card";
4
- import { unavailableTranscriptLabel } from "../format";
13
+ import { formatTime, unavailableTranscriptLabel } from "../format";
5
14
  import { ConversationBrief } from "./ConversationBrief";
6
15
  import { transcriptMessagesFromEvents } from "./eventTranscript";
7
16
  import type {
@@ -39,7 +48,6 @@ import {
39
48
  groupTranscriptMessages,
40
49
  type RenderedTranscriptEntry,
41
50
  } from "./transcriptRenderModel";
42
- import { transcriptEmptyClass } from "./transcriptStyles";
43
51
  import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch";
44
52
  import { TranscriptRow, TranscriptRows } from "./TranscriptRows";
45
53
  import { TranscriptTimestampProvider } from "./TranscriptTimestamp";
@@ -74,13 +82,20 @@ export const ConversationTranscriptView = memo(
74
82
  conversation: ConversationTranscript;
75
83
  }) => void;
76
84
  conversation: ConversationTranscript;
85
+ /** Sends that history does not have yet. History wins on message id. */
86
+ pendingMessages?: readonly ConversationMailboxMessage[];
77
87
  responding?: boolean;
78
88
  }) {
79
89
  // Cards can change while their Message events stay unchanged.
80
90
  const { events, annotations } = props.conversation;
81
91
  const messages = useMemo(
82
- () => transcriptMessagesFromEvents(events, annotations),
83
- [events, annotations],
92
+ () =>
93
+ transcriptMessagesFromEvents(
94
+ events,
95
+ annotations,
96
+ props.pendingMessages,
97
+ ),
98
+ [events, annotations, props.pendingMessages],
84
99
  );
85
100
 
86
101
  return (
@@ -110,7 +125,10 @@ function SegmentEvents(props: {
110
125
  return (
111
126
  <TranscriptRows>
112
127
  {props.conversation.eventHistory.status === "expired" ? (
113
- <ExpiredTranscriptView conversation={props.conversation} />
128
+ <ExpiredTranscriptView
129
+ brief={props.conversation.brief}
130
+ expiredAt={props.conversation.eventHistory.expiredAt}
131
+ />
114
132
  ) : props.conversation.eventHistory.status === "available" ? (
115
133
  <VisibleTranscriptEntries
116
134
  onOpenSubagentTranscript={props.onOpenSubagentTranscript}
@@ -131,9 +149,9 @@ function SegmentEvents(props: {
131
149
  conversation={props.conversation}
132
150
  />
133
151
  ) : (
134
- <div className={transcriptEmptyClass()}>
152
+ <EmptyTelemetry>
135
153
  {unavailableTranscriptLabel(props.conversation)}
136
- </div>
154
+ </EmptyTelemetry>
137
155
  )}
138
156
  {props.responding ? (
139
157
  <TranscriptRow indent>
@@ -145,21 +163,37 @@ function SegmentEvents(props: {
145
163
  }
146
164
 
147
165
  function ExpiredTranscriptView(props: {
148
- conversation: ConversationTranscript;
166
+ brief: ConversationTranscript["brief"];
167
+ expiredAt: string;
149
168
  }) {
150
169
  return (
151
170
  <>
152
- {props.conversation.brief ? (
171
+ {props.brief ? (
153
172
  <Card as="section" padding="md" variant="section">
154
173
  <h3 className="mt-0 mb-3 font-display text-lg font-medium text-dashboard-text">
155
174
  Brief
156
175
  </h3>
157
- <ConversationBrief brief={props.conversation.brief} />
176
+ <ConversationBrief brief={props.brief} />
158
177
  </Card>
159
178
  ) : null}
160
- <div className={transcriptEmptyClass()}>
161
- {unavailableTranscriptLabel(props.conversation)}
162
- </div>
179
+ <Empty className="flex items-start gap-3 border-dashed border-dashboard-border-emphasis bg-dashboard-fill-soft">
180
+ <EmptyMedia>
181
+ <History />
182
+ </EmptyMedia>
183
+ <div className="min-w-0">
184
+ <EmptyTitle>Transcript expired</EmptyTitle>
185
+ <EmptyDescription className="mt-1 text-sm">
186
+ The retention policy removed these events on{" "}
187
+ <time dateTime={props.expiredAt}>
188
+ {formatTime(props.expiredAt, {
189
+ dateStyle: "medium",
190
+ timeStyle: "short",
191
+ })}
192
+ </time>
193
+ .{props.brief ? " The brief above is still available." : null}
194
+ </EmptyDescription>
195
+ </div>
196
+ </Empty>
163
197
  </>
164
198
  );
165
199
  }
@@ -348,9 +382,7 @@ function TranscriptEntryList(props: {
348
382
  }
349
383
 
350
384
  if (search.active && rows.length === 0) {
351
- return (
352
- <div className={transcriptEmptyClass()}>No events match your search.</div>
353
- );
385
+ return <EmptyTelemetry>No events match your search.</EmptyTelemetry>;
354
386
  }
355
387
 
356
388
  // Let rows participate in the parent transcript stack so messages, tool
@@ -20,6 +20,7 @@ import { buildConversations, getDashboardTimeZone } from "../format";
20
20
  import { useDebouncedValue } from "../debouncedValue";
21
21
  import type { Conversation } from "../types";
22
22
  import { ConversationPage } from "./ConversationPage";
23
+ import { useWaitingConversationIds } from "../review-desk/queries";
23
24
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
24
25
 
25
26
  const CONVERSATION_PAGE_SIZE = 20;
@@ -61,6 +62,7 @@ export function ConversationWorkspace() {
61
62
  useEffect(() => {
62
63
  if (page > totalPages) setPage(totalPages);
63
64
  }, [page, totalPages]);
65
+ const waitingConversationIds = useWaitingConversationIds();
64
66
  const { finishedConversationIds, markRead } =
65
67
  useConversationFinishedIndicators(
66
68
  conversations,
@@ -107,6 +109,7 @@ export function ConversationWorkspace() {
107
109
  finishedConversationIds={finishedConversationIds}
108
110
  loading={feed.isPending}
109
111
  timeZone={getDashboardTimeZone()}
112
+ waitingConversationIds={waitingConversationIds}
110
113
  />
111
114
  <PagePagination
112
115
  className="pt-1"
@@ -135,6 +138,7 @@ export function ConversationWorkspace() {
135
138
  query={query}
136
139
  selectedId={selectedId}
137
140
  timeZone={getDashboardTimeZone()}
141
+ waitingConversationIds={waitingConversationIds}
138
142
  />
139
143
  </div>
140
144
  <section
@@ -3,6 +3,39 @@ import { useState, type ReactNode } from "react";
3
3
 
4
4
  import { cn, dashboardDisclosureChevronClass } from "../styles";
5
5
 
6
+ /**
7
+ * Group one kind of detail in a compact card. Conversation details and event
8
+ * details share it, so both panels read the same way. A `label` names the
9
+ * card as a region.
10
+ */
11
+ export function DetailsCard(props: {
12
+ children: ReactNode;
13
+ count?: number;
14
+ label?: string;
15
+ title?: string;
16
+ }) {
17
+ return (
18
+ <section
19
+ aria-label={props.label}
20
+ className="grid min-w-0 gap-2.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
21
+ >
22
+ {props.title ? (
23
+ <h3 className="m-0 flex min-w-0 items-center gap-1.5 break-words font-sans text-xs font-medium text-dashboard-text-muted">
24
+ {props.title}
25
+ {props.count ? (
26
+ <span className="tabular-nums text-dashboard-text-faint">
27
+ {props.count.toLocaleString("en-US")}
28
+ </span>
29
+ ) : null}
30
+ </h3>
31
+ ) : null}
32
+ <div className="min-w-0 break-words font-sans text-xs leading-relaxed text-dashboard-text-muted">
33
+ {props.children}
34
+ </div>
35
+ </section>
36
+ );
37
+ }
38
+
6
39
  /** Rows a details list shows before the reader asks for the rest. */
7
40
  const COLLAPSED_ROW_COUNT = 3;
8
41
 
@@ -12,6 +12,7 @@ import {
12
12
  import { dashboardDisclosureChevronClass } from "../styles";
13
13
 
14
14
  import { RedactedMarker } from "./TranscriptRedacted";
15
+ import { DetailsCard } from "./DetailsList";
15
16
  import { TranscriptText } from "./TranscriptText";
16
17
  import { eventLogModel } from "./eventLog";
17
18
  import { formatCostBreakdown } from "../format";
@@ -61,7 +62,9 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
61
62
  content = (
62
63
  <>
63
64
  {Object.keys(metadata).length > 0 ? (
64
- <DetailValue value={metadata} />
65
+ <DetailSection>
66
+ <DetailValue value={metadata} />
67
+ </DetailSection>
65
68
  ) : null}
66
69
  {summary ? (
67
70
  <DetailSection title="Continuation summary">
@@ -76,7 +79,9 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
76
79
  const { type: _, text, redacted, ...metadata } = data;
77
80
  content = (
78
81
  <>
79
- <DetailValue value={metadata} />
82
+ <DetailSection>
83
+ <DetailValue value={metadata} />
84
+ </DetailSection>
80
85
  <DetailSection title="Message">
81
86
  {redacted ? (
82
87
  <RedactedMarker />
@@ -97,14 +102,14 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
97
102
  <DetailSection key={call.toolCallId} title={name}>
98
103
  <DetailValue value={metadata} />
99
104
  {input !== undefined ? (
100
- <DetailSection title="Input">
105
+ <DetailSubsection title="Input">
101
106
  <DetailValue value={input} />
102
- </DetailSection>
107
+ </DetailSubsection>
103
108
  ) : null}
104
109
  {output !== undefined ? (
105
- <DetailSection title="Output">
110
+ <DetailSubsection title="Output">
106
111
  <DetailValue value={output} />
107
- </DetailSection>
112
+ </DetailSubsection>
108
113
  ) : null}
109
114
  </DetailSection>
110
115
  );
@@ -132,13 +137,15 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
132
137
  const { type: _, presentation, ...metadata } = data;
133
138
  content = (
134
139
  <>
135
- <DetailValue value={metadata} />
140
+ <DetailSection>
141
+ <DetailValue value={metadata} />
142
+ </DetailSection>
136
143
  <DetailSection title={presentation.title}>
137
144
  {presentation.preview ? (
138
145
  <TranscriptText mentions={mentions} text={presentation.preview} />
139
146
  ) : null}
140
147
  {presentation.details?.map((detail, index) => (
141
- <DetailSection key={index} title={detail.title}>
148
+ <DetailSubsection key={index} title={detail.title}>
142
149
  {detail.description ? (
143
150
  <TranscriptText
144
151
  mentions={mentions}
@@ -149,7 +156,7 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
149
156
  {detail.metadata ? (
150
157
  <DetailValue value={detail.metadata} />
151
158
  ) : null}
152
- </DetailSection>
159
+ </DetailSubsection>
153
160
  ))}
154
161
  </DetailSection>
155
162
  </>
@@ -158,12 +165,16 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
158
165
  }
159
166
  default: {
160
167
  const { type: _, ...fields } = data;
161
- content = <DetailValue value={fields} />;
168
+ content = (
169
+ <DetailSection>
170
+ <DetailValue value={fields} />
171
+ </DetailSection>
172
+ );
162
173
  }
163
174
  }
164
175
 
165
176
  return (
166
- <div className="@container grid min-w-0 gap-6">
177
+ <div className="@container grid min-w-0 gap-3">
167
178
  <DetailSection
168
179
  title={
169
180
  data.type === "handoff" ? "Target configuration" : "Configuration"
@@ -197,7 +208,7 @@ function RawEventJson({ event }: { event: ConversationReportEvent }) {
197
208
  const [open, setOpen] = useState(false);
198
209
  const json = open ? JSON.stringify(event, null, 2) : "";
199
210
  return (
200
- <div className="relative min-w-0 border-t border-dashboard-border pt-4">
211
+ <div className="relative min-w-0">
201
212
  <Collapsible onOpenChange={setOpen} open={open}>
202
213
  <CollapsibleTrigger className="group flex min-h-9 cursor-pointer items-center gap-1 pr-12 text-xs text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-dashboard-focus">
203
214
  <ChevronRight
@@ -207,7 +218,7 @@ function RawEventJson({ event }: { event: ConversationReportEvent }) {
207
218
  Raw JSON
208
219
  </CollapsibleTrigger>
209
220
  <CollapsibleContent>
210
- <div className="absolute right-0 top-4">
221
+ <div className="absolute right-0 top-0">
211
222
  <CopyButton getText={() => json} label="Copy JSON" />
212
223
  </div>
213
224
  <div
@@ -224,12 +235,28 @@ function RawEventJson({ event }: { event: ConversationReportEvent }) {
224
235
  );
225
236
  }
226
237
 
227
- function DetailSection(props: { title: string; children: ReactNode }) {
238
+ // Top-level groups use the conversation details card. A titled card is a
239
+ // region, so readers can jump to a section such as "Message".
240
+ function DetailSection(props: { title?: string; children: ReactNode }) {
241
+ return (
242
+ <DetailsCard label={props.title} title={props.title}>
243
+ <div className="grid min-w-0 gap-3 text-sm text-dashboard-text">
244
+ {props.children}
245
+ </div>
246
+ </DetailsCard>
247
+ );
248
+ }
249
+
250
+ // A group inside a card, such as a tool input. It does not nest another card.
251
+ function DetailSubsection(props: { title: string; children: ReactNode }) {
228
252
  return (
229
- <section aria-label={props.title} className="grid min-w-0 gap-2">
230
- <h3 className="m-0 border-b border-dashboard-border pb-2 text-sm font-semibold text-dashboard-text">
253
+ <section
254
+ aria-label={props.title}
255
+ className="grid min-w-0 gap-1.5 border-t border-dashboard-border-subtle pt-3"
256
+ >
257
+ <h4 className="m-0 font-sans text-xs font-medium text-dashboard-text-muted">
231
258
  {props.title}
232
- </h3>
259
+ </h4>
233
260
  {props.children}
234
261
  </section>
235
262
  );
@@ -275,8 +302,8 @@ function DetailValue({ value }: { value: unknown }) {
275
302
  key={key}
276
303
  className={
277
304
  nested
278
- ? "grid min-w-0 gap-2 py-2"
279
- : "grid min-w-0 grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-baseline gap-x-4 py-2 @min-[30rem]:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]"
305
+ ? "grid min-w-0 gap-2 py-2 first:pt-0 last:pb-0"
306
+ : "grid min-w-0 grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-baseline gap-x-4 py-2 first:pt-0 last:pb-0 @min-[30rem]:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]"
280
307
  }
281
308
  >
282
309
  <dt className="break-words text-xs leading-relaxed text-dashboard-text-muted">