@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.
- 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 +103 -65
- package/src/client/components/AGENTS.md +30 -59
- package/src/client/components/AutomationCard.tsx +2 -1
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +143 -39
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/AGENTS.md +12 -37
- 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 +137 -40
- 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/conversationRoutes.ts +30 -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/AGENTS.md +6 -25
- 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/pages/settings/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
- package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
- package/src/client/review-desk/ReviewItemActions.tsx +6 -2
- package/src/client/review-desk/ReviewItemCard.tsx +58 -17
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/queries.ts +4 -2
- package/src/client/review-desk/reviewItemFormat.ts +83 -1
- package/src/client/components/charts/AGENTS.md +0 -15
- 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/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/system/AGENTS.md +0 -12
- package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
|
@@ -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,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
|
-
|
|
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">
|
|
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
|
|
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.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
|
202
|
-
{buttonField.
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
{
|
|
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>
|
|
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=
|
|
358
|
+
description={USER_INPUT_NOTE.description}
|
|
263
359
|
disabled={sending}
|
|
264
|
-
label=
|
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
{
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
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
|
|
33
|
-
- A pending exit must not close a new panel or another Conversation.
|
|
34
|
-
|
|
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 {
|
|
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,
|