@sentry/junior-dashboard 0.266.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 (73) 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/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 +4 -1
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/ui/alert.tsx +1 -1
  15. package/src/client/components/ui/button-group.tsx +59 -0
  16. package/src/client/components/ui/dialog.tsx +29 -35
  17. package/src/client/components/ui/drawer.tsx +140 -0
  18. package/src/client/components/ui/empty.tsx +30 -1
  19. package/src/client/components/ui/separator.tsx +22 -0
  20. package/src/client/components/ui/useSheet.ts +5 -1
  21. package/src/client/conversations/ComposerImages.tsx +66 -14
  22. package/src/client/conversations/ConversationComposer.tsx +84 -12
  23. package/src/client/conversations/ConversationDetails.tsx +3 -27
  24. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  25. package/src/client/conversations/ConversationPage.tsx +156 -2
  26. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  27. package/src/client/conversations/DetailsList.tsx +33 -0
  28. package/src/client/conversations/EventDetails.tsx +46 -19
  29. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  30. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  31. package/src/client/conversations/README.md +30 -13
  32. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
  34. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  35. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  36. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  37. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  38. package/src/client/conversations/TranscriptMessageView.tsx +7 -1
  39. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  40. package/src/client/conversations/TranscriptTurnContextView.tsx +33 -18
  41. package/src/client/conversations/TranscriptView.tsx +7 -3
  42. package/src/client/conversations/UserInputRequestCard.tsx +244 -0
  43. package/src/client/conversations/conversationOutbox.ts +46 -0
  44. package/src/client/conversations/eventTime.ts +13 -1
  45. package/src/client/conversations/eventTranscript.ts +12 -5
  46. package/src/client/conversations/queries.ts +127 -26
  47. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  48. package/src/client/conversations/userInputRequest.ts +56 -0
  49. package/src/client/format.ts +10 -3
  50. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  51. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  52. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  53. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  54. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  55. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  56. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  57. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  58. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  59. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  60. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  61. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  62. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  63. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  64. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  65. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  66. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  67. package/src/client/pages/automations/README.md +56 -46
  68. package/src/client/types.ts +3 -0
  69. package/src/mock-reporting/fixtures.ts +44 -2
  70. package/src/client/conversations/transcriptStyles.ts +0 -10
  71. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  72. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  73. 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}
@@ -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
@@ -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">