@sentry/junior-dashboard 0.256.0 → 0.257.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.
package/src/auth.ts CHANGED
@@ -1,25 +1,10 @@
1
- import { betterAuth } from "better-auth/minimal";
2
1
  import { dashboardSessionSchema } from "./api/schema";
3
2
  import type { DashboardSession } from "./api/schema";
4
- import { resolveDashboardBaseURL } from "./url";
5
-
6
- const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
7
3
 
8
4
  export type DashboardUser = DashboardSession["user"];
9
5
  export type { DashboardSession };
10
6
 
11
- export interface DashboardAuthConfig {
12
- agentName?: string;
13
- baseURL?: string;
14
- authPath: string;
15
- trustedOrigins: string[];
16
- secret?: string;
17
- googleClientId?: string;
18
- googleClientSecret?: string;
19
- googleHostedDomain?: string;
20
- sessionMaxAgeSeconds?: number;
21
- }
22
-
7
+ /** Google sign-in session that core shares with Slack. */
23
8
  export interface DashboardAuth {
24
9
  handler(request: Request): Promise<Response>;
25
10
  getSession(request: Request): Promise<DashboardSession | null>;
@@ -78,114 +63,3 @@ export function sanitizeDashboardSession(
78
63
  },
79
64
  });
80
65
  }
81
-
82
- function required(value: string | undefined, name: string): string {
83
- if (!value?.trim()) {
84
- throw new Error(`${name} is required for Junior dashboard auth`);
85
- }
86
- return value.trim();
87
- }
88
-
89
- function firstHostedDomain(domains: string[]): string | undefined {
90
- return domains.length === 1 ? domains[0] : undefined;
91
- }
92
-
93
- /** Create the Better Auth bridge used by dashboard browser routes. */
94
- export function createDashboardAuth(
95
- config: DashboardAuthConfig,
96
- ): DashboardAuth {
97
- const secret = required(
98
- config.secret ??
99
- process.env.BETTER_AUTH_SECRET ??
100
- process.env.JUNIOR_SECRET,
101
- "JUNIOR_SECRET or BETTER_AUTH_SECRET",
102
- );
103
- const baseURL = resolveDashboardBaseURL({ baseURL: config.baseURL });
104
- const googleClientId = required(
105
- config.googleClientId ?? process.env.GOOGLE_CLIENT_ID,
106
- "GOOGLE_CLIENT_ID",
107
- );
108
- const googleClientSecret = required(
109
- config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET,
110
- "GOOGLE_CLIENT_SECRET",
111
- );
112
-
113
- const auth = betterAuth({
114
- appName: `${config.agentName?.trim() || "Junior"} Dashboard`,
115
- baseURL,
116
- basePath: config.authPath,
117
- secret,
118
- trustedOrigins: config.trustedOrigins,
119
- socialProviders: {
120
- google: {
121
- clientId: googleClientId,
122
- clientSecret: googleClientSecret,
123
- hd: config.googleHostedDomain,
124
- prompt: "select_account",
125
- mapProfileToUser(profile) {
126
- return {
127
- email: profile.email,
128
- emailVerified: profile.email_verified,
129
- image: profile.picture,
130
- name: profile.name,
131
- };
132
- },
133
- },
134
- },
135
- account: {
136
- storeStateStrategy: "cookie",
137
- storeAccountCookie: false,
138
- updateAccountOnSignIn: false,
139
- },
140
- session: {
141
- expiresIn: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
142
- disableSessionRefresh: true,
143
- cookieCache: {
144
- enabled: true,
145
- strategy: "jwe",
146
- maxAge: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
147
- refreshCache: false,
148
- },
149
- },
150
- });
151
-
152
- return {
153
- handler(request) {
154
- return auth.handler(request);
155
- },
156
- async getSession(request) {
157
- const session = await auth.api.getSession({ headers: request.headers });
158
- if (!session) {
159
- return null;
160
- }
161
- return sanitizeDashboardSession(session as DashboardSession);
162
- },
163
- async signInWithGoogle(request, callbackURL) {
164
- const result = await auth.api.signInSocial({
165
- body: {
166
- provider: "google",
167
- callbackURL,
168
- },
169
- headers: request.headers,
170
- returnHeaders: true,
171
- });
172
-
173
- if (!("url" in result.response) || !result.response.url) {
174
- throw new Error("Google sign-in did not return a redirect URL");
175
- }
176
-
177
- result.headers.set("location", result.response.url);
178
- return new Response(null, {
179
- status: 302,
180
- headers: result.headers,
181
- });
182
- },
183
- };
184
- }
185
-
186
- /** Resolve a Google hosted-domain login hint when it is unambiguous. */
187
- export function resolveGoogleHostedDomainHint(
188
- domains: string[],
189
- ): string | undefined {
190
- return firstHostedDomain(domains.map((domain) => domain.toLowerCase()));
191
- }
@@ -1,5 +1,5 @@
1
1
  import type { Ref } from "react";
2
- import { Search } from "lucide-react";
2
+ import { Search, X } from "lucide-react";
3
3
 
4
4
  import { cn } from "../styles";
5
5
 
@@ -30,7 +30,7 @@ export function SearchInput(props: {
30
30
  <input
31
31
  aria-label={props.label}
32
32
  className={cn(
33
- "w-full rounded-md border border-dashboard-border font-sans text-sm text-dashboard-text outline-none transition-colors placeholder:text-dashboard-text-muted/70 hover:border-dashboard-border-emphasis focus:border-dashboard-focus",
33
+ "w-full rounded-md border border-dashboard-border font-sans text-sm text-dashboard-text outline-none transition-colors placeholder:text-dashboard-text-muted/70 hover:border-dashboard-border-emphasis focus:border-dashboard-focus [&::-webkit-search-cancel-button]:appearance-none",
34
34
  searchInputClass[size],
35
35
  )}
36
36
  onChange={(event) => props.onChange(event.currentTarget.value)}
@@ -39,6 +39,23 @@ export function SearchInput(props: {
39
39
  type="search"
40
40
  value={props.value}
41
41
  />
42
+ {props.value ? (
43
+ <button
44
+ aria-label="Clear search"
45
+ className={cn(
46
+ "absolute top-1/2 grid size-6 -translate-y-1/2 cursor-pointer place-items-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
47
+ clearButtonClass[size],
48
+ )}
49
+ onClick={(event) => {
50
+ props.onChange("");
51
+ // The button unmounts once the value is empty, so keep focus in the search box.
52
+ event.currentTarget.parentElement?.querySelector("input")?.focus();
53
+ }}
54
+ type="button"
55
+ >
56
+ <X aria-hidden="true" size={14} strokeWidth={2.5} />
57
+ </button>
58
+ ) : null}
42
59
  </div>
43
60
  );
44
61
  }
@@ -48,9 +65,14 @@ const searchIconClass: Record<SearchInputSize, string> = {
48
65
  default: "left-3",
49
66
  };
50
67
 
68
+ const clearButtonClass: Record<SearchInputSize, string> = {
69
+ compact: "right-1.5",
70
+ default: "right-2",
71
+ };
72
+
51
73
  const searchInputClass: Record<SearchInputSize, string> = {
52
74
  compact:
53
- "h-9 bg-dashboard-overlay-soft pr-3 pl-8 focus:ring-1 focus:ring-dashboard-focus/25",
75
+ "h-9 bg-dashboard-overlay-soft pr-8 pl-8 focus:ring-1 focus:ring-dashboard-focus/25",
54
76
  default:
55
- "h-10 bg-dashboard-overlay-soft pr-3 pl-9 focus:ring-1 focus:ring-dashboard-focus/25",
77
+ "h-10 bg-dashboard-overlay-soft pr-9 pl-9 focus:ring-1 focus:ring-dashboard-focus/25",
56
78
  };
@@ -43,6 +43,7 @@ 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";
46
47
 
47
48
  type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
48
49
  type TranscriptContextEntry = Extract<TranscriptEntry, { kind: "context" }>;
@@ -85,14 +86,16 @@ export const ConversationTranscriptView = memo(
85
86
 
86
87
  return (
87
88
  <TranscriptTimestampProvider>
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>
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>
96
99
  </TranscriptTimestampProvider>
97
100
  );
98
101
  },
@@ -1,5 +1,6 @@
1
1
  import { ChevronRight } from "lucide-react";
2
2
 
3
+ import { renderMentionText } from "./transcriptMentions";
3
4
  import { messageRawText } from "./transcriptRenderModel";
4
5
  import { HighlightText } from "./transcriptSearch";
5
6
  import { RedactedMarker } from "./TranscriptRedacted";
@@ -16,7 +17,7 @@ export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
16
17
  const heading = (
17
18
  <span className="flex min-h-6 min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
18
19
  <span className="min-w-0 break-words font-display text-sm font-semibold leading-6 text-dashboard-text">
19
- <HighlightText text={summary} />
20
+ {renderMentionText(summary, "event-summary")}
20
21
  </span>
21
22
  {props.message.eventType ? (
22
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">
@@ -5,13 +5,14 @@ import {
5
5
  findTranscriptMarkdownLinks,
6
6
  TRANSCRIPT_ANCHOR_CLASS,
7
7
  } from "./transcriptMarkdownLinks";
8
+ import { renderMentionText } from "./transcriptMentions";
8
9
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
9
10
 
10
11
  type TableAlignment = "left" | "center" | "right" | null;
11
12
 
12
13
  type MarkdownBlock =
13
14
  | { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
14
- | { type: "list"; ordered: boolean; items: string[] }
15
+ | { type: "list"; ordered: boolean; items: string[]; start?: number }
15
16
  | {
16
17
  type: "table";
17
18
  alignments: TableAlignment[];
@@ -33,11 +34,11 @@ export function TranscriptMarkdown(props: {
33
34
  className={cn(
34
35
  "min-w-0 break-words text-dashboard-text [overflow-wrap:anywhere]",
35
36
  props.compact
36
- ? "font-sans text-base leading-7"
37
+ ? "font-sans text-base leading-relaxed"
37
38
  : "font-sans text-sm leading-6 md:leading-7",
38
39
  )}
39
40
  >
40
- <div className="grid min-w-0 gap-2">
41
+ <div className={cn("grid min-w-0", props.compact ? "gap-3" : "gap-2")}>
41
42
  {blocks.map((block, index) => (
42
43
  <MarkdownBlockView block={block} key={index} />
43
44
  ))}
@@ -50,7 +51,7 @@ function MarkdownBlockView(props: { block: MarkdownBlock }) {
50
51
  switch (props.block.type) {
51
52
  case "heading": {
52
53
  const className = cn(
53
- "m-0 min-w-0 font-semibold tracking-[-0.01em] text-dashboard-text",
54
+ "m-0 min-w-0 font-semibold tracking-[-0.01em] text-dashboard-text [&:not(:first-child)]:mt-1 [&+*]:-mt-1",
54
55
  props.block.level <= 2 ? "text-[1.05em]" : "text-[1em]",
55
56
  );
56
57
  const content = renderInlineMarkdown(props.block.text);
@@ -74,9 +75,10 @@ function MarkdownBlockView(props: { block: MarkdownBlock }) {
74
75
  return (
75
76
  <ListTag
76
77
  className={cn(
77
- "m-0 grid min-w-0 list-outside gap-1 pl-5 text-dashboard-text",
78
+ "m-0 grid min-w-0 list-outside gap-1.5 pl-6 text-dashboard-text marker:text-dashboard-text-muted",
78
79
  props.block.ordered ? "list-decimal" : "list-disc",
79
80
  )}
81
+ start={props.block.start}
80
82
  >
81
83
  {props.block.items.map((item, index) => (
82
84
  <li className="min-w-0 pl-1" key={index}>
@@ -122,6 +124,7 @@ function parseMarkdownProseBlocks(text: string): MarkdownBlock[] {
122
124
  const ordered = isOrderedListItem(line);
123
125
  const unordered = isUnorderedListItem(line);
124
126
  if (ordered || unordered) {
127
+ const start = ordered ? Number(/\d+/.exec(line)?.[0]) : undefined;
125
128
  const items: string[] = [];
126
129
  while (
127
130
  index < lines.length &&
@@ -131,13 +134,18 @@ function parseMarkdownProseBlocks(text: string): MarkdownBlock[] {
131
134
  ) {
132
135
  items.push(
133
136
  (lines[index] ?? "").replace(
134
- ordered ? /^\s*\d+\.\s+/ : /^\s*[-*+]\s+/,
137
+ ordered ? ORDERED_LIST_MARKER : UNORDERED_LIST_MARKER,
135
138
  "",
136
139
  ),
137
140
  );
138
141
  index += 1;
139
142
  }
140
- blocks.push({ items, ordered, type: "list" });
143
+ blocks.push({
144
+ items,
145
+ ordered,
146
+ start: start === 1 ? undefined : start,
147
+ type: "list",
148
+ });
141
149
  continue;
142
150
  }
143
151
 
@@ -323,6 +331,11 @@ function splitTableRow(line: string): string[] {
323
331
  }
324
332
 
325
333
  function matchHeading(line: string): MarkdownBlock | undefined {
334
+ // Agents often write a bold-only line as a section label. Give it heading
335
+ // spacing so the section reads as a group.
336
+ if (/^\s*(?:\*\*[^*]+\*\*|__[^_]+__):?\s*$/.test(line)) {
337
+ return { level: 3, text: line.trim(), type: "heading" };
338
+ }
326
339
  const match = /^(#{1,6})\s+(.+)$/.exec(line);
327
340
  if (!match) return undefined;
328
341
  return {
@@ -332,12 +345,16 @@ function matchHeading(line: string): MarkdownBlock | undefined {
332
345
  };
333
346
  }
334
347
 
348
+ // Slack-style replies use "•" bullets and "1)" numbers; render them as lists.
349
+ const UNORDERED_LIST_MARKER = /^\s*[-*+•◦]\s+(?=\S)/;
350
+ const ORDERED_LIST_MARKER = /^\s*\d+[.)]\s+(?=\S)/;
351
+
335
352
  function isUnorderedListItem(line: string): boolean {
336
- return /^\s*[-*+]\s+\S/.test(line);
353
+ return UNORDERED_LIST_MARKER.test(line);
337
354
  }
338
355
 
339
356
  function isOrderedListItem(line: string): boolean {
340
- return /^\s*\d+\.\s+\S/.test(line);
357
+ return ORDERED_LIST_MARKER.test(line);
341
358
  }
342
359
 
343
360
  function renderInlineMarkdown(text: string): ReactNode[] {
@@ -435,10 +452,10 @@ function renderLinkText(text: string, keyBase: string): ReactNode[] {
435
452
  for (const link of links) {
436
453
  if (link.start > cursor) {
437
454
  nodes.push(
438
- <HighlightText
439
- key={`${keyBase}-s-${part++}`}
440
- text={text.slice(cursor, link.start)}
441
- />,
455
+ ...renderMentionText(
456
+ text.slice(cursor, link.start),
457
+ `${keyBase}-s-${part++}`,
458
+ ),
442
459
  );
443
460
  }
444
461
  nodes.push(
@@ -451,7 +468,7 @@ function renderLinkText(text: string, keyBase: string): ReactNode[] {
451
468
 
452
469
  if (cursor < text.length) {
453
470
  nodes.push(
454
- <HighlightText key={`${keyBase}-s-${part}`} text={text.slice(cursor)} />,
471
+ ...renderMentionText(text.slice(cursor), `${keyBase}-s-${part}`),
455
472
  );
456
473
  }
457
474
  return nodes;
@@ -213,6 +213,7 @@ export function transcriptMessagesFromEvents(
213
213
  ...(data.actorIdentity
214
214
  ? { actorIdentity: data.actorIdentity }
215
215
  : undefined),
216
+ ...(data.mentions ? { mentions: data.mentions } : undefined),
216
217
  ...(data.eventType ? { eventType: data.eventType } : undefined),
217
218
  eventObjectType: data.eventObjectType,
218
219
  ...(data.trustedSummary
@@ -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
+ }
@@ -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
+ }
@@ -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. */