@sentry/junior-dashboard 0.265.0 → 0.267.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 (104) hide show
  1. package/dist/app.js +49 -12
  2. package/dist/client.js +94 -63
  3. package/dist/index.js +49 -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 +35 -53
  9. package/src/client/components/ActorAvatar.tsx +10 -22
  10. package/src/client/components/ConnectionBanner.tsx +4 -3
  11. package/src/client/components/CopyButton.tsx +3 -4
  12. package/src/client/components/DetailsPanel.tsx +12 -13
  13. package/src/client/components/Drawer.tsx +46 -23
  14. package/src/client/components/EmptyTelemetry.tsx +5 -3
  15. package/src/client/components/ImageAttachment.tsx +49 -63
  16. package/src/client/components/InlineError.tsx +8 -4
  17. package/src/client/components/Notice.tsx +19 -26
  18. package/src/client/components/Pagination.tsx +26 -26
  19. package/src/client/components/ParticipantAvatarStack.tsx +13 -10
  20. package/src/client/components/README.md +28 -3
  21. package/src/client/components/SearchInput.tsx +51 -41
  22. package/src/client/components/VersionDriftBanner.tsx +9 -7
  23. package/src/client/components/charts/CacheWrites.tsx +52 -43
  24. package/src/client/components/ui/alert.tsx +64 -0
  25. package/src/client/components/ui/avatar.tsx +53 -0
  26. package/src/client/components/ui/button-group.tsx +59 -0
  27. package/src/client/components/ui/button.tsx +2 -1
  28. package/src/client/components/ui/dialog.tsx +60 -0
  29. package/src/client/components/ui/drawer.tsx +140 -0
  30. package/src/client/components/ui/empty.tsx +61 -0
  31. package/src/client/components/ui/input-group.tsx +96 -0
  32. package/src/client/components/ui/pagination.tsx +60 -0
  33. package/src/client/components/ui/separator.tsx +22 -0
  34. package/src/client/components/ui/spinner.tsx +21 -0
  35. package/src/client/components/ui/table.tsx +22 -1
  36. package/src/client/components/ui/tooltip.tsx +1 -1
  37. package/src/client/components/ui/useSheet.ts +5 -1
  38. package/src/client/conversations/ComposerImages.tsx +66 -14
  39. package/src/client/conversations/ConversationChildAgents.tsx +5 -5
  40. package/src/client/conversations/ConversationComposer.tsx +84 -12
  41. package/src/client/conversations/ConversationDetails.tsx +3 -27
  42. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  43. package/src/client/conversations/ConversationHeader.tsx +5 -4
  44. package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
  45. package/src/client/conversations/ConversationPage.tsx +156 -2
  46. package/src/client/conversations/ConversationSidebar.tsx +5 -4
  47. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  48. package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
  49. package/src/client/conversations/ConversationWatches.tsx +67 -59
  50. package/src/client/conversations/DetailsList.tsx +77 -29
  51. package/src/client/conversations/EventDetails.tsx +46 -19
  52. package/src/client/conversations/EventUsage.tsx +99 -74
  53. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  54. package/src/client/conversations/PendingMailboxStack.tsx +312 -119
  55. package/src/client/conversations/README.md +30 -10
  56. package/src/client/conversations/ShareConversationButton.tsx +3 -4
  57. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  58. package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
  59. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  60. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  61. package/src/client/conversations/TranscriptEventView.tsx +35 -16
  62. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  63. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  64. package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
  65. package/src/client/conversations/TranscriptMessageView.tsx +7 -1
  66. package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
  67. package/src/client/conversations/TranscriptSummary.tsx +13 -7
  68. package/src/client/conversations/TranscriptTurnContextView.tsx +78 -56
  69. package/src/client/conversations/TranscriptView.tsx +7 -3
  70. package/src/client/conversations/UserInputRequestCard.tsx +244 -0
  71. package/src/client/conversations/conversationOutbox.ts +46 -0
  72. package/src/client/conversations/eventLog.ts +1 -0
  73. package/src/client/conversations/eventTime.ts +13 -1
  74. package/src/client/conversations/eventTranscript.ts +12 -5
  75. package/src/client/conversations/queries.ts +127 -26
  76. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  77. package/src/client/conversations/transcriptMentions.tsx +18 -5
  78. package/src/client/conversations/userInputRequest.ts +56 -0
  79. package/src/client/format.ts +10 -3
  80. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  81. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  82. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  83. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  84. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  85. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  86. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  87. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  88. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  89. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  90. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  91. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  92. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  93. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  94. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  95. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  96. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  97. package/src/client/pages/automations/README.md +56 -46
  98. package/src/client/types.ts +3 -0
  99. package/src/mock-reporting/fixtures.ts +44 -2
  100. package/src/mock-reporting/watches.ts +1 -1
  101. package/src/client/conversations/transcriptStyles.ts +0 -10
  102. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  103. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  104. 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,
@@ -184,6 +196,10 @@ export function ConversationPage(props: {
184
196
  () => committedMessageIds(detail.data?.events),
185
197
  [detail.data?.events],
186
198
  );
199
+ const userInputRequest = useMemo(
200
+ () => openUserInputRequest(detail.data?.events),
201
+ [detail.data?.events],
202
+ );
187
203
  // Cancel needs the latest mailbox watermark, but not as a render prop. Live
188
204
  // polls refresh generatedAt every 2s even when the queue is unchanged.
189
205
  const pendingGeneratedAtRef = useRef(detail.pendingGeneratedAt);
@@ -434,6 +450,7 @@ export function ConversationPage(props: {
434
450
  loadingPreviousPage={detail.isLoadingPreviousPage}
435
451
  onLoadPreviousPage={detail.loadPreviousPage}
436
452
  onSelectedEventSeqChange={setSelectedEventSeq}
453
+ pendingMessages={detail.inlineMessages}
437
454
  pinRequestVersion={pinRequestVersion}
438
455
  responding={
439
456
  !detail.error && conversationIsResponding(transcript)
@@ -466,7 +483,17 @@ export function ConversationPage(props: {
466
483
  // every 2s; a prop would bust footer memo while the reader types.
467
484
  pendingGeneratedAtRef={pendingGeneratedAtRef}
468
485
  pendingMessages={detail.pendingMessages}
486
+ // Without live history, an inline send would never leave the
487
+ // transcript tail. A started Turn reads a new send only later.
488
+ // Keep those sends in the queue. An earlier inline send that
489
+ // history does not have yet can start that Turn first.
490
+ sendsInline={
491
+ detail.data.eventHistory.status === "available" &&
492
+ !conversationIsResponding(detail.data) &&
493
+ detail.inlineMessages.length === 0
494
+ }
469
495
  stoppable={live}
496
+ userInputRequest={userInputRequest}
470
497
  />
471
498
  ) : undefined
472
499
  }
@@ -548,8 +575,15 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
548
575
  pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
549
576
  pendingGeneratedAtRef: { current: string | undefined };
550
577
  pendingMessages: readonly ConversationMailboxMessage[];
578
+ /**
579
+ * True when history is live and no Turn has started, so the next Turn will
580
+ * read a new send at once.
581
+ */
582
+ sendsInline: boolean;
551
583
  /** True while the conversation has a Turn that a stop can end. */
552
584
  stoppable: boolean;
585
+ /** Junior's open question, if no user message answers it yet. */
586
+ userInputRequest?: OpenUserInputRequest;
553
587
  }) {
554
588
  const appendMessage = useAppendConversationMessage(props.conversationId);
555
589
  const stopTurn = useStopConversationTurn(props.conversationId);
@@ -558,6 +592,9 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
558
592
  const cancelPendingMessages = useCancelConversationPendingMessages(
559
593
  props.conversationId,
560
594
  );
595
+ const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
596
+ useState<string>();
597
+ const [queueActionError, setQueueActionError] = useState<string>();
561
598
  // Keep submit identity stable across mutation status flips so the memoized
562
599
  // composer does not re-render while the reader is still typing.
563
600
  const appendMessageRef = useRef(appendMessage);
@@ -583,10 +620,21 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
583
620
  if (!window.matchMedia("(max-width: 767px)").matches) return;
584
621
  onPinRequestRef.current();
585
622
  }, [pendingMessageVersion]);
623
+ // No Turn runs and nothing waits in the queue, so the next send would
624
+ // leave the queue almost at once. Show it in the transcript instead. A
625
+ // conversation can be active before its Turn starts; that still counts.
626
+ const committedIds = new Set(props.committedMessageIds);
627
+ const sendInlineRef = useRef(false);
628
+ sendInlineRef.current =
629
+ props.sendsInline &&
630
+ !props.pendingMessages.some(
631
+ (message) => !committedIds.has(message.messageId),
632
+ );
586
633
  const onSubmit = useCallback(
587
634
  async (message: string, idempotencyKey: string, images?: InputImage[]) => {
588
635
  await appendMessageRef.current.mutateAsync({
589
636
  idempotencyKey,
637
+ inline: sendInlineRef.current,
590
638
  message,
591
639
  images,
592
640
  });
@@ -608,11 +656,19 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
608
656
  cancelPendingMessagesRef.current.reset();
609
657
  }
610
658
  if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
659
+ setQueueActionError(undefined);
611
660
  onPinRequestRef.current();
612
661
  }, []);
613
662
  const cancellableMessageIds = props.pendingMessages
614
663
  .filter((message) => message.clientStatus === undefined)
615
664
  .map((message) => message.inboundMessageId);
665
+ const onAnswerUserInput = useCallback(async (answer: string) => {
666
+ onPinRequestRef.current();
667
+ await appendMessageRef.current.mutateAsync({
668
+ idempotencyKey: crypto.randomUUID(),
669
+ message: answer,
670
+ });
671
+ }, []);
616
672
  const onCancelMessage = useCallback(
617
673
  (message: ConversationMailboxMessage) => {
618
674
  const receivedBefore = props.pendingGeneratedAtRef.current;
@@ -633,6 +689,86 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
633
689
  ),
634
690
  );
635
691
 
692
+ // Edit uses its own cancel mutation so remove errors stay on remove.
693
+ const editCancel = useCancelConversationPendingMessages(props.conversationId);
694
+ const editCancelRef = useRef(editCancel);
695
+ editCancelRef.current = editCancel;
696
+ const editingRef = useRef(false);
697
+ const composerRef = useRef<ConversationComposerHandle>(null);
698
+ const viewerEmail = useDashboardCoreData().data?.me.user.email;
699
+ const onEditMessage = useCallback(
700
+ async (message: ConversationMailboxMessage) => {
701
+ const receivedBefore = props.pendingGeneratedAtRef.current;
702
+ // The image read runs before the cancel starts, so isPending alone does
703
+ // not stop a second click.
704
+ if (!receivedBefore || editingRef.current) return;
705
+ editingRef.current = true;
706
+ const inboundMessageId = message.inboundMessageId;
707
+ setEditTargetInboundMessageId(inboundMessageId);
708
+ setQueueActionError(undefined);
709
+ try {
710
+ // Read images first. A failed read must leave the message queued.
711
+ const images = await readStoredComposerImages(
712
+ props.conversationId,
713
+ message.attachments,
714
+ );
715
+ // The reader left during the read. Keep the message in the queue.
716
+ if (!composerRef.current) return;
717
+ // Check before the cancel. After it, images that do not fit are lost.
718
+ if (!composerRef.current.canRestoreImages(images)) {
719
+ setQueueActionError("Remove some draft images to edit that message.");
720
+ return;
721
+ }
722
+ const result = await editCancelRef.current.mutateAsync({
723
+ inboundMessageIds: [inboundMessageId],
724
+ receivedBefore,
725
+ });
726
+ // Junior can read the message before the cancel lands. Then it is
727
+ // already in the Conversation and there is nothing to edit.
728
+ if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) {
729
+ setQueueActionError("Junior already read that message.");
730
+ return;
731
+ }
732
+ const restored = { images, text: message.text ?? "" };
733
+ if (composerRef.current) {
734
+ composerRef.current.restoreMessage(restored);
735
+ } else {
736
+ // The reader left during the cancel. Keep the text in the saved
737
+ // draft so it is there when they come back.
738
+ restoreStoredDraftText(props.conversationId, restored.text);
739
+ }
740
+ } catch {
741
+ setQueueActionError("Could not edit the queued message. Try again.");
742
+ } finally {
743
+ editingRef.current = false;
744
+ setEditTargetInboundMessageId(undefined);
745
+ }
746
+ },
747
+ [props.conversationId, props.pendingGeneratedAtRef],
748
+ );
749
+
750
+ const steerPendingMessages = useSteerConversationPendingMessages(
751
+ props.conversationId,
752
+ );
753
+ const steerPendingMessagesRef = useRef(steerPendingMessages);
754
+ steerPendingMessagesRef.current = steerPendingMessages;
755
+ const onSteerMessage = useCallback((message: ConversationMailboxMessage) => {
756
+ if (steerPendingMessagesRef.current.isPending) return;
757
+ setQueueActionError(undefined);
758
+ steerPendingMessagesRef.current.mutate(
759
+ { inboundMessageIds: [message.inboundMessageId] },
760
+ {
761
+ onError: () =>
762
+ setQueueActionError(
763
+ "Could not send the queued message now. Try again.",
764
+ ),
765
+ },
766
+ );
767
+ }, []);
768
+ const steerTargetInboundMessageId = steerPendingMessages.isPending
769
+ ? steerPendingMessages.variables?.inboundMessageIds[0]
770
+ : undefined;
771
+
636
772
  const onStop = useCallback(async () => {
637
773
  const result = await stopTurnRef.current.mutateAsync();
638
774
  const restored = result.stoppedMessages.map((message) => message.text);
@@ -657,22 +793,40 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
657
793
  {props.pendingAuthorization ? (
658
794
  <PendingAuthorization authorization={props.pendingAuthorization} />
659
795
  ) : null}
796
+ {/* A queued message already answers the question. */}
797
+ {props.userInputRequest && props.pendingMessages.length === 0 ? (
798
+ <UserInputRequestCard
799
+ key={props.userInputRequest.toolCallId}
800
+ onAnswer={onAnswerUserInput}
801
+ request={props.userInputRequest}
802
+ />
803
+ ) : null}
660
804
  <PendingMailboxStack
661
805
  cancelError={cancelError}
662
806
  cancelPending={cancelPendingMessages.isPending}
663
807
  cancelTargetInboundMessageId={cancelTargetInboundMessageId}
664
808
  committedMessageIds={props.committedMessageIds}
809
+ conversationId={props.conversationId}
810
+ editTargetInboundMessageId={editTargetInboundMessageId}
665
811
  messages={props.pendingMessages}
666
812
  onCancelMessage={onCancelMessage}
813
+ onEditMessage={onEditMessage}
667
814
  onLayoutChange={onMailboxLayoutChange}
668
815
  onRetry={onRetry}
816
+ onSteerMessage={props.stoppable ? onSteerMessage : undefined}
817
+ steerTargetInboundMessageId={steerTargetInboundMessageId}
818
+ viewerEmail={viewerEmail}
669
819
  />
670
820
  </>
671
821
  }
672
822
  >
673
823
  <ConversationComposer
674
824
  draftId={props.conversationId}
675
- error={stopTurn.error ? "Could not stop Junior. Try again." : undefined}
825
+ error={
826
+ queueActionError ??
827
+ (stopTurn.error ? "Could not stop Junior. Try again." : undefined)
828
+ }
829
+ ref={composerRef}
676
830
  label="Continue this conversation"
677
831
  submitLabel="Send"
678
832
  onFocus={onComposerFocus}
@@ -19,6 +19,7 @@ import {
19
19
  } from "./conversationSections";
20
20
  import { EmptyTelemetry } from "../components/EmptyTelemetry";
21
21
  import { SearchInput } from "../components/SearchInput";
22
+ import { Button } from "../components/ui/button";
22
23
  import { Skeleton } from "../components/ui/skeleton";
23
24
  import {
24
25
  Tooltip,
@@ -112,14 +113,14 @@ export function ConversationSidebar(props: {
112
113
  <Tooltip>
113
114
  <TooltipTrigger
114
115
  render={
115
- <button
116
+ <Button
116
117
  aria-label="New conversation"
117
- className="grid size-7 cursor-pointer place-items-center rounded-md text-dashboard-text-muted transition hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
118
+ className="size-7"
118
119
  onClick={props.onNewConversation}
119
- type="button"
120
+ size="icon"
120
121
  >
121
122
  <SquarePen aria-hidden="true" size={15} />
122
- </button>
123
+ </Button>
123
124
  }
124
125
  />
125
126
  <TooltipContent size="compact">New conversation</TooltipContent>
@@ -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
@@ -13,6 +13,16 @@ import {
13
13
  totalConversationCost,
14
14
  } from "../format";
15
15
  import { ShimmerText } from "../components/ShimmerText";
16
+ import {
17
+ Table,
18
+ TableBody,
19
+ TableCell,
20
+ TableFooter,
21
+ TableHead,
22
+ TableHeader,
23
+ TableRow,
24
+ } from "../components/ui/table";
25
+ import { cn } from "../styles";
16
26
  import type { Conversation } from "../types";
17
27
  import { liveModelId } from "./ConversationMeta";
18
28
  import {
@@ -70,7 +80,7 @@ function tokenTypeParts(
70
80
  return parts.length ? parts : undefined;
71
81
  }
72
82
 
73
- const CELL = "text-right align-top font-sans tabular-nums";
83
+ const CELL = "px-0 text-right align-top font-sans tabular-nums";
74
84
 
75
85
  /**
76
86
  * One usage table. Every table in the breakdown shares column widths, so the
@@ -85,85 +95,118 @@ function BreakdownTable(props: {
85
95
  total?: { cost: string; count: string; label: string };
86
96
  }) {
87
97
  return (
88
- <div className="min-w-0 overflow-x-auto">
89
- <table
90
- aria-label={props.label}
91
- className="w-full min-w-72 table-fixed border-collapse text-xs leading-relaxed"
92
- >
93
- <colgroup>
94
- <col />
95
- <col className="w-20" />
96
- <col className="w-20" />
97
- </colgroup>
98
- <thead>
99
- <tr className="border-b border-dashboard-border text-dashboard-text-muted">
100
- <th scope="col" className="py-2 text-left font-normal">
101
- {props.label}
102
- </th>
103
- <th scope="col" className="py-2 text-right font-normal">
104
- {props.countLabel}
105
- </th>
106
- <th scope="col" className="py-2 text-right font-normal">
107
- Cost
108
- </th>
109
- </tr>
110
- </thead>
111
- {props.rows.map((row) => (
112
- <tbody
98
+ <Table
99
+ aria-label={props.label}
100
+ className="min-w-72 table-fixed text-xs leading-relaxed"
101
+ >
102
+ <colgroup>
103
+ <col />
104
+ <col className="w-20" />
105
+ <col className="w-20" />
106
+ </colgroup>
107
+ <TableHeader>
108
+ <TableRow className="border-dashboard-border">
109
+ <TableHead
110
+ scope="col"
111
+ className="px-0 py-2 leading-relaxed font-normal"
112
+ >
113
+ {props.label}
114
+ </TableHead>
115
+ <TableHead
116
+ scope="col"
117
+ className="px-0 py-2 text-right leading-relaxed font-normal"
118
+ >
119
+ {props.countLabel}
120
+ </TableHead>
121
+ <TableHead
122
+ scope="col"
123
+ className="px-0 py-2 text-right leading-relaxed font-normal"
124
+ >
125
+ Cost
126
+ </TableHead>
127
+ </TableRow>
128
+ </TableHeader>
129
+ {props.rows.map((row) => {
130
+ const padding = row.parts ? "pt-2 pb-0.5" : "py-2";
131
+ return (
132
+ <TableBody
113
133
  className="border-b border-dashboard-border-subtle last-of-type:border-b-0"
114
134
  key={row.key}
115
135
  >
116
- <tr className="text-dashboard-text">
117
- <th
136
+ {/* The tbody draws the group border, so its rows have none. */}
137
+ <TableRow className="border-0">
138
+ <TableHead
118
139
  scope="row"
119
- className={`pr-3 text-left font-normal ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}
140
+ className={cn(
141
+ "pl-0 pr-3 align-top leading-relaxed font-normal text-dashboard-text",
142
+ padding,
143
+ )}
120
144
  >
121
145
  <span className="block truncate">{row.label}</span>
122
- </th>
123
- <td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
124
- {row.count}
125
- </td>
126
- <td className={`${CELL} ${row.parts ? "pt-2 pb-0.5" : "py-2"}`}>
127
- {row.cost}
128
- </td>
129
- </tr>
146
+ </TableHead>
147
+ <TableCell className={cn(CELL, padding)}>{row.count}</TableCell>
148
+ <TableCell className={cn(CELL, padding)}>{row.cost}</TableCell>
149
+ </TableRow>
130
150
  {row.parts?.map((part, index, parts) => {
131
- const padding = index === parts.length - 1 ? "pb-2" : "";
151
+ const partPadding =
152
+ index === parts.length - 1 ? "pt-0 pb-2" : "py-0";
132
153
  return (
133
- <tr
134
- className="text-2xs leading-5 text-dashboard-text-faint"
154
+ <TableRow
155
+ className="border-0 text-2xs leading-5"
135
156
  key={part.label}
136
157
  >
137
- <th
158
+ <TableHead
138
159
  scope="row"
139
- className={`truncate pl-3 pr-3 text-left font-normal ${padding}`}
160
+ className={cn(
161
+ "truncate pl-3 pr-3 align-top text-2xs leading-5 font-normal text-dashboard-text-faint",
162
+ partPadding,
163
+ )}
140
164
  >
141
165
  {part.label}
142
- </th>
143
- <td className={`${CELL} ${padding}`}>{part.count}</td>
144
- <td className={`${CELL} ${padding}`}>{part.cost}</td>
145
- </tr>
166
+ </TableHead>
167
+ <TableCell
168
+ className={cn(
169
+ CELL,
170
+ "text-dashboard-text-faint",
171
+ partPadding,
172
+ )}
173
+ >
174
+ {part.count}
175
+ </TableCell>
176
+ <TableCell
177
+ className={cn(
178
+ CELL,
179
+ "text-dashboard-text-faint",
180
+ partPadding,
181
+ )}
182
+ >
183
+ {part.cost}
184
+ </TableCell>
185
+ </TableRow>
146
186
  );
147
187
  })}
148
- </tbody>
149
- ))}
150
- {props.total ? (
151
- <tfoot>
152
- <tr className="border-t border-dashboard-border font-semibold text-dashboard-text">
153
- <th scope="row" className="py-2 text-left font-semibold">
154
- {props.total.label}
155
- </th>
156
- <td className="py-2 text-right font-sans tabular-nums">
157
- {props.total.count}
158
- </td>
159
- <td className="py-2 text-right font-sans tabular-nums">
160
- {props.total.cost}
161
- </td>
162
- </tr>
163
- </tfoot>
164
- ) : null}
165
- </table>
166
- </div>
188
+ </TableBody>
189
+ );
190
+ })}
191
+ {props.total ? (
192
+ <TableFooter className="font-semibold">
193
+ <TableRow>
194
+ <TableHead
195
+ scope="row"
196
+ className="px-0 py-2 leading-relaxed font-semibold text-dashboard-text"
197
+ >
198
+ {props.total.label}
199
+ </TableHead>
200
+ <TableCell className="px-0 py-2 text-right font-sans tabular-nums">
201
+ {props.total.count}
202
+ </TableCell>
203
+ <TableCell className="px-0 py-2 text-right font-sans tabular-nums">
204
+ {props.total.cost}
205
+ </TableCell>
206
+ </TableRow>
207
+ </TableFooter>
208
+ ) : null}
209
+ </Table>
167
210
  );
168
211
  }
169
212