@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.
- package/README.md +3 -2
- package/dist/app.js +65 -184
- package/dist/client.js +37 -37
- package/dist/index.d.ts +36 -42
- package/dist/index.js +65 -184
- package/dist/tailwind.css +1 -1
- package/package.json +6 -6
- package/src/api/schema.ts +1 -2
- package/src/app.ts +15 -73
- package/src/auth.ts +8 -131
- package/src/authenticated-routes.ts +1 -2
- package/src/client/components/SearchInput.tsx +26 -4
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/TranscriptActivityGroup.tsx +3 -2
- 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/transcriptFailure.ts +0 -4
- package/src/client/conversations/transcriptMarkdownLinks.ts +3 -0
- package/src/client/conversations/transcriptMentions.tsx +104 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +4 -4
- package/src/client/pages/automations/automationDraft.ts +1 -1
- 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/pages/system/PluginBarChart.tsx +8 -8
- package/src/client/types.ts +2 -0
- package/src/client.tsx +3 -3
- package/src/mock-reporting/fixtures.ts +15 -1
package/src/app.ts
CHANGED
|
@@ -25,11 +25,9 @@ import {
|
|
|
25
25
|
dashboardProfileUpdateSchema,
|
|
26
26
|
} from "./api/schema";
|
|
27
27
|
import {
|
|
28
|
-
createDashboardAuth,
|
|
29
28
|
dashboardBearerSession,
|
|
30
29
|
dashboardPersonalBearerToken,
|
|
31
30
|
dashboardSessionIsAuthorized,
|
|
32
|
-
resolveGoogleHostedDomainHint,
|
|
33
31
|
sanitizeDashboardSession,
|
|
34
32
|
verifiedDashboardSessionEmail,
|
|
35
33
|
type DashboardAuth,
|
|
@@ -80,16 +78,14 @@ export interface JuniorDashboardOptions {
|
|
|
80
78
|
baseURL?: string;
|
|
81
79
|
authPath?: string;
|
|
82
80
|
authRequired?: boolean;
|
|
83
|
-
allowedGoogleDomains?: string[];
|
|
84
|
-
allowedEmails?: string[];
|
|
85
|
-
sessionMaxAgeSeconds?: number;
|
|
86
|
-
trustedOrigins?: string[];
|
|
87
81
|
auth?: DashboardAuth;
|
|
88
82
|
componentGallery?: boolean;
|
|
89
83
|
mockConversations?: boolean;
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
interface DashboardRuntimeOptions extends JuniorDashboardOptions {
|
|
87
|
+
/** The app's `allowedDomains`, from core. Auth requires at least one. */
|
|
88
|
+
allowedDomains?: string[];
|
|
93
89
|
authenticatedRoutes?: readonly AuthenticatedRoute[];
|
|
94
90
|
/** The queue `createApp()` uses, so web input reaches the same worker. */
|
|
95
91
|
conversationWorkQueue?: ConversationWorkQueue;
|
|
@@ -117,37 +113,6 @@ function normalizeValues(values: string[] | undefined): string[] {
|
|
|
117
113
|
];
|
|
118
114
|
}
|
|
119
115
|
|
|
120
|
-
/** Read dashboard list env vars as comma-separated strings or JSON arrays. */
|
|
121
|
-
function readEnvList(name: string): string[] | undefined {
|
|
122
|
-
const value = process.env[name];
|
|
123
|
-
if (!value?.trim()) {
|
|
124
|
-
return undefined;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (value.trim().startsWith("[")) {
|
|
128
|
-
let parsed: unknown;
|
|
129
|
-
try {
|
|
130
|
-
parsed = JSON.parse(value);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
throw new Error(`${name} must be a JSON string array`, {
|
|
133
|
-
cause: error,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
if (
|
|
137
|
-
!Array.isArray(parsed) ||
|
|
138
|
-
parsed.some((item) => typeof item !== "string")
|
|
139
|
-
) {
|
|
140
|
-
throw new Error(`${name} must be a JSON string array`);
|
|
141
|
-
}
|
|
142
|
-
return parsed;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return value
|
|
146
|
-
.split(",")
|
|
147
|
-
.map((item) => item.trim())
|
|
148
|
-
.filter(Boolean);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
116
|
/** Read dashboard boolean env vars; only explicit true/false values apply. */
|
|
152
117
|
function readEnvFlag(name: string): boolean | undefined {
|
|
153
118
|
const value = process.env[name]?.trim();
|
|
@@ -164,13 +129,6 @@ function resolveDashboardOptions(
|
|
|
164
129
|
...options,
|
|
165
130
|
authRequired:
|
|
166
131
|
options.authRequired ?? readEnvFlag("JUNIOR_DASHBOARD_AUTH_REQUIRED"),
|
|
167
|
-
allowedGoogleDomains:
|
|
168
|
-
options.allowedGoogleDomains ??
|
|
169
|
-
readEnvList("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"),
|
|
170
|
-
allowedEmails:
|
|
171
|
-
options.allowedEmails ?? readEnvList("JUNIOR_DASHBOARD_ALLOWED_EMAILS"),
|
|
172
|
-
trustedOrigins:
|
|
173
|
-
options.trustedOrigins ?? readEnvList("JUNIOR_DASHBOARD_TRUSTED_ORIGINS"),
|
|
174
132
|
componentGallery:
|
|
175
133
|
options.componentGallery ??
|
|
176
134
|
readEnvFlag("JUNIOR_DASHBOARD_COMPONENT_GALLERY"),
|
|
@@ -410,8 +368,7 @@ export function createDashboardApp(
|
|
|
410
368
|
options.authPath ?? DEFAULT_AUTH_PATH,
|
|
411
369
|
DEFAULT_AUTH_PATH,
|
|
412
370
|
);
|
|
413
|
-
const allowedDomains = normalizeValues(options.
|
|
414
|
-
const allowedEmails = normalizeValues(options.allowedEmails);
|
|
371
|
+
const allowedDomains = normalizeValues(options.allowedDomains);
|
|
415
372
|
const agentName = options.agentName?.trim() || "Junior";
|
|
416
373
|
|
|
417
374
|
const authRequired = options.authRequired !== false;
|
|
@@ -421,27 +378,15 @@ export function createDashboardApp(
|
|
|
421
378
|
canonicalBaseURL = resolveDashboardBaseURL({ baseURL: configuredBaseURL });
|
|
422
379
|
}
|
|
423
380
|
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
allowedDomains.length === 0 &&
|
|
427
|
-
allowedEmails.length === 0
|
|
428
|
-
) {
|
|
429
|
-
throw new Error(
|
|
430
|
-
"Junior dashboard auth requires allowedGoogleDomains or allowedEmails",
|
|
431
|
-
);
|
|
381
|
+
if (authRequired && allowedDomains.length === 0) {
|
|
382
|
+
throw new Error("Junior dashboard auth requires allowedDomains");
|
|
432
383
|
}
|
|
433
384
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
baseURL: options.baseURL,
|
|
440
|
-
trustedOrigins: options.trustedOrigins ?? [],
|
|
441
|
-
googleHostedDomain: resolveGoogleHostedDomainHint(allowedDomains),
|
|
442
|
-
sessionMaxAgeSeconds: options.sessionMaxAgeSeconds,
|
|
443
|
-
}))
|
|
444
|
-
: undefined;
|
|
385
|
+
// Core owns the Google sign-in session.
|
|
386
|
+
const auth = options.auth;
|
|
387
|
+
if (authRequired && !auth) {
|
|
388
|
+
throw new Error("Junior dashboard auth requires the core sign-in session");
|
|
389
|
+
}
|
|
445
390
|
const app = new Hono<{ Variables: Variables }>();
|
|
446
391
|
const authenticatedRoutes = options.authenticatedRoutes ?? [];
|
|
447
392
|
app.use("*", async (c, next) => {
|
|
@@ -456,14 +401,11 @@ export function createDashboardApp(
|
|
|
456
401
|
authenticatedRoutes,
|
|
457
402
|
componentGallery: options.componentGallery,
|
|
458
403
|
});
|
|
459
|
-
if (!auth) {
|
|
404
|
+
if (!authRequired || !auth) {
|
|
460
405
|
return Response.redirect(returnUrl, 302);
|
|
461
406
|
}
|
|
462
407
|
const session = await auth.getSession(c.req.raw);
|
|
463
|
-
if (
|
|
464
|
-
session &&
|
|
465
|
-
dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails)
|
|
466
|
-
) {
|
|
408
|
+
if (session && dashboardSessionIsAuthorized(session, allowedDomains)) {
|
|
467
409
|
return Response.redirect(returnUrl, 302);
|
|
468
410
|
}
|
|
469
411
|
return auth.signInWithGoogle(c.req.raw, returnUrl);
|
|
@@ -552,7 +494,7 @@ export function createDashboardApp(
|
|
|
552
494
|
componentGallery: options.componentGallery,
|
|
553
495
|
});
|
|
554
496
|
}
|
|
555
|
-
if (!dashboardSessionIsAuthorized(session, allowedDomains
|
|
497
|
+
if (!dashboardSessionIsAuthorized(session, allowedDomains)) {
|
|
556
498
|
return forbidden(c.req.raw, agentName);
|
|
557
499
|
}
|
|
558
500
|
const sanitizedSession = sanitizeDashboardSession(session);
|
|
@@ -575,6 +517,7 @@ export function createDashboardApp(
|
|
|
575
517
|
c.set("viewer", viewer);
|
|
576
518
|
}
|
|
577
519
|
await next();
|
|
520
|
+
return undefined;
|
|
578
521
|
};
|
|
579
522
|
|
|
580
523
|
app.use("*", requireAuth);
|
|
@@ -633,8 +576,7 @@ export function createDashboardApp(
|
|
|
633
576
|
);
|
|
634
577
|
app.get("/api/config", () => {
|
|
635
578
|
return jsonResponse(dashboardConfigSchema, {
|
|
636
|
-
|
|
637
|
-
allowedGoogleDomainCount: allowedDomains.length,
|
|
579
|
+
allowedDomainCount: allowedDomains.length,
|
|
638
580
|
authRequired,
|
|
639
581
|
authPath,
|
|
640
582
|
basePath,
|
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 owns. */
|
|
23
8
|
export interface DashboardAuth {
|
|
24
9
|
handler(request: Request): Promise<Response>;
|
|
25
10
|
getSession(request: Request): Promise<DashboardSession | null>;
|
|
@@ -40,19 +25,22 @@ export function dashboardBearerSession(email: string): DashboardSession {
|
|
|
40
25
|
return { user: { email, emailVerified: true } };
|
|
41
26
|
}
|
|
42
27
|
|
|
43
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Check the allowed domains against one verified session. Core accepts a
|
|
30
|
+
* Google sign-in only for a Workspace account on an allowed domain. This
|
|
31
|
+
* check also applies a changed list to existing sessions and personal tokens.
|
|
32
|
+
*/
|
|
44
33
|
export function dashboardSessionIsAuthorized(
|
|
45
34
|
session: DashboardSession,
|
|
46
35
|
allowedDomains: string[],
|
|
47
|
-
allowedEmails: string[],
|
|
48
36
|
): boolean {
|
|
49
37
|
const email = session.user.email.toLowerCase();
|
|
50
38
|
const separator = email.lastIndexOf("@");
|
|
51
39
|
const domain = separator > 0 ? email.slice(separator + 1) : undefined;
|
|
52
40
|
return Boolean(
|
|
53
41
|
session.user.emailVerified &&
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
domain !== undefined &&
|
|
43
|
+
allowedDomains.includes(domain),
|
|
56
44
|
);
|
|
57
45
|
}
|
|
58
46
|
|
|
@@ -78,114 +66,3 @@ export function sanitizeDashboardSession(
|
|
|
78
66
|
},
|
|
79
67
|
});
|
|
80
68
|
}
|
|
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
|
-
}
|
|
@@ -19,8 +19,7 @@ export function isAuthPath(
|
|
|
19
19
|
const requestSegments = segments(pathname);
|
|
20
20
|
return routes.some((route) => {
|
|
21
21
|
const routeSegments = segments(route.path);
|
|
22
|
-
for (
|
|
23
|
-
const pattern = routeSegments[index];
|
|
22
|
+
for (const [index, pattern] of routeSegments.entries()) {
|
|
24
23
|
if (pattern === "*") return true;
|
|
25
24
|
const value = requestSegments[index];
|
|
26
25
|
if (!value || (pattern !== value && !pattern.startsWith(":"))) {
|
|
@@ -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
|
},
|
|
@@ -101,9 +101,10 @@ export function activityGroupSummary(
|
|
|
101
101
|
(entry) => entry.kind === "attachments_delivered",
|
|
102
102
|
).length;
|
|
103
103
|
const eventCount = entries.filter((entry) => entry.kind === "message").length;
|
|
104
|
+
const [onlyHandoff] = handoffs;
|
|
104
105
|
const handoffSummary =
|
|
105
|
-
handoffs.length === 1
|
|
106
|
-
? `model handoff to ${
|
|
106
|
+
handoffs.length === 1 && onlyHandoff
|
|
107
|
+
? `model handoff to ${onlyHandoff.modelId} (${onlyHandoff.modelProfile})`
|
|
107
108
|
: handoffs.length > 1
|
|
108
109
|
? countLabel(handoffs.length, "model handoff", "model handoffs")
|
|
109
110
|
: undefined;
|
|
@@ -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
|
|
@@ -55,8 +55,6 @@ export function transcriptFailureTitle(
|
|
|
55
55
|
case "agent_run_failed":
|
|
56
56
|
return "Internal error";
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
return "Internal error";
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
/** Short description for one failed turn in the transcript. */
|
|
@@ -111,8 +109,6 @@ export function transcriptFailureDescription(
|
|
|
111
109
|
case "agent_run_failed":
|
|
112
110
|
return `${agentName} hit an internal error during this turn.`;
|
|
113
111
|
}
|
|
114
|
-
|
|
115
|
-
return `${agentName} hit an internal error during this turn.`;
|
|
116
112
|
}
|
|
117
113
|
|
|
118
114
|
/** Search text for one failed turn in the transcript. */
|
|
@@ -88,6 +88,7 @@ function findNextMarkdownLink(
|
|
|
88
88
|
start: linkStart,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
+
return undefined;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
function findMarkdownLinkSyntaxRanges(
|
|
@@ -148,6 +149,7 @@ function readMarkdownDestination(
|
|
|
148
149
|
const href = text.slice(start, index);
|
|
149
150
|
return href ? { end: index + 1, href } : undefined;
|
|
150
151
|
}
|
|
152
|
+
return undefined;
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
function findCommonMarkAutolinks(
|
|
@@ -199,6 +201,7 @@ function findNextBareLink(
|
|
|
199
201
|
start: match.index,
|
|
200
202
|
};
|
|
201
203
|
}
|
|
204
|
+
return undefined;
|
|
202
205
|
}
|
|
203
206
|
|
|
204
207
|
function findBareLinks(
|