@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
@@ -15,6 +15,35 @@ function Empty({ className, ...props }: ComponentProps<"div">) {
15
15
  );
16
16
  }
17
17
 
18
+ /** Hold the icon that names the kind of empty state. */
19
+ function EmptyMedia({ className, ...props }: ComponentProps<"div">) {
20
+ return (
21
+ <div
22
+ aria-hidden="true"
23
+ data-slot="empty-media"
24
+ className={cn(
25
+ "grid size-8 shrink-0 place-items-center rounded-md bg-dashboard-fill-strong text-dashboard-text-subtle [&_svg:not([class*='size-'])]:size-4",
26
+ className,
27
+ )}
28
+ {...props}
29
+ />
30
+ );
31
+ }
32
+
33
+ /** Name the empty state in a few words. */
34
+ function EmptyTitle({ className, ...props }: ComponentProps<"div">) {
35
+ return (
36
+ <div
37
+ data-slot="empty-title"
38
+ className={cn(
39
+ "font-display text-base font-medium leading-tight text-dashboard-text",
40
+ className,
41
+ )}
42
+ {...props}
43
+ />
44
+ );
45
+ }
46
+
18
47
  /** Explain why the region is empty and what the user can do next. */
19
48
  function EmptyDescription({ className, ...props }: ComponentProps<"div">) {
20
49
  return (
@@ -29,4 +58,4 @@ function EmptyDescription({ className, ...props }: ComponentProps<"div">) {
29
58
  );
30
59
  }
31
60
 
32
- export { Empty, EmptyDescription };
61
+ export { Empty, EmptyDescription, EmptyMedia, EmptyTitle };
@@ -0,0 +1,22 @@
1
+ import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
2
+ import { cn } from "@dashboard/lib/utils";
3
+
4
+ function Separator({
5
+ className,
6
+ orientation = "horizontal",
7
+ ...props
8
+ }: SeparatorPrimitive.Props) {
9
+ return (
10
+ <SeparatorPrimitive
11
+ data-slot="separator"
12
+ orientation={orientation}
13
+ className={cn(
14
+ "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
15
+ className,
16
+ )}
17
+ {...props}
18
+ />
19
+ );
20
+ }
21
+
22
+ export { Separator };
@@ -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>
@@ -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 (
@@ -35,6 +35,7 @@ export function ConversationHomeList(props: {
35
35
  finishedConversationIds: ReadonlySet<string>;
36
36
  loading?: boolean;
37
37
  timeZone: string;
38
+ waitingConversationIds: ReadonlySet<string>;
38
39
  }) {
39
40
  const [archivedConversation, setArchivedConversation] =
40
41
  useState<Conversation>();
@@ -94,6 +95,7 @@ export function ConversationHomeList(props: {
94
95
  key={section.key}
95
96
  onArchiveError={handleArchiveError}
96
97
  onArchived={handleArchived}
98
+ waitingConversationIds={props.waitingConversationIds}
97
99
  section={section}
98
100
  />
99
101
  ))}
@@ -169,6 +171,7 @@ function ConversationCardSection(props: {
169
171
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
170
172
  onArchived(conversation: Conversation): void;
171
173
  section: ConversationSection;
174
+ waitingConversationIds: ReadonlySet<string>;
172
175
  }) {
173
176
  return (
174
177
  <section aria-labelledby={`conversation-section-${props.section.key}`}>
@@ -188,6 +191,7 @@ function ConversationCardSection(props: {
188
191
  key={conversation.id}
189
192
  onArchiveError={props.onArchiveError}
190
193
  onArchived={props.onArchived}
194
+ waitingOnViewer={props.waitingConversationIds.has(conversation.id)}
191
195
  />
192
196
  ))}
193
197
  </div>
@@ -200,6 +204,7 @@ function ConversationCard(props: {
200
204
  finishedSinceSeen: boolean;
201
205
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
202
206
  onArchived(conversation: Conversation): void;
207
+ waitingOnViewer: boolean;
203
208
  }) {
204
209
  const conversation = props.conversation;
205
210
  const archive = useArchiveConversation(conversation.id, {
@@ -231,6 +236,7 @@ function ConversationCard(props: {
231
236
  finishedSinceSeen={props.finishedSinceSeen}
232
237
  isPrivate={isPrivate}
233
238
  status={status}
239
+ waitingOnViewer={props.waitingOnViewer}
234
240
  />
235
241
  </span>
236
242
  <h4 className="m-0 truncate font-display text-base font-medium leading-snug text-dashboard-text">
@@ -4,12 +4,26 @@ import { ActiveIndicator } from "../components/ActiveIndicator";
4
4
  import { cn } from "../styles";
5
5
  import type { VisualStatus } from "../types";
6
6
 
7
- /** Render a conversation status marker, with new completions taking priority. */
7
+ /**
8
+ * Render a conversation status marker. "Waiting on you" replaces every other
9
+ * marker, then new completions take priority.
10
+ */
8
11
  export function ConversationListStatusIcon(props: {
9
12
  finishedSinceSeen: boolean;
10
13
  isPrivate: boolean;
11
14
  status: VisualStatus;
15
+ waitingOnViewer: boolean;
12
16
  }) {
17
+ if (props.waitingOnViewer) {
18
+ return (
19
+ <span
20
+ aria-label="Waiting on you"
21
+ className="size-2 shrink-0 rounded-full bg-violet-300 shadow-[0_0_6px_rgba(196,181,253,0.7)]"
22
+ role="img"
23
+ />
24
+ );
25
+ }
26
+
13
27
  if (props.finishedSinceSeen) {
14
28
  return (
15
29
  <span