@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
@@ -37,6 +37,19 @@ function TableBody({ className, ...props }: ComponentProps<"tbody">) {
37
37
  );
38
38
  }
39
39
 
40
+ function TableFooter({ className, ...props }: ComponentProps<"tfoot">) {
41
+ return (
42
+ <tfoot
43
+ data-slot="table-footer"
44
+ className={cn(
45
+ "border-t border-dashboard-border font-medium [&>tr]:last:border-b-0",
46
+ className,
47
+ )}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+
40
53
  function TableRow({ className, ...props }: ComponentProps<"tr">) {
41
54
  return (
42
55
  <tr
@@ -77,4 +90,12 @@ function TableCell({ className, ...props }: ComponentProps<"td">) {
77
90
  );
78
91
  }
79
92
 
80
- export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
93
+ export {
94
+ Table,
95
+ TableHeader,
96
+ TableBody,
97
+ TableFooter,
98
+ TableHead,
99
+ TableRow,
100
+ TableCell,
101
+ };
@@ -132,7 +132,7 @@ function TooltipContent({
132
132
  collisionPadding={8}
133
133
  side={side}
134
134
  sideOffset={sideOffset}
135
- className="isolate z-50 data-anchor-hidden:hidden"
135
+ className="isolate z-50 data-anchor-hidden:invisible"
136
136
  >
137
137
  <TooltipPrimitive.Popup
138
138
  data-slot="tooltip-content"
@@ -48,7 +48,11 @@ export function useSheet(
48
48
  open,
49
49
  modal: topmost ? "trap-focus" : false,
50
50
  disablePointerDismissal: !topmost,
51
- onOpenChange(nextOpen, details) {
51
+ // Dialog and Drawer roots both use this handler, so read only `cancel`.
52
+ onOpenChange(
53
+ nextOpen: boolean,
54
+ details: Pick<Dialog.Root.ChangeEventDetails, "cancel">,
55
+ ) {
52
56
  if (!nextOpen && !topmost) {
53
57
  details.cancel();
54
58
  return;
@@ -4,7 +4,31 @@ import {
4
4
  MAX_INPUT_IMAGE_BYTES,
5
5
  MAX_INPUT_IMAGES,
6
6
  type InputImage,
7
+ type MessageAttachment,
7
8
  } from "@sentry/junior/api/schema";
9
+ import { Button } from "../components/ui/button";
10
+ import {
11
+ Tooltip,
12
+ TooltipContent,
13
+ TooltipTrigger,
14
+ } from "../components/ui/tooltip";
15
+ import {
16
+ conversationAttachmentUrl,
17
+ inputImageSrc,
18
+ } from "./TranscriptAttachment";
19
+
20
+ /** Decoded size of base64 image data, as the server limit counts it. */
21
+ function inputImageBytes(images: readonly InputImage[]): number {
22
+ return images.reduce((sum, image) => sum + (image.data.length * 3) / 4, 0);
23
+ }
24
+
25
+ /** True when these images fit the message image count and size limits. */
26
+ export function composerImagesFit(images: readonly InputImage[]): boolean {
27
+ return (
28
+ images.length <= MAX_INPUT_IMAGES &&
29
+ inputImageBytes(images) <= MAX_INPUT_IMAGE_BYTES
30
+ );
31
+ }
8
32
 
9
33
  /** Read a bounded image selection for the message request and local previews. */
10
34
  export async function readComposerImages(
@@ -13,10 +37,7 @@ export async function readComposerImages(
13
37
  ): Promise<InputImage[]> {
14
38
  if (current.length + files.length > MAX_INPUT_IMAGES)
15
39
  throw new Error("Attach up to 3 images.");
16
- const currentBytes = current.reduce(
17
- (sum, image) => sum + (image.data.length * 3) / 4,
18
- 0,
19
- );
40
+ const currentBytes = inputImageBytes(current);
20
41
  if (
21
42
  currentBytes + files.reduce((sum, file) => sum + file.size, 0) >
22
43
  MAX_INPUT_IMAGE_BYTES
@@ -51,6 +72,28 @@ export async function readComposerImages(
51
72
  return [...current, ...images];
52
73
  }
53
74
 
75
+ /** Read stored image attachments back into composer images for an edit. */
76
+ export async function readStoredComposerImages(
77
+ conversationId: string,
78
+ attachments: readonly MessageAttachment[] | undefined,
79
+ ): Promise<InputImage[]> {
80
+ const images = (attachments ?? []).filter((attachment) =>
81
+ INPUT_IMAGE_TYPES.some((type) => type === attachment.contentType),
82
+ );
83
+ const files = await Promise.all(
84
+ images.map(async (attachment) => {
85
+ const response = await fetch(
86
+ conversationAttachmentUrl(conversationId, attachment.id),
87
+ );
88
+ if (!response.ok) throw new Error("Could not read the queued image.");
89
+ return new File([await response.blob()], attachment.filename, {
90
+ type: attachment.contentType,
91
+ });
92
+ }),
93
+ );
94
+ return await readComposerImages(files, []);
95
+ }
96
+
54
97
  /** Show removable images before sending; bytes stay in memory until submit. */
55
98
  export function ComposerImages(props: {
56
99
  images: InputImage[];
@@ -67,17 +110,26 @@ export function ComposerImages(props: {
67
110
  <img
68
111
  alt={image.filename}
69
112
  className="h-20 w-24 rounded-md object-contain"
70
- src={`data:${image.contentType};base64,${image.data}`}
113
+ src={inputImageSrc(image)}
71
114
  />
72
- <button
73
- aria-label={`Remove ${image.filename}`}
74
- disabled={props.disabled}
75
- className="absolute right-0 top-0 grid size-7 cursor-pointer place-items-center rounded-full border border-dashboard-border bg-dashboard-surface-raised text-dashboard-text hover:bg-dashboard-fill-hover disabled:cursor-default disabled:opacity-50"
76
- onClick={() => props.onRemove(index)}
77
- type="button"
78
- >
79
- <X aria-hidden="true" size={14} />
80
- </button>
115
+ <Tooltip>
116
+ <TooltipTrigger
117
+ render={
118
+ <Button
119
+ aria-label={`Remove ${image.filename}`}
120
+ className="absolute right-0 top-0 size-7 rounded-full border border-dashboard-border bg-dashboard-surface-raised text-dashboard-text data-disabled:hover:!bg-dashboard-surface-raised data-disabled:hover:!text-dashboard-text"
121
+ disabled={props.disabled}
122
+ focusableWhenDisabled
123
+ onClick={() => props.onRemove(index)}
124
+ size="icon"
125
+ type="button"
126
+ >
127
+ <X aria-hidden="true" size={14} />
128
+ </Button>
129
+ }
130
+ />
131
+ <TooltipContent size="compact">Remove image</TooltipContent>
132
+ </Tooltip>
81
133
  </div>
82
134
  ))}
83
135
  </div>
@@ -7,7 +7,6 @@ import {
7
7
  CircleDot,
8
8
  CirclePause,
9
9
  CircleX,
10
- LoaderCircle,
11
10
  LockKeyhole,
12
11
  type LucideIcon,
13
12
  } from "lucide-react";
@@ -17,6 +16,7 @@ import {
17
16
  } from "@sentry/junior/api/schema";
18
17
 
19
18
  import type { StatusChipTone } from "../components/StatusChip";
19
+ import { Spinner } from "../components/ui/spinner";
20
20
  import {
21
21
  formatConversationCostTotal,
22
22
  formatRuntime,
@@ -115,7 +115,6 @@ const statusIcons: Partial<Record<string, LucideIcon>> = {
115
115
  error: CircleX,
116
116
  failed: CircleX,
117
117
  paused: CirclePause,
118
- running: LoaderCircle,
119
118
  };
120
119
 
121
120
  const statusIconToneClass: Record<StatusChipTone, string> = {
@@ -129,7 +128,10 @@ const statusIconToneClass: Record<StatusChipTone, string> = {
129
128
 
130
129
  /** Mark a child agent status with one icon and an audible label. */
131
130
  function ChildAgentStatusIcon(props: { status: string }) {
132
- const Icon = statusIcons[props.status] ?? Circle;
131
+ const Icon =
132
+ props.status === "running"
133
+ ? Spinner
134
+ : (statusIcons[props.status] ?? Circle);
133
135
  return (
134
136
  <span className="mt-px grid size-4 shrink-0 place-items-center">
135
137
  <Icon
@@ -137,8 +139,6 @@ function ChildAgentStatusIcon(props: { status: string }) {
137
139
  className={cn(
138
140
  "size-3.5",
139
141
  statusIconToneClass[subagentStatusTone(props.status)],
140
- props.status === "running" &&
141
- "animate-spin motion-reduce:animate-none",
142
142
  )}
143
143
  />
144
144
  <span className="sr-only">{props.status}</span>
@@ -1,14 +1,20 @@
1
1
  import { INPUT_IMAGE_TYPES, type InputImage } from "@sentry/junior/api/schema";
2
- import { ComposerImages, readComposerImages } from "./ComposerImages";
2
+ import {
3
+ ComposerImages,
4
+ composerImagesFit,
5
+ readComposerImages,
6
+ } from "./ComposerImages";
3
7
  import {
4
8
  memo,
5
9
  useEffect,
6
10
  useId,
11
+ useImperativeHandle,
7
12
  useRef,
8
13
  useState,
9
14
  type FormEvent,
10
15
  type KeyboardEvent,
11
16
  type ReactNode,
17
+ type Ref,
12
18
  } from "react";
13
19
  import { ImagePlus, Send, Square } from "lucide-react";
14
20
 
@@ -56,6 +62,14 @@ export function conversationAttemptForSubmit(
56
62
  };
57
63
  }
58
64
 
65
+ /** Imperative composer actions for the reply footer. */
66
+ export type ConversationComposerHandle = {
67
+ /** True when the draft has room for these images right now. */
68
+ canRestoreImages(images: readonly InputImage[]): boolean;
69
+ /** Put queued text and images ahead of the draft. Queued images win. */
70
+ restoreMessage(message: { images?: InputImage[]; text: string }): void;
71
+ };
72
+
59
73
  type ConversationComposerProps = {
60
74
  disabled?: boolean;
61
75
  draftId: string;
@@ -73,6 +87,8 @@ type ConversationComposerProps = {
73
87
  * the pending queue instead.
74
88
  */
75
89
  restoreDraftOnError?: boolean;
90
+ /** Lets the parent put a message taken out of the queue back in the draft. */
91
+ ref?: Ref<ConversationComposerHandle>;
76
92
  submitLabel: string;
77
93
  onFocus?: () => void;
78
94
  onSubmit(
@@ -93,7 +109,7 @@ type ConversationComposerProps = {
93
109
  export const ConversationComposer = memo(function ConversationComposer(
94
110
  props: ConversationComposerProps,
95
111
  ) {
96
- const storageKey = `${DRAFT_STORAGE_PREFIX}${encodeURIComponent(props.draftId)}`;
112
+ const storageKey = draftStorageKey(props.draftId);
97
113
  const [initialDraft] = useState<ConversationDraft>(() =>
98
114
  readStoredDraft(storageKey),
99
115
  );
@@ -184,7 +200,18 @@ export const ConversationComposer = memo(function ConversationComposer(
184
200
  setReadingImages(true);
185
201
  setImageError(undefined);
186
202
  try {
187
- replaceImages(await readComposerImages(files, imagesRef.current));
203
+ const before = imagesRef.current;
204
+ const added = (await readComposerImages(files, before)).slice(
205
+ before.length,
206
+ );
207
+ // An edit can restore images during the read. Add to those images
208
+ // instead of replacing them.
209
+ const next = [...imagesRef.current, ...added];
210
+ if (!composerImagesFit(next)) {
211
+ setImageError("Attach up to 3 images that total 3 MB or less.");
212
+ } else {
213
+ replaceImages(next);
214
+ }
188
215
  textareaRef.current?.focus({ preventScroll: true });
189
216
  } catch (error) {
190
217
  setImageError(
@@ -306,6 +333,12 @@ export const ConversationComposer = memo(function ConversationComposer(
306
333
  };
307
334
 
308
335
  // Put queued text back ahead of anything the reader already typed.
336
+ const prependDraftText = (text: string) => {
337
+ const next = withPrependedText(draftRef.current, text);
338
+ replaceMessage(next);
339
+ storeDraft(storageKey, next);
340
+ };
341
+
309
342
  const stopTurn = async () => {
310
343
  if (!props.onStop || stoppingRef.current) return;
311
344
  stoppingRef.current = true;
@@ -319,16 +352,33 @@ export const ConversationComposer = memo(function ConversationComposer(
319
352
  stoppingRef.current = false;
320
353
  }
321
354
  if (!restored) return;
322
- const current = draftRef.current.text;
323
- const next: ConversationDraft = {
324
- ...draftRef.current,
325
- text: current.trim() ? `${restored}\n${current}` : restored,
326
- };
327
- replaceMessage(next);
328
- storeDraft(storageKey, next);
355
+ prependDraftText(restored);
329
356
  textareaRef.current?.focus({ preventScroll: true });
330
357
  };
331
358
 
359
+ useImperativeHandle(props.ref, () => ({
360
+ canRestoreImages: (images) =>
361
+ !images.length || composerImagesFit([...images, ...imagesRef.current]),
362
+ restoreMessage: (message) => {
363
+ // An image-only message has no text to put ahead of the draft.
364
+ if (message.text.trim()) prependDraftText(message.text);
365
+ const restoredImages = message.images ?? [];
366
+ if (restoredImages.length) {
367
+ // The queued images already left the queue, so they win. A draft
368
+ // image added during the cancel stays only while it still fits.
369
+ const merged = [...restoredImages];
370
+ for (const image of imagesRef.current) {
371
+ if (composerImagesFit([...merged, image])) merged.push(image);
372
+ }
373
+ if (merged.length < restoredImages.length + imagesRef.current.length) {
374
+ setImageError("Some draft images did not fit. Add them again.");
375
+ }
376
+ replaceImages(merged);
377
+ }
378
+ textareaRef.current?.focus({ preventScroll: true });
379
+ },
380
+ }));
381
+
332
382
  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
333
383
  if (
334
384
  event.key === "Enter" &&
@@ -484,7 +534,6 @@ export const ConversationComposer = memo(function ConversationComposer(
484
534
  aria-label={props.stopPending ? "Stopping" : "Stop"}
485
535
  disabled={props.stopPending || !online}
486
536
  onClick={() => void stopTurn()}
487
- title="Stop Junior"
488
537
  >
489
538
  <Square aria-hidden="true" fill="currentColor" size={12} />
490
539
  <span className="hidden md:inline">
@@ -501,7 +550,6 @@ export const ConversationComposer = memo(function ConversationComposer(
501
550
  sendLocked ||
502
551
  readingImages
503
552
  }
504
- title={sendLocked ? "Sending message" : props.submitLabel}
505
553
  variant="primary"
506
554
  type="submit"
507
555
  >
@@ -571,6 +619,30 @@ function readStoredDraft(storageKey: string): ConversationDraft {
571
619
  return emptyDraft();
572
620
  }
573
621
 
622
+ function draftStorageKey(draftId: string): string {
623
+ return `${DRAFT_STORAGE_PREFIX}${encodeURIComponent(draftId)}`;
624
+ }
625
+
626
+ function withPrependedText(
627
+ draft: ConversationDraft,
628
+ text: string,
629
+ ): ConversationDraft {
630
+ return {
631
+ ...draft,
632
+ text: draft.text.trim() ? `${text}\n${draft.text}` : text,
633
+ };
634
+ }
635
+
636
+ /**
637
+ * Put queued text ahead of the saved draft when the composer is not mounted.
638
+ * The composer reads the saved draft the next time it opens.
639
+ */
640
+ export function restoreStoredDraftText(draftId: string, text: string): void {
641
+ if (!text.trim()) return;
642
+ const storageKey = draftStorageKey(draftId);
643
+ storeDraft(storageKey, withPrependedText(readStoredDraft(storageKey), text));
644
+ }
645
+
574
646
  function storeDraft(storageKey: string, draft: ConversationDraft): void {
575
647
  if (typeof window === "undefined") return;
576
648
  try {
@@ -28,6 +28,7 @@ import {
28
28
  ConversationWatches,
29
29
  useConversationWatches,
30
30
  } from "./ConversationWatches";
31
+ import { DetailsCard } from "./DetailsList";
31
32
  import { ShareConversationButton } from "./ShareConversationButton";
32
33
  import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
33
34
 
@@ -158,13 +159,13 @@ export function ConversationDetails(props: {
158
159
  </div>
159
160
  ) : null}
160
161
  {sections.map((section) => (
161
- <DetailsSection
162
+ <DetailsCard
162
163
  count={section.count}
163
164
  key={section.key}
164
165
  title={section.title}
165
166
  >
166
167
  {section.content}
167
- </DetailsSection>
168
+ </DetailsCard>
168
169
  ))}
169
170
  </div>
170
171
  ) : (
@@ -201,31 +202,6 @@ function TabCount(props: { count: number | undefined }) {
201
202
  );
202
203
  }
203
204
 
204
- /** Group one kind of conversation detail in a compact card. */
205
- function DetailsSection(props: {
206
- children: ReactNode;
207
- count?: number;
208
- title?: string;
209
- }) {
210
- return (
211
- <section className="grid min-w-0 gap-2.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3">
212
- {props.title ? (
213
- <h3 className="m-0 flex items-center gap-1.5 font-sans text-xs font-medium text-dashboard-text-muted">
214
- {props.title}
215
- {props.count ? (
216
- <span className="tabular-nums text-dashboard-text-faint">
217
- {props.count.toLocaleString("en-US")}
218
- </span>
219
- ) : null}
220
- </h3>
221
- ) : null}
222
- <div className="min-w-0 break-words font-sans text-xs leading-relaxed text-dashboard-text-muted">
223
- {props.children}
224
- </div>
225
- </section>
226
- );
227
- }
228
-
229
205
  /** Draw the Sentry glyph in the current text color, sized like a header icon. */
230
206
  function SentryGlyph() {
231
207
  return (
@@ -4,6 +4,10 @@ import { ChevronDown, ChevronUp } from "lucide-react";
4
4
  import { cn } from "../styles";
5
5
  import { DetailsPanel } from "../components/DetailsPanel";
6
6
  import { Button } from "../components/ui/button";
7
+ import {
8
+ ButtonGroup,
9
+ ButtonGroupSeparator,
10
+ } from "../components/ui/button-group";
7
11
  import { Skeleton } from "../components/ui/skeleton";
8
12
  import type { ConversationTranscript } from "../types";
9
13
  import { useEventTime } from "./eventTime";
@@ -11,8 +15,9 @@ import { EventDetails } from "./EventDetails";
11
15
  import { ForkConversationButton } from "./ForkConversationButton";
12
16
 
13
17
  // Step buttons keep focus at the ends of the log, so style the ARIA state.
18
+ // They sit in one bordered group, so they are a little smaller than `size-9`.
14
19
  const stepButtonClass =
15
- "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:!bg-transparent data-disabled:hover:!text-dashboard-text-muted";
20
+ "size-8 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:!bg-transparent data-disabled:hover:!text-dashboard-text-muted";
16
21
 
17
22
  /**
18
23
  * Show the selected event without blocking the main conversation's event log.
@@ -30,14 +35,21 @@ export function ConversationEventDetails(props: {
30
35
  }) {
31
36
  const { conversation, event, onSelect } = props;
32
37
  const time = useEventTime();
38
+ // The sequence number matches the `event` link parameter and the row label.
39
+ // The line stays on one row beside the actions. The tooltip keeps the
40
+ // milliseconds that the short timestamp leaves out.
33
41
  const timestamp = event ? (
34
- <time
35
- className="block break-words font-mono text-xs text-dashboard-text-muted"
36
- dateTime={event.createdAt}
37
- title={event.createdAt}
38
- >
39
- {time.formatTimestamp(event.createdAt)}
40
- </time>
42
+ <p className="m-0 flex min-w-0 gap-x-1.5 whitespace-nowrap font-sans text-xs tabular-nums text-dashboard-text-muted">
43
+ <span className="shrink-0">#{event.seq}</span>
44
+ <span aria-hidden="true">·</span>
45
+ <time
46
+ className="min-w-0 truncate"
47
+ dateTime={event.createdAt}
48
+ title={time.formatTimestamp(event.createdAt)}
49
+ >
50
+ {time.formatShortTimestamp(event.createdAt)}
51
+ </time>
52
+ </p>
41
53
  ) : null;
42
54
  const title = event ? (
43
55
  <>
@@ -54,7 +66,10 @@ export function ConversationEventDetails(props: {
54
66
  );
55
67
  const actions =
56
68
  onSelect && event ? (
57
- <>
69
+ <ButtonGroup
70
+ aria-label="Event navigation"
71
+ className="rounded-md border border-dashboard-border"
72
+ >
58
73
  <Button
59
74
  aria-label="Previous event"
60
75
  className={stepButtonClass}
@@ -68,6 +83,7 @@ export function ConversationEventDetails(props: {
68
83
  >
69
84
  <ChevronUp aria-hidden="true" size={15} />
70
85
  </Button>
86
+ <ButtonGroupSeparator />
71
87
  <Button
72
88
  aria-label="Next event"
73
89
  className={stepButtonClass}
@@ -81,7 +97,7 @@ export function ConversationEventDetails(props: {
81
97
  >
82
98
  <ChevronDown aria-hidden="true" size={15} />
83
99
  </Button>
84
- </>
100
+ </ButtonGroup>
85
101
  ) : undefined;
86
102
 
87
103
  return (
@@ -10,6 +10,7 @@ import {
10
10
  import { Ellipsis, Menu } from "lucide-react";
11
11
 
12
12
  import { SearchInput } from "../components/SearchInput";
13
+ import { Button } from "../components/ui/button";
13
14
  import { cn } from "../styles";
14
15
  import {
15
16
  MobileHeaderActionsPortal,
@@ -149,17 +150,17 @@ export function ConversationHeader(props: {
149
150
  view={props.view}
150
151
  />
151
152
  {openMobileNavigation ? (
152
- <button
153
- className="mt-0.5 flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline-none"
153
+ <Button
154
+ className="mt-0.5"
154
155
  onClick={() => {
155
156
  setMobileMenuOpen(false);
156
157
  openMobileNavigation();
157
158
  }}
158
- type="button"
159
+ size="menu"
159
160
  >
160
161
  <Menu aria-hidden="true" size={16} strokeWidth={2} />
161
162
  App menu
162
- </button>
163
+ </Button>
163
164
  ) : null}
164
165
  </div>
165
166
  ) : null}
@@ -235,19 +235,16 @@ function MenuActionButton(props: {
235
235
  pressed?: boolean;
236
236
  }) {
237
237
  return (
238
- <button
238
+ <Button
239
239
  aria-label={props.label}
240
240
  aria-pressed={props.pressed}
241
- className={cn(
242
- "flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-strong focus-visible:outline-none",
243
- props.pressed && "bg-dashboard-fill-strong",
244
- )}
241
+ className={props.pressed ? "bg-dashboard-fill-strong" : undefined}
245
242
  onClick={props.onClick}
246
- type="button"
243
+ size="menu"
247
244
  >
248
245
  {props.children}
249
246
  <span>{props.label}</span>
250
- </button>
247
+ </Button>
251
248
  );
252
249
  }
253
250
 
@@ -262,16 +259,15 @@ function ArchiveConversationButton(
262
259
  const Icon = props.archived ? ArchiveRestore : Archive;
263
260
  if (props.layout === "menu") {
264
261
  return (
265
- <button
262
+ <Button
266
263
  aria-label={label}
267
- className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-strong focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
268
264
  disabled={props.disabled}
269
265
  onClick={props.onClick}
270
- type="button"
266
+ size="menu"
271
267
  >
272
268
  <Icon aria-hidden="true" size={16} strokeWidth={2} />
273
269
  <span>{label}</span>
274
- </button>
270
+ </Button>
275
271
  );
276
272
  }
277
273
  return (