@sentry/junior-dashboard 0.265.0 → 0.266.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 (47) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +48 -48
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/components/ActorAvatar.tsx +10 -22
  7. package/src/client/components/ConnectionBanner.tsx +4 -3
  8. package/src/client/components/CopyButton.tsx +3 -4
  9. package/src/client/components/EmptyTelemetry.tsx +5 -3
  10. package/src/client/components/ImageAttachment.tsx +49 -63
  11. package/src/client/components/InlineError.tsx +8 -4
  12. package/src/client/components/Notice.tsx +19 -26
  13. package/src/client/components/Pagination.tsx +26 -26
  14. package/src/client/components/ParticipantAvatarStack.tsx +10 -10
  15. package/src/client/components/README.md +17 -1
  16. package/src/client/components/SearchInput.tsx +51 -41
  17. package/src/client/components/VersionDriftBanner.tsx +9 -7
  18. package/src/client/components/charts/CacheWrites.tsx +52 -43
  19. package/src/client/components/ui/alert.tsx +64 -0
  20. package/src/client/components/ui/avatar.tsx +53 -0
  21. package/src/client/components/ui/button.tsx +2 -1
  22. package/src/client/components/ui/dialog.tsx +66 -0
  23. package/src/client/components/ui/empty.tsx +32 -0
  24. package/src/client/components/ui/input-group.tsx +96 -0
  25. package/src/client/components/ui/pagination.tsx +60 -0
  26. package/src/client/components/ui/spinner.tsx +21 -0
  27. package/src/client/components/ui/table.tsx +22 -1
  28. package/src/client/components/ui/tooltip.tsx +1 -1
  29. package/src/client/conversations/ConversationChildAgents.tsx +5 -5
  30. package/src/client/conversations/ConversationHeader.tsx +5 -4
  31. package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
  32. package/src/client/conversations/ConversationSidebar.tsx +5 -4
  33. package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
  34. package/src/client/conversations/ConversationWatches.tsx +67 -59
  35. package/src/client/conversations/DetailsList.tsx +44 -29
  36. package/src/client/conversations/EventUsage.tsx +99 -74
  37. package/src/client/conversations/PendingMailboxStack.tsx +5 -10
  38. package/src/client/conversations/README.md +3 -0
  39. package/src/client/conversations/ShareConversationButton.tsx +3 -4
  40. package/src/client/conversations/TranscriptEventView.tsx +35 -16
  41. package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
  42. package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
  43. package/src/client/conversations/TranscriptSummary.tsx +34 -2
  44. package/src/client/conversations/TranscriptTurnContextView.tsx +45 -38
  45. package/src/client/conversations/eventLog.ts +1 -0
  46. package/src/client/conversations/transcriptMentions.tsx +18 -5
  47. package/src/mock-reporting/watches.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.265.0",
3
+ "version": "0.266.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -37,8 +37,8 @@
37
37
  "recharts": "3.8.0",
38
38
  "shiki": "4.1.0",
39
39
  "zod": "^4.5.4",
40
- "@sentry/junior": "0.265.0",
41
- "@sentry/junior-plugin-api": "0.265.0"
40
+ "@sentry/junior": "0.266.0",
41
+ "@sentry/junior-plugin-api": "0.266.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@oxlint/plugins": "1.79.0",
@@ -1,5 +1,4 @@
1
- import { useState } from "react";
2
- import { cn } from "../styles";
1
+ import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
3
2
 
4
3
  /** Show an actor's image, with initials when the image is missing or fails. */
5
4
  export function ActorAvatar(props: {
@@ -7,7 +6,6 @@ export function ActorAvatar(props: {
7
6
  imageUrl?: string;
8
7
  size: "detail" | "list";
9
8
  }) {
10
- const [failedUrl, setFailedUrl] = useState<string>();
11
9
  const words = props.name.trim().split(/\s+/).filter(Boolean);
12
10
  // The compact size fits one initial at the 12px type floor.
13
11
  const initials =
@@ -17,24 +15,14 @@ export function ActorAvatar(props: {
17
15
  ? `${words[0]![0]}${words.at(-1)![0]}`
18
16
  : props.name.slice(0, 2);
19
17
  return (
20
- <span
21
- aria-hidden="true"
22
- className={cn(
23
- "relative inline-grid shrink-0 place-items-center overflow-hidden rounded-full border-2 border-dashboard-bg bg-dashboard-focus font-sans font-semibold leading-none tracking-tight text-dashboard-text-inverse shadow-sm",
24
- props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
25
- )}
26
- >
27
- {initials.toUpperCase()}
28
- {props.imageUrl && props.imageUrl !== failedUrl ? (
29
- <img
30
- alt=""
31
- className="absolute inset-0 size-full object-cover"
32
- loading="lazy"
33
- onError={() => setFailedUrl(props.imageUrl)}
34
- referrerPolicy="no-referrer"
35
- src={props.imageUrl}
36
- />
37
- ) : null}
38
- </span>
18
+ <Avatar aria-hidden="true" size={props.size === "list" ? "sm" : "default"}>
19
+ <AvatarImage
20
+ alt=""
21
+ loading="lazy"
22
+ referrerPolicy="no-referrer"
23
+ src={props.imageUrl}
24
+ />
25
+ <AvatarFallback>{initials.toUpperCase()}</AvatarFallback>
26
+ </Avatar>
39
27
  );
40
28
  }
@@ -1,4 +1,5 @@
1
1
  import { useDashboardOnline } from "../connection";
2
+ import { Alert } from "./ui/alert";
2
3
 
3
4
  /** Explain that cached data remains visible while network reads are paused. */
4
5
  export function ConnectionBanner() {
@@ -6,12 +7,12 @@ export function ConnectionBanner() {
6
7
  if (online) return null;
7
8
 
8
9
  return (
9
- <div
10
+ <Alert
10
11
  aria-live="polite"
11
- className="border-t border-amber-300/15 bg-amber-300/[0.07] px-3 py-1.5 text-center font-sans text-xs text-amber-100/80"
12
+ className="block rounded-none border-0 border-t border-amber-300/15 bg-amber-300/[0.07] px-3 py-1.5 text-center font-sans text-xs text-amber-100/80"
12
13
  role="status"
13
14
  >
14
15
  You’re offline. Drafts stay on this device.
15
- </div>
16
+ </Alert>
16
17
  );
17
18
  }
@@ -44,16 +44,15 @@ export function CopyButton(props: {
44
44
 
45
45
  if (props.layout === "menu") {
46
46
  return (
47
- <button
47
+ <Button
48
48
  aria-label={label}
49
- 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"
50
49
  disabled={!props.getText || status === "copying"}
51
50
  onClick={() => void copyText()}
52
- type="button"
51
+ size="menu"
53
52
  >
54
53
  <Icon aria-hidden="true" size={16} strokeWidth={2} />
55
54
  <span>{label}</span>
56
- </button>
55
+ </Button>
57
56
  );
58
57
  }
59
58
 
@@ -1,10 +1,12 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
+ import { Empty, EmptyDescription } from "./ui/empty";
4
+
3
5
  /** Render the dashboard empty-state block with quiet warning context. */
4
6
  export function EmptyTelemetry(props: { children: ReactNode }) {
5
7
  return (
6
- <div className="min-w-0 rounded-lg border border-dashboard-border-strong bg-dashboard-bg px-4 py-3 text-base leading-relaxed text-dashboard-text-muted">
7
- {props.children}
8
- </div>
8
+ <Empty>
9
+ <EmptyDescription>{props.children}</EmptyDescription>
10
+ </Empty>
9
11
  );
10
12
  }
@@ -1,17 +1,13 @@
1
1
  import { ExternalLink, X } from "lucide-react";
2
2
  import {
3
3
  type MouseEvent,
4
- useEffect,
5
- useId,
6
- useRef,
7
4
  useState,
8
5
  type ComponentPropsWithoutRef,
9
6
  } from "react";
10
7
 
11
- import {
12
- acquireBodyScrollLock,
13
- releaseBodyScrollLock,
14
- } from "../bodyScrollLock";
8
+ import { Button } from "./ui/button";
9
+ import { Dialog, DialogClose, DialogContent, DialogTitle } from "./ui/dialog";
10
+ import { useSheet } from "./ui/useSheet";
15
11
 
16
12
  /** Return true when an image link click should open the inline preview. */
17
13
  export function shouldPreviewImageAttachment(
@@ -45,9 +41,6 @@ export function ImageAttachment(
45
41
  triggerClassName?: string;
46
42
  } & Omit<ComponentPropsWithoutRef<"img">, "alt" | "className" | "src">,
47
43
  ) {
48
- const dialogRef = useRef<HTMLDialogElement>(null);
49
- const bodyScrollLockedRef = useRef(false);
50
- const titleId = useId();
51
44
  const [open, setOpen] = useState(false);
52
45
  const {
53
46
  context,
@@ -58,20 +51,6 @@ export function ImageAttachment(
58
51
  ...imageProps
59
52
  } = props;
60
53
 
61
- const unlockBodyScroll = () => {
62
- if (!bodyScrollLockedRef.current) return;
63
- releaseBodyScrollLock();
64
- bodyScrollLockedRef.current = false;
65
- };
66
-
67
- const close = () => {
68
- dialogRef.current?.close();
69
- setOpen(false);
70
- unlockBodyScroll();
71
- };
72
-
73
- useEffect(() => () => unlockBodyScroll(), []);
74
-
75
54
  return (
76
55
  <>
77
56
  <a
@@ -80,12 +59,7 @@ export function ImageAttachment(
80
59
  onClick={(event) => {
81
60
  if (!shouldPreviewImageAttachment(event)) return;
82
61
  event.preventDefault();
83
- const dialog = dialogRef.current;
84
- if (!dialog || dialog.open) return;
85
- acquireBodyScrollLock();
86
- bodyScrollLockedRef.current = true;
87
62
  setOpen(true);
88
- dialog.showModal();
89
63
  }}
90
64
  rel="noreferrer"
91
65
  target="_blank"
@@ -97,62 +71,74 @@ export function ImageAttachment(
97
71
  src={src}
98
72
  />
99
73
  </a>
100
- <dialog
101
- aria-labelledby={titleId}
102
- className="m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-dashboard-ink p-0 text-dashboard-text backdrop:bg-dashboard-overlay md:m-auto md:h-[min(90dvh,960px)] md:w-[min(92vw,1440px)] md:rounded-lg md:border md:border-dashboard-border-emphasis"
103
- onClick={(event) => {
104
- if (event.target === event.currentTarget) close();
105
- }}
106
- onClose={() => {
107
- setOpen(false);
108
- unlockBodyScroll();
109
- }}
110
- ref={dialogRef}
74
+ {open ? (
75
+ <ImagePreview
76
+ context={context}
77
+ filename={filename}
78
+ onClose={() => setOpen(false)}
79
+ src={src}
80
+ />
81
+ ) : null}
82
+ </>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Show one image above the page. `useSheet` shares the document scroll lock
88
+ * with sheets and keeps Escape and backdrop dismissal on the front modal.
89
+ */
90
+ function ImagePreview(props: {
91
+ context?: string;
92
+ filename: string;
93
+ onClose(): void;
94
+ src: string;
95
+ }) {
96
+ const sheet = useSheet(props.onClose);
97
+ return (
98
+ <Dialog {...sheet.rootProps}>
99
+ <DialogContent
100
+ {...sheet.contentProps}
101
+ className="top-0 left-0 h-dvh w-screen max-w-none translate-x-0 translate-y-0 gap-0 overflow-hidden rounded-none border-0 bg-dashboard-ink p-0 shadow-none sm:max-w-none md:top-1/2 md:left-1/2 md:h-[min(90dvh,960px)] md:w-[min(92vw,1440px)] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-lg md:border"
111
102
  >
112
103
  <div className="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] bg-dashboard-bg-elevated">
113
104
  <header className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-1.5 border-b border-dashboard-border-strong bg-dashboard-surface-raised px-3 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] md:px-4">
114
105
  <div className="min-w-0">
115
- <div
116
- className="truncate font-mono text-xs text-dashboard-text"
117
- id={titleId}
118
- >
119
- {filename}
120
- </div>
121
- {context ? (
106
+ <DialogTitle className="truncate font-mono text-xs leading-normal font-normal">
107
+ {props.filename}
108
+ </DialogTitle>
109
+ {props.context ? (
122
110
  <div className="truncate font-mono text-2xs text-dashboard-text-muted">
123
- {context}
111
+ {props.context}
124
112
  </div>
125
113
  ) : null}
126
114
  </div>
127
115
  <a
128
116
  className="inline-flex min-h-10 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-dashboard-text-muted no-underline transition-colors hover:bg-dashboard-fill-hover hover:text-dashboard-text"
129
- href={src}
117
+ href={props.src}
130
118
  rel="noreferrer"
131
119
  target="_blank"
132
120
  >
133
121
  <ExternalLink aria-hidden="true" size={14} />
134
122
  <span className="max-sm:sr-only">Open raw file</span>
135
123
  </a>
136
- <button
137
- className="inline-flex min-h-10 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-hover hover:text-dashboard-text"
138
- onClick={close}
139
- type="button"
124
+ <DialogClose
125
+ render={
126
+ <Button className="h-10 gap-1.5 border-0 bg-transparent px-2.5 text-xs text-dashboard-text-muted hover:bg-dashboard-fill-hover hover:text-dashboard-text" />
127
+ }
140
128
  >
141
129
  <X aria-hidden="true" size={16} />
142
130
  <span>Close</span>
143
- </button>
131
+ </DialogClose>
144
132
  </header>
145
133
  <div className="grid min-h-0 place-items-center overflow-auto p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:p-6">
146
- {open ? (
147
- <img
148
- alt={filename}
149
- className="max-h-full max-w-full object-contain"
150
- src={src}
151
- />
152
- ) : null}
134
+ <img
135
+ alt={props.filename}
136
+ className="max-h-full max-w-full object-contain"
137
+ src={props.src}
138
+ />
153
139
  </div>
154
140
  </div>
155
- </dialog>
156
- </>
141
+ </DialogContent>
142
+ </Dialog>
157
143
  );
158
144
  }
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
3
  import { cn } from "../styles";
4
+ import { Alert } from "./ui/alert";
4
5
 
5
6
  /** Render a compact error message with consistent alert semantics. */
6
7
  export function InlineError(props: {
@@ -8,11 +9,14 @@ export function InlineError(props: {
8
9
  className?: string;
9
10
  }) {
10
11
  return (
11
- <p
12
- className={cn("m-0 text-sm text-rose-300", props.className)}
13
- role="alert"
12
+ <Alert
13
+ className={cn(
14
+ "block rounded-none border-0 bg-transparent p-0 text-rose-300",
15
+ props.className,
16
+ )}
17
+ variant="destructive"
14
18
  >
15
19
  {props.children}
16
- </p>
20
+ </Alert>
17
21
  );
18
22
  }
@@ -1,7 +1,9 @@
1
- import type { ButtonHTMLAttributes, ReactNode } from "react";
1
+ import type { ComponentProps, ReactNode } from "react";
2
2
  import type { LucideIcon } from "lucide-react";
3
3
 
4
4
  import { cn } from "../styles";
5
+ import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
6
+ import { Button } from "./ui/button";
5
7
 
6
8
  type NoticeTone = "default" | "error";
7
9
 
@@ -14,7 +16,7 @@ type NoticeProps = {
14
16
  tone?: NoticeTone;
15
17
  };
16
18
 
17
- type NoticeActionProps = ButtonHTMLAttributes<HTMLButtonElement> & {
19
+ type NoticeActionProps = ComponentProps<typeof Button> & {
18
20
  tone?: NoticeTone;
19
21
  };
20
22
 
@@ -29,11 +31,10 @@ export function Notice({
29
31
  }: NoticeProps) {
30
32
  const isError = tone === "error";
31
33
  return (
32
- <div
33
- className={cn(
34
- "overflow-hidden rounded-xl border bg-dashboard-surface-raised shadow-[0_16px_40px_rgba(0,0,0,0.5)]",
35
- isError ? "border-rose-300/25" : "border-dashboard-border-strong",
36
- )}
34
+ <Alert
35
+ className="block gap-0 overflow-hidden rounded-xl p-0 shadow-[0_16px_40px_rgba(0,0,0,0.5)]"
36
+ role={isError ? "alert" : "status"}
37
+ variant={isError ? "destructive" : "default"}
37
38
  >
38
39
  <div className="flex min-w-0 items-center gap-3 px-3 py-3">
39
40
  {Icon ? (
@@ -48,31 +49,26 @@ export function Notice({
48
49
  <Icon aria-hidden="true" size={16} />
49
50
  </div>
50
51
  ) : null}
51
- <div className="min-w-0 flex-1" role={isError ? "alert" : "status"}>
52
- <div
53
- className={cn(
54
- "font-display text-sm font-medium leading-snug",
55
- isError ? "text-rose-50" : "text-dashboard-text",
56
- )}
57
- >
52
+ <div className="min-w-0 flex-1">
53
+ <AlertTitle className="font-display text-sm leading-snug">
58
54
  {title}
59
55
  {detail ? " " : null}
60
- </div>
56
+ </AlertTitle>
61
57
  {detail ? (
62
- <div
58
+ <AlertDescription
63
59
  className={cn(
64
60
  "mt-0.5 truncate font-sans text-xs",
65
- isError ? "text-rose-100/70" : "text-dashboard-text-muted",
61
+ isError && "text-rose-100/70",
66
62
  )}
67
63
  >
68
64
  {detail}
69
- </div>
65
+ </AlertDescription>
70
66
  ) : null}
71
67
  </div>
72
68
  {action}
73
69
  </div>
74
70
  {children}
75
- </div>
71
+ </Alert>
76
72
  );
77
73
  }
78
74
 
@@ -80,22 +76,19 @@ export function Notice({
80
76
  export function NoticeAction({
81
77
  className,
82
78
  tone = "default",
83
- type = "button",
84
79
  ...props
85
80
  }: NoticeActionProps) {
86
81
  const isError = tone === "error";
87
82
  return (
88
- <button
83
+ <Button
89
84
  {...props}
90
85
  className={cn(
91
- "shrink-0 cursor-pointer px-3 py-1.5 text-xs transition focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-40",
92
- isError ? "focus:ring-rose-300/40" : "focus:ring-dashboard-focus/35",
86
+ "h-auto shrink-0 rounded-lg border-0 px-3 py-1.5 text-xs leading-normal",
93
87
  isError
94
- ? "rounded-lg bg-rose-200/15 font-sans font-medium text-rose-50 hover:bg-rose-200/25"
95
- : "rounded-lg bg-dashboard-accent/10 font-sans font-medium text-dashboard-accent-text hover:bg-dashboard-accent/20",
88
+ ? "bg-rose-200/15 text-rose-50 hover:bg-rose-200/25 hover:text-rose-50 focus-visible:outline-rose-300/55 data-disabled:hover:bg-rose-200/15 data-disabled:hover:text-rose-50"
89
+ : "bg-dashboard-accent/10 text-dashboard-accent-text hover:bg-dashboard-accent/20 hover:text-dashboard-accent-text data-disabled:hover:bg-dashboard-accent/10 data-disabled:hover:text-dashboard-accent-text",
96
90
  className,
97
91
  )}
98
- type={type}
99
92
  />
100
93
  );
101
94
  }
@@ -1,7 +1,12 @@
1
- import { ChevronLeft, ChevronRight } from "lucide-react";
2
-
3
1
  import { cn } from "../styles";
4
2
  import { Button } from "./ui/button";
3
+ import {
4
+ Pagination,
5
+ PaginationContent,
6
+ PaginationItem,
7
+ PaginationNext,
8
+ PaginationPrevious,
9
+ } from "./ui/pagination";
5
10
 
6
11
  /** Render a centered load-more control for cursor or progressive lists. */
7
12
  export function LoadMorePagination(props: {
@@ -54,10 +59,9 @@ export function PagePagination(props: {
54
59
  const end = Math.min(props.total, current * props.pageSize);
55
60
 
56
61
  return (
57
- <div
58
- aria-label="Pagination"
62
+ <Pagination
59
63
  className={cn(
60
- "flex flex-wrap items-center justify-between gap-3",
64
+ "flex-wrap items-center justify-between gap-3",
61
65
  props.className,
62
66
  )}
63
67
  >
@@ -65,27 +69,23 @@ export function PagePagination(props: {
65
69
  Showing {start.toLocaleString("en-US")}-{end.toLocaleString("en-US")} of{" "}
66
70
  {props.total.toLocaleString("en-US")}
67
71
  </p>
68
- <div className="flex items-center gap-2">
69
- <Button
70
- aria-label="Previous page"
71
- disabled={current <= 1}
72
- onClick={() => props.onPageChange(current - 1)}
73
- size="icon"
74
- >
75
- <ChevronLeft aria-hidden="true" size={16} />
76
- </Button>
77
- <span className="min-w-16 text-center font-sans tabular-nums text-xs text-dashboard-text-muted">
72
+ <PaginationContent>
73
+ <PaginationItem>
74
+ <PaginationPrevious
75
+ disabled={current <= 1}
76
+ onClick={() => props.onPageChange(current - 1)}
77
+ />
78
+ </PaginationItem>
79
+ <PaginationItem className="min-w-16 text-center font-sans tabular-nums text-xs text-dashboard-text-muted">
78
80
  {current} / {props.pageCount}
79
- </span>
80
- <Button
81
- aria-label="Next page"
82
- disabled={current >= props.pageCount}
83
- onClick={() => props.onPageChange(current + 1)}
84
- size="icon"
85
- >
86
- <ChevronRight aria-hidden="true" size={16} />
87
- </Button>
88
- </div>
89
- </div>
81
+ </PaginationItem>
82
+ <PaginationItem>
83
+ <PaginationNext
84
+ disabled={current >= props.pageCount}
85
+ onClick={() => props.onPageChange(current + 1)}
86
+ />
87
+ </PaginationItem>
88
+ </PaginationContent>
89
+ </Pagination>
90
90
  );
91
91
  }
@@ -1,9 +1,9 @@
1
1
  import type { ActorIdentity } from "@sentry/junior/api/schema";
2
2
 
3
3
  import { actorLabel } from "../format";
4
- import { cn } from "../styles";
5
4
  import type { Conversation } from "../types";
6
5
  import { ActorAvatar } from "./ActorAvatar";
6
+ import { Avatar, AvatarFallback } from "./ui/avatar";
7
7
  import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
8
8
 
9
9
  const MAX_VISIBLE_PARTICIPANTS = 3;
@@ -51,7 +51,7 @@ function ParticipantTooltipContent(props: { participant: ActorIdentity }) {
51
51
  );
52
52
  }
53
53
 
54
- function Avatar(props: {
54
+ function ParticipantAvatar(props: {
55
55
  participant: ActorIdentity;
56
56
  size: ParticipantAvatarStackProps["size"];
57
57
  }) {
@@ -101,7 +101,7 @@ export function ParticipantAvatarStack(props: ParticipantAvatarStackProps) {
101
101
  role="group"
102
102
  >
103
103
  {visible.map((participant, index) => (
104
- <Avatar
104
+ <ParticipantAvatar
105
105
  key={participantKey(participant, index)}
106
106
  participant={participant}
107
107
  size={props.size}
@@ -111,19 +111,19 @@ export function ParticipantAvatarStack(props: ParticipantAvatarStackProps) {
111
111
  <Tooltip>
112
112
  <TooltipTrigger
113
113
  render={
114
- <span
114
+ <Avatar
115
115
  aria-label={`${hidden.length} more participants: ${hidden
116
116
  .map(participantDescription)
117
117
  .join("; ")}`}
118
- className={cn(
119
- "relative -ml-1 inline-grid shrink-0 pointer-events-none [@media(hover:hover)_and_(pointer:fine)]:pointer-events-auto place-items-center rounded-full border-2 border-dashboard-bg bg-dashboard-surface-active font-sans font-semibold tabular-nums leading-none text-dashboard-text-muted focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
120
- props.size === "list" ? "size-6 text-2xs" : "size-7 text-xs",
121
- )}
118
+ className="-ml-1 pointer-events-none bg-dashboard-surface-active shadow-none focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus [@media(hover:hover)_and_(pointer:fine)]:pointer-events-auto"
122
119
  onKeyDown={(event) => event.stopPropagation()}
120
+ size={props.size === "list" ? "sm" : "default"}
123
121
  tabIndex={0}
124
122
  >
125
- +{hidden.length}
126
- </span>
123
+ <AvatarFallback className="tabular-nums tracking-normal text-dashboard-text-muted">
124
+ +{hidden.length}
125
+ </AvatarFallback>
126
+ </Avatar>
127
127
  }
128
128
  />
129
129
  <TooltipContent>
@@ -35,6 +35,11 @@ file when every caller needs the same style or variant. App components compose
35
35
  | `Drawer`, `BottomSheet` | `ui/sheet` |
36
36
  | `layout/DashboardHeader` (nav) | `ui/sheet` |
37
37
  | `ProfileMenu` (desktop) | `ui/popover` |
38
+ | `ActorAvatar` | `ui/avatar` |
39
+ | `ParticipantAvatarStack` | `ui/avatar` |
40
+ | `SearchInput` | `ui/input-group` |
41
+ | `PagePagination` | `ui/pagination` |
42
+ | `EmptyTelemetry` | `ui/empty` |
38
43
  | `TimeRangeSelector` | `ui/toggle-group` |
39
44
  | `ThemeSwitcher` | `ui/toggle-group` |
40
45
  | `TranscriptViewToggle` | `ui/toggle-group` |
@@ -42,6 +47,9 @@ file when every caller needs the same style or variant. App components compose
42
47
  | `charts/ActivityChartTooltip` | `ui/tooltip` |
43
48
  | `conversations/ToolFrame` | `ui/trace` |
44
49
  | `TranscriptReasoningView` | `ui/trace` |
50
+ | `ImageAttachment` (preview) | `ui/dialog` |
51
+ | `Notice`, `InlineError` | `ui/alert` |
52
+ | App banners | `ui/alert` |
45
53
  | Recharts activity charts | `ui/chart` |
46
54
 
47
55
  Rules for adopted components:
@@ -54,6 +62,11 @@ Rules for adopted components:
54
62
  - Use `ui/alert-dialog` for every confirmation. Do not use `window.confirm`.
55
63
  Keep the confirmed item in state after close, so the closing dialog keeps its
56
64
  text.
65
+ - Use `useSheet` for each app `ui/dialog`, as for sheets. It shares the
66
+ document scroll lock. Only the front modal closes on Escape or a backdrop
67
+ tap. Mount the dialog only while it is open.
68
+ - `ui/alert` sets `role="alert"`. Pass `role="status"` for a message that is
69
+ not an error.
57
70
  - `ui/toast` has no global notification manager. Features call
58
71
  `useToastManager` inside the mounted `Toaster` provider.
59
72
  - Put a checkbox or radio inside its `<label>`. Give each `RadioGroup` an
@@ -68,6 +81,8 @@ Rules for adopted components:
68
81
  - Build a form field from `ui/field`. Give `FieldError` the id
69
82
  `<control id>-error` and `FieldDescription` the id `<control id>-help`. The
70
83
  control sets `aria-invalid` and `aria-describedby`.
84
+ - Use `Button size="menu"` for a full-width icon and label row in a mobile
85
+ overflow menu. A pressed row adds `bg-dashboard-fill-strong`.
71
86
  - For a link that looks like a button, put `buttonVariants()` on the router
72
87
  `Link`. A form page has one `Button variant="primary"` for its main save
73
88
  action.
@@ -141,7 +156,8 @@ Shared class strings live in `src/client/styles.ts`.
141
156
  - **Loading.** Use `ui/skeleton` for placeholder leaves and `PageContentSkeleton`
142
157
  variants for page bodies. Use `PageRouteLoading` when a page keeps its real
143
158
  header above a body skeleton. Use a feature-owned loading component for
144
- distinct geometry, such as conversations.
159
+ distinct geometry, such as conversations. Use `ui/spinner` for an inline
160
+ pending icon.
145
161
 
146
162
  ## Navigation
147
163