@sentry/junior-dashboard 0.256.0 → 0.258.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.
@@ -0,0 +1,104 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { Link } from "react-router";
3
+ import type { ActorIdentity } from "@sentry/junior/api/schema";
4
+
5
+ import { peoplePath } from "../format";
6
+ import type { TranscriptViewMessage } from "../types";
7
+ import { TRANSCRIPT_ANCHOR_CLASS } from "./transcriptMarkdownLinks";
8
+ import { HighlightText } from "./transcriptSearch";
9
+
10
+ type TranscriptMention = {
11
+ end: number;
12
+ id: string;
13
+ label?: string;
14
+ sigil: "@" | "#";
15
+ start: number;
16
+ };
17
+
18
+ // Matches the user and channel tokens that core keeps in message text.
19
+ const SLACK_MENTION_RE = /<([@#])([A-Z0-9]+)(?:\|([^<>]*))?>/g;
20
+
21
+ const TranscriptMentionPeople = createContext<
22
+ ReadonlyMap<string, ActorIdentity>
23
+ >(new Map());
24
+
25
+ /** Share the people that the API resolved for mentions in one transcript. */
26
+ export function TranscriptMentionPeopleProvider(props: {
27
+ children: ReactNode;
28
+ messages: readonly TranscriptViewMessage[];
29
+ }) {
30
+ const people = new Map<string, ActorIdentity>();
31
+ for (const message of props.messages) {
32
+ for (const person of message.mentions ?? []) {
33
+ if (person.slackUserId) people.set(person.slackUserId, person);
34
+ }
35
+ }
36
+ return (
37
+ <TranscriptMentionPeople.Provider value={people}>
38
+ {props.children}
39
+ </TranscriptMentionPeople.Provider>
40
+ );
41
+ }
42
+
43
+ /** Render text with Slack `<@U123>` and `<#C123|name>` tokens as readable mentions. */
44
+ export function renderMentionText(text: string, keyBase: string): ReactNode[] {
45
+ const nodes: ReactNode[] = [];
46
+ let cursor = 0;
47
+ for (const mention of findMentions(text)) {
48
+ if (mention.start > cursor) {
49
+ nodes.push(
50
+ <HighlightText
51
+ key={`${keyBase}-m-${cursor}`}
52
+ text={text.slice(cursor, mention.start)}
53
+ />,
54
+ );
55
+ }
56
+ nodes.push(
57
+ <MentionView key={`${keyBase}-m-${mention.start}`} mention={mention} />,
58
+ );
59
+ cursor = mention.end;
60
+ }
61
+ if (cursor < text.length) {
62
+ nodes.push(
63
+ <HighlightText
64
+ key={`${keyBase}-m-${cursor}`}
65
+ text={text.slice(cursor)}
66
+ />,
67
+ );
68
+ }
69
+ return nodes;
70
+ }
71
+
72
+ function findMentions(text: string): TranscriptMention[] {
73
+ return [...text.matchAll(SLACK_MENTION_RE)].map((match) => ({
74
+ end: match.index + match[0].length,
75
+ id: match[2]!,
76
+ label: match[3]?.trim() || undefined,
77
+ sigil: match[1] as "@" | "#",
78
+ start: match.index,
79
+ }));
80
+ }
81
+
82
+ function MentionView(props: { mention: TranscriptMention }) {
83
+ const people = useContext(TranscriptMentionPeople);
84
+ const { id, label, sigil } = props.mention;
85
+ const person = sigil === "@" ? people.get(id) : undefined;
86
+ const name = person?.fullName ?? person?.slackUserName ?? label ?? id;
87
+ const text = <HighlightText text={`${sigil}${name.replace(/^[@#]/, "")}`} />;
88
+ if (person?.email) {
89
+ return (
90
+ <Link
91
+ className={TRANSCRIPT_ANCHOR_CLASS}
92
+ title={id}
93
+ to={peoplePath(person.email)}
94
+ >
95
+ {text}
96
+ </Link>
97
+ );
98
+ }
99
+ return (
100
+ <span className="font-medium text-dashboard-accent-text" title={id}>
101
+ {text}
102
+ </span>
103
+ );
104
+ }
@@ -141,10 +141,10 @@ export function AutomationOutcomeFields(props: {
141
141
  disabled={index === 0}
142
142
  onClick={() => {
143
143
  const next = [...props.value];
144
- [next[index - 1], next[index]] = [
145
- next[index],
146
- next[index - 1],
147
- ];
144
+ const previous = next[index - 1];
145
+ if (!previous) return;
146
+ next[index - 1] = outcome;
147
+ next[index] = previous;
148
148
  props.onChange(next);
149
149
  }}
150
150
  >
@@ -53,7 +53,7 @@ export function scheduleDraft(
53
53
  ...fields,
54
54
  timezone: value.schedule.timezone,
55
55
  time: `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`,
56
- weekdays: days?.map((day) => weekdays[day]),
56
+ weekdays: days?.flatMap((day) => weekdays[day] ?? []),
57
57
  };
58
58
  }
59
59
  if (value.nextRunAtMs) {
@@ -1,4 +1,5 @@
1
1
  import { FormNotice } from "../../components/FormNotice";
2
+ import { SearchInput } from "../../components/SearchInput";
2
3
  import { Select } from "../../components/Select";
3
4
  import { ObjectFixtures } from "./ObjectFixtures";
4
5
  import { ChartFixtures } from "./ChartFixtures";
@@ -191,6 +192,7 @@ function FoundationsGalleryPage() {
191
192
  const [navigationOpen, setNavigationOpen] = useState(false);
192
193
  const [drawerOpen, setDrawerOpen] = useState(false);
193
194
  const [nestedDrawerOpen, setNestedDrawerOpen] = useState(false);
195
+ const [searchQuery, setSearchQuery] = useState("deploy failure");
194
196
 
195
197
  return (
196
198
  <GalleryShell
@@ -471,6 +473,12 @@ function FoundationsGalleryPage() {
471
473
  disabled
472
474
  value="read only"
473
475
  />
476
+ <SearchInput
477
+ label="Search conversations"
478
+ onChange={setSearchQuery}
479
+ placeholder="Search conversations…"
480
+ value={searchQuery}
481
+ />
474
482
  </div>
475
483
  </Fixture>
476
484
  <Fixture title="Status labels">
@@ -10,6 +10,7 @@ import { SectionIntro } from "../../components/layout/SectionIntro";
10
10
  import { StatCard } from "../../components/metrics/StatCard";
11
11
  import { formatCompactNumber, formatCostSummary } from "../../format";
12
12
  import { CodeActivityChart } from "../code/CodeActivityChart";
13
+ import { ProfileCodeChanges } from "./ProfileCodeChanges";
13
14
 
14
15
  function mergeRate(value: number | undefined): string {
15
16
  return value === undefined ? "—" : `${Math.round(value * 100)}%`;
@@ -28,13 +29,14 @@ function costUsd(value: number | undefined): string {
28
29
  function hasCodeActivity(report: CodePersonReport): boolean {
29
30
  const summary = report.summary;
30
31
  return (
32
+ report.changes.length > 0 ||
31
33
  summary.created + summary.merged + summary.closed > 0 ||
32
34
  (summary.costUsd ?? 0) > 0 ||
33
35
  report.activityDays.some((day) => day.created + day.merged + day.closed > 0)
34
36
  );
35
37
  }
36
38
 
37
- /** Render person-scoped native code change activity. */
39
+ /** Render person-scoped native code change activity and recent code changes. */
38
40
  export function ProfileCodeActivity(props: {
39
41
  range: TimeRangeDays;
40
42
  report: CodePersonReport;
@@ -86,6 +88,12 @@ export function ProfileCodeActivity(props: {
86
88
  })}
87
89
  range={props.range}
88
90
  />
91
+ {props.report.changes.length > 0 ? (
92
+ <ProfileCodeChanges
93
+ changes={props.report.changes}
94
+ generatedAt={props.report.generatedAt}
95
+ />
96
+ ) : null}
89
97
  </section>
90
98
  );
91
99
  }
@@ -0,0 +1,104 @@
1
+ import { useState } from "react";
2
+ import type { CodeChangeSummaryReport } from "@sentry/junior/api/schema";
3
+
4
+ import { EmptyTelemetry } from "../../components/EmptyTelemetry";
5
+ import { FilterGroup } from "../../components/FilterBar";
6
+ import { Select } from "../../components/Select";
7
+ import { Card } from "../../components/layout/Card";
8
+ import { CodeChangeRow } from "../code/CodeChangeRow";
9
+
10
+ const DAY_MS = 24 * 60 * 60 * 1_000;
11
+
12
+ const STATE_OPTIONS = [
13
+ { label: "Open", value: "open" },
14
+ { label: "Merged", value: "merged" },
15
+ { label: "Closed", value: "closed" },
16
+ { label: "All", value: "" },
17
+ ] as const;
18
+
19
+ const OPENED_OPTIONS = [
20
+ { label: "Any time", value: "" },
21
+ { label: "Last 24 hours", value: "1" },
22
+ { label: "Last 7 days", value: "7" },
23
+ { label: "Last 30 days", value: "30" },
24
+ { label: "Last 90 days", value: "90" },
25
+ ] as const;
26
+
27
+ /**
28
+ * List one person's recent code changes. Open changes show first, and the
29
+ * person can filter by state and by the date the change was opened.
30
+ */
31
+ export function ProfileCodeChanges(props: {
32
+ changes: CodeChangeSummaryReport[];
33
+ generatedAt: string;
34
+ }) {
35
+ const [state, setState] = useState("open");
36
+ const [openedDays, setOpenedDays] = useState("");
37
+ const openedSinceMs = openedDays
38
+ ? Date.parse(props.generatedAt) - Number(openedDays) * DAY_MS
39
+ : undefined;
40
+ const inDateRange = props.changes.filter(
41
+ (change) =>
42
+ openedSinceMs === undefined ||
43
+ Date.parse(change.openedAt) >= openedSinceMs,
44
+ );
45
+ const changes = inDateRange.filter(
46
+ (change) => !state || change.state === state,
47
+ );
48
+ const count = (value: string) =>
49
+ value
50
+ ? inDateRange.filter((change) => change.state === value).length
51
+ : inDateRange.length;
52
+ return (
53
+ <Card as="section">
54
+ <div className="flex flex-wrap items-end justify-between gap-4 border-b border-dashboard-border-subtle px-4 py-3">
55
+ <div className="font-display text-lg text-dashboard-text">
56
+ Recent changes
57
+ </div>
58
+ <div className="flex flex-wrap gap-3">
59
+ <FilterGroup className="w-36" label="State">
60
+ <Select
61
+ aria-label="Filter changes by state"
62
+ onChange={(event) => setState(event.currentTarget.value)}
63
+ value={state}
64
+ >
65
+ {STATE_OPTIONS.map((option) => (
66
+ <option key={option.value} value={option.value}>
67
+ {option.label} ({count(option.value)})
68
+ </option>
69
+ ))}
70
+ </Select>
71
+ </FilterGroup>
72
+ <FilterGroup className="w-36" label="Opened">
73
+ <Select
74
+ aria-label="Filter changes by opened date"
75
+ onChange={(event) => setOpenedDays(event.currentTarget.value)}
76
+ value={openedDays}
77
+ >
78
+ {OPENED_OPTIONS.map((option) => (
79
+ <option key={option.value} value={option.value}>
80
+ {option.label}
81
+ </option>
82
+ ))}
83
+ </Select>
84
+ </FilterGroup>
85
+ </div>
86
+ </div>
87
+ {changes.length > 0 ? (
88
+ <div>
89
+ {changes.map((change) => (
90
+ <CodeChangeRow change={change} key={change.id} />
91
+ ))}
92
+ </div>
93
+ ) : (
94
+ <div className="p-4">
95
+ <EmptyTelemetry>
96
+ {state && inDateRange.length > 0
97
+ ? `No ${state} changes. Set State to All to see ${inDateRange.length} more.`
98
+ : "No changes match these filters."}
99
+ </EmptyTelemetry>
100
+ </div>
101
+ )}
102
+ </Card>
103
+ );
104
+ }
@@ -23,6 +23,12 @@ const toneColors = {
23
23
  warning: "#fbbf24",
24
24
  } as const;
25
25
 
26
+ function seriesColor(series: Widget["series"][number], index: number): string {
27
+ return series.tone
28
+ ? toneColors[series.tone]
29
+ : (colors[index % colors.length] ?? colors[0]);
30
+ }
31
+
26
32
  /** Render a validated plugin-owned categorical bar chart. */
27
33
  export function PluginBarChart(props: {
28
34
  range?: TimeRangeDays;
@@ -69,9 +75,7 @@ export function PluginBarChart(props: {
69
75
  <ChartLegend
70
76
  ariaLabel="Chart legend"
71
77
  items={widget.series.map((series, index) => ({
72
- color: series.tone
73
- ? toneColors[series.tone]
74
- : colors[index % colors.length],
78
+ color: seriesColor(series, index),
75
79
  key: series.key,
76
80
  label: series.label,
77
81
  }))}
@@ -122,11 +126,7 @@ export function PluginBarChart(props: {
122
126
  >
123
127
  <rect
124
128
  aria-label={`${formatActivityCategoryTooltipLabel(category.label)}, ${series.label}: ${formatted}`}
125
- fill={
126
- series.tone
127
- ? toneColors[series.tone]
128
- : colors[seriesIndex % colors.length]
129
- }
129
+ fill={seriesColor(series, seriesIndex)}
130
130
  height={renderedHeight}
131
131
  opacity={value ? 0.82 : 0.1}
132
132
  rx="1.5"
@@ -122,6 +122,8 @@ export type TranscriptViewMessage = {
122
122
  trustedSummary?: string;
123
123
  /** Whether the source message addressed Junior directly. */
124
124
  explicitMention?: boolean;
125
+ /** People mentioned in the message text. */
126
+ mentions?: ActorIdentity[];
125
127
  /** Whether a non-mention message was used as input to a turn. */
126
128
  context?: boolean;
127
129
  /** Stable history/message id used to drop pending rows after commit. */
package/src/client.tsx CHANGED
@@ -22,18 +22,18 @@ class DashboardErrorBoundary extends Component<
22
22
  { children: ReactNode },
23
23
  ErrorBoundaryState
24
24
  > {
25
- state: ErrorBoundaryState = { error: null };
25
+ override state: ErrorBoundaryState = { error: null };
26
26
 
27
27
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
28
28
  return { error };
29
29
  }
30
30
 
31
- componentDidCatch(error: Error, errorInfo: ErrorInfo) {
31
+ override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
32
32
  const stack = error.stack ?? errorInfo.componentStack;
33
33
  window.__JUNIOR_DASHBOARD_SHOW_ERROR__?.(stack ? new Error(stack) : error);
34
34
  }
35
35
 
36
- render() {
36
+ override render() {
37
37
  if (this.state.error) {
38
38
  return <DashboardErrorPanel error={this.state.error} />;
39
39
  }
@@ -1397,6 +1397,13 @@ function simpleConversation(
1397
1397
  });
1398
1398
  }
1399
1399
 
1400
+ const MOCK_MENTION_MORGAN: ActorIdentity = {
1401
+ email: "dev@example.com",
1402
+ fullName: "Morgan Lee",
1403
+ slackUserId: "U123",
1404
+ slackUserName: "morgan",
1405
+ };
1406
+
1400
1407
  function eventAutomationRunConversation(
1401
1408
  nowMs: number,
1402
1409
  ): ConversationDetailReport {
@@ -1444,6 +1451,7 @@ function eventAutomationRunConversation(
1444
1451
  "",
1445
1452
  "End with `finishAutomationRun`.",
1446
1453
  ].join("\n"),
1454
+ mentions: [MOCK_MENTION_MORGAN],
1447
1455
  }),
1448
1456
  reportEvent(1, at(1_000), {
1449
1457
  type: "turn_lifecycle",
@@ -1455,7 +1463,12 @@ function eventAutomationRunConversation(
1455
1463
  type: "message",
1456
1464
  messageId: `${EVENT_AUTOMATION_RUN_CONVERSATION_ID}:reply`,
1457
1465
  role: "assistant",
1458
- text: "ACME-42 is closed. The saved-card checkout failure was fixed by the token refresh change and verified in production.",
1466
+ text: "<@U123> ACME-42 is closed. The saved-card checkout failure was fixed by the token refresh change and verified in production. cc <@U456> and <@U999> in <#C42|proj-checkout>.",
1467
+ // U456 has no People profile, and U999 is not resolved.
1468
+ mentions: [
1469
+ MOCK_MENTION_MORGAN,
1470
+ { fullName: "Casey Nguyen", slackUserId: "U456" },
1471
+ ],
1459
1472
  }),
1460
1473
  reportEvent(3, at(14_000), {
1461
1474
  type: "turn_lifecycle",
@@ -2682,6 +2695,7 @@ export function readMockPeopleCode(
2682
2695
  const overview = readMockCodeOverview();
2683
2696
  return {
2684
2697
  activityDays: overview.activityDays,
2698
+ changes: overview.changes,
2685
2699
  generatedAt: overview.generatedAt,
2686
2700
  summary: overview.summary,
2687
2701
  windowEnd: overview.windowEnd,