@sentry/junior-dashboard 0.267.0 → 0.269.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 (59) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +47 -8
  3. package/dist/client.js +74 -78
  4. package/dist/index.js +47 -8
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +4 -4
  7. package/src/api/schema.ts +2 -1
  8. package/src/app.ts +11 -7
  9. package/src/client/App.tsx +20 -4
  10. package/src/client/api.ts +4 -19
  11. package/src/client/components/AGENTS.md +65 -0
  12. package/src/client/components/ActorAvatar.tsx +1 -1
  13. package/src/client/components/ParticipantAvatarStack.tsx +3 -1
  14. package/src/client/components/ProfileMenu.tsx +88 -29
  15. package/src/client/components/UserInputForm.tsx +283 -0
  16. package/src/client/components/charts/AGENTS.md +15 -0
  17. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/conversations/AGENTS.md +42 -0
  20. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  21. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  22. package/src/client/conversations/ConversationPage.tsx +4 -27
  23. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  24. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  25. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  26. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  27. package/src/client/conversations/TranscriptMessageView.tsx +3 -0
  28. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  29. package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
  30. package/src/client/conversations/turnContext.ts +15 -0
  31. package/src/client/format.ts +28 -28
  32. package/src/client/pages/SettingsPage.tsx +103 -3
  33. package/src/client/pages/automations/AGENTS.md +34 -0
  34. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  35. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  36. package/src/client/pages/automations/AutomationOverview.tsx +3 -3
  37. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  38. package/src/client/pages/code/RepositorySections.tsx +1 -0
  39. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  40. package/src/client/pages/system/AGENTS.md +12 -0
  41. package/src/client/primaryNav.ts +15 -2
  42. package/src/client/review-desk/ConversationReviewBar.tsx +68 -0
  43. package/src/client/review-desk/ReviewDeskPage.tsx +297 -0
  44. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  45. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  46. package/src/client/review-desk/ReviewItemDetail.tsx +123 -0
  47. package/src/client/review-desk/queries.ts +173 -0
  48. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  49. package/src/mock-reporting/fixtures.ts +10 -0
  50. package/src/mock-reporting/routes.ts +20 -0
  51. package/src/shell.ts +3 -0
  52. package/src/tailwind.css +1 -1
  53. package/src/client/components/README.md +0 -215
  54. package/src/client/components/charts/README.md +0 -38
  55. package/src/client/conversations/README.md +0 -159
  56. package/src/client/conversations/UserInputRequestCard.tsx +0 -244
  57. package/src/client/conversations/userInputRequest.ts +0 -56
  58. package/src/client/pages/automations/README.md +0 -97
  59. package/src/client/pages/system/README.md +0 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.267.0",
3
+ "version": "0.269.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,10 +39,10 @@
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "4.1.0",
41
41
  "zod": "^4.5.4",
42
- "@sentry/junior-plugin-api": "0.267.0"
42
+ "@sentry/junior-plugin-api": "0.269.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@sentry/junior": "0.267.0"
45
+ "@sentry/junior": "0.269.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@oxlint/plugins": "1.79.0",
@@ -62,7 +62,7 @@
62
62
  "tw-animate-css": "^1.4.0",
63
63
  "typescript": "^7.0.2",
64
64
  "vitest": "^4.1.12",
65
- "@sentry/junior": "0.267.0"
65
+ "@sentry/junior": "0.269.0"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
package/src/api/schema.ts CHANGED
@@ -18,6 +18,8 @@ export const dashboardIdentitySchema = z
18
18
  .object({
19
19
  user: dashboardSessionUserSchema
20
20
  .extend({
21
+ /** Junior API path of the viewer's Slack photo, then Google photo. */
22
+ avatarUrl: z.string().optional(),
21
23
  /** Junior-wide admin role. The server still checks it on every admin route. */
22
24
  isAdmin: z.boolean(),
23
25
  })
@@ -38,7 +40,6 @@ export const dashboardConfigSchema = z
38
40
  authPath: z.string(),
39
41
  basePath: z.string(),
40
42
  sentryConversationLinks: z.boolean(),
41
- timeZone: z.string().optional(),
42
43
  version: z.string().min(1),
43
44
  })
44
45
  .strict();
package/src/app.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  authenticatePersonalToken,
5
5
  createJuniorApi,
6
6
  jsonResponse,
7
+ readViewerAvatarUrl,
7
8
  resolveViewerUser,
8
9
  updateViewerDisplayName,
9
10
  type ConversationWorkQueue,
@@ -300,11 +301,6 @@ function mockViewerFromSession(session: DashboardSession) {
300
301
  };
301
302
  }
302
303
 
303
- /** Return an operator-configured timezone override, or undefined to let each viewer's browser use its own local timezone. */
304
- function dashboardTimeZone(): string | undefined {
305
- return process.env.JUNIOR_TIMEZONE || undefined;
306
- }
307
-
308
304
  function pluginRoutePrefix(pluginName: string): string {
309
305
  return `/api/plugins/${pluginName}`;
310
306
  }
@@ -565,15 +561,21 @@ export function createDashboardApp(
565
561
  authPath,
566
562
  basePath,
567
563
  sentryConversationLinks: hasSentryConversationLinks(),
568
- timeZone: dashboardTimeZone(),
569
564
  version: JUNIOR_VERSION,
570
565
  });
571
566
  });
572
- app.get("/api/me", (c) => {
567
+ // Mock reporting has no SQL identities, so it has no profile photos.
568
+ const viewerAvatarUrl = async (userId: string | undefined) =>
569
+ userId && !options.mockConversations
570
+ ? await readViewerAvatarUrl(userId)
571
+ : undefined;
572
+ app.get("/api/me", async (c) => {
573
573
  const session = c.get("authSession");
574
574
  const viewer = c.get("viewer");
575
+ const avatarUrl = await viewerAvatarUrl(viewer?.id);
575
576
  return jsonResponse(dashboardIdentitySchema, {
576
577
  user: {
578
+ ...(avatarUrl ? { avatarUrl } : undefined),
577
579
  email: session.user.email,
578
580
  emailVerified: session.user.emailVerified,
579
581
  isAdmin: viewer?.isAdmin === true,
@@ -632,8 +634,10 @@ export function createDashboardApp(
632
634
  { status: 404 },
633
635
  );
634
636
  }
637
+ const avatarUrl = await viewerAvatarUrl(updated.id);
635
638
  return jsonResponse(dashboardIdentitySchema, {
636
639
  user: {
640
+ ...(avatarUrl ? { avatarUrl } : undefined),
637
641
  email: session.user.email,
638
642
  emailVerified: session.user.emailVerified,
639
643
  isAdmin: updated.isAdmin === true,
@@ -24,7 +24,6 @@ import {
24
24
  buildConversations,
25
25
  conversationDetailTitle,
26
26
  conversationDisplayTitle,
27
- setDashboardTimeZone,
28
27
  } from "./format";
29
28
  import { isNewConversationPath } from "./conversations/conversationRoutes";
30
29
  import { useDashboardServerVersion } from "./dashboard-version";
@@ -66,6 +65,8 @@ import {
66
65
  } from "./pages/user/PluginUserPage";
67
66
  import { AdminPage } from "./pages/admin/AdminPage";
68
67
  import { buildPrimaryNavItems } from "./primaryNav";
68
+ import { ReviewDeskPage } from "./review-desk/ReviewDeskPage";
69
+ import { useReviewItemCountData } from "./review-desk/queries";
69
70
  import type { DashboardCoreData } from "./types";
70
71
 
71
72
  const dashboardNoise = {
@@ -82,9 +83,6 @@ export function DashboardShell() {
82
83
  const data = query.data;
83
84
  const serverVersion = useDashboardServerVersion(data?.config.version);
84
85
  const userPages = userPagesQuery.data ?? [];
85
- if (data) {
86
- setDashboardTimeZone(data.config.timeZone);
87
- }
88
86
  const loading = !data && !query.error;
89
87
  const loggedIn = Boolean(data?.config.authRequired && data.me.user.email);
90
88
  const personalSpendQuery = usePersonalSpendData(loggedIn);
@@ -111,11 +109,13 @@ export function DashboardShell() {
111
109
  ? (conversationDetailTitle(conversationDetail.data) ??
112
110
  conversationDisplayTitle(mobileConversation))
113
111
  : undefined;
112
+ const reviewCountQuery = useReviewItemCountData(loggedIn);
114
113
  const primaryNavItems = buildPrimaryNavItems({
115
114
  loading,
116
115
  loggedIn,
117
116
  pathname: location.pathname,
118
117
  primaryUserPages,
118
+ reviewCount: reviewCountQuery.data?.needsViewer,
119
119
  userPagesPending: userPagesQuery.isPending,
120
120
  });
121
121
 
@@ -362,6 +362,22 @@ export function DashboardShell() {
362
362
  }
363
363
  path="/"
364
364
  />
365
+ <Route
366
+ element={
367
+ loading ? (
368
+ <PageRouteLoading
369
+ description="Items that need a person, and who can act on them."
370
+ label="Loading review desk"
371
+ title="Review desk"
372
+ />
373
+ ) : loggedIn ? (
374
+ <ReviewDeskPage />
375
+ ) : (
376
+ <Navigate replace to="/" />
377
+ )
378
+ }
379
+ path="/review"
380
+ />
365
381
  <Route element={<CodePage />} path="/code" />
366
382
  {codeRepositoryTabs.map((tab) => (
367
383
  <Route
package/src/client/api.ts CHANGED
@@ -33,23 +33,9 @@ import { fetchDashboardJson } from "./http";
33
33
  import type { DashboardCoreData, SystemData } from "./types";
34
34
 
35
35
  const dashboardMetadataStaleTimeMs = 5 * 60_000;
36
- const PERSONAL_SPEND_REFRESH_MS = 5 * 60_000;
37
- const MIN_PERSONAL_SPEND_REFRESH_MS = 1_000;
38
-
39
- /** Schedule the next spend refresh from the age of the server-cached report. */
40
- export function personalSpendRefreshDelay(
41
- generatedAt: string | undefined,
42
- nowMs = Date.now(),
43
- ): number {
44
- if (!generatedAt) return PERSONAL_SPEND_REFRESH_MS;
45
- const generatedAtMs = Date.parse(generatedAt);
46
- if (!Number.isFinite(generatedAtMs)) return PERSONAL_SPEND_REFRESH_MS;
47
- const ageMs = Math.max(0, nowMs - generatedAtMs);
48
- return Math.max(
49
- MIN_PERSONAL_SPEND_REFRESH_MS,
50
- PERSONAL_SPEND_REFRESH_MS - ageMs,
51
- );
52
- }
36
+ // The server reuses its cached report until the viewer's spend changes, so a
37
+ // short poll shows a finished Turn without a full aggregate query each time.
38
+ const PERSONAL_SPEND_REFRESH_MS = 10_000;
53
39
 
54
40
  /** Fetch dashboard shell data shared across browser routes. */
55
41
  export function useDashboardCoreData() {
@@ -314,8 +300,7 @@ export function usePersonalSpendData(enabled: boolean) {
314
300
  "/api/people/me/spend",
315
301
  signal,
316
302
  ),
317
- refetchInterval: (query) =>
318
- personalSpendRefreshDelay(query.state.data?.generatedAt),
303
+ refetchInterval: PERSONAL_SPEND_REFRESH_MS,
319
304
  retry: false,
320
305
  staleTime: 0,
321
306
  });
@@ -0,0 +1,65 @@
1
+ # Dashboard UI kit
2
+
3
+ Shared dashboard components live here. Feature pages compose them instead of
4
+ copying Tailwind blocks. Follow `policies/frontend-components.md`.
5
+ `pnpm dashboard-style:check` enforces the token rules.
6
+
7
+ ## shadcn/ui
8
+
9
+ - Add components with
10
+ `pnpm --filter @sentry/junior-dashboard exec shadcn add <component>`. They go
11
+ to `ui/`.
12
+ - `ui/` files are shadcn source on dashboard tokens. Remove unused exports.
13
+ Change a `ui/` file when every caller needs the same style.
14
+ - Use `ui/` components directly. Add an app component only when it adds
15
+ structure or behavior, not to rename a `ui/` component or its props.
16
+ - Do not apply the CLI's global border or body resets. Keep the Tailwind
17
+ radius scale.
18
+
19
+ ## Component rules
20
+
21
+ - Use `ui/alert-dialog` for every confirmation. Never use `window.confirm`.
22
+ - Use `ui/dialog` for a short task form. Never put a form in a `Drawer` or
23
+ `DetailsPanel`. Use `useSheet` for each dialog and mount it only while open.
24
+ - Use `ui/trace` for transcript activity rows. Put only inline elements in
25
+ the row button.
26
+ - Give an icon-only control a label with `TooltipContent size="compact"`.
27
+ Never use the native `title` attribute.
28
+ - Set `focusableWhenDisabled` on a `ui/button` tooltip trigger that can be
29
+ disabled.
30
+ - Build form fields from `ui/field`. Use the ids `<control id>-error` and
31
+ `<control id>-help`.
32
+ - Put a checkbox or radio inside its `<label>`. Give each `RadioGroup` an
33
+ `aria-label`.
34
+ - `ui/alert` sets `role="alert"`. Pass `role="status"` for a message that is
35
+ not an error.
36
+ - A form page has one `Button variant="primary"`.
37
+ - `ObjectIcon` owns native object icons. Do not replace it with a provider
38
+ logo.
39
+
40
+ ## Tokens
41
+
42
+ - Keep every color in a token in `src/tailwind.css`, so each theme can
43
+ redefine it. Use `light:` only for values that no token covers.
44
+ - Use `var(--color-*)` for chart series. Never use hex values.
45
+ - Do not use `white/*` or `black/*` fills, borders, or rings, or hex surface
46
+ colors. Shadows may use black.
47
+ - Use `outline-dashboard-focus` for keyboard focus only.
48
+ - Use the named type scale. Keep `text-2xs` for compact badges and chart
49
+ annotations.
50
+ - Labels use sentence case. Do not use uppercase or letter spacing.
51
+
52
+ ## Navigation
53
+
54
+ - App navigation uses pills, page links use `SegmentedNav`, and panel
55
+ switches use `ui/tabs`. Keep the three levels distinct.
56
+
57
+ ## Ownership
58
+
59
+ - Conversation mobile geometry has hard owners: `layout/VisualViewportShell`,
60
+ `conversations/ChatLayout`, and `conversations/ComposerDock`. Pages pass
61
+ children only. Do not add viewport, offset, or safe-area math in pages.
62
+ - Home and create must not use `ComposerDock`.
63
+ - Feature surfaces stay under `conversations/` or `pages/` until two real
64
+ callers share them. Add a named component on the second real use.
65
+ - Keep Tailwind classes on the owning component.
@@ -17,7 +17,7 @@ export function ActorAvatar(props: {
17
17
  return (
18
18
  <Avatar aria-hidden="true" size={props.size === "list" ? "sm" : "default"}>
19
19
  <AvatarImage
20
- alt=""
20
+ alt="User avatar"
21
21
  loading="lazy"
22
22
  referrerPolicy="no-referrer"
23
23
  src={props.imageUrl}
@@ -13,7 +13,8 @@ type ParticipantAvatarStackProps = {
13
13
  size: "detail" | "list";
14
14
  };
15
15
 
16
- function participantName(participant: ActorIdentity): string {
16
+ /** Name an actor for display: full name first, then handle, email, or ID. */
17
+ export function participantName(participant: ActorIdentity): string {
17
18
  return (
18
19
  participant.fullName?.trim() ||
19
20
  participant.slackUserName?.trim() ||
@@ -70,6 +71,7 @@ export function ParticipantAvatar(props: {
70
71
  tabIndex={0}
71
72
  >
72
73
  <ActorAvatar
74
+ imageUrl={props.participant.avatarUrl}
73
75
  name={participantName(props.participant)}
74
76
  size={props.size}
75
77
  />
@@ -8,7 +8,7 @@ import {
8
8
  ShieldCheck,
9
9
  UserRound,
10
10
  } from "lucide-react";
11
- import { useState } from "react";
11
+ import { useEffect, useRef, useState } from "react";
12
12
  import { Link } from "react-router";
13
13
  import type { PersonalSpendReport } from "@sentry/junior/api/schema";
14
14
  import type { PluginUserPageLink } from "@sentry/junior-plugin-api";
@@ -18,6 +18,7 @@ import { pluginUserPagePath } from "../pages/user/PluginUserPage";
18
18
  import { cn, dashboardInteractiveTextClass } from "../styles";
19
19
  import type { Identity } from "../types";
20
20
  import { ThemeSwitcher } from "./ThemeSwitcher";
21
+ import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
21
22
  import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
22
23
 
23
24
  type ProfileMenuProps = {
@@ -49,6 +50,83 @@ function initials(name: string | null | undefined, email: string): string {
49
50
  return email.slice(0, 1).toUpperCase();
50
51
  }
51
52
 
53
+ /** Show the viewer's photo, with initials while it loads or when there is none. */
54
+ function ProfileAvatar({
55
+ className,
56
+ user,
57
+ }: {
58
+ className?: string;
59
+ user: Identity["user"];
60
+ }) {
61
+ return (
62
+ <Avatar
63
+ aria-hidden="true"
64
+ className={cn("border-0 shadow-black/40", className)}
65
+ >
66
+ <AvatarImage alt="User avatar" src={user.avatarUrl} />
67
+ <AvatarFallback className="font-bold tracking-wide">
68
+ {initials(user.name, user.email!)}
69
+ </AvatarFallback>
70
+ </Avatar>
71
+ );
72
+ }
73
+
74
+ const SPEND_COUNT_UP_MS = 700;
75
+
76
+ /**
77
+ * Show one spend total. When the total changes, count up from the last shown
78
+ * value and tint the figure until the count ends.
79
+ */
80
+ function SpendAmount({ value }: { value?: number }) {
81
+ const [shown, setShown] = useState(value);
82
+ const [counting, setCounting] = useState(false);
83
+ const shownRef = useRef(value);
84
+
85
+ useEffect(() => {
86
+ const from = shownRef.current;
87
+ if (
88
+ value === undefined ||
89
+ from === undefined ||
90
+ from === value ||
91
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
92
+ ) {
93
+ shownRef.current = value;
94
+ setShown(value);
95
+ setCounting(false);
96
+ return;
97
+ }
98
+
99
+ const startedAt = performance.now();
100
+ let frame = 0;
101
+ const step = (time: number) => {
102
+ const progress = Math.min(1, (time - startedAt) / SPEND_COUNT_UP_MS);
103
+ const eased = 1 - (1 - progress) ** 3;
104
+ const next = progress < 1 ? from + (value - from) * eased : value;
105
+ shownRef.current = next;
106
+ setShown(next);
107
+ if (progress < 1) {
108
+ frame = requestAnimationFrame(step);
109
+ } else {
110
+ setCounting(false);
111
+ }
112
+ };
113
+ setCounting(true);
114
+ frame = requestAnimationFrame(step);
115
+ return () => cancelAnimationFrame(frame);
116
+ }, [value]);
117
+
118
+ return (
119
+ <span
120
+ className={cn(
121
+ "transition-colors duration-700 motion-reduce:transition-none",
122
+ counting && "text-dashboard-accent-text",
123
+ )}
124
+ >
125
+ {shown === undefined ? "—" : formatCostSummary({ total: shown })}
126
+ </span>
127
+ );
128
+ }
129
+
52
130
  const profileLinkClass =
53
131
  "flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-semibold text-dashboard-text no-underline transition-colors hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:bg-dashboard-fill-strong focus-visible:text-dashboard-text focus-visible:outline-none";
54
132
 
@@ -73,19 +151,10 @@ export function ProfileMenu({
73
151
  (page) => page.navigation === "profile",
74
152
  );
75
153
 
76
- const avatar = (
77
- <span
78
- aria-hidden="true"
79
- className="grid size-8 shrink-0 place-items-center rounded-full bg-dashboard-focus text-xs font-bold tracking-wide text-dashboard-text-inverse shadow-sm shadow-black/40"
80
- >
81
- {initials(identity.user.name, email)}
82
- </span>
83
- );
84
-
85
154
  const identityBlock = (
86
155
  <div className="border-b border-dashboard-border-strong px-2.5 py-2.5">
87
156
  <div className="flex min-w-0 items-center gap-2.5">
88
- {avatar}
157
+ <ProfileAvatar className="size-8" user={identity.user} />
89
158
  <div className="min-w-0">
90
159
  <p className="m-0 truncate text-sm font-semibold text-dashboard-text">
91
160
  {name}
@@ -101,13 +170,13 @@ export function ProfileMenu({
101
170
  <span>
102
171
  7d{" "}
103
172
  <span className="font-semibold text-dashboard-text">
104
- {sevenDaySpend}
173
+ <SpendAmount value={spend?.sevenDaysUsd} />
105
174
  </span>
106
175
  </span>
107
176
  <span>
108
177
  30d{" "}
109
178
  <span className="font-semibold text-dashboard-text">
110
- {thirtyDaySpend}
179
+ <SpendAmount value={spend?.thirtyDaysUsd} />
111
180
  </span>
112
181
  </span>
113
182
  </div>
@@ -186,13 +255,13 @@ export function ProfileMenu({
186
255
  <span className="whitespace-nowrap">
187
256
  7d{" "}
188
257
  <span className="font-semibold text-dashboard-text">
189
- {sevenDaySpend}
258
+ <SpendAmount value={spend?.sevenDaysUsd} />
190
259
  </span>
191
260
  </span>
192
261
  <span className="whitespace-nowrap">
193
262
  30d{" "}
194
263
  <span className="font-semibold text-dashboard-text">
195
- {thirtyDaySpend}
264
+ <SpendAmount value={spend?.thirtyDaysUsd} />
196
265
  </span>
197
266
  </span>
198
267
  </div>
@@ -254,12 +323,7 @@ export function ProfileMenu({
254
323
  aria-label={`Signed in as ${name}`}
255
324
  className="flex min-w-0 items-center gap-2.5 px-1"
256
325
  >
257
- <span
258
- aria-hidden="true"
259
- className="grid size-7 shrink-0 place-items-center rounded-full bg-dashboard-focus text-xs font-bold tracking-wide text-dashboard-text-inverse shadow-sm shadow-black/40"
260
- >
261
- {initials(identity.user.name, email)}
262
- </span>
326
+ <ProfileAvatar user={identity.user} />
263
327
  <div className="min-w-0">
264
328
  <p className="m-0 truncate text-xs font-semibold text-dashboard-text">
265
329
  {name}
@@ -304,7 +368,7 @@ export function ProfileMenu({
304
368
  7d
305
369
  </span>
306
370
  <span className="text-xs font-semibold text-dashboard-text">
307
- {sevenDaySpend}
371
+ <SpendAmount value={spend?.sevenDaysUsd} />
308
372
  </span>
309
373
  </span>
310
374
  <span
@@ -316,16 +380,11 @@ export function ProfileMenu({
316
380
  30d
317
381
  </span>
318
382
  <span className="text-xs font-semibold text-dashboard-text">
319
- {thirtyDaySpend}
383
+ <SpendAmount value={spend?.thirtyDaysUsd} />
320
384
  </span>
321
385
  </span>
322
386
  </span>
323
- <span
324
- aria-hidden="true"
325
- className="grid size-7 place-items-center rounded-full bg-dashboard-focus text-xs font-bold tracking-wide text-dashboard-text-inverse shadow-sm shadow-black/40"
326
- >
327
- {initials(identity.user.name, email)}
328
- </span>
387
+ <ProfileAvatar user={identity.user} />
329
388
  <ChevronDown
330
389
  aria-hidden="true"
331
390
  className={cn(