@sentry/junior-dashboard 0.269.0 → 0.271.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 (76) 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 +103 -65
  7. package/src/client/components/AGENTS.md +30 -59
  8. package/src/client/components/AutomationCard.tsx +2 -1
  9. package/src/client/components/ProfileMenu.tsx +0 -24
  10. package/src/client/components/UserInputForm.tsx +143 -39
  11. package/src/client/components/layout/PageHeader.tsx +25 -10
  12. package/src/client/conversations/AGENTS.md +12 -37
  13. package/src/client/conversations/ConversationBrief.tsx +55 -36
  14. package/src/client/conversations/ConversationComposer.tsx +27 -8
  15. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  16. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  17. package/src/client/conversations/ConversationPage.tsx +137 -40
  18. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  19. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  20. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  21. package/src/client/conversations/ConversationWatches.tsx +4 -1
  22. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  23. package/src/client/conversations/EventDetails.tsx +6 -9
  24. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  25. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  26. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  27. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  28. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  29. package/src/client/conversations/TranscriptRows.tsx +6 -1
  30. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  31. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  32. package/src/client/conversations/TranscriptView.tsx +6 -6
  33. package/src/client/conversations/conversationOutbox.ts +13 -3
  34. package/src/client/conversations/conversationRoutes.ts +30 -3
  35. package/src/client/conversations/conversationSections.ts +36 -8
  36. package/src/client/conversations/eventTranscript.ts +1 -0
  37. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  38. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  39. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  40. package/src/client/display-text/DisplayText.tsx +68 -0
  41. package/src/client/display-text/emoji.ts +54 -0
  42. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  43. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  44. package/src/client/display-text/mentions.tsx +148 -0
  45. package/src/client/display-text/plainText.ts +29 -0
  46. package/src/client/pages/automations/AGENTS.md +6 -25
  47. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  48. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  49. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  50. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  51. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  52. package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
  53. package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
  54. package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
  55. package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
  56. package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
  57. package/src/client/primaryNav.ts +1 -1
  58. package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
  59. package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
  60. package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
  61. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  62. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  63. package/src/client/review-desk/ReviewItemContext.tsx +108 -0
  64. package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
  65. package/src/client/review-desk/ReviewItemView.tsx +159 -0
  66. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  67. package/src/client/review-desk/queries.ts +4 -2
  68. package/src/client/review-desk/reviewItemFormat.ts +83 -1
  69. package/src/client/components/charts/AGENTS.md +0 -15
  70. package/src/client/conversations/TranscriptText.tsx +0 -47
  71. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  72. package/src/client/conversations/transcriptMentions.tsx +0 -134
  73. package/src/client/pages/ConnectionsPage.tsx +0 -207
  74. package/src/client/pages/PersonalTokensPage.tsx +0 -205
  75. package/src/client/pages/system/AGENTS.md +0 -12
  76. package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
@@ -1,8 +1,17 @@
1
- import { useId, useRef, useState, type FormEvent, type ReactNode } from "react";
1
+ import { ArrowRight } from "lucide-react";
2
+ import {
3
+ useEffect,
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type FormEvent,
8
+ type ReactNode,
9
+ } from "react";
2
10
  import {
3
11
  buttonUserInputField,
4
12
  isEmptyUserInputAnswer,
5
13
  normalizeUserInputAnswer,
14
+ USER_INPUT_NOTE,
6
15
  type UserInputAnswer,
7
16
  type UserInputField,
8
17
  type UserInputRequest,
@@ -20,10 +29,37 @@ import { Textarea } from "./ui/textarea";
20
29
  const optionClass =
21
30
  "flex cursor-pointer items-start gap-3 rounded-md border border-dashboard-border bg-dashboard-surface-panel px-3 py-2.5 text-sm transition-colors hover:border-dashboard-border-emphasis hover:bg-dashboard-surface-hover has-[[data-checked]]:border-dashboard-accent/60 has-[[data-checked]]:bg-dashboard-accent/10 has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-60";
22
31
 
23
- function OptionText(props: { description?: string; label: string }) {
32
+ // A described choice is one button row, so its description stays beside its
33
+ // label and one click is still the answer. The picked row keeps the accent
34
+ // while the answer sends.
35
+ const choiceButtonClass =
36
+ "group flex w-full cursor-pointer items-center gap-3 rounded-md border border-dashboard-border bg-dashboard-surface-raised px-3 py-2.5 text-left font-sans text-sm transition-colors hover:border-dashboard-border-emphasis hover:bg-dashboard-surface-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus disabled:cursor-not-allowed disabled:opacity-60 data-[picked]:border-dashboard-accent/60 data-[picked]:bg-dashboard-accent/10 data-[picked]:opacity-100";
37
+
38
+ // Marks the choice that Junior recommends, next to its label.
39
+ function SuggestedLabel(props: { className?: string }) {
40
+ return (
41
+ <span
42
+ className={cn(
43
+ "ml-2 text-xs font-normal text-dashboard-accent-text/80",
44
+ props.className,
45
+ )}
46
+ >
47
+ Suggested
48
+ </span>
49
+ );
50
+ }
51
+
52
+ function OptionText(props: {
53
+ description?: string;
54
+ label: string;
55
+ recommended?: boolean;
56
+ }) {
24
57
  return (
25
58
  <span className="grid min-w-0 gap-0.5 break-words">
26
- <span className="font-medium text-dashboard-text">{props.label}</span>
59
+ <span className="font-medium text-dashboard-text">
60
+ {props.label}
61
+ {props.recommended ? <SuggestedLabel /> : null}
62
+ </span>
27
63
  {props.description ? (
28
64
  <span className="text-xs text-dashboard-text-muted">
29
65
  {props.description}
@@ -41,9 +77,15 @@ function ChoiceField(props: {
41
77
  }) {
42
78
  const id = useId();
43
79
  const { field } = props;
80
+ // Radios already say that one choice counts. Say it for checkboxes only.
44
81
  const legend = (
45
82
  <legend className="mb-2 p-0 text-sm font-semibold text-dashboard-text">
46
83
  {field.label}
84
+ {field.kind === "multi_choice" ? (
85
+ <span className="ml-2 text-xs font-normal text-dashboard-text-muted">
86
+ Pick any
87
+ </span>
88
+ ) : null}
47
89
  </legend>
48
90
  );
49
91
  const description = field.description ? (
@@ -71,6 +113,7 @@ function ChoiceField(props: {
71
113
  <OptionText
72
114
  description={option.description}
73
115
  label={option.label}
116
+ recommended={option.recommended}
74
117
  />
75
118
  </label>
76
119
  ))}
@@ -100,7 +143,11 @@ function ChoiceField(props: {
100
143
  )
101
144
  }
102
145
  />
103
- <OptionText description={option.description} label={option.label} />
146
+ <OptionText
147
+ description={option.description}
148
+ label={option.label}
149
+ recommended={option.recommended}
150
+ />
104
151
  </label>
105
152
  ))}
106
153
  </div>
@@ -140,18 +187,23 @@ function TextField(props: {
140
187
  /**
141
188
  * Render the answer controls of an action item request. A request with
142
189
  * one short choice field shows one button per choice, and a click is the
143
- * answer. Other requests show a form that the reader submits once. A failed
144
- * `onSubmit` lets the reader try again. `secondaryActions`, such as Dismiss,
145
- * sit in the same action row, after the answer controls.
190
+ * answer. Choices with descriptions stack as rows, so each description stays
191
+ * with its choice. Other requests show a form that the reader submits once.
192
+ * A failed `onSubmit` lets the reader try again. `secondaryActions`, such as
193
+ * Dismiss, sit after the answer controls. `onUnsentChange` reports whether
194
+ * the form has picks or text that the reader has not sent.
146
195
  */
147
196
  export function UserInputForm(props: {
148
197
  onSubmit(answer: UserInputAnswer): Promise<void>;
198
+ onUnsentChange?(unsent: boolean): void;
149
199
  request: UserInputRequest;
150
200
  requestId: string;
151
201
  secondaryActions?: ReactNode;
152
202
  }) {
153
203
  const [sending, setSending] = useState(false);
204
+ const [picked, setPicked] = useState<string>();
154
205
  const [failed, setFailed] = useState(false);
206
+ const choicesId = useId();
155
207
  const [values, setValues] = useState<Record<string, string[]>>({});
156
208
  const [other, setOther] = useState("");
157
209
  const buttonField = buttonUserInputField(props.request);
@@ -170,20 +222,35 @@ export function UserInputForm(props: {
170
222
  } catch {
171
223
  sendLock.current = false;
172
224
  setSending(false);
225
+ setPicked(undefined);
173
226
  setFailed(true);
174
227
  }
175
228
  }
176
229
 
230
+ /** Send one button choice as the whole answer. */
231
+ function pick(fieldId: string, label: string) {
232
+ setPicked(label);
233
+ void send({ requestId: props.requestId, values: { [fieldId]: [label] } });
234
+ }
235
+
177
236
  const answer: UserInputAnswer = {
178
237
  other,
179
238
  requestId: props.requestId,
180
239
  values,
181
240
  };
241
+ const normalized = normalizeUserInputAnswer(props.request, answer);
242
+ const answeredCount = Object.keys(normalized.values).length;
243
+ const fieldCount = props.request.fields.length;
244
+ // A button answer sends on click, so only the form can hold unsent input.
245
+ const unsent =
246
+ !buttonField && !sending && !isEmptyUserInputAnswer(normalized);
247
+ const onUnsentChange = props.onUnsentChange;
248
+ useEffect(() => {
249
+ onUnsentChange?.(unsent);
250
+ }, [onUnsentChange, unsent]);
182
251
  function submit(event: FormEvent) {
183
252
  event.preventDefault();
184
- if (
185
- !isEmptyUserInputAnswer(normalizeUserInputAnswer(props.request, answer))
186
- ) {
253
+ if (!isEmptyUserInputAnswer(normalized)) {
187
254
  void send(answer);
188
255
  }
189
256
  }
@@ -196,41 +263,70 @@ export function UserInputForm(props: {
196
263
 
197
264
  return (
198
265
  <>
199
- {buttonField ? (
266
+ {buttonField?.options.some((option) => option.description) ? (
200
267
  <>
201
- <div className="mt-3 flex flex-wrap items-center gap-2">
202
- {buttonField.options.map((option) => (
203
- <Button
268
+ <div
269
+ aria-label={buttonField.label}
270
+ className="mt-3 grid gap-2"
271
+ role="group"
272
+ >
273
+ {buttonField.options.map((option, index) => (
274
+ <button
275
+ aria-describedby={
276
+ option.description ? `${choicesId}-${index}` : undefined
277
+ }
278
+ aria-labelledby={`${choicesId}-${index}-label`}
279
+ className={choiceButtonClass}
280
+ data-picked={picked === option.label ? "" : undefined}
204
281
  disabled={sending}
205
282
  key={option.label}
206
- onClick={() =>
207
- void send({
208
- requestId: props.requestId,
209
- values: { [buttonField.id]: [option.label] },
210
- })
211
- }
212
- title={option.description}
283
+ onClick={() => pick(buttonField.id, option.label)}
284
+ type="button"
213
285
  >
214
- {option.label}
215
- </Button>
286
+ <span className="grid min-w-0 flex-1 gap-0.5 break-words">
287
+ <span
288
+ className="font-medium text-dashboard-text"
289
+ id={`${choicesId}-${index}-label`}
290
+ >
291
+ {option.label}
292
+ {option.recommended ? <SuggestedLabel /> : null}
293
+ </span>
294
+ {option.description ? (
295
+ <span
296
+ className="text-xs text-dashboard-text-muted"
297
+ id={`${choicesId}-${index}`}
298
+ >
299
+ {option.description}
300
+ </span>
301
+ ) : null}
302
+ </span>
303
+ <ArrowRight
304
+ aria-hidden="true"
305
+ className="shrink-0 text-dashboard-text-faint opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[picked]:opacity-100"
306
+ size={16}
307
+ />
308
+ </button>
216
309
  ))}
217
- {secondary}
218
310
  </div>
219
- {buttonField.options.some((option) => option.description) ? (
220
- <ul className="mb-0 mt-2 list-none p-0 text-xs text-dashboard-text-muted">
221
- {buttonField.options
222
- .filter((option) => option.description)
223
- .map((option) => (
224
- <li key={option.label}>
225
- <span className="font-medium text-dashboard-text">
226
- {option.label}
227
- </span>
228
- : {option.description}
229
- </li>
230
- ))}
231
- </ul>
311
+ {secondary ? (
312
+ <div className="mt-3 flex flex-wrap items-center">{secondary}</div>
232
313
  ) : null}
233
314
  </>
315
+ ) : buttonField ? (
316
+ <div className="mt-3 flex flex-wrap items-center gap-2">
317
+ {buttonField.options.map((option) => (
318
+ <Button
319
+ disabled={sending}
320
+ key={option.label}
321
+ onClick={() => pick(buttonField.id, option.label)}
322
+ >
323
+ {option.label}
324
+ {/* The button gap already spaces the label. */}
325
+ {option.recommended ? <SuggestedLabel className="ml-0" /> : null}
326
+ </Button>
327
+ ))}
328
+ {secondary}
329
+ </div>
234
330
  ) : (
235
331
  <form className="mt-4 grid gap-5" onSubmit={submit}>
236
332
  {props.request.fields.map((field) =>
@@ -259,9 +355,9 @@ export function UserInputForm(props: {
259
355
  )}
260
356
  <TextField
261
357
  className="min-h-16"
262
- description="Optional. Anything else, or an answer that is not listed."
358
+ description={USER_INPUT_NOTE.description}
263
359
  disabled={sending}
264
- label="Other"
360
+ label={USER_INPUT_NOTE.label}
265
361
  onChange={setOther}
266
362
  value={other}
267
363
  />
@@ -269,6 +365,14 @@ export function UserInputForm(props: {
269
365
  <Button disabled={sending} type="submit" variant="primary">
270
366
  Send
271
367
  </Button>
368
+ {fieldCount > 1 ? (
369
+ <span
370
+ aria-live="polite"
371
+ className="text-xs text-dashboard-text-muted"
372
+ >
373
+ {answeredCount} of {fieldCount} answered
374
+ </span>
375
+ ) : null}
272
376
  {secondary}
273
377
  </div>
274
378
  </form>
@@ -5,17 +5,20 @@ import {
5
5
  type TimeRangeDays,
6
6
  } from "../controls/TimeRangeSelector";
7
7
  import { useDocumentTitle } from "../../documentTitle";
8
+ import { cn } from "../../styles";
8
9
 
9
10
  /**
10
11
  * Render a spacious analytics page heading with optional controls.
11
12
  *
12
13
  * Also names the browser tab after the page. Pass `documentTitle` when the tab
13
- * needs more context than the visible heading.
14
+ * needs more context than the visible heading. Pass `navigation` to put page
15
+ * links under the heading, with their underline on the header border.
14
16
  */
15
17
  export function PageHeader(props: {
16
18
  actions?: ReactNode;
17
- description: ReactNode;
19
+ description?: ReactNode;
18
20
  documentTitle?: string;
21
+ navigation?: ReactNode;
19
22
  onRangeChange?(value: TimeRangeDays): void;
20
23
  range?: TimeRangeDays;
21
24
  rangeOptions?: TimeRangeDays[];
@@ -36,16 +39,28 @@ export function PageHeader(props: {
36
39
  const actions = props.actions ?? rangeActions;
37
40
 
38
41
  return (
39
- <header className="flex min-w-0 items-center justify-between gap-6 border-b border-dashboard-border-subtle pb-4 sm:pb-6 max-md:flex-col max-md:items-stretch">
40
- <div className="min-w-0">
41
- <h2 className="m-0 font-display text-3xl font-light leading-none tracking-[-0.03em] text-dashboard-text sm:text-4xl">
42
- {props.title}
43
- </h2>
44
- <div className="mt-2 max-w-2xl font-sans text-xs leading-relaxed text-dashboard-text-muted sm:text-sm">
45
- {props.description}
42
+ <header
43
+ className={cn(
44
+ "min-w-0 border-b border-dashboard-border-subtle",
45
+ !props.navigation && "pb-4 sm:pb-6",
46
+ )}
47
+ >
48
+ <div className="flex min-w-0 items-center justify-between gap-6 max-md:flex-col max-md:items-stretch">
49
+ <div className="min-w-0">
50
+ <h2 className="m-0 font-display text-3xl font-light leading-none tracking-[-0.03em] text-dashboard-text sm:text-4xl">
51
+ {props.title}
52
+ </h2>
53
+ {props.description ? (
54
+ <div className="mt-2 max-w-2xl font-sans text-xs leading-relaxed text-dashboard-text-muted sm:text-sm">
55
+ {props.description}
56
+ </div>
57
+ ) : null}
46
58
  </div>
59
+ {actions ? <div className="shrink-0">{actions}</div> : null}
47
60
  </div>
48
- {actions ? <div className="shrink-0">{actions}</div> : null}
61
+ {props.navigation ? (
62
+ <div className="mt-4 sm:mt-6">{props.navigation}</div>
63
+ ) : null}
49
64
  </header>
50
65
  );
51
66
  }
@@ -1,42 +1,17 @@
1
1
  # Transcript components
2
2
 
3
- Keep transcript layout in these feature components. Use the UI kit in
4
- `../components/` for shared controls and overlays.
5
-
6
- ## Ownership
7
-
8
- - `TranscriptRows` and `TranscriptRow` own row gaps, padding, and indent.
9
- Keep horizontal padding inside each row, so focus outlines are not clipped.
10
- - `TranscriptMessageShell` owns the avatar column and message gutter.
11
- - `TranscriptSummaryTrigger` owns disclosure padding and focus. Do not repeat
12
- negative margins at call sites.
13
- - `ChatLayout` keeps the Conversation header outside the transcript scroll.
14
- - `eventLogRows` and `filterEventLogRows` own row text and search match. The
15
- page step controls use them too.
16
-
17
- ## Rules
18
-
19
- - Display text keeps Slack tokens such as `<@U123>`. The API returns
20
- `mentions` beside the owning object. Render with `TranscriptText` or
21
- `MentionText`. Do not resolve names in the dashboard, and do not rewrite
22
- tokens into links on the server.
23
- - The Automation trigger card shows the credentials from the dispatch record
24
- of the run, not the current setting. Keep the run input collapsed, because
25
- it is not from a person.
26
- - Each `Transcript` sets the panel for its own rows. A nested transcript never
27
- uses the panel of the page behind it.
28
- - The page has one right panel. Conversation details, event details, and turn
29
- context never show at the same time. Closing a panel returns focus to the
30
- control that opened it.
3
+ - Keep horizontal padding inside each transcript row, so focus outlines are
4
+ not clipped. Do not add negative margins at `TranscriptSummaryTrigger` call
5
+ sites.
6
+ - The Automation trigger card shows the credentials from the run's dispatch
7
+ record, not the current setting. Keep the run input collapsed, because it
8
+ is not from a person.
9
+ - A nested `Transcript` never uses the panel of the page behind it.
10
+ - The page shows one right panel at a time. Closing a panel returns focus to
11
+ the control that opened it.
31
12
  - The URL owns the selected event in the main Conversation. Standalone
32
- transcripts keep selection in `ConversationEventLog`.
33
- - A pending exit must not close a new panel or another Conversation.
34
- - A pending fork blocks dismissal of its dialog.
13
+ transcripts keep it in `ConversationEventLog`.
14
+ - A pending exit must not close a new panel or another Conversation. A
15
+ pending fork blocks dismissal of its dialog.
35
16
  - Raw JSON keeps the original ISO values. Persist the UTC/local mode, not the
36
17
  timezone.
37
-
38
- ## Checks
39
-
40
- - Review with `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true pnpm dev`.
41
- - Keep visual checks out of automated assertions. Browser tests cover
42
- opening, closing, and keyboard interaction.
@@ -1,6 +1,9 @@
1
1
  import { ChevronRight } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
- import type { ConversationDetailReport } from "@sentry/junior/api/schema";
3
+ import type {
4
+ ActorIdentity,
5
+ ConversationDetailReport,
6
+ } from "@sentry/junior/api/schema";
4
7
 
5
8
  import { Detail, DetailList } from "../components/DetailList";
6
9
  import { StatusChip, type StatusChipTone } from "../components/StatusChip";
@@ -11,7 +14,7 @@ import {
11
14
  } from "../components/ui/collapsible";
12
15
  import { formatTime } from "../format";
13
16
  import { cn, dashboardDisclosureChevronClass } from "../styles";
14
- import { MentionPeopleProvider, renderMentionText } from "./transcriptMentions";
17
+ import { DisplayText } from "../display-text/DisplayText";
15
18
 
16
19
  type ConversationBriefReport = NonNullable<ConversationDetailReport["brief"]>;
17
20
 
@@ -31,31 +34,22 @@ const outcomeTones: Record<BriefContent["outcome"]["status"], StatusChipTone> =
31
34
  export function ConversationBrief(props: {
32
35
  brief: ConversationBriefReport;
33
36
  variant?: "summary" | "full";
34
- }) {
35
- return (
36
- <MentionPeopleProvider mentions={props.brief.mentions ?? []}>
37
- <BriefContentView brief={props.brief} variant={props.variant} />
38
- </MentionPeopleProvider>
39
- );
40
- }
41
-
42
- function BriefContentView(props: {
43
- brief: ConversationBriefReport;
44
- variant?: "summary" | "full";
45
37
  }) {
46
38
  const { content } = props.brief;
39
+ const mentions = props.brief.mentions;
47
40
  if (props.variant === "summary") {
48
41
  return (
49
42
  <div className="grid min-w-0 gap-4">
50
- <p className="m-0 text-sm leading-relaxed text-dashboard-text">
51
- <BriefText text={content.summary} />
52
- </p>
43
+ <DisplayText mentions={mentions} text={content.summary} />
53
44
  {content.openDecisions.length > 0 ? (
54
45
  <div className="grid gap-2">
55
46
  <h4 className="m-0 text-sm font-medium text-dashboard-text">
56
47
  Open decisions
57
48
  </h4>
58
- <BriefOpenDecisions decisions={content.openDecisions} />
49
+ <BriefOpenDecisions
50
+ decisions={content.openDecisions}
51
+ mentions={mentions}
52
+ />
59
53
  </div>
60
54
  ) : null}
61
55
  </div>
@@ -75,16 +69,14 @@ function BriefContentView(props: {
75
69
  })}
76
70
  </span>
77
71
  </div>
78
- <p className="m-0 text-sm leading-relaxed text-dashboard-text">
79
- <BriefText text={content.summary} />
80
- </p>
72
+ <DisplayText mentions={mentions} text={content.summary} />
81
73
  <BriefSection title="Intent & outcome">
82
74
  <DetailList>
83
75
  <Detail label="Intent">
84
- <BriefText text={content.intent} />
76
+ <DisplayText mentions={mentions} text={content.intent} />
85
77
  </Detail>
86
78
  <Detail label="Outcome">
87
- <BriefText text={content.outcome.text} />
79
+ <DisplayText mentions={mentions} text={content.outcome.text} />
88
80
  </Detail>
89
81
  </DetailList>
90
82
  </BriefSection>
@@ -93,12 +85,18 @@ function BriefContentView(props: {
93
85
  <DetailList>
94
86
  {content.decisions.length ? (
95
87
  <Detail label="Decisions">
96
- <BriefDecisions decisions={content.decisions} />
88
+ <BriefDecisions
89
+ decisions={content.decisions}
90
+ mentions={mentions}
91
+ />
97
92
  </Detail>
98
93
  ) : null}
99
94
  {content.openDecisions.length ? (
100
95
  <Detail label="Open decisions">
101
- <BriefOpenDecisions decisions={content.openDecisions} />
96
+ <BriefOpenDecisions
97
+ decisions={content.openDecisions}
98
+ mentions={mentions}
99
+ />
102
100
  </Detail>
103
101
  ) : null}
104
102
  </DetailList>
@@ -111,7 +109,7 @@ function BriefContentView(props: {
111
109
  <DetailList>
112
110
  {content.facts.length ? (
113
111
  <Detail label="Facts">
114
- <BriefList items={content.facts} />
112
+ <BriefList items={content.facts} mentions={mentions} />
115
113
  </Detail>
116
114
  ) : null}
117
115
  {content.links.length ? (
@@ -184,7 +182,10 @@ function BriefSection(props: { children: ReactNode; title: string }) {
184
182
  );
185
183
  }
186
184
 
187
- function BriefDecisions(props: { decisions: BriefContent["decisions"] }) {
185
+ function BriefDecisions(props: {
186
+ decisions: BriefContent["decisions"];
187
+ mentions?: readonly ActorIdentity[];
188
+ }) {
188
189
  return (
189
190
  <ul className="m-0 grid list-none gap-2.5 p-0">
190
191
  {props.decisions.map((decision, index) => (
@@ -197,12 +198,21 @@ function BriefDecisions(props: { decisions: BriefContent["decisions"] }) {
197
198
  {decision.kind}
198
199
  </StatusChip>
199
200
  <span className="min-w-0">
200
- <BriefText text={decision.text} />
201
+ <DisplayText
202
+ inline
203
+ mentions={props.mentions}
204
+ text={decision.text}
205
+ />
201
206
  </span>
202
207
  </div>
203
208
  {decision.by ? (
204
209
  <div className="mt-0.5 text-xs text-dashboard-text-muted">
205
- by · <BriefText text={decision.by} />
210
+ by ·{" "}
211
+ <DisplayText
212
+ inline
213
+ mentions={props.mentions}
214
+ text={decision.by}
215
+ />
206
216
  </div>
207
217
  ) : null}
208
218
  </li>
@@ -213,17 +223,27 @@ function BriefDecisions(props: { decisions: BriefContent["decisions"] }) {
213
223
 
214
224
  function BriefOpenDecisions(props: {
215
225
  decisions: BriefContent["openDecisions"];
226
+ mentions?: readonly ActorIdentity[];
216
227
  }) {
217
228
  return (
218
229
  <ul className="m-0 grid list-none gap-2 p-0">
219
230
  {props.decisions.map((decision, index) => (
220
231
  <li key={`${decision.text}:${index}`}>
221
232
  <div>
222
- <BriefText text={decision.text} />
233
+ <DisplayText
234
+ inline
235
+ mentions={props.mentions}
236
+ text={decision.text}
237
+ />
223
238
  </div>
224
239
  {decision.owner ? (
225
240
  <div className="mt-1 text-xs text-dashboard-text-muted">
226
- Owner: <BriefText text={decision.owner} />
241
+ Owner:{" "}
242
+ <DisplayText
243
+ inline
244
+ mentions={props.mentions}
245
+ text={decision.owner}
246
+ />
227
247
  </div>
228
248
  ) : null}
229
249
  </li>
@@ -232,22 +252,21 @@ function BriefOpenDecisions(props: {
232
252
  );
233
253
  }
234
254
 
235
- function BriefList(props: { items: string[] }) {
255
+ function BriefList(props: {
256
+ items: string[];
257
+ mentions?: readonly ActorIdentity[];
258
+ }) {
236
259
  return (
237
260
  <ul className="my-0 grid gap-1.5 pl-4">
238
261
  {props.items.map((item, index) => (
239
262
  <li key={`${item}:${index}`}>
240
- <BriefText text={item} />
263
+ <DisplayText inline mentions={props.mentions} text={item} />
241
264
  </li>
242
265
  ))}
243
266
  </ul>
244
267
  );
245
268
  }
246
269
 
247
- function BriefText(props: { text: string }) {
248
- return <>{renderMentionText(props.text, "brief")}</>;
249
- }
250
-
251
270
  function displayLabel(value: string): string {
252
271
  return value.replaceAll("_", " ");
253
272
  }
@@ -90,6 +90,11 @@ type ConversationComposerProps = {
90
90
  /** Lets the parent put a message taken out of the queue back in the draft. */
91
91
  ref?: Ref<ConversationComposerHandle>;
92
92
  submitLabel: string;
93
+ /**
94
+ * Return a promise to ask the reader before a send. The draft stays when it
95
+ * resolves false. Return undefined to send at once.
96
+ */
97
+ confirmSubmit?: () => Promise<boolean> | undefined;
93
98
  onFocus?: () => void;
94
99
  onSubmit(
95
100
  message: string,
@@ -140,6 +145,8 @@ export const ConversationComposer = memo(function ConversationComposer(
140
145
  });
141
146
  // Blocks same-tick double fire. For create restore, also blocks until settle.
142
147
  const submittingRef = useRef(false);
148
+ // Blocks a second send while the reader answers `confirmSubmit`.
149
+ const confirmingRef = useRef(false);
143
150
  const stoppingRef = useRef(false);
144
151
  // Monotonic token so a late failed create never restores over a newer submit.
145
152
  const submitTokenRef = useRef(0);
@@ -255,18 +262,30 @@ export const ConversationComposer = memo(function ConversationComposer(
255
262
  syncTextareaHeight();
256
263
  };
257
264
 
265
+ const canSubmit = () =>
266
+ Boolean(draftRef.current.text.trim() || imagesRef.current.length) &&
267
+ online &&
268
+ !submittingRef.current &&
269
+ !confirmingRef.current &&
270
+ !sendLocked &&
271
+ !readingImagesRef.current;
272
+
258
273
  const submit = async (event?: FormEvent) => {
259
274
  event?.preventDefault();
275
+ if (!canSubmit()) return;
276
+ const confirmation = props.confirmSubmit?.();
277
+ if (confirmation) {
278
+ confirmingRef.current = true;
279
+ let confirmed: boolean;
280
+ try {
281
+ confirmed = await confirmation;
282
+ } finally {
283
+ confirmingRef.current = false;
284
+ }
285
+ if (!confirmed) return;
286
+ }
260
287
  const text = draftRef.current.text.trim();
261
288
  const submittedImages = imagesRef.current;
262
- if (
263
- (!text && !submittedImages.length) ||
264
- !online ||
265
- submittingRef.current ||
266
- sendLocked ||
267
- readingImagesRef.current
268
- )
269
- return;
270
289
 
271
290
  const attempt = conversationAttemptForSubmit(
272
291
  attemptRef.current,
@@ -110,6 +110,7 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
110
110
  <li
111
111
  className="scroll-mt-32 scroll-mb-2"
112
112
  data-event-seq={event.seq}
113
+ data-scroll-anchor={`event:${event.seq}`}
113
114
  key={event.seq}
114
115
  >
115
116
  <button