@sentry/junior-dashboard 0.255.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/README.md +3 -2
- package/dist/app.js +54 -145
- package/dist/client.js +37 -37
- package/dist/index.d.ts +1 -2
- package/dist/index.js +54 -145
- package/dist/tailwind.css +1 -1
- package/package.json +3 -4
- package/src/app.ts +6 -18
- package/src/auth.ts +1 -127
- package/src/client/components/SearchInput.tsx +26 -4
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/TranscriptEventView.tsx +2 -1
- package/src/client/conversations/TranscriptMarkdown.tsx +31 -14
- package/src/client/conversations/eventTranscript.ts +1 -0
- package/src/client/conversations/transcriptMentions.tsx +104 -0
- package/src/client/pages/dev/ComponentsPage.tsx +8 -0
- package/src/client/pages/people/ProfileCodeActivity.tsx +9 -1
- package/src/client/pages/people/ProfileCodeChanges.tsx +104 -0
- package/src/client/types.ts +2 -0
- package/src/mock-reporting/fixtures.ts +15 -1
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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 ?
|
|
137
|
+
ordered ? ORDERED_LIST_MARKER : UNORDERED_LIST_MARKER,
|
|
135
138
|
"",
|
|
136
139
|
),
|
|
137
140
|
);
|
|
138
141
|
index += 1;
|
|
139
142
|
}
|
|
140
|
-
blocks.push({
|
|
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
|
|
353
|
+
return UNORDERED_LIST_MARKER.test(line);
|
|
337
354
|
}
|
|
338
355
|
|
|
339
356
|
function isOrderedListItem(line: string): boolean {
|
|
340
|
-
return
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -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. */
|