@sentry/junior-dashboard 0.268.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 (81) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +12 -9
  3. package/dist/client.js +81 -81
  4. package/dist/index.js +12 -9
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +2 -1
  8. package/src/app.ts +11 -7
  9. package/src/client/App.tsx +15 -9
  10. package/src/client/api.ts +4 -19
  11. package/src/client/components/AGENTS.md +65 -0
  12. package/src/client/components/ActorAvatar.tsx +1 -1
  13. package/src/client/components/AutomationCard.tsx +2 -1
  14. package/src/client/components/ParticipantAvatarStack.tsx +1 -0
  15. package/src/client/components/ProfileMenu.tsx +88 -29
  16. package/src/client/components/UserInputForm.tsx +110 -37
  17. package/src/client/components/charts/AGENTS.md +15 -0
  18. package/src/client/conversations/AGENTS.md +38 -0
  19. package/src/client/conversations/ConversationBrief.tsx +55 -36
  20. package/src/client/conversations/ConversationComposer.tsx +27 -8
  21. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  22. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  23. package/src/client/conversations/ConversationPage.tsx +90 -56
  24. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  25. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  27. package/src/client/conversations/ConversationWatches.tsx +4 -1
  28. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  29. package/src/client/conversations/EventDetails.tsx +6 -9
  30. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  31. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  32. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  33. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  34. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  35. package/src/client/conversations/TranscriptRows.tsx +6 -1
  36. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  37. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  38. package/src/client/conversations/TranscriptView.tsx +6 -6
  39. package/src/client/conversations/conversationOutbox.ts +13 -3
  40. package/src/client/conversations/conversationSections.ts +36 -8
  41. package/src/client/conversations/eventTranscript.ts +1 -0
  42. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  43. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  44. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  45. package/src/client/display-text/DisplayText.tsx +68 -0
  46. package/src/client/display-text/emoji.ts +54 -0
  47. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  48. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  49. package/src/client/display-text/mentions.tsx +148 -0
  50. package/src/client/display-text/plainText.ts +29 -0
  51. package/src/client/format.ts +7 -23
  52. package/src/client/pages/automations/AGENTS.md +34 -0
  53. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  54. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  55. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  56. package/src/client/pages/automations/AutomationOverview.tsx +5 -5
  57. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  58. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  59. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  60. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  61. package/src/client/pages/system/AGENTS.md +12 -0
  62. package/src/client/review-desk/ConversationReviewBar.tsx +14 -9
  63. package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
  64. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  65. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  66. package/src/client/review-desk/ReviewItemDetail.tsx +17 -9
  67. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  68. package/src/client/review-desk/queries.ts +25 -16
  69. package/src/client/review-desk/reviewItemFormat.ts +29 -1
  70. package/src/mock-reporting/routes.ts +1 -1
  71. package/src/tailwind.css +1 -1
  72. package/src/client/components/README.md +0 -219
  73. package/src/client/components/charts/README.md +0 -38
  74. package/src/client/conversations/README.md +0 -159
  75. package/src/client/conversations/TranscriptText.tsx +0 -47
  76. package/src/client/conversations/UserInputRequestCard.tsx +0 -39
  77. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  78. package/src/client/conversations/transcriptMentions.tsx +0 -134
  79. package/src/client/conversations/userInputRequest.ts +0 -56
  80. package/src/client/pages/automations/README.md +0 -97
  81. package/src/client/pages/system/README.md +0 -23
@@ -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,6 +29,12 @@ 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
 
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
+
23
38
  function OptionText(props: { description?: string; label: string }) {
24
39
  return (
25
40
  <span className="grid min-w-0 gap-0.5 break-words">
@@ -41,9 +56,13 @@ function ChoiceField(props: {
41
56
  }) {
42
57
  const id = useId();
43
58
  const { field } = props;
59
+ // Say how many choices count, so a reader knows when a field is done.
44
60
  const legend = (
45
61
  <legend className="mb-2 p-0 text-sm font-semibold text-dashboard-text">
46
62
  {field.label}
63
+ <span className="ml-2 text-xs font-normal text-dashboard-text-muted">
64
+ {field.kind === "choice" ? "Pick one" : "Pick any"}
65
+ </span>
47
66
  </legend>
48
67
  );
49
68
  const description = field.description ? (
@@ -138,20 +157,25 @@ function TextField(props: {
138
157
  }
139
158
 
140
159
  /**
141
- * Render the answer controls of a `requestUserInput` request. A request with
160
+ * Render the answer controls of an action item request. A request with
142
161
  * 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.
162
+ * answer. Choices with descriptions stack as rows, so each description stays
163
+ * with its choice. Other requests show a form that the reader submits once.
164
+ * A failed `onSubmit` lets the reader try again. `secondaryActions`, such as
165
+ * Dismiss, sit after the answer controls. `onUnsentChange` reports whether
166
+ * the form has picks or text that the reader has not sent.
146
167
  */
147
168
  export function UserInputForm(props: {
148
169
  onSubmit(answer: UserInputAnswer): Promise<void>;
170
+ onUnsentChange?(unsent: boolean): void;
149
171
  request: UserInputRequest;
150
172
  requestId: string;
151
173
  secondaryActions?: ReactNode;
152
174
  }) {
153
175
  const [sending, setSending] = useState(false);
176
+ const [picked, setPicked] = useState<string>();
154
177
  const [failed, setFailed] = useState(false);
178
+ const choicesId = useId();
155
179
  const [values, setValues] = useState<Record<string, string[]>>({});
156
180
  const [other, setOther] = useState("");
157
181
  const buttonField = buttonUserInputField(props.request);
@@ -170,20 +194,35 @@ export function UserInputForm(props: {
170
194
  } catch {
171
195
  sendLock.current = false;
172
196
  setSending(false);
197
+ setPicked(undefined);
173
198
  setFailed(true);
174
199
  }
175
200
  }
176
201
 
202
+ /** Send one button choice as the whole answer. */
203
+ function pick(fieldId: string, label: string) {
204
+ setPicked(label);
205
+ void send({ requestId: props.requestId, values: { [fieldId]: [label] } });
206
+ }
207
+
177
208
  const answer: UserInputAnswer = {
178
209
  other,
179
210
  requestId: props.requestId,
180
211
  values,
181
212
  };
213
+ const normalized = normalizeUserInputAnswer(props.request, answer);
214
+ const answeredCount = Object.keys(normalized.values).length;
215
+ const fieldCount = props.request.fields.length;
216
+ // A button answer sends on click, so only the form can hold unsent input.
217
+ const unsent =
218
+ !buttonField && !sending && !isEmptyUserInputAnswer(normalized);
219
+ const onUnsentChange = props.onUnsentChange;
220
+ useEffect(() => {
221
+ onUnsentChange?.(unsent);
222
+ }, [onUnsentChange, unsent]);
182
223
  function submit(event: FormEvent) {
183
224
  event.preventDefault();
184
- if (
185
- !isEmptyUserInputAnswer(normalizeUserInputAnswer(props.request, answer))
186
- ) {
225
+ if (!isEmptyUserInputAnswer(normalized)) {
187
226
  void send(answer);
188
227
  }
189
228
  }
@@ -196,41 +235,67 @@ export function UserInputForm(props: {
196
235
 
197
236
  return (
198
237
  <>
199
- {buttonField ? (
238
+ {buttonField?.options.some((option) => option.description) ? (
200
239
  <>
201
- <div className="mt-3 flex flex-wrap items-center gap-2">
202
- {buttonField.options.map((option) => (
203
- <Button
240
+ <div
241
+ aria-label={buttonField.label}
242
+ className="mt-3 grid gap-2"
243
+ role="group"
244
+ >
245
+ {buttonField.options.map((option, index) => (
246
+ <button
247
+ aria-describedby={
248
+ option.description ? `${choicesId}-${index}` : undefined
249
+ }
250
+ aria-labelledby={`${choicesId}-${index}-label`}
251
+ className={choiceButtonClass}
252
+ data-picked={picked === option.label ? "" : undefined}
204
253
  disabled={sending}
205
254
  key={option.label}
206
- onClick={() =>
207
- void send({
208
- requestId: props.requestId,
209
- values: { [buttonField.id]: [option.label] },
210
- })
211
- }
212
- title={option.description}
255
+ onClick={() => pick(buttonField.id, option.label)}
256
+ type="button"
213
257
  >
214
- {option.label}
215
- </Button>
258
+ <span className="grid min-w-0 flex-1 gap-0.5 break-words">
259
+ <span
260
+ className="font-medium text-dashboard-text"
261
+ id={`${choicesId}-${index}-label`}
262
+ >
263
+ {option.label}
264
+ </span>
265
+ {option.description ? (
266
+ <span
267
+ className="text-xs text-dashboard-text-muted"
268
+ id={`${choicesId}-${index}`}
269
+ >
270
+ {option.description}
271
+ </span>
272
+ ) : null}
273
+ </span>
274
+ <ArrowRight
275
+ aria-hidden="true"
276
+ 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"
277
+ size={16}
278
+ />
279
+ </button>
216
280
  ))}
217
- {secondary}
218
281
  </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>
282
+ {secondary ? (
283
+ <div className="mt-3 flex flex-wrap items-center">{secondary}</div>
232
284
  ) : null}
233
285
  </>
286
+ ) : buttonField ? (
287
+ <div className="mt-3 flex flex-wrap items-center gap-2">
288
+ {buttonField.options.map((option) => (
289
+ <Button
290
+ disabled={sending}
291
+ key={option.label}
292
+ onClick={() => pick(buttonField.id, option.label)}
293
+ >
294
+ {option.label}
295
+ </Button>
296
+ ))}
297
+ {secondary}
298
+ </div>
234
299
  ) : (
235
300
  <form className="mt-4 grid gap-5" onSubmit={submit}>
236
301
  {props.request.fields.map((field) =>
@@ -259,9 +324,9 @@ export function UserInputForm(props: {
259
324
  )}
260
325
  <TextField
261
326
  className="min-h-16"
262
- description="Optional. Anything else, or an answer that is not listed."
327
+ description={USER_INPUT_NOTE.description}
263
328
  disabled={sending}
264
- label="Other"
329
+ label={USER_INPUT_NOTE.label}
265
330
  onChange={setOther}
266
331
  value={other}
267
332
  />
@@ -269,6 +334,14 @@ export function UserInputForm(props: {
269
334
  <Button disabled={sending} type="submit" variant="primary">
270
335
  Send
271
336
  </Button>
337
+ {fieldCount > 1 ? (
338
+ <span
339
+ aria-live="polite"
340
+ className="text-xs text-dashboard-text-muted"
341
+ >
342
+ {answeredCount} of {fieldCount} answered
343
+ </span>
344
+ ) : null}
272
345
  {secondary}
273
346
  </div>
274
347
  </form>
@@ -0,0 +1,15 @@
1
+ # System cache reporting
2
+
3
+ - `cache-input.ts` owns the cache input calculation.
4
+ - `ConversationMetricDay` comes from stored Run metrics, not Sentry spans.
5
+ Its input, cached input, and cache-creation counters are disjoint. Add
6
+ them once for total input. Do not include output. Never use this transform
7
+ for Sentry span totals.
8
+ - Period shares use summed token counts, not the mean of bucket shares.
9
+ - A missing counter is not a zero. When an active bucket has no counter, omit
10
+ its share and the whole-period share.
11
+ - Do not call these values a cache hit rate or a cache failure rate. Field
12
+ presence is not call-level coverage.
13
+ - The page time control selects the buckets for the chart and `CacheWrites`.
14
+ Do not add a local time selector.
15
+ - Add a reporting contract before you add per-call filters or drilldowns.
@@ -0,0 +1,38 @@
1
+ # Transcript components
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
+ - The Automation trigger card shows the credentials from the dispatch record
20
+ of the run, not the current setting. Keep the run input collapsed, because
21
+ it is not from a person.
22
+ - Each `Transcript` sets the panel for its own rows. A nested transcript never
23
+ uses the panel of the page behind it.
24
+ - The page has one right panel. Conversation details, event details, and turn
25
+ context never show at the same time. Closing a panel returns focus to the
26
+ control that opened it.
27
+ - The URL owns the selected event in the main Conversation. Standalone
28
+ transcripts keep selection in `ConversationEventLog`.
29
+ - A pending exit must not close a new panel or another Conversation.
30
+ - A pending fork blocks dismissal of its dialog.
31
+ - Raw JSON keeps the original ISO values. Persist the UTC/local mode, not the
32
+ timezone.
33
+
34
+ ## Checks
35
+
36
+ - Review with `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS=true pnpm dev`.
37
+ - Keep visual checks out of automated assertions. Browser tests cover
38
+ 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