@sentry/junior-dashboard 0.269.0 → 0.270.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.
Files changed (55) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +78 -75
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/client/App.tsx +14 -4
  7. package/src/client/components/AutomationCard.tsx +2 -1
  8. package/src/client/components/UserInputForm.tsx +109 -36
  9. package/src/client/conversations/AGENTS.md +0 -4
  10. package/src/client/conversations/ConversationBrief.tsx +55 -36
  11. package/src/client/conversations/ConversationComposer.tsx +27 -8
  12. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  13. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  14. package/src/client/conversations/ConversationPage.tsx +93 -32
  15. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  16. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  17. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  18. package/src/client/conversations/ConversationWatches.tsx +4 -1
  19. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  20. package/src/client/conversations/EventDetails.tsx +6 -9
  21. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  22. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  23. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  24. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  25. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  26. package/src/client/conversations/TranscriptRows.tsx +6 -1
  27. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  28. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  29. package/src/client/conversations/TranscriptView.tsx +6 -6
  30. package/src/client/conversations/conversationOutbox.ts +13 -3
  31. package/src/client/conversations/conversationSections.ts +36 -8
  32. package/src/client/conversations/eventTranscript.ts +1 -0
  33. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  34. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  35. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  36. package/src/client/display-text/DisplayText.tsx +68 -0
  37. package/src/client/display-text/emoji.ts +54 -0
  38. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  39. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  40. package/src/client/display-text/mentions.tsx +148 -0
  41. package/src/client/display-text/plainText.ts +29 -0
  42. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  43. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  44. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  45. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  46. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  47. package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
  48. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  49. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  50. package/src/client/review-desk/ReviewItemDetail.tsx +2 -2
  51. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  52. package/src/client/review-desk/reviewItemFormat.ts +29 -1
  53. package/src/client/conversations/TranscriptText.tsx +0 -47
  54. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  55. package/src/client/conversations/transcriptMentions.tsx +0 -134
@@ -1,11 +1,11 @@
1
- export const TRANSCRIPT_ANCHOR_CLASS =
1
+ export const ANCHOR_CLASS =
2
2
  "font-medium text-dashboard-accent-text underline decoration-dashboard-accent/35 underline-offset-2 transition-colors hover:text-dashboard-text hover:decoration-dashboard-text-solid";
3
3
 
4
4
  /**
5
- * Keep a transcript link destination only when it is an absolute `http:`,
5
+ * Keep a link destination only when it is an absolute `http:`,
6
6
  * `https:`, or `mailto:` URL. Return undefined to drop the link.
7
7
  */
8
- export function safeTranscriptHref(href: string): string | undefined {
8
+ export function safeHref(href: string): string | undefined {
9
9
  const trimmed = href.trim();
10
10
  const hasControlCharacter = [...trimmed].some((character) => {
11
11
  const code = character.charCodeAt(0);
@@ -23,3 +23,16 @@ export function safeTranscriptHref(href: string): string | undefined {
23
23
  return undefined;
24
24
  }
25
25
  }
26
+
27
+ /** Slack writes links as `<https://example.com>` or `<https://example.com|label>`. */
28
+ export const SLACK_LINK_RE = /<((?:https?|mailto):[^\s|<>]+)(?:\|([^<>]*))?>/g;
29
+
30
+ /**
31
+ * Rewrite labeled Slack links as Markdown links. Markdown already reads a
32
+ * bare `<https://example.com>` as a link.
33
+ */
34
+ export function slackLinksToMarkdown(text: string): string {
35
+ return text.replace(SLACK_LINK_RE, (match, url: string, label?: string) =>
36
+ label?.trim() ? `[${label.trim()}](${url})` : match,
37
+ );
38
+ }
@@ -11,37 +11,53 @@ import {
11
11
  TableHeader,
12
12
  TableRow,
13
13
  } from "../components/ui/table";
14
- import { cn } from "../styles";
15
14
  import {
16
- safeTranscriptHref,
17
- TRANSCRIPT_ANCHOR_CLASS,
18
- } from "./transcriptMarkdownLinks";
19
- import { renderMentionText } from "./transcriptMentions";
20
- import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
15
+ HighlightText,
16
+ useTranscriptSearch,
17
+ } from "../conversations/transcriptSearch";
18
+ import { cn } from "../styles";
19
+ import { ANCHOR_CLASS, safeHref, slackLinksToMarkdown } from "./links";
20
+ import { renderTextTokens } from "./mentions";
21
21
 
22
22
  /**
23
- * Render transcript markdown as readable prose.
23
+ * Render display text Markdown as readable prose.
24
24
  *
25
25
  * The text is CommonMark plus GFM, with a hard break for each newline. Agent
26
- * replies also use Slack-style `•` bullets and bold-only section labels, so
27
- * those render as lists and headings too.
26
+ * replies also use Slack-style `•` bullets, `<url|label>` links, and
27
+ * bold-only section labels, so those render as lists, links, and headings
28
+ * too. Inline mode keeps only inline formatting, so the text fits in one
29
+ * line or in a button.
28
30
  */
29
- export function TranscriptMarkdown(props: {
30
- compact?: boolean;
31
+ export function MarkdownText(props: {
32
+ chat?: boolean;
33
+ inline?: boolean;
31
34
  text: string;
32
35
  }): ReactNode {
33
36
  if (!props.text.trim()) return null;
37
+ const source = normalizeSlackBullets(slackLinksToMarkdown(props.text));
38
+
39
+ if (props.inline) {
40
+ return (
41
+ <Markdown
42
+ components={INLINE_COMPONENTS}
43
+ remarkPlugins={[remarkGfm]}
44
+ urlTransform={safeHref}
45
+ >
46
+ {source}
47
+ </Markdown>
48
+ );
49
+ }
34
50
 
35
51
  return (
36
52
  <div
37
53
  className={cn(
38
54
  "min-w-0 break-words text-dashboard-text [overflow-wrap:anywhere]",
39
- props.compact
55
+ props.chat
40
56
  ? "font-sans text-base leading-relaxed"
41
57
  : "font-sans text-sm leading-6 md:leading-7",
42
58
  )}
43
59
  >
44
- <div className={cn("grid min-w-0", props.compact ? "gap-3" : "gap-2")}>
60
+ <div className={cn("grid min-w-0", props.chat ? "gap-3" : "gap-2")}>
45
61
  <Markdown
46
62
  components={COMPONENTS}
47
63
  remarkPlugins={[
@@ -50,9 +66,9 @@ export function TranscriptMarkdown(props: {
50
66
  remarkBareUrlStops,
51
67
  remarkSectionLabels,
52
68
  ]}
53
- urlTransform={safeTranscriptHref}
69
+ urlTransform={safeHref}
54
70
  >
55
- {normalizeSlackBullets(props.text)}
71
+ {source}
56
72
  </Markdown>
57
73
  </div>
58
74
  </div>
@@ -255,13 +271,61 @@ const COMPONENTS: Components = {
255
271
  ),
256
272
  };
257
273
 
258
- // Text nodes can hold Slack mention tokens and transcript search matches.
274
+ // Text nodes can hold Slack mention tokens, emoji codes, and search matches.
259
275
  function renderText(children: ReactNode): ReactNode {
260
276
  return Children.map(children, (child, index) =>
261
- typeof child === "string" ? renderMentionText(child, `t-${index}`) : child,
277
+ typeof child === "string" ? renderTextTokens(child, `t-${index}`) : child,
262
278
  );
263
279
  }
264
280
 
281
+ // Inline mode ends each block with a newline. A one-line container shows it
282
+ // as a space, and a `whitespace-pre-wrap` container keeps the line break.
283
+ function InlineBlock(props: { children?: ReactNode }) {
284
+ return (
285
+ <>
286
+ {renderText(props.children)}
287
+ {"\n"}
288
+ </>
289
+ );
290
+ }
291
+
292
+ function InlineCell(props: { children?: ReactNode }) {
293
+ return <>{renderText(props.children)} </>;
294
+ }
295
+
296
+ function InlineContainer(props: { children?: ReactNode }) {
297
+ return <>{props.children}</>;
298
+ }
299
+
300
+ const INLINE_COMPONENTS: Components = {
301
+ // A link cannot sit in a button or in another link, so keep the label.
302
+ a: ({ children }) => <>{renderText(children)}</>,
303
+ blockquote: InlineContainer,
304
+ code: COMPONENTS.code,
305
+ del: COMPONENTS.del,
306
+ em: COMPONENTS.em,
307
+ h1: InlineBlock,
308
+ h2: InlineBlock,
309
+ h3: InlineBlock,
310
+ h4: InlineBlock,
311
+ h5: InlineBlock,
312
+ h6: InlineBlock,
313
+ hr: () => "\n",
314
+ img: ({ alt }) => alt ?? null,
315
+ li: InlineBlock,
316
+ ol: InlineContainer,
317
+ p: InlineBlock,
318
+ pre: InlineContainer,
319
+ strong: COMPONENTS.strong,
320
+ table: InlineContainer,
321
+ tbody: InlineContainer,
322
+ td: InlineCell,
323
+ th: InlineCell,
324
+ thead: InlineContainer,
325
+ tr: InlineBlock,
326
+ ul: InlineContainer,
327
+ };
328
+
265
329
  function alignClass(align: unknown): string | undefined {
266
330
  if (align === "center") return "text-center";
267
331
  if (align === "right") return "text-right";
@@ -269,13 +333,13 @@ function alignClass(align: unknown): string | undefined {
269
333
  }
270
334
 
271
335
  function TranscriptAnchor(props: { children: ReactNode; href?: string }) {
272
- // `safeTranscriptHref` removes unsafe destinations. Keep their label text.
336
+ // `safeHref` removes unsafe destinations. Keep their label text.
273
337
  if (!props.href) return <>{renderText(props.children)}</>;
274
338
 
275
339
  const opensNewTab = /^https?:/i.test(props.href);
276
340
  return (
277
341
  <a
278
- className={TRANSCRIPT_ANCHOR_CLASS}
342
+ className={ANCHOR_CLASS}
279
343
  href={props.href}
280
344
  rel={opensNewTab ? "noreferrer" : undefined}
281
345
  target={opensNewTab ? "_blank" : undefined}
@@ -0,0 +1,148 @@
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 { HighlightText } from "../conversations/transcriptSearch";
6
+ import { peoplePath } from "../format";
7
+ import { cn } from "../styles";
8
+ import { replaceSlackEmoji } from "./emoji";
9
+ import { ANCHOR_CLASS } from "./links";
10
+
11
+ type Mention = {
12
+ end: number;
13
+ id: string;
14
+ label?: string;
15
+ sigil: "@" | "#";
16
+ start: number;
17
+ };
18
+
19
+ type MentionContext = {
20
+ links: boolean;
21
+ people: ReadonlyMap<string, ActorIdentity>;
22
+ };
23
+
24
+ // Matches the user and channel tokens that core keeps in message text.
25
+ const SLACK_MENTION_RE = /<([@#])([A-Z0-9]+)(?:\|([^<>]*))?>/g;
26
+
27
+ const MentionPeople = createContext<MentionContext>({
28
+ links: true,
29
+ people: new Map(),
30
+ });
31
+
32
+ /** Share the people that the API resolved for one display text. */
33
+ export function MentionPeopleProvider(props: {
34
+ children: ReactNode;
35
+ links: boolean;
36
+ mentions: readonly ActorIdentity[];
37
+ }) {
38
+ return (
39
+ <MentionPeople.Provider
40
+ value={{ links: props.links, people: mentionPeople(props.mentions) }}
41
+ >
42
+ {props.children}
43
+ </MentionPeople.Provider>
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Render one text run with Slack `<@U123>` and `<#C123|name>` tokens as
49
+ * readable mentions, Slack `:name:` codes as emoji, and search matches
50
+ * highlighted. It reads people from the nearest `MentionPeopleProvider`.
51
+ */
52
+ export function renderTextTokens(text: string, keyBase: string): ReactNode[] {
53
+ const nodes: ReactNode[] = [];
54
+ let cursor = 0;
55
+ for (const mention of findMentions(text)) {
56
+ if (mention.start > cursor) {
57
+ nodes.push(
58
+ <HighlightText
59
+ key={`${keyBase}-m-${cursor}`}
60
+ text={replaceSlackEmoji(text.slice(cursor, mention.start))}
61
+ />,
62
+ );
63
+ }
64
+ nodes.push(
65
+ <MentionView key={`${keyBase}-m-${mention.start}`} mention={mention} />,
66
+ );
67
+ cursor = mention.end;
68
+ }
69
+ if (cursor < text.length) {
70
+ nodes.push(
71
+ <HighlightText
72
+ key={`${keyBase}-m-${cursor}`}
73
+ text={replaceSlackEmoji(text.slice(cursor))}
74
+ />,
75
+ );
76
+ }
77
+ return nodes;
78
+ }
79
+
80
+ /** Return one text run as a string, with the names and emoji it renders. */
81
+ export function plainTextTokens(
82
+ text: string,
83
+ mentions: readonly ActorIdentity[],
84
+ ): string {
85
+ const people = mentionPeople(mentions);
86
+ let result = "";
87
+ let cursor = 0;
88
+ for (const mention of findMentions(text)) {
89
+ result += replaceSlackEmoji(text.slice(cursor, mention.start));
90
+ result += mentionLabel(mention, people);
91
+ cursor = mention.end;
92
+ }
93
+ return result + replaceSlackEmoji(text.slice(cursor));
94
+ }
95
+
96
+ function mentionPeople(
97
+ mentions: readonly ActorIdentity[],
98
+ ): Map<string, ActorIdentity> {
99
+ const people = new Map<string, ActorIdentity>();
100
+ for (const person of mentions) {
101
+ if (person.slackUserId) people.set(person.slackUserId, person);
102
+ }
103
+ return people;
104
+ }
105
+
106
+ function mentionLabel(
107
+ mention: Mention,
108
+ people: ReadonlyMap<string, ActorIdentity>,
109
+ ): string {
110
+ const { id, label, sigil } = mention;
111
+ const person = sigil === "@" ? people.get(id) : undefined;
112
+ const name = person?.fullName ?? person?.slackUserName ?? label ?? id;
113
+ return `${sigil}${name.replace(/^[@#]/, "")}`;
114
+ }
115
+
116
+ function findMentions(text: string): Mention[] {
117
+ return [...text.matchAll(SLACK_MENTION_RE)].map((match) => ({
118
+ end: match.index + match[0].length,
119
+ id: match[2]!,
120
+ label: match[3]?.trim() || undefined,
121
+ sigil: match[1] as "@" | "#",
122
+ start: match.index,
123
+ }));
124
+ }
125
+
126
+ function MentionView(props: { mention: Mention }) {
127
+ const { links, people } = useContext(MentionPeople);
128
+ const { id, sigil } = props.mention;
129
+ const person = sigil === "@" ? people.get(id) : undefined;
130
+ const text = <HighlightText text={mentionLabel(props.mention, people)} />;
131
+ if (links && person?.email) {
132
+ return (
133
+ <Link
134
+ // Previews sit in cards that disable pointer events on their text.
135
+ className={cn("pointer-events-auto", ANCHOR_CLASS)}
136
+ title={id}
137
+ to={peoplePath(person.email)}
138
+ >
139
+ {text}
140
+ </Link>
141
+ );
142
+ }
143
+ return (
144
+ <span className="font-medium text-dashboard-accent-text" title={id}>
145
+ {text}
146
+ </span>
147
+ );
148
+ }
@@ -0,0 +1,29 @@
1
+ import type { ActorIdentity } from "@sentry/junior/api/schema";
2
+
3
+ import { plainTextTokens } from "./mentions";
4
+ import { SLACK_LINK_RE } from "./links";
5
+
6
+ /**
7
+ * Return display text as one plain string for places that cannot hold
8
+ * markup, such as a `title` attribute. It shows the same names and emoji as
9
+ * `DisplayText`, without the Markdown syntax.
10
+ */
11
+ export function displayPlainText(
12
+ text: string,
13
+ mentions: readonly ActorIdentity[] = [],
14
+ ): string {
15
+ return plainTextTokens(stripMarkdown(text), mentions);
16
+ }
17
+
18
+ function stripMarkdown(text: string): string {
19
+ return text
20
+ .replace(SLACK_LINK_RE, (_, url: string, label?: string) =>
21
+ label?.trim() ? label.trim() : url,
22
+ )
23
+ .replace(/\[([^\]]+)\]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
24
+ .replace(/(^|\s)([*_~`]{1,3})(?=\S)/g, "$1")
25
+ .replace(/([*_~`]{1,3})(?=\s|$|[.,!?;:])/g, "")
26
+ .replace(/^\s*(?:#{1,6}|[-+*]|\d+\.)\s+/gm, "")
27
+ .replace(/\s+/g, " ")
28
+ .trim();
29
+ }
@@ -57,13 +57,13 @@ export function AutomationFilters(props: {
57
57
  key: "type",
58
58
  label: "Type",
59
59
  value: filters.type === "all" ? "" : filters.type,
60
- text: filters.type === "scheduled" ? "Scheduled" : "Event",
60
+ valueLabel: filters.type === "scheduled" ? "Scheduled" : "Event",
61
61
  },
62
62
  {
63
63
  key: "creator",
64
64
  label: "Creator",
65
65
  value: filters.creator,
66
- text:
66
+ valueLabel:
67
67
  data?.creators.find((option) => option.value === filters.creator)
68
68
  ?.label ?? filters.creator,
69
69
  },
@@ -71,7 +71,7 @@ export function AutomationFilters(props: {
71
71
  key: "destination",
72
72
  label: "Destination",
73
73
  value: filters.destination,
74
- text:
74
+ valueLabel:
75
75
  data?.destinations.find(
76
76
  (option) => option.value === filters.destination,
77
77
  )?.label ?? filters.destination,
@@ -80,7 +80,7 @@ export function AutomationFilters(props: {
80
80
  key: "state",
81
81
  label: "State",
82
82
  value: filters.state === "all" ? "" : filters.state,
83
- text: stateOptions.find((option) => option.value === filters.state)
83
+ valueLabel: stateOptions.find((option) => option.value === filters.state)
84
84
  ?.label,
85
85
  },
86
86
  ].filter((filter) => filter.value);
@@ -163,11 +163,11 @@ export function AutomationFilters(props: {
163
163
  key={filter.key}
164
164
  aria-label={`Remove ${filter.key} filter`}
165
165
  className="min-h-9 max-w-full text-xs"
166
- title={`${filter.label}: ${filter.text}`}
166
+ title={`${filter.label}: ${filter.valueLabel}`}
167
167
  onClick={() => onChange(filter.key, "")}
168
168
  >
169
169
  <span className="truncate">
170
- {filter.label}: {filter.text}
170
+ {filter.label}: {filter.valueLabel}
171
171
  </span>
172
172
  <X aria-hidden="true" className="shrink-0" size={12} />
173
173
  </Button>
@@ -15,7 +15,7 @@ import {
15
15
  type TimeRangeDays,
16
16
  } from "../../components/controls/TimeRangeSelector";
17
17
  import { Card } from "../../components/layout/Card";
18
- import { TranscriptText } from "../../conversations/TranscriptText";
18
+ import { DisplayText } from "../../display-text/DisplayText";
19
19
  import { automationPath, formatCostSummary, peoplePath } from "../../format";
20
20
  import { pathWithSearch } from "../../searchParams";
21
21
  import { dashboardInlineLinkClass } from "../../styles";
@@ -76,7 +76,7 @@ function AutomationSettings(props: { automation: AutomationSummary }) {
76
76
  <div className="min-w-0 [&>section:first-child]:border-t-0 [&>section:first-child]:pt-0">
77
77
  <AutomationFormSection title="What to do">
78
78
  <div className="text-base leading-relaxed [overflow-wrap:anywhere]">
79
- <TranscriptText
79
+ <DisplayText
80
80
  mentions={automation.mentions ?? []}
81
81
  text={automation.instruction}
82
82
  />
@@ -29,6 +29,7 @@ import { InlineError } from "../../components/InlineError";
29
29
  import { PageContentSkeleton } from "../../components/PageContentSkeleton";
30
30
  import { StatusChip } from "../../components/StatusChip";
31
31
  import { Card } from "../../components/layout/Card";
32
+ import { DisplayText } from "../../display-text/DisplayText";
32
33
  import { formatRelativeTime } from "../../format";
33
34
  import { DashboardApiError, fetchDashboardJson, post } from "../../http";
34
35
  import { cn } from "../../styles";
@@ -364,7 +365,10 @@ function fieldValue(
364
365
  return definition.title ?? "First line of the instruction";
365
366
  case "instruction":
366
367
  return (
367
- <span className="whitespace-pre-wrap">{definition.instruction}</span>
368
+ <DisplayText
369
+ mentions={automation.mentions}
370
+ text={definition.instruction}
371
+ />
368
372
  );
369
373
  case "when":
370
374
  if (version.kind === "scheduled") {
@@ -431,7 +435,7 @@ function LineDiff(props: { after: string; before: string }) {
431
435
  {line.kind === "added" ? "Added: " : "Removed: "}
432
436
  </span>
433
437
  )}
434
- {line.text || " "}
438
+ {line.value || " "}
435
439
  </span>
436
440
  </div>
437
441
  ))}
@@ -439,7 +443,7 @@ function LineDiff(props: { after: string; before: string }) {
439
443
  );
440
444
  }
441
445
 
442
- type DiffLine = { kind: "added" | "removed" | "same"; text: string };
446
+ type DiffLine = { kind: "added" | "removed" | "same"; value: string };
443
447
 
444
448
  /** Line diff from the longest common subsequence. Instructions stay short. */
445
449
  function diffLines(before: string, after: string): DiffLine[] {
@@ -461,17 +465,17 @@ function diffLines(before: string, after: string): DiffLine[] {
461
465
  let j = 0;
462
466
  while (i < a.length && j < b.length) {
463
467
  if (a[i] === b[j]) {
464
- lines.push({ kind: "same", text: a[i]! });
468
+ lines.push({ kind: "same", value: a[i]! });
465
469
  i++;
466
470
  j++;
467
471
  } else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) {
468
- lines.push({ kind: "removed", text: a[i++]! });
472
+ lines.push({ kind: "removed", value: a[i++]! });
469
473
  } else {
470
- lines.push({ kind: "added", text: b[j++]! });
474
+ lines.push({ kind: "added", value: b[j++]! });
471
475
  }
472
476
  }
473
- while (i < a.length) lines.push({ kind: "removed", text: a[i++]! });
474
- while (j < b.length) lines.push({ kind: "added", text: b[j++]! });
477
+ while (i < a.length) lines.push({ kind: "removed", value: a[i++]! });
478
+ while (j < b.length) lines.push({ kind: "added", value: b[j++]! });
475
479
  return lines;
476
480
  }
477
481
 
@@ -16,6 +16,7 @@ import {
16
16
  } from "../../components/controls/TimeRangeSelector";
17
17
  import { Card } from "../../components/layout/Card";
18
18
  import { ConversationHomeList } from "../../conversations/ConversationHomeList";
19
+ import { attentionFirst } from "../../conversations/conversationSections";
19
20
  import { buildConversations, getDashboardTimeZone } from "../../format";
20
21
  import { useDebouncedValue } from "../../debouncedValue";
21
22
  import { useWaitingConversationIds } from "../../review-desk/queries";
@@ -52,7 +53,11 @@ export function RepositoryConversationsTab(props: { repositoryId: string }) {
52
53
  </EmptyTelemetry>
53
54
  ) : (
54
55
  <ConversationHomeList
55
- conversations={pageItems(conversations, page, PAGE_SIZE)}
56
+ conversations={pageItems(
57
+ attentionFirst(conversations, waitingConversationIds),
58
+ page,
59
+ PAGE_SIZE,
60
+ )}
56
61
  emptyLabel={
57
62
  search
58
63
  ? "No conversations match this search."
@@ -18,16 +18,12 @@ import {
18
18
  TooltipContent,
19
19
  TooltipTrigger,
20
20
  } from "../../components/ui/tooltip";
21
- import { TranscriptText } from "../../conversations/TranscriptText";
21
+ import { DisplayText } from "../../display-text/DisplayText";
22
22
  import type {
23
23
  PluginUserPageRecord,
24
24
  PluginUserPageRecordAction,
25
25
  } from "../user/pluginUserPageData";
26
26
 
27
- // The memory plugin API does not resolve mentions yet, so memory text shows
28
- // Slack ids instead of names.
29
- const NO_MEMORY_MENTIONS: readonly [] = [];
30
-
31
27
  // The heading, origin, and scope rows already show these values.
32
28
  const SUMMARIZED_METADATA = new Set([
33
29
  "Learned",
@@ -81,15 +77,11 @@ export function MemoryDetails(props: {
81
77
  {record ? (
82
78
  <>
83
79
  <div className="grid gap-3 text-sm leading-relaxed">
84
- <TranscriptText
85
- mentions={NO_MEMORY_MENTIONS}
86
- text={record.title}
87
- />
80
+ {/* The memory plugin API does not resolve mentions yet, so
81
+ memory text shows Slack ids instead of names. */}
82
+ <DisplayText text={record.title} />
88
83
  {record.description ? (
89
- <TranscriptText
90
- mentions={NO_MEMORY_MENTIONS}
91
- text={record.description}
92
- />
84
+ <DisplayText text={record.description} />
93
85
  ) : null}
94
86
  </div>
95
87
  <MemoryFacts record={record} />
@@ -49,7 +49,6 @@ export function ConversationReviewBar(props: { conversationId: string }) {
49
49
  label="Review items"
50
50
  onClose={() => setOpen(false)}
51
51
  openKey={props.conversationId}
52
- width="narrow"
53
52
  >
54
53
  <div className="grid gap-3">
55
54
  {items.map((item) => (
@@ -8,10 +8,11 @@ import {
8
8
  import { InlineError } from "../components/InlineError";
9
9
  import { UserInputForm } from "../components/UserInputForm";
10
10
  import { Button } from "../components/ui/button";
11
- import { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
11
+ import { DisplayText } from "../display-text/DisplayText";
12
12
  import { formatRelativeTime } from "../format";
13
13
  import { DashboardApiError } from "../http";
14
14
  import { useReviewItemAction } from "./queries";
15
+ import { useReportUnsentAnswer } from "./UnsentAnswers";
15
16
 
16
17
  function personLabel(person: { displayName?: string; email: string }) {
17
18
  return person.displayName ?? person.email;
@@ -79,6 +80,8 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
79
80
  const { item } = props;
80
81
  const action = useReviewItemAction();
81
82
  const [error, setError] = useState<string>();
83
+ const [unsent, setUnsent] = useState(false);
84
+ useReportUnsentAnswer(item, unsent);
82
85
 
83
86
  if (item.status !== "open") return <ClosedSummary item={item} />;
84
87
  if (!item.canAct) {
@@ -126,9 +129,10 @@ export function ReviewItemActions(props: { item: ReviewItemPayload }) {
126
129
  {item.kind === "action" && item.request ? (
127
130
  <div className="text-sm">
128
131
  <div className="font-medium">
129
- <TranscriptMarkdown text={item.request.question} />
132
+ <DisplayText text={item.request.question} />
130
133
  </div>
131
134
  <UserInputForm
135
+ onUnsentChange={setUnsent}
132
136
  onSubmit={(answer) =>
133
137
  run({
134
138
  action: "submit",