@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.
- package/README.md +7 -105
- package/dist/app.js +12 -9
- package/dist/client.js +81 -81
- package/dist/index.js +12 -9
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +2 -1
- package/src/app.ts +11 -7
- package/src/client/App.tsx +15 -9
- package/src/client/api.ts +4 -19
- package/src/client/components/AGENTS.md +65 -0
- package/src/client/components/ActorAvatar.tsx +1 -1
- package/src/client/components/AutomationCard.tsx +2 -1
- package/src/client/components/ParticipantAvatarStack.tsx +1 -0
- package/src/client/components/ProfileMenu.tsx +88 -29
- package/src/client/components/UserInputForm.tsx +110 -37
- package/src/client/components/charts/AGENTS.md +15 -0
- package/src/client/conversations/AGENTS.md +38 -0
- package/src/client/conversations/ConversationBrief.tsx +55 -36
- package/src/client/conversations/ConversationComposer.tsx +27 -8
- package/src/client/conversations/ConversationEventLog.tsx +1 -0
- package/src/client/conversations/ConversationHomeList.tsx +43 -26
- package/src/client/conversations/ConversationPage.tsx +90 -56
- package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
- package/src/client/conversations/ConversationSidebar.tsx +55 -25
- package/src/client/conversations/ConversationTranscript.tsx +3 -2
- package/src/client/conversations/ConversationWatches.tsx +4 -1
- package/src/client/conversations/ConversationWorkspace.tsx +43 -25
- package/src/client/conversations/EventDetails.tsx +6 -9
- package/src/client/conversations/PendingMailboxStack.tsx +5 -2
- package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
- package/src/client/conversations/TranscriptEventView.tsx +9 -3
- package/src/client/conversations/TranscriptMessageView.tsx +6 -5
- package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
- package/src/client/conversations/TranscriptRows.tsx +6 -1
- package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
- package/src/client/conversations/TranscriptToolView.tsx +2 -1
- package/src/client/conversations/TranscriptView.tsx +6 -6
- package/src/client/conversations/conversationOutbox.ts +13 -3
- package/src/client/conversations/conversationSections.ts +36 -8
- package/src/client/conversations/eventTranscript.ts +1 -0
- package/src/client/conversations/transcriptBottomPinning.ts +171 -81
- package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
- package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
- package/src/client/display-text/DisplayText.tsx +68 -0
- package/src/client/display-text/emoji.ts +54 -0
- package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
- package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
- package/src/client/display-text/mentions.tsx +148 -0
- package/src/client/display-text/plainText.ts +29 -0
- package/src/client/format.ts +7 -23
- package/src/client/pages/automations/AGENTS.md +34 -0
- package/src/client/pages/automations/AutomationActions.tsx +18 -20
- package/src/client/pages/automations/AutomationEditor.tsx +3 -4
- package/src/client/pages/automations/AutomationFilters.tsx +6 -6
- package/src/client/pages/automations/AutomationOverview.tsx +5 -5
- package/src/client/pages/automations/AutomationStatus.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
- package/src/client/pages/code/RepositoryTabs.tsx +6 -1
- package/src/client/pages/memory/MemoryDetails.tsx +5 -13
- package/src/client/pages/system/AGENTS.md +12 -0
- package/src/client/review-desk/ConversationReviewBar.tsx +14 -9
- package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
- package/src/client/review-desk/ReviewItemActions.tsx +6 -2
- package/src/client/review-desk/ReviewItemCard.tsx +58 -17
- package/src/client/review-desk/ReviewItemDetail.tsx +17 -9
- package/src/client/review-desk/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/queries.ts +25 -16
- package/src/client/review-desk/reviewItemFormat.ts +29 -1
- package/src/mock-reporting/routes.ts +1 -1
- package/src/tailwind.css +1 -1
- package/src/client/components/README.md +0 -219
- package/src/client/components/charts/README.md +0 -38
- package/src/client/conversations/README.md +0 -159
- package/src/client/conversations/TranscriptText.tsx +0 -47
- package/src/client/conversations/UserInputRequestCard.tsx +0 -39
- package/src/client/conversations/conversationActivityPreview.ts +0 -16
- package/src/client/conversations/transcriptMentions.tsx +0 -134
- package/src/client/conversations/userInputRequest.ts +0 -56
- package/src/client/pages/automations/README.md +0 -97
- package/src/client/pages/system/README.md +0 -23
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
|
202
|
-
{buttonField.
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
{
|
|
220
|
-
<
|
|
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=
|
|
327
|
+
description={USER_INPUT_NOTE.description}
|
|
263
328
|
disabled={sending}
|
|
264
|
-
label=
|
|
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 {
|
|
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 {
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
76
|
+
<DisplayText mentions={mentions} text={content.intent} />
|
|
85
77
|
</Detail>
|
|
86
78
|
<Detail label="Outcome">
|
|
87
|
-
<
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
<
|
|
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 ·
|
|
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
|
-
<
|
|
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:
|
|
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: {
|
|
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
|
-
<
|
|
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,
|