@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.
- package/dist/app.js +2 -2
- package/dist/client.js +78 -75
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/client/App.tsx +14 -4
- package/src/client/components/AutomationCard.tsx +2 -1
- package/src/client/components/UserInputForm.tsx +109 -36
- package/src/client/conversations/AGENTS.md +0 -4
- 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 +93 -32
- 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/pages/automations/AutomationFilters.tsx +6 -6
- package/src/client/pages/automations/AutomationOverview.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/review-desk/ConversationReviewBar.tsx +0 -1
- 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 +2 -2
- package/src/client/review-desk/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/reviewItemFormat.ts +29 -1
- package/src/client/conversations/TranscriptText.tsx +0 -47
- package/src/client/conversations/conversationActivityPreview.ts +0 -16
- package/src/client/conversations/transcriptMentions.tsx +0 -134
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/junior-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.270.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"remark-gfm": "^4.0.1",
|
|
40
40
|
"shiki": "4.1.0",
|
|
41
41
|
"zod": "^4.5.4",
|
|
42
|
-
"@sentry/junior-plugin-api": "0.
|
|
42
|
+
"@sentry/junior-plugin-api": "0.270.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@sentry/junior": "0.
|
|
45
|
+
"@sentry/junior": "0.270.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@oxlint/plugins": "1.79.0",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@types/react": "^19.2.15",
|
|
53
53
|
"@types/react-dom": "^19.2.3",
|
|
54
54
|
"@vitest/coverage-v8": "4.1.12",
|
|
55
|
+
"emojibase-data": "^18.0.0",
|
|
55
56
|
"eslint": "^10.8.0",
|
|
56
57
|
"nitro": "3.0.260522-beta",
|
|
57
58
|
"oxlint": "^1.79.0",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"tw-animate-css": "^1.4.0",
|
|
63
64
|
"typescript": "^7.0.2",
|
|
64
65
|
"vitest": "^4.1.12",
|
|
65
|
-
"@sentry/junior": "0.
|
|
66
|
+
"@sentry/junior": "0.270.0"
|
|
66
67
|
},
|
|
67
68
|
"scripts": {
|
|
68
69
|
"build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
|
package/src/client/App.tsx
CHANGED
|
@@ -124,10 +124,20 @@ export function DashboardShell() {
|
|
|
124
124
|
}, [location.pathname]);
|
|
125
125
|
|
|
126
126
|
async function signOut() {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
// Better Auth rejects a request body without a JSON content type. On
|
|
128
|
+
// Vercel, a bodiless POST still arrives with an empty body.
|
|
129
|
+
const response = await fetch(
|
|
130
|
+
`${data?.config.authPath ?? "/api/auth"}/sign-out`,
|
|
131
|
+
{
|
|
132
|
+
body: "{}",
|
|
133
|
+
credentials: "same-origin",
|
|
134
|
+
headers: { "content-type": "application/json" },
|
|
135
|
+
method: "POST",
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`Sign-out returned ${response.status}`);
|
|
140
|
+
}
|
|
131
141
|
window.location.assign(data?.config.basePath ?? "/");
|
|
132
142
|
}
|
|
133
143
|
|
|
@@ -4,6 +4,7 @@ import { StatusChip } from "./StatusChip";
|
|
|
4
4
|
import { ArrowUpRight, CircleAlert, Clock3 } from "lucide-react";
|
|
5
5
|
import { Link } from "react-router";
|
|
6
6
|
import type { AutomationCard as AutomationCardValue } from "@sentry/junior/api/schema";
|
|
7
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
7
8
|
import { automationPath } from "../format";
|
|
8
9
|
|
|
9
10
|
/** Show a saved automation inside a reply, with a preview and a detail link. */
|
|
@@ -43,7 +44,7 @@ export function AutomationCard({
|
|
|
43
44
|
</StatusChip>
|
|
44
45
|
)}
|
|
45
46
|
<p className="m-0 line-clamp-3 break-words text-sm leading-relaxed text-dashboard-text-muted">
|
|
46
|
-
{card.instruction}
|
|
47
|
+
<DisplayText inline text={card.instruction} />
|
|
47
48
|
</p>
|
|
48
49
|
<div className="flex items-start gap-2 text-xs leading-relaxed text-dashboard-text">
|
|
49
50
|
<Clock3
|
|
@@ -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 ? (
|
|
@@ -140,18 +159,23 @@ function TextField(props: {
|
|
|
140
159
|
/**
|
|
141
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>
|
|
@@ -16,10 +16,6 @@ Keep transcript layout in these feature components. Use the UI kit in
|
|
|
16
16
|
|
|
17
17
|
## Rules
|
|
18
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
19
|
- The Automation trigger card shows the credentials from the dispatch record
|
|
24
20
|
of the run, not the current setting. Keep the run input collapsed, because
|
|
25
21
|
it is not from a person.
|
|
@@ -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,
|