@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/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.allowedGoogleDomains);
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
- authRequired &&
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
- const auth = authRequired
435
- ? (options.auth ??
436
- createDashboardAuth({
437
- agentName,
438
- authPath,
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, allowedEmails)) {
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
- allowedEmailCount: allowedEmails.length,
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
- 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 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
- /** Check the dashboard allowlist against one verified session. */
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
- (allowedEmails.includes(email) ||
55
- (domain !== undefined && allowedDomains.includes(domain))),
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 (let index = 0; index < routeSegments.length; index += 1) {
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-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
  },
@@ -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 ${handoffs[0].modelId} (${handoffs[0].modelProfile})`
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
- <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
@@ -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(