@sentry/junior-dashboard 0.258.0 → 0.260.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 (65) hide show
  1. package/README.md +5 -3
  2. package/dist/app.js +88 -37
  3. package/dist/chunks/github-light-QHW3AEMR.js +1 -0
  4. package/dist/client.js +86 -50
  5. package/dist/index.js +88 -37
  6. package/dist/tailwind.css +1 -1
  7. package/package.json +3 -3
  8. package/src/client/App.tsx +0 -1
  9. package/src/client/code.tsx +10 -4
  10. package/src/client/components/ProfileMenu.tsx +3 -0
  11. package/src/client/components/README.md +12 -2
  12. package/src/client/components/ThemeSwitcher.tsx +48 -0
  13. package/src/client/components/charts/ActivityChart.tsx +6 -7
  14. package/src/client/components/charts/InputCacheChart.tsx +1 -1
  15. package/src/client/components/charts/SystemMetricCharts.tsx +3 -3
  16. package/src/client/components/charts/WorkspaceUsageChart.tsx +1 -1
  17. package/src/client/components/charts/cache-input.ts +11 -3
  18. package/src/client/components/layout/DashboardHeader.tsx +2 -9
  19. package/src/client/conversations/ConversationBrief.tsx +35 -9
  20. package/src/client/conversations/ConversationHomeList.tsx +7 -1
  21. package/src/client/conversations/ConversationMeta.tsx +19 -7
  22. package/src/client/conversations/ConversationTranscript.tsx +8 -11
  23. package/src/client/conversations/EventDetails.tsx +9 -5
  24. package/src/client/conversations/README.md +6 -0
  25. package/src/client/conversations/TranscriptContextEventView.tsx +5 -1
  26. package/src/client/conversations/TranscriptEventView.tsx +48 -9
  27. package/src/client/conversations/TranscriptMessageView.tsx +6 -1
  28. package/src/client/conversations/TranscriptStructuredEventView.tsx +4 -2
  29. package/src/client/conversations/TranscriptText.tsx +28 -20
  30. package/src/client/conversations/conversationActivityPreview.ts +6 -0
  31. package/src/client/conversations/eventTranscript.ts +4 -1
  32. package/src/client/conversations/transcriptMentions.tsx +27 -10
  33. package/src/client/pages/admin/AdminPage.tsx +14 -5
  34. package/src/client/pages/admin/ServiceAccountsSection.tsx +231 -0
  35. package/src/client/pages/automations/AutomationCostChart.tsx +2 -2
  36. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +4 -1
  37. package/src/client/pages/automations/AutomationExecutionChart.tsx +2 -2
  38. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +3 -3
  39. package/src/client/pages/code/CodeActivityChart.tsx +4 -4
  40. package/src/client/pages/dev/ComponentsPage.tsx +7 -3
  41. package/src/client/pages/dev/ConversationFixture.tsx +29 -0
  42. package/src/client/pages/locations/LocationActivityChart.tsx +14 -4
  43. package/src/client/pages/locations/LocationDirectoryActivityChart.tsx +14 -9
  44. package/src/client/pages/memory/MemoryCostChart.tsx +5 -5
  45. package/src/client/pages/memory/MemoryDetailsDrawer.tsx +9 -2
  46. package/src/client/pages/memory/MemoryTimeline.tsx +2 -2
  47. package/src/client/pages/people/ContributionGrid.tsx +8 -8
  48. package/src/client/pages/people/PeopleActivityChart.tsx +23 -10
  49. package/src/client/pages/system/BaseSandboxCard.tsx +76 -0
  50. package/src/client/pages/system/ConversationActivityChart.tsx +2 -2
  51. package/src/client/pages/system/PluginBarChart.tsx +12 -6
  52. package/src/client/pages/system/SandboxSizeSummary.tsx +47 -0
  53. package/src/client/pages/system/WorkspaceDetails.tsx +11 -1
  54. package/src/client/pages/system/WorkspaceEditor.tsx +19 -0
  55. package/src/client/pages/system/WorkspaceFormPage.tsx +28 -19
  56. package/src/client/pages/system/WorkspaceList.tsx +6 -1
  57. package/src/client/pages/system/WorkspacesPage.tsx +18 -12
  58. package/src/client/pages/system/workspaceDraft.ts +8 -0
  59. package/src/client/theme.ts +52 -0
  60. package/src/client/types.ts +8 -1
  61. package/src/mock-reporting/fixtures.ts +48 -35
  62. package/src/shell.ts +4 -1
  63. package/src/tailwind.css +109 -0
  64. package/src/theme.ts +49 -0
  65. package/src/client/pages/system/BaselineSnapshotCard.tsx +0 -18
@@ -6,8 +6,18 @@ the policy. `pnpm dashboard-style:check` enforces the token rules below.
6
6
 
7
7
  ## Foundations
8
8
 
9
- Tokens live in the `@theme` block of `src/tailwind.css`. Light mode does not
10
- exist yet. Keep every color in a token so a second theme can redefine them.
9
+ Tokens live in the `@theme` block of `src/tailwind.css`. Keep every color in a
10
+ token so each theme can redefine it.
11
+
12
+ - **Themes.** Dark is the default. The light theme redefines every token under
13
+ `:root[data-theme="light"]`. It also mirrors the light status and chart
14
+ shades (50-400) to their dark pair, so `text-rose-300` stays readable on the
15
+ light canvas. The head script in `src/theme.ts` sets `data-theme` before first
16
+ paint from the saved choice or the system color scheme. `ThemeSwitcher` saves
17
+ the choice for this browser. Use the `light:` variant only for one-off values
18
+ that no token covers, such as provider brand colors.
19
+ - **Charts.** Use `var(--color-*)` for series colors in SVG attributes, not hex
20
+ values, so they follow the theme.
11
21
 
12
22
  - **Surfaces.** Use the surface ladder, darkest to lightest: `bg`,
13
23
  `bg-elevated` (drawers), `surface-panel` (sidebar, cards, bubbles),
@@ -0,0 +1,48 @@
1
+ import { Monitor, Moon, Sun, type LucideIcon } from "lucide-react";
2
+
3
+ import type { DashboardThemeChoice } from "../../theme";
4
+ import { cn } from "../styles";
5
+ import { useDashboardThemeChoice } from "../theme";
6
+ import { ToggleButton } from "./Button";
7
+
8
+ const themeChoices = [
9
+ { Icon: Monitor, label: "System", value: "system" },
10
+ { Icon: Sun, label: "Light", value: "light" },
11
+ { Icon: Moon, label: "Dark", value: "dark" },
12
+ ] as const satisfies ReadonlyArray<{
13
+ Icon: LucideIcon;
14
+ label: string;
15
+ value: DashboardThemeChoice;
16
+ }>;
17
+
18
+ /** Choose the dashboard theme for this browser: system, light, or dark. */
19
+ export function ThemeSwitcher(props: { className?: string }) {
20
+ const [choice, setChoice] = useDashboardThemeChoice();
21
+ return (
22
+ <div
23
+ className={cn("flex items-center justify-between gap-3", props.className)}
24
+ >
25
+ <span className="font-sans text-sm font-semibold text-dashboard-text">
26
+ Theme
27
+ </span>
28
+ <div
29
+ aria-label="Theme"
30
+ className="flex gap-0.5 rounded-lg border border-dashboard-border p-0.5"
31
+ role="group"
32
+ >
33
+ {themeChoices.map(({ Icon, label, value }) => (
34
+ <ToggleButton
35
+ aria-label={`${label} theme`}
36
+ key={value}
37
+ onClick={() => setChoice(value)}
38
+ pressed={choice === value}
39
+ title={label}
40
+ variant="segment"
41
+ >
42
+ <Icon aria-hidden="true" size={14} strokeWidth={2} />
43
+ </ToggleButton>
44
+ ))}
45
+ </div>
46
+ </div>
47
+ );
48
+ }
@@ -248,7 +248,7 @@ export function ActivityChartGrid(props: {
248
248
  return (
249
249
  <g key={ratio}>
250
250
  <line
251
- stroke="rgba(255,255,255,0.07)"
251
+ stroke="var(--color-dashboard-border-subtle)"
252
252
  strokeDasharray="3 5"
253
253
  x1={props.layout.left}
254
254
  x2={props.layout.width - props.layout.right}
@@ -332,9 +332,6 @@ export function activityChartAverage(values: readonly number[]): number {
332
332
  export const chartAverageLabelClassName =
333
333
  "fill-dashboard-text-solid font-sans tabular-nums leading-none";
334
334
 
335
- /** Solid chip behind average labels so series colors never wash out the text. */
336
- const chartAverageLabelChip = "rgba(9, 12, 14, 0.92)";
337
-
338
335
  /**
339
336
  * Opt-in horizontal average across the plotted buckets.
340
337
  * Renders a dashed guide plus a compact "value / day" label on the right.
@@ -380,7 +377,7 @@ export function ActivityChartAverageLine(props: {
380
377
  return (
381
378
  <g aria-label={`average ${label}`} pointerEvents="none">
382
379
  <line
383
- stroke={props.stroke ?? "rgba(255,255,255,0.55)"}
380
+ stroke={props.stroke ?? "var(--color-dashboard-text-faint)"}
384
381
  strokeDasharray="4 4"
385
382
  strokeOpacity={0.85}
386
383
  strokeWidth={1.25}
@@ -389,8 +386,10 @@ export function ActivityChartAverageLine(props: {
389
386
  y1={y}
390
387
  y2={y}
391
388
  />
389
+ {/* Solid chip so series colors never wash out the label text. */}
392
390
  <rect
393
- fill={chartAverageLabelChip}
391
+ fill="var(--color-dashboard-bg-elevated)"
392
+ fillOpacity={0.92}
394
393
  height={chipHeight}
395
394
  rx={3}
396
395
  width={chipWidth}
@@ -399,7 +398,7 @@ export function ActivityChartAverageLine(props: {
399
398
  />
400
399
  <text
401
400
  className={chartAverageLabelClassName}
402
- fill="#ffffff"
401
+ fill="var(--color-dashboard-text-solid)"
403
402
  fontSize={fontSize}
404
403
  textAnchor="middle"
405
404
  x={chipX + chipWidth / 2}
@@ -164,7 +164,7 @@ export function InputCacheChart(props: {
164
164
  {bars}
165
165
  {incomplete ? (
166
166
  <rect
167
- fill="#94a3b8"
167
+ fill="var(--color-slate-400)"
168
168
  x={x(index) - barWidth / 2}
169
169
  y={layout.top + layout.plotHeight - 3}
170
170
  width={barWidth}
@@ -49,7 +49,7 @@ function compactDuration(value: number): string {
49
49
  function tokenChart(): ChartConfig {
50
50
  return {
51
51
  axisFormat: formatCompactNumber,
52
- color: "#22d3ee",
52
+ color: "var(--color-cyan-400)",
53
53
  format: formatCompactNumber,
54
54
  metric: "tokens",
55
55
  title: "Token usage",
@@ -61,7 +61,7 @@ function supportingCharts(): ChartConfig[] {
61
61
  return [
62
62
  {
63
63
  axisFormat: compactCurrency,
64
- color: "#fbbf24",
64
+ color: "var(--color-amber-400)",
65
65
  format: (value) => formatCostSummary({ total: value }),
66
66
  metric: "costUsd",
67
67
  title: "Model spend",
@@ -69,7 +69,7 @@ function supportingCharts(): ChartConfig[] {
69
69
  },
70
70
  {
71
71
  axisFormat: compactDuration,
72
- color: "#a78bfa",
72
+ color: "var(--color-violet-400)",
73
73
  format: formatDuration,
74
74
  metric: "durationMs",
75
75
  title: "Runtime",
@@ -14,7 +14,7 @@ import { Card } from "../layout/Card";
14
14
  import { formatActivityChartAverage, formatCompactNumber } from "../../format";
15
15
  import type { WorkspaceUsageDay } from "./workspaceUsage";
16
16
 
17
- const USAGE_COLOR = "#a78bfa";
17
+ const USAGE_COLOR = "var(--color-violet-400)";
18
18
 
19
19
  /** Plot successful Workspace switches for one recipe over a trailing window. */
20
20
  export function WorkspaceUsageChart(props: {
@@ -1,9 +1,17 @@
1
1
  import type { ConversationMetricDay } from "@sentry/junior/api/schema";
2
2
 
3
3
  export const CACHE_INPUT_SERIES = [
4
- { key: "cachedInputTokens", label: "From cache", color: "#22d3ee" },
5
- { key: "cacheCreationTokens", label: "Written to cache", color: "#fbbf24" },
6
- { key: "inputTokens", label: "Uncached", color: "#a78bfa" },
4
+ {
5
+ key: "cachedInputTokens",
6
+ label: "From cache",
7
+ color: "var(--color-cyan-400)",
8
+ },
9
+ {
10
+ key: "cacheCreationTokens",
11
+ label: "Written to cache",
12
+ color: "var(--color-amber-400)",
13
+ },
14
+ { key: "inputTokens", label: "Uncached", color: "var(--color-violet-400)" },
7
15
  ] as const;
8
16
 
9
17
  export type CacheInput = Pick<
@@ -14,11 +14,7 @@ import {
14
14
  MobileSecondaryNavigationSlot,
15
15
  useRegisterOpenMobileNavigation,
16
16
  } from "./DashboardChrome";
17
- import {
18
- cn,
19
- dashboardContainerClass,
20
- dashboardInteractiveTextClass,
21
- } from "../../styles";
17
+ import { cn, dashboardInteractiveTextClass } from "../../styles";
22
18
 
23
19
  export type DashboardHeaderNavItem = {
24
20
  key: string;
@@ -37,7 +33,6 @@ const focusableSelector = [
37
33
 
38
34
  /** Render the primary dashboard shell header and optional mobile nav sheet. */
39
35
  export function DashboardHeader(props: {
40
- compact?: boolean;
41
36
  /** Back target used on mobile conversation detail. */
42
37
  mobileBackTo?: string;
43
38
  mobileNavigationOpen: boolean;
@@ -151,12 +146,10 @@ export function DashboardHeader(props: {
151
146
  <header className="relative bg-dashboard-bg">
152
147
  <div
153
148
  className={cn(
154
- props.compact ? "w-full min-w-0" : dashboardContainerClass,
155
- "grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-x-2 px-3 py-2 md:gap-x-5 md:gap-y-3 md:px-4 md:py-4",
149
+ "grid w-full min-w-0 grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-x-2 px-3 py-2 md:gap-x-5 md:gap-y-3 md:px-7 md:py-3",
156
150
  props.profile
157
151
  ? "md:grid-cols-[auto_minmax(0,1fr)_auto]"
158
152
  : "md:grid-cols-[auto_minmax(0,1fr)]",
159
- props.compact ? "md:px-7 md:py-3" : "md:px-8",
160
153
  )}
161
154
  >
162
155
  {conversationMode && props.mobileBackTo ? (
@@ -5,6 +5,7 @@ import type { ConversationDetailReport } from "@sentry/junior/api/schema";
5
5
  import { Detail, DetailList } from "../components/DetailList";
6
6
  import { StatusChip, type StatusChipTone } from "../components/StatusChip";
7
7
  import { formatTime } from "../format";
8
+ import { MentionPeopleProvider, renderMentionText } from "./transcriptMentions";
8
9
 
9
10
  type ConversationBriefReport = NonNullable<ConversationDetailReport["brief"]>;
10
11
 
@@ -24,13 +25,24 @@ const outcomeTones: Record<BriefContent["outcome"]["status"], StatusChipTone> =
24
25
  export function ConversationBrief(props: {
25
26
  brief: ConversationBriefReport;
26
27
  variant?: "summary" | "full";
28
+ }) {
29
+ return (
30
+ <MentionPeopleProvider mentions={props.brief.mentions ?? []}>
31
+ <BriefContentView brief={props.brief} variant={props.variant} />
32
+ </MentionPeopleProvider>
33
+ );
34
+ }
35
+
36
+ function BriefContentView(props: {
37
+ brief: ConversationBriefReport;
38
+ variant?: "summary" | "full";
27
39
  }) {
28
40
  const { content } = props.brief;
29
41
  if (props.variant === "summary") {
30
42
  return (
31
43
  <div className="grid min-w-0 gap-4">
32
44
  <p className="m-0 text-sm leading-relaxed text-dashboard-text">
33
- {content.summary}
45
+ <BriefText text={content.summary} />
34
46
  </p>
35
47
  {content.openDecisions.length > 0 ? (
36
48
  <div className="grid gap-2">
@@ -58,12 +70,16 @@ export function ConversationBrief(props: {
58
70
  </span>
59
71
  </div>
60
72
  <p className="m-0 text-sm leading-relaxed text-dashboard-text">
61
- {content.summary}
73
+ <BriefText text={content.summary} />
62
74
  </p>
63
75
  <BriefSection title="Intent & outcome">
64
76
  <DetailList>
65
- <Detail label="Intent">{content.intent}</Detail>
66
- <Detail label="Outcome">{content.outcome.text}</Detail>
77
+ <Detail label="Intent">
78
+ <BriefText text={content.intent} />
79
+ </Detail>
80
+ <Detail label="Outcome">
81
+ <BriefText text={content.outcome.text} />
82
+ </Detail>
67
83
  </DetailList>
68
84
  </BriefSection>
69
85
  {content.decisions.length || content.openDecisions.length ? (
@@ -169,11 +185,13 @@ function BriefDecisions(props: { decisions: BriefContent["decisions"] }) {
169
185
  >
170
186
  {decision.kind}
171
187
  </StatusChip>
172
- <span className="min-w-0">{decision.text}</span>
188
+ <span className="min-w-0">
189
+ <BriefText text={decision.text} />
190
+ </span>
173
191
  </div>
174
192
  {decision.by ? (
175
193
  <div className="mt-0.5 text-xs text-dashboard-text-muted">
176
- by · {decision.by}
194
+ by · <BriefText text={decision.by} />
177
195
  </div>
178
196
  ) : null}
179
197
  </li>
@@ -189,10 +207,12 @@ function BriefOpenDecisions(props: {
189
207
  <ul className="m-0 grid list-none gap-2 p-0">
190
208
  {props.decisions.map((decision, index) => (
191
209
  <li key={`${decision.text}:${index}`}>
192
- <div>{decision.text}</div>
210
+ <div>
211
+ <BriefText text={decision.text} />
212
+ </div>
193
213
  {decision.owner ? (
194
214
  <div className="mt-1 text-xs text-dashboard-text-muted">
195
- Owner: {decision.owner}
215
+ Owner: <BriefText text={decision.owner} />
196
216
  </div>
197
217
  ) : null}
198
218
  </li>
@@ -205,12 +225,18 @@ function BriefList(props: { items: string[] }) {
205
225
  return (
206
226
  <ul className="my-0 grid gap-1.5 pl-4">
207
227
  {props.items.map((item, index) => (
208
- <li key={`${item}:${index}`}>{item}</li>
228
+ <li key={`${item}:${index}`}>
229
+ <BriefText text={item} />
230
+ </li>
209
231
  ))}
210
232
  </ul>
211
233
  );
212
234
  }
213
235
 
236
+ function BriefText(props: { text: string }) {
237
+ return <>{renderMentionText(props.text, "brief")}</>;
238
+ }
239
+
214
240
  function displayLabel(value: string): string {
215
241
  return value.replaceAll("_", " ");
216
242
  }
@@ -22,6 +22,7 @@ import { ConversationArchiveNotices } from "./ConversationArchiveNotices";
22
22
  import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
23
23
  import { conversationPath } from "./conversationRoutes";
24
24
  import { useArchiveConversation } from "./queries";
25
+ import { MentionText } from "./transcriptMentions";
25
26
  import {
26
27
  buildConversationSections,
27
28
  type ConversationSection,
@@ -265,7 +266,12 @@ function ConversationCard(props: {
265
266
  </div>
266
267
  {conversation.activityPreview ? (
267
268
  <p className="relative z-[1] m-0 line-clamp-2 font-sans text-sm leading-relaxed text-dashboard-text-subtle pointer-events-none">
268
- {formatConversationActivityPreview(conversation.activityPreview.text)}
269
+ <MentionText
270
+ mentions={conversation.activityPreview.mentions ?? []}
271
+ text={formatConversationActivityPreview(
272
+ conversation.activityPreview.text,
273
+ )}
274
+ />
269
275
  </p>
270
276
  ) : (
271
277
  <p className="relative z-[1] m-0 font-sans text-sm leading-relaxed text-dashboard-text-muted pointer-events-none">
@@ -167,7 +167,7 @@ export function ConversationSidebarAnnotations(props: {
167
167
  {useMobileFacepile ? (
168
168
  <SidebarAnnotationIconFacepile
169
169
  annotations={mobileFacepileAnnotations}
170
- // Discs sit on the sidebar panel / row surface (#09090b).
170
+ // Discs sit on the sidebar panel / row surface.
171
171
  cutoutColor="var(--color-dashboard-surface-panel)"
172
172
  />
173
173
  ) : (
@@ -343,21 +343,33 @@ type SidebarAnnotationIconName = NonNullable<
343
343
  >;
344
344
 
345
345
  const SIDEBAR_ICON_PRESENTATION = {
346
- "circle-dot": { className: "text-[#3fb950]", Icon: CircleDot, label: "Open" },
346
+ "circle-dot": {
347
+ className: "text-[#3fb950] light:text-[#1a7f37]",
348
+ Icon: CircleDot,
349
+ label: "Open",
350
+ },
347
351
  "circle-dashed": {
348
- className: "text-[#8c959f]",
352
+ className: "text-[#8c959f] light:text-[#59636e]",
349
353
  Icon: CircleDashed,
350
354
  label: "Draft",
351
355
  },
352
- "circle-x": { className: "text-[#f85149]", Icon: CircleX, label: "Closed" },
353
- "git-merge": { className: "text-[#a371f7]", Icon: GitMerge, label: "Merged" },
356
+ "circle-x": {
357
+ className: "text-[#f85149] light:text-[#cf222e]",
358
+ Icon: CircleX,
359
+ label: "Closed",
360
+ },
361
+ "git-merge": {
362
+ className: "text-[#a371f7] light:text-[#8250df]",
363
+ Icon: GitMerge,
364
+ label: "Merged",
365
+ },
354
366
  "git-pull-request": {
355
- className: "text-[#3fb950]",
367
+ className: "text-[#3fb950] light:text-[#1a7f37]",
356
368
  Icon: GitPullRequest,
357
369
  label: "Open pull request",
358
370
  },
359
371
  "triangle-alert": {
360
- className: "text-[#d29922]",
372
+ className: "text-[#d29922] light:text-[#9a6700]",
361
373
  Icon: TriangleAlert,
362
374
  label: "Needs attention",
363
375
  },
@@ -43,7 +43,6 @@ import { transcriptEmptyClass } from "./transcriptStyles";
43
43
  import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch";
44
44
  import { TranscriptRow, TranscriptRows } from "./TranscriptRows";
45
45
  import { TranscriptTimestampProvider } from "./TranscriptTimestamp";
46
- import { TranscriptMentionPeopleProvider } from "./transcriptMentions";
47
46
 
48
47
  type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
49
48
  type TranscriptContextEntry = Extract<TranscriptEntry, { kind: "context" }>;
@@ -86,16 +85,14 @@ export const ConversationTranscriptView = memo(
86
85
 
87
86
  return (
88
87
  <TranscriptTimestampProvider>
89
- <TranscriptMentionPeopleProvider messages={messages}>
90
- <section className="min-w-0 pt-1">
91
- <SegmentEvents
92
- onOpenSubagentTranscript={props.onOpenSubagentTranscript}
93
- conversation={props.conversation}
94
- messages={messages}
95
- responding={props.responding}
96
- />
97
- </section>
98
- </TranscriptMentionPeopleProvider>
88
+ <section className="min-w-0 pt-1">
89
+ <SegmentEvents
90
+ onOpenSubagentTranscript={props.onOpenSubagentTranscript}
91
+ conversation={props.conversation}
92
+ messages={messages}
93
+ responding={props.responding}
94
+ />
95
+ </section>
99
96
  </TranscriptTimestampProvider>
100
97
  );
101
98
  },
@@ -10,6 +10,7 @@ import { EventUsage } from "./EventUsage";
10
10
  /** Present event content as readable sections, with the exact report available on demand. */
11
11
  export function EventDetails({ event }: { event: ConversationReportEvent }) {
12
12
  const data = event.data;
13
+ const mentions = event.mentions ?? [];
13
14
  const model = eventLogModel(event);
14
15
  const { usage, ...callAttributes } = event.modelCall ?? {};
15
16
  let content: ReactNode;
@@ -54,7 +55,7 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
54
55
  ) : null}
55
56
  {summary ? (
56
57
  <DetailSection title="Continuation summary">
57
- <TranscriptText text={summary} />
58
+ <TranscriptText mentions={mentions} text={summary} />
58
59
  </DetailSection>
59
60
  ) : null}
60
61
  </>
@@ -70,7 +71,7 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
70
71
  {redacted ? (
71
72
  <RedactedMarker />
72
73
  ) : (
73
- <TranscriptText text={text ?? ""} />
74
+ <TranscriptText mentions={mentions} text={text ?? ""} />
74
75
  )}
75
76
  </DetailSection>
76
77
  </>
@@ -112,7 +113,7 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
112
113
  {part.redacted ? (
113
114
  <RedactedMarker />
114
115
  ) : (
115
- <TranscriptText text={part.text ?? ""} />
116
+ <TranscriptText mentions={mentions} text={part.text ?? ""} />
116
117
  )}
117
118
  </DetailSection>
118
119
  ));
@@ -124,12 +125,15 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
124
125
  <DetailValue value={metadata} />
125
126
  <DetailSection title={presentation.title}>
126
127
  {presentation.preview ? (
127
- <TranscriptText text={presentation.preview} />
128
+ <TranscriptText mentions={mentions} text={presentation.preview} />
128
129
  ) : null}
129
130
  {presentation.details?.map((detail, index) => (
130
131
  <DetailSection key={index} title={detail.title}>
131
132
  {detail.description ? (
132
- <TranscriptText text={detail.description} />
133
+ <TranscriptText
134
+ mentions={mentions}
135
+ text={detail.description}
136
+ />
133
137
  ) : null}
134
138
  {detail.content ? <DetailValue value={detail.content} /> : null}
135
139
  {detail.metadata ? (
@@ -34,6 +34,12 @@ shared controls and overlays.
34
34
  a person, so it stays collapsed behind "Show prompt".
35
35
  - `TranscriptAttachments` owns wrapping for both message attachments and file
36
36
  deliveries. `ImageAttachment` owns image previews.
37
+ - Display text keeps Slack tokens such as `<@U123>` and `<#C123|name>`. The
38
+ API resolves the people in that text. It returns them as `mentions` next to
39
+ the object that owns the text: an event, an activity preview, a Brief, or an
40
+ Automation. Render this text with `TranscriptText` or `MentionText`. Both
41
+ require `mentions`, so a caller cannot forget them. Do not resolve names in
42
+ the dashboard, and do not rewrite tokens into links on the server.
37
43
  - `TranscriptTurnContextView` owns context content. The shared `Drawer` owns
38
44
  dismissal, focus trapping, focus return, scroll locking, and panel layout.
39
45
 
@@ -44,7 +44,11 @@ export function TranscriptContextEventView(props: {
44
44
  Continuation summary
45
45
  </summary>
46
46
  <div className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-dashboard-text-muted">
47
- <TranscriptText role="system" text={event.summary} />
47
+ <TranscriptText
48
+ mentions={props.part.mentions ?? []}
49
+ role="system"
50
+ text={event.summary}
51
+ />
48
52
  </div>
49
53
  </details>
50
54
  ) : null}
@@ -1,28 +1,67 @@
1
1
  import { ChevronRight } from "lucide-react";
2
2
 
3
- import { renderMentionText } from "./transcriptMentions";
3
+ import { MentionText } from "./transcriptMentions";
4
4
  import { messageRawText } from "./transcriptRenderModel";
5
5
  import { HighlightText } from "./transcriptSearch";
6
6
  import { RedactedMarker } from "./TranscriptRedacted";
7
7
  import type { TranscriptViewMessage } from "../types";
8
8
  import { TranscriptSummary } from "./TranscriptSummary";
9
9
 
10
+ function EventTypeChip(props: { eventType: string }) {
11
+ return (
12
+ <span className="break-all rounded-md border border-dashboard-border px-1.5 py-0.5 font-mono text-2xs leading-none text-dashboard-text-muted">
13
+ <HighlightText text={props.eventType} />
14
+ </span>
15
+ );
16
+ }
17
+
18
+ /** Heading for several Events that arrived in one message. */
19
+ function batchSummary(events: readonly { label?: string }[]): string {
20
+ // Name the resource only when every Event is about it.
21
+ const label = events[0]?.label;
22
+ const shared = label && events.every((event) => event.label === label);
23
+ return `${events.length} events${shared ? ` · ${label}` : ""}`;
24
+ }
25
+
10
26
  /** Render an Event Message whose heading expands its full text. */
11
27
  export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
12
28
  const text = messageRawText(props.message);
13
29
  const redacted = props.message.parts.some(
14
30
  (part) => part.type === "text" && part.redacted,
15
31
  );
16
- const summary = props.message.trustedSummary ?? props.message.eventType ?? "";
32
+ const events = props.message.events ?? [];
33
+ const mentions = props.message.mentions ?? [];
34
+ const isBatch = events.length > 1;
35
+ const summary = isBatch
36
+ ? batchSummary(events)
37
+ : (props.message.trustedSummary ?? props.message.eventType ?? "");
17
38
  const heading = (
18
- <span className="flex min-h-6 min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
19
- <span className="min-w-0 break-words font-display text-sm font-semibold leading-6 text-dashboard-text">
20
- {renderMentionText(summary, "event-summary")}
21
- </span>
22
- {props.message.eventType ? (
23
- <span className="break-all rounded-md border border-dashboard-border px-1.5 py-0.5 font-mono text-2xs leading-none text-dashboard-text-muted">
24
- <HighlightText text={props.message.eventType} />
39
+ <span className="flex min-w-0 flex-col gap-1">
40
+ <span className="flex min-h-6 min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
41
+ <span className="min-w-0 break-words font-display text-sm font-semibold leading-6 text-dashboard-text">
42
+ <MentionText mentions={mentions} text={summary} />
25
43
  </span>
44
+ {!isBatch && props.message.eventType ? (
45
+ <EventTypeChip eventType={props.message.eventType} />
46
+ ) : null}
47
+ </span>
48
+ {isBatch ? (
49
+ <ul className="flex min-w-0 flex-col gap-1">
50
+ {events.map((event, index) => (
51
+ <li
52
+ key={index}
53
+ className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-5 text-dashboard-text-muted"
54
+ >
55
+ <span className="min-w-0 break-words">
56
+ <MentionText
57
+ mentions={mentions}
58
+ text={event.trustedSummary ?? event.eventType}
59
+ />
60
+ </span>
61
+ <EventTypeChip eventType={event.eventType} />
62
+ </li>
63
+ ))}
64
+ </ul>
26
65
  ) : null}
27
66
  </span>
28
67
  );
@@ -54,7 +54,12 @@ export const TranscriptMessageView = memo(
54
54
  <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
55
55
  {props.message.parts.map((part, index) =>
56
56
  part.type === "text" ? (
57
- <TranscriptText key={index} role={role} text={part.text ?? ""} />
57
+ <TranscriptText
58
+ key={index}
59
+ mentions={props.message.mentions ?? []}
60
+ role={role}
61
+ text={part.text ?? ""}
62
+ />
58
63
  ) : null,
59
64
  )}
60
65
  </div>
@@ -1,5 +1,6 @@
1
1
  import { formatMessageTimestamp } from "../format";
2
2
  import type { TranscriptViewStructuredEventPart } from "../types";
3
+ import { MentionText } from "./transcriptMentions";
3
4
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
4
5
  import { TranscriptSummary } from "./TranscriptSummary";
5
6
 
@@ -14,6 +15,7 @@ export function TranscriptStructuredEventView(props: {
14
15
  const presentation = props.part.presentation;
15
16
  const timestamp = formatMessageTimestamp(props.timestamp);
16
17
  const details = presentation.details ?? [];
18
+ const mentions = props.part.mentions ?? [];
17
19
  // The rail icon box is h-6. A leading-6 title shares its center line.
18
20
  const surfaceClass = "min-w-0 font-sans text-xs";
19
21
  // A lone detail often repeats the event title, such as "Loaded AGENTS.md"
@@ -32,7 +34,7 @@ export function TranscriptStructuredEventView(props: {
32
34
  )}
33
35
  {detail.description ? (
34
36
  <div className="mt-1 whitespace-pre-wrap break-words text-xs leading-relaxed text-dashboard-text-muted">
35
- <HighlightText text={detail.description} />
37
+ <MentionText mentions={mentions} text={detail.description} />
36
38
  </div>
37
39
  ) : null}
38
40
  {detail.content ? (
@@ -64,7 +66,7 @@ export function TranscriptStructuredEventView(props: {
64
66
  </div>
65
67
  {presentation.preview ? (
66
68
  <div className="mt-0.5 truncate text-xs text-dashboard-text-muted">
67
- <HighlightText text={presentation.preview} />
69
+ <MentionText mentions={mentions} text={presentation.preview} />
68
70
  </div>
69
71
  ) : null}
70
72
  </div>