@sentry/junior-dashboard 0.268.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 (38) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +12 -9
  3. package/dist/client.js +72 -75
  4. package/dist/index.js +12 -9
  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 +1 -5
  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 +1 -0
  14. package/src/client/components/ProfileMenu.tsx +88 -29
  15. package/src/client/components/UserInputForm.tsx +1 -1
  16. package/src/client/components/charts/AGENTS.md +15 -0
  17. package/src/client/conversations/AGENTS.md +42 -0
  18. package/src/client/conversations/ConversationPage.tsx +0 -27
  19. package/src/client/format.ts +7 -23
  20. package/src/client/pages/automations/AGENTS.md +34 -0
  21. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  22. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  23. package/src/client/pages/automations/AutomationOverview.tsx +3 -3
  24. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  25. package/src/client/pages/system/AGENTS.md +12 -0
  26. package/src/client/review-desk/ConversationReviewBar.tsx +14 -8
  27. package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
  28. package/src/client/review-desk/ReviewItemDetail.tsx +15 -7
  29. package/src/client/review-desk/queries.ts +25 -16
  30. package/src/mock-reporting/routes.ts +1 -1
  31. package/src/tailwind.css +1 -1
  32. package/src/client/components/README.md +0 -219
  33. package/src/client/components/charts/README.md +0 -38
  34. package/src/client/conversations/README.md +0 -159
  35. package/src/client/conversations/UserInputRequestCard.tsx +0 -39
  36. package/src/client/conversations/userInputRequest.ts +0 -56
  37. package/src/client/pages/automations/README.md +0 -97
  38. 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.268.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.268.0"
42
+ "@sentry/junior-plugin-api": "0.269.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@sentry/junior": "0.268.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.268.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";
@@ -84,9 +83,6 @@ export function DashboardShell() {
84
83
  const data = query.data;
85
84
  const serverVersion = useDashboardServerVersion(data?.config.version);
86
85
  const userPages = userPagesQuery.data ?? [];
87
- if (data) {
88
- setDashboardTimeZone(data.config.timeZone);
89
- }
90
86
  const loading = !data && !query.error;
91
87
  const loggedIn = Boolean(data?.config.authRequired && data.me.user.email);
92
88
  const personalSpendQuery = usePersonalSpendData(loggedIn);
@@ -119,7 +115,7 @@ export function DashboardShell() {
119
115
  loggedIn,
120
116
  pathname: location.pathname,
121
117
  primaryUserPages,
122
- reviewCount: reviewCountQuery.data?.mine,
118
+ reviewCount: reviewCountQuery.data?.needsViewer,
123
119
  userPagesPending: userPagesQuery.isPending,
124
120
  });
125
121
 
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}
@@ -71,6 +71,7 @@ export function ParticipantAvatar(props: {
71
71
  tabIndex={0}
72
72
  >
73
73
  <ActorAvatar
74
+ imageUrl={props.participant.avatarUrl}
74
75
  name={participantName(props.participant)}
75
76
  size={props.size}
76
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(
@@ -138,7 +138,7 @@ function TextField(props: {
138
138
  }
139
139
 
140
140
  /**
141
- * Render the answer controls of a `requestUserInput` request. A request with
141
+ * Render the answer controls of an action item request. A request with
142
142
  * one short choice field shows one button per choice, and a click is the
143
143
  * answer. Other requests show a form that the reader submits once. A failed
144
144
  * `onSubmit` lets the reader try again. `secondaryActions`, such as Dismiss,
@@ -0,0 +1,15 @@
1
+ # System cache reporting
2
+
3
+ - `cache-input.ts` owns the cache input calculation.
4
+ - `ConversationMetricDay` comes from stored Run metrics, not Sentry spans.
5
+ Its input, cached input, and cache-creation counters are disjoint. Add
6
+ them once for total input. Do not include output. Never use this transform
7
+ for Sentry span totals.
8
+ - Period shares use summed token counts, not the mean of bucket shares.
9
+ - A missing counter is not a zero. When an active bucket has no counter, omit
10
+ its share and the whole-period share.
11
+ - Do not call these values a cache hit rate or a cache failure rate. Field
12
+ presence is not call-level coverage.
13
+ - The page time control selects the buckets for the chart and `CacheWrites`.
14
+ Do not add a local time selector.
15
+ - Add a reporting contract before you add per-call filters or drilldowns.
@@ -0,0 +1,42 @@
1
+ # Transcript components
2
+
3
+ Keep transcript layout in these feature components. Use the UI kit in
4
+ `../components/` for shared controls and overlays.
5
+
6
+ ## Ownership
7
+
8
+ - `TranscriptRows` and `TranscriptRow` own row gaps, padding, and indent.
9
+ Keep horizontal padding inside each row, so focus outlines are not clipped.
10
+ - `TranscriptMessageShell` owns the avatar column and message gutter.
11
+ - `TranscriptSummaryTrigger` owns disclosure padding and focus. Do not repeat
12
+ negative margins at call sites.
13
+ - `ChatLayout` keeps the Conversation header outside the transcript scroll.
14
+ - `eventLogRows` and `filterEventLogRows` own row text and search match. The
15
+ page step controls use them too.
16
+
17
+ ## Rules
18
+
19
+ - Display text keeps Slack tokens such as `<@U123>`. The API returns
20
+ `mentions` beside the owning object. Render with `TranscriptText` or
21
+ `MentionText`. Do not resolve names in the dashboard, and do not rewrite
22
+ tokens into links on the server.
23
+ - The Automation trigger card shows the credentials from the dispatch record
24
+ of the run, not the current setting. Keep the run input collapsed, because
25
+ it is not from a person.
26
+ - Each `Transcript` sets the panel for its own rows. A nested transcript never
27
+ uses the panel of the page behind it.
28
+ - The page has one right panel. Conversation details, event details, and turn
29
+ context never show at the same time. Closing a panel returns focus to the
30
+ control that opened it.
31
+ - The URL owns the selected event in the main Conversation. Standalone
32
+ transcripts keep selection in `ConversationEventLog`.
33
+ - A pending exit must not close a new panel or another Conversation.
34
+ - A pending fork blocks dismissal of its dialog.
35
+ - Raw JSON keeps the original ISO values. Persist the UTC/local mode, not the
36
+ timezone.
37
+
38
+ ## Checks
39
+
40
+ - Review with `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true pnpm dev`.
41
+ - Keep visual checks out of automated assertions. Browser tests cover
42
+ opening, closing, and keyboard interaction.
@@ -73,11 +73,6 @@ import {
73
73
  hasConversationUsageBreakdown,
74
74
  } from "./ConversationUsageBreakdown";
75
75
  import { PendingMailboxStack } from "./PendingMailboxStack";
76
- import { UserInputRequestCard } from "./UserInputRequestCard";
77
- import {
78
- openUserInputRequest,
79
- type OpenUserInputRequest,
80
- } from "./userInputRequest";
81
76
  import {
82
77
  buildConversations,
83
78
  conversationDetailTitle,
@@ -197,10 +192,6 @@ export function ConversationPage(props: {
197
192
  () => committedMessageIds(detail.data?.events),
198
193
  [detail.data?.events],
199
194
  );
200
- const userInputRequest = useMemo(
201
- () => openUserInputRequest(detail.data?.events),
202
- [detail.data?.events],
203
- );
204
195
  // Cancel needs the latest mailbox watermark, but not as a render prop. Live
205
196
  // polls refresh generatedAt every 2s even when the queue is unchanged.
206
197
  const pendingGeneratedAtRef = useRef(detail.pendingGeneratedAt);
@@ -494,7 +485,6 @@ export function ConversationPage(props: {
494
485
  detail.inlineMessages.length === 0
495
486
  }
496
487
  stoppable={live}
497
- userInputRequest={userInputRequest}
498
488
  />
499
489
  ) : undefined
500
490
  }
@@ -585,8 +575,6 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
585
575
  sendsInline: boolean;
586
576
  /** True while the conversation has a Turn that a stop can end. */
587
577
  stoppable: boolean;
588
- /** Junior's open question, if no user message answers it yet. */
589
- userInputRequest?: OpenUserInputRequest;
590
578
  }) {
591
579
  const appendMessage = useAppendConversationMessage(props.conversationId);
592
580
  const stopTurn = useStopConversationTurn(props.conversationId);
@@ -665,13 +653,6 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
665
653
  const cancellableMessageIds = props.pendingMessages
666
654
  .filter((message) => message.clientStatus === undefined)
667
655
  .map((message) => message.inboundMessageId);
668
- const onAnswerUserInput = useCallback(async (answer: string) => {
669
- onPinRequestRef.current();
670
- await appendMessageRef.current.mutateAsync({
671
- idempotencyKey: crypto.randomUUID(),
672
- message: answer,
673
- });
674
- }, []);
675
656
  const onCancelMessage = useCallback(
676
657
  (message: ConversationMailboxMessage) => {
677
658
  const receivedBefore = props.pendingGeneratedAtRef.current;
@@ -796,14 +777,6 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
796
777
  {props.pendingAuthorization ? (
797
778
  <PendingAuthorization authorization={props.pendingAuthorization} />
798
779
  ) : null}
799
- {/* A queued message already answers the question. */}
800
- {props.userInputRequest && props.pendingMessages.length === 0 ? (
801
- <UserInputRequestCard
802
- key={props.userInputRequest.toolCallId}
803
- onAnswer={onAnswerUserInput}
804
- request={props.userInputRequest}
805
- />
806
- ) : null}
807
780
  <ConversationReviewBar conversationId={props.conversationId} />
808
781
  <PendingMailboxStack
809
782
  cancelError={cancelError}
@@ -19,8 +19,8 @@ import { getDashboardAgentName } from "./agentName";
19
19
  import { formatDuration } from "./components/Duration";
20
20
  import { conversationTranscriptMessages } from "./conversations/eventTranscript";
21
21
 
22
- /** Detect the viewer's own local timezone as the default display timezone. */
23
- function browserTimeZone(): string {
22
+ /** Read the viewer's own browser timezone, used for absolute timestamps and charts. */
23
+ export function getDashboardTimeZone(): string {
24
24
  try {
25
25
  return Intl.DateTimeFormat().resolvedOptions().timeZone;
26
26
  } catch {
@@ -28,22 +28,6 @@ function browserTimeZone(): string {
28
28
  }
29
29
  }
30
30
 
31
- let dashboardTimeZone = browserTimeZone();
32
-
33
- /** Apply an operator-configured timezone override from the config API, if any. Otherwise the viewer's own browser timezone is used. */
34
- export function setDashboardTimeZone(timeZone: string | undefined): void {
35
- dashboardTimeZone = timeZone || browserTimeZone();
36
- }
37
-
38
- /** Read the dashboard display timezone used for absolute timestamps and charts. */
39
- export function getDashboardTimeZone(): string {
40
- return dashboardTimeZone;
41
- }
42
-
43
- function displayTimeZone(): string {
44
- return dashboardTimeZone;
45
- }
46
-
47
31
  function isActiveSummary(summary: ConversationSummaryReport): boolean {
48
32
  return summary.status === "active";
49
33
  }
@@ -69,7 +53,7 @@ function parseTime(value: string | undefined): number | null {
69
53
  return Number.isFinite(time) ? time : null;
70
54
  }
71
55
 
72
- /** Format absolute dashboard timestamps in the configured display timezone. */
56
+ /** Format absolute dashboard timestamps in the viewer's browser timezone. */
73
57
  export function formatTime(
74
58
  value: string | undefined,
75
59
  options?: Intl.DateTimeFormatOptions,
@@ -78,7 +62,7 @@ export function formatTime(
78
62
  if (time == null) return "none";
79
63
  return new Date(time).toLocaleString(undefined, {
80
64
  ...options,
81
- timeZone: displayTimeZone(),
65
+ timeZone: getDashboardTimeZone(),
82
66
  });
83
67
  }
84
68
 
@@ -166,10 +150,10 @@ export function formatMessageTimestamp(
166
150
  ? date.toLocaleString(undefined, {
167
151
  dateStyle: "medium",
168
152
  timeStyle: "medium",
169
- timeZone: displayTimeZone(),
153
+ timeZone: getDashboardTimeZone(),
170
154
  })
171
155
  : date.toLocaleTimeString(undefined, {
172
- timeZone: displayTimeZone(),
156
+ timeZone: getDashboardTimeZone(),
173
157
  });
174
158
  }
175
159
 
@@ -210,7 +194,7 @@ export function formatTranscriptTimestampDetails(value: number): {
210
194
  return {
211
195
  local: date.toLocaleString(undefined, {
212
196
  ...options,
213
- timeZone: displayTimeZone(),
197
+ timeZone: getDashboardTimeZone(),
214
198
  }),
215
199
  utc: date.toLocaleString(undefined, { ...options, timeZone: "UTC" }),
216
200
  };