@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,11 +1,11 @@
|
|
|
1
|
-
export const
|
|
1
|
+
export const ANCHOR_CLASS =
|
|
2
2
|
"font-medium text-dashboard-accent-text underline decoration-dashboard-accent/35 underline-offset-2 transition-colors hover:text-dashboard-text hover:decoration-dashboard-text-solid";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Keep a
|
|
5
|
+
* Keep a link destination only when it is an absolute `http:`,
|
|
6
6
|
* `https:`, or `mailto:` URL. Return undefined to drop the link.
|
|
7
7
|
*/
|
|
8
|
-
export function
|
|
8
|
+
export function safeHref(href: string): string | undefined {
|
|
9
9
|
const trimmed = href.trim();
|
|
10
10
|
const hasControlCharacter = [...trimmed].some((character) => {
|
|
11
11
|
const code = character.charCodeAt(0);
|
|
@@ -23,3 +23,16 @@ export function safeTranscriptHref(href: string): string | undefined {
|
|
|
23
23
|
return undefined;
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/** Slack writes links as `<https://example.com>` or `<https://example.com|label>`. */
|
|
28
|
+
export const SLACK_LINK_RE = /<((?:https?|mailto):[^\s|<>]+)(?:\|([^<>]*))?>/g;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rewrite labeled Slack links as Markdown links. Markdown already reads a
|
|
32
|
+
* bare `<https://example.com>` as a link.
|
|
33
|
+
*/
|
|
34
|
+
export function slackLinksToMarkdown(text: string): string {
|
|
35
|
+
return text.replace(SLACK_LINK_RE, (match, url: string, label?: string) =>
|
|
36
|
+
label?.trim() ? `[${label.trim()}](${url})` : match,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -11,37 +11,53 @@ import {
|
|
|
11
11
|
TableHeader,
|
|
12
12
|
TableRow,
|
|
13
13
|
} from "../components/ui/table";
|
|
14
|
-
import { cn } from "../styles";
|
|
15
14
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from "
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
15
|
+
HighlightText,
|
|
16
|
+
useTranscriptSearch,
|
|
17
|
+
} from "../conversations/transcriptSearch";
|
|
18
|
+
import { cn } from "../styles";
|
|
19
|
+
import { ANCHOR_CLASS, safeHref, slackLinksToMarkdown } from "./links";
|
|
20
|
+
import { renderTextTokens } from "./mentions";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Render
|
|
23
|
+
* Render display text Markdown as readable prose.
|
|
24
24
|
*
|
|
25
25
|
* The text is CommonMark plus GFM, with a hard break for each newline. Agent
|
|
26
|
-
* replies also use Slack-style `•` bullets
|
|
27
|
-
* those render as lists and headings
|
|
26
|
+
* replies also use Slack-style `•` bullets, `<url|label>` links, and
|
|
27
|
+
* bold-only section labels, so those render as lists, links, and headings
|
|
28
|
+
* too. Inline mode keeps only inline formatting, so the text fits in one
|
|
29
|
+
* line or in a button.
|
|
28
30
|
*/
|
|
29
|
-
export function
|
|
30
|
-
|
|
31
|
+
export function MarkdownText(props: {
|
|
32
|
+
chat?: boolean;
|
|
33
|
+
inline?: boolean;
|
|
31
34
|
text: string;
|
|
32
35
|
}): ReactNode {
|
|
33
36
|
if (!props.text.trim()) return null;
|
|
37
|
+
const source = normalizeSlackBullets(slackLinksToMarkdown(props.text));
|
|
38
|
+
|
|
39
|
+
if (props.inline) {
|
|
40
|
+
return (
|
|
41
|
+
<Markdown
|
|
42
|
+
components={INLINE_COMPONENTS}
|
|
43
|
+
remarkPlugins={[remarkGfm]}
|
|
44
|
+
urlTransform={safeHref}
|
|
45
|
+
>
|
|
46
|
+
{source}
|
|
47
|
+
</Markdown>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
34
50
|
|
|
35
51
|
return (
|
|
36
52
|
<div
|
|
37
53
|
className={cn(
|
|
38
54
|
"min-w-0 break-words text-dashboard-text [overflow-wrap:anywhere]",
|
|
39
|
-
props.
|
|
55
|
+
props.chat
|
|
40
56
|
? "font-sans text-base leading-relaxed"
|
|
41
57
|
: "font-sans text-sm leading-6 md:leading-7",
|
|
42
58
|
)}
|
|
43
59
|
>
|
|
44
|
-
<div className={cn("grid min-w-0", props.
|
|
60
|
+
<div className={cn("grid min-w-0", props.chat ? "gap-3" : "gap-2")}>
|
|
45
61
|
<Markdown
|
|
46
62
|
components={COMPONENTS}
|
|
47
63
|
remarkPlugins={[
|
|
@@ -50,9 +66,9 @@ export function TranscriptMarkdown(props: {
|
|
|
50
66
|
remarkBareUrlStops,
|
|
51
67
|
remarkSectionLabels,
|
|
52
68
|
]}
|
|
53
|
-
urlTransform={
|
|
69
|
+
urlTransform={safeHref}
|
|
54
70
|
>
|
|
55
|
-
{
|
|
71
|
+
{source}
|
|
56
72
|
</Markdown>
|
|
57
73
|
</div>
|
|
58
74
|
</div>
|
|
@@ -255,13 +271,61 @@ const COMPONENTS: Components = {
|
|
|
255
271
|
),
|
|
256
272
|
};
|
|
257
273
|
|
|
258
|
-
// Text nodes can hold Slack mention tokens and
|
|
274
|
+
// Text nodes can hold Slack mention tokens, emoji codes, and search matches.
|
|
259
275
|
function renderText(children: ReactNode): ReactNode {
|
|
260
276
|
return Children.map(children, (child, index) =>
|
|
261
|
-
typeof child === "string" ?
|
|
277
|
+
typeof child === "string" ? renderTextTokens(child, `t-${index}`) : child,
|
|
262
278
|
);
|
|
263
279
|
}
|
|
264
280
|
|
|
281
|
+
// Inline mode ends each block with a newline. A one-line container shows it
|
|
282
|
+
// as a space, and a `whitespace-pre-wrap` container keeps the line break.
|
|
283
|
+
function InlineBlock(props: { children?: ReactNode }) {
|
|
284
|
+
return (
|
|
285
|
+
<>
|
|
286
|
+
{renderText(props.children)}
|
|
287
|
+
{"\n"}
|
|
288
|
+
</>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function InlineCell(props: { children?: ReactNode }) {
|
|
293
|
+
return <>{renderText(props.children)} </>;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function InlineContainer(props: { children?: ReactNode }) {
|
|
297
|
+
return <>{props.children}</>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const INLINE_COMPONENTS: Components = {
|
|
301
|
+
// A link cannot sit in a button or in another link, so keep the label.
|
|
302
|
+
a: ({ children }) => <>{renderText(children)}</>,
|
|
303
|
+
blockquote: InlineContainer,
|
|
304
|
+
code: COMPONENTS.code,
|
|
305
|
+
del: COMPONENTS.del,
|
|
306
|
+
em: COMPONENTS.em,
|
|
307
|
+
h1: InlineBlock,
|
|
308
|
+
h2: InlineBlock,
|
|
309
|
+
h3: InlineBlock,
|
|
310
|
+
h4: InlineBlock,
|
|
311
|
+
h5: InlineBlock,
|
|
312
|
+
h6: InlineBlock,
|
|
313
|
+
hr: () => "\n",
|
|
314
|
+
img: ({ alt }) => alt ?? null,
|
|
315
|
+
li: InlineBlock,
|
|
316
|
+
ol: InlineContainer,
|
|
317
|
+
p: InlineBlock,
|
|
318
|
+
pre: InlineContainer,
|
|
319
|
+
strong: COMPONENTS.strong,
|
|
320
|
+
table: InlineContainer,
|
|
321
|
+
tbody: InlineContainer,
|
|
322
|
+
td: InlineCell,
|
|
323
|
+
th: InlineCell,
|
|
324
|
+
thead: InlineContainer,
|
|
325
|
+
tr: InlineBlock,
|
|
326
|
+
ul: InlineContainer,
|
|
327
|
+
};
|
|
328
|
+
|
|
265
329
|
function alignClass(align: unknown): string | undefined {
|
|
266
330
|
if (align === "center") return "text-center";
|
|
267
331
|
if (align === "right") return "text-right";
|
|
@@ -269,13 +333,13 @@ function alignClass(align: unknown): string | undefined {
|
|
|
269
333
|
}
|
|
270
334
|
|
|
271
335
|
function TranscriptAnchor(props: { children: ReactNode; href?: string }) {
|
|
272
|
-
// `
|
|
336
|
+
// `safeHref` removes unsafe destinations. Keep their label text.
|
|
273
337
|
if (!props.href) return <>{renderText(props.children)}</>;
|
|
274
338
|
|
|
275
339
|
const opensNewTab = /^https?:/i.test(props.href);
|
|
276
340
|
return (
|
|
277
341
|
<a
|
|
278
|
-
className={
|
|
342
|
+
className={ANCHOR_CLASS}
|
|
279
343
|
href={props.href}
|
|
280
344
|
rel={opensNewTab ? "noreferrer" : undefined}
|
|
281
345
|
target={opensNewTab ? "_blank" : undefined}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { HighlightText } from "../conversations/transcriptSearch";
|
|
6
|
+
import { peoplePath } from "../format";
|
|
7
|
+
import { cn } from "../styles";
|
|
8
|
+
import { replaceSlackEmoji } from "./emoji";
|
|
9
|
+
import { ANCHOR_CLASS } from "./links";
|
|
10
|
+
|
|
11
|
+
type Mention = {
|
|
12
|
+
end: number;
|
|
13
|
+
id: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
sigil: "@" | "#";
|
|
16
|
+
start: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type MentionContext = {
|
|
20
|
+
links: boolean;
|
|
21
|
+
people: ReadonlyMap<string, ActorIdentity>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Matches the user and channel tokens that core keeps in message text.
|
|
25
|
+
const SLACK_MENTION_RE = /<([@#])([A-Z0-9]+)(?:\|([^<>]*))?>/g;
|
|
26
|
+
|
|
27
|
+
const MentionPeople = createContext<MentionContext>({
|
|
28
|
+
links: true,
|
|
29
|
+
people: new Map(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Share the people that the API resolved for one display text. */
|
|
33
|
+
export function MentionPeopleProvider(props: {
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
links: boolean;
|
|
36
|
+
mentions: readonly ActorIdentity[];
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<MentionPeople.Provider
|
|
40
|
+
value={{ links: props.links, people: mentionPeople(props.mentions) }}
|
|
41
|
+
>
|
|
42
|
+
{props.children}
|
|
43
|
+
</MentionPeople.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render one text run with Slack `<@U123>` and `<#C123|name>` tokens as
|
|
49
|
+
* readable mentions, Slack `:name:` codes as emoji, and search matches
|
|
50
|
+
* highlighted. It reads people from the nearest `MentionPeopleProvider`.
|
|
51
|
+
*/
|
|
52
|
+
export function renderTextTokens(text: string, keyBase: string): ReactNode[] {
|
|
53
|
+
const nodes: ReactNode[] = [];
|
|
54
|
+
let cursor = 0;
|
|
55
|
+
for (const mention of findMentions(text)) {
|
|
56
|
+
if (mention.start > cursor) {
|
|
57
|
+
nodes.push(
|
|
58
|
+
<HighlightText
|
|
59
|
+
key={`${keyBase}-m-${cursor}`}
|
|
60
|
+
text={replaceSlackEmoji(text.slice(cursor, mention.start))}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
nodes.push(
|
|
65
|
+
<MentionView key={`${keyBase}-m-${mention.start}`} mention={mention} />,
|
|
66
|
+
);
|
|
67
|
+
cursor = mention.end;
|
|
68
|
+
}
|
|
69
|
+
if (cursor < text.length) {
|
|
70
|
+
nodes.push(
|
|
71
|
+
<HighlightText
|
|
72
|
+
key={`${keyBase}-m-${cursor}`}
|
|
73
|
+
text={replaceSlackEmoji(text.slice(cursor))}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return nodes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Return one text run as a string, with the names and emoji it renders. */
|
|
81
|
+
export function plainTextTokens(
|
|
82
|
+
text: string,
|
|
83
|
+
mentions: readonly ActorIdentity[],
|
|
84
|
+
): string {
|
|
85
|
+
const people = mentionPeople(mentions);
|
|
86
|
+
let result = "";
|
|
87
|
+
let cursor = 0;
|
|
88
|
+
for (const mention of findMentions(text)) {
|
|
89
|
+
result += replaceSlackEmoji(text.slice(cursor, mention.start));
|
|
90
|
+
result += mentionLabel(mention, people);
|
|
91
|
+
cursor = mention.end;
|
|
92
|
+
}
|
|
93
|
+
return result + replaceSlackEmoji(text.slice(cursor));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mentionPeople(
|
|
97
|
+
mentions: readonly ActorIdentity[],
|
|
98
|
+
): Map<string, ActorIdentity> {
|
|
99
|
+
const people = new Map<string, ActorIdentity>();
|
|
100
|
+
for (const person of mentions) {
|
|
101
|
+
if (person.slackUserId) people.set(person.slackUserId, person);
|
|
102
|
+
}
|
|
103
|
+
return people;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mentionLabel(
|
|
107
|
+
mention: Mention,
|
|
108
|
+
people: ReadonlyMap<string, ActorIdentity>,
|
|
109
|
+
): string {
|
|
110
|
+
const { id, label, sigil } = mention;
|
|
111
|
+
const person = sigil === "@" ? people.get(id) : undefined;
|
|
112
|
+
const name = person?.fullName ?? person?.slackUserName ?? label ?? id;
|
|
113
|
+
return `${sigil}${name.replace(/^[@#]/, "")}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findMentions(text: string): Mention[] {
|
|
117
|
+
return [...text.matchAll(SLACK_MENTION_RE)].map((match) => ({
|
|
118
|
+
end: match.index + match[0].length,
|
|
119
|
+
id: match[2]!,
|
|
120
|
+
label: match[3]?.trim() || undefined,
|
|
121
|
+
sigil: match[1] as "@" | "#",
|
|
122
|
+
start: match.index,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function MentionView(props: { mention: Mention }) {
|
|
127
|
+
const { links, people } = useContext(MentionPeople);
|
|
128
|
+
const { id, sigil } = props.mention;
|
|
129
|
+
const person = sigil === "@" ? people.get(id) : undefined;
|
|
130
|
+
const text = <HighlightText text={mentionLabel(props.mention, people)} />;
|
|
131
|
+
if (links && person?.email) {
|
|
132
|
+
return (
|
|
133
|
+
<Link
|
|
134
|
+
// Previews sit in cards that disable pointer events on their text.
|
|
135
|
+
className={cn("pointer-events-auto", ANCHOR_CLASS)}
|
|
136
|
+
title={id}
|
|
137
|
+
to={peoplePath(person.email)}
|
|
138
|
+
>
|
|
139
|
+
{text}
|
|
140
|
+
</Link>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return (
|
|
144
|
+
<span className="font-medium text-dashboard-accent-text" title={id}>
|
|
145
|
+
{text}
|
|
146
|
+
</span>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
2
|
+
|
|
3
|
+
import { plainTextTokens } from "./mentions";
|
|
4
|
+
import { SLACK_LINK_RE } from "./links";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Return display text as one plain string for places that cannot hold
|
|
8
|
+
* markup, such as a `title` attribute. It shows the same names and emoji as
|
|
9
|
+
* `DisplayText`, without the Markdown syntax.
|
|
10
|
+
*/
|
|
11
|
+
export function displayPlainText(
|
|
12
|
+
text: string,
|
|
13
|
+
mentions: readonly ActorIdentity[] = [],
|
|
14
|
+
): string {
|
|
15
|
+
return plainTextTokens(stripMarkdown(text), mentions);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripMarkdown(text: string): string {
|
|
19
|
+
return text
|
|
20
|
+
.replace(SLACK_LINK_RE, (_, url: string, label?: string) =>
|
|
21
|
+
label?.trim() ? label.trim() : url,
|
|
22
|
+
)
|
|
23
|
+
.replace(/\[([^\]]+)\]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
|
|
24
|
+
.replace(/(^|\s)([*_~`]{1,3})(?=\S)/g, "$1")
|
|
25
|
+
.replace(/([*_~`]{1,3})(?=\s|$|[.,!?;:])/g, "")
|
|
26
|
+
.replace(/^\s*(?:#{1,6}|[-+*]|\d+\.)\s+/gm, "")
|
|
27
|
+
.replace(/\s+/g, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
package/src/client/format.ts
CHANGED
|
@@ -19,8 +19,8 @@ import { getDashboardAgentName } from "./agentName";
|
|
|
19
19
|
import { formatDuration } from "./components/Duration";
|
|
20
20
|
import { conversationTranscriptMessages } from "./conversations/eventTranscript";
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
function
|
|
22
|
+
/** Read the viewer's own browser timezone, used for absolute timestamps and charts. */
|
|
23
|
+
export function getDashboardTimeZone(): string {
|
|
24
24
|
try {
|
|
25
25
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
26
26
|
} catch {
|
|
@@ -28,22 +28,6 @@ function browserTimeZone(): string {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
let dashboardTimeZone = browserTimeZone();
|
|
32
|
-
|
|
33
|
-
/** Apply an operator-configured timezone override from the config API, if any. Otherwise the viewer's own browser timezone is used. */
|
|
34
|
-
export function setDashboardTimeZone(timeZone: string | undefined): void {
|
|
35
|
-
dashboardTimeZone = timeZone || browserTimeZone();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Read the dashboard display timezone used for absolute timestamps and charts. */
|
|
39
|
-
export function getDashboardTimeZone(): string {
|
|
40
|
-
return dashboardTimeZone;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function displayTimeZone(): string {
|
|
44
|
-
return dashboardTimeZone;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
31
|
function isActiveSummary(summary: ConversationSummaryReport): boolean {
|
|
48
32
|
return summary.status === "active";
|
|
49
33
|
}
|
|
@@ -69,7 +53,7 @@ function parseTime(value: string | undefined): number | null {
|
|
|
69
53
|
return Number.isFinite(time) ? time : null;
|
|
70
54
|
}
|
|
71
55
|
|
|
72
|
-
/** Format absolute dashboard timestamps in the
|
|
56
|
+
/** Format absolute dashboard timestamps in the viewer's browser timezone. */
|
|
73
57
|
export function formatTime(
|
|
74
58
|
value: string | undefined,
|
|
75
59
|
options?: Intl.DateTimeFormatOptions,
|
|
@@ -78,7 +62,7 @@ export function formatTime(
|
|
|
78
62
|
if (time == null) return "none";
|
|
79
63
|
return new Date(time).toLocaleString(undefined, {
|
|
80
64
|
...options,
|
|
81
|
-
timeZone:
|
|
65
|
+
timeZone: getDashboardTimeZone(),
|
|
82
66
|
});
|
|
83
67
|
}
|
|
84
68
|
|
|
@@ -166,10 +150,10 @@ export function formatMessageTimestamp(
|
|
|
166
150
|
? date.toLocaleString(undefined, {
|
|
167
151
|
dateStyle: "medium",
|
|
168
152
|
timeStyle: "medium",
|
|
169
|
-
timeZone:
|
|
153
|
+
timeZone: getDashboardTimeZone(),
|
|
170
154
|
})
|
|
171
155
|
: date.toLocaleTimeString(undefined, {
|
|
172
|
-
timeZone:
|
|
156
|
+
timeZone: getDashboardTimeZone(),
|
|
173
157
|
});
|
|
174
158
|
}
|
|
175
159
|
|
|
@@ -210,7 +194,7 @@ export function formatTranscriptTimestampDetails(value: number): {
|
|
|
210
194
|
return {
|
|
211
195
|
local: date.toLocaleString(undefined, {
|
|
212
196
|
...options,
|
|
213
|
-
timeZone:
|
|
197
|
+
timeZone: getDashboardTimeZone(),
|
|
214
198
|
}),
|
|
215
199
|
utc: date.toLocaleString(undefined, { ...options, timeZone: "UTC" }),
|
|
216
200
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Automation pages
|
|
2
|
+
|
|
3
|
+
## Boundaries
|
|
4
|
+
|
|
5
|
+
- Core owns validation, edit access, creator-only rules, revision checks,
|
|
6
|
+
Schedule previews, and the Event catalog. The page shows core errors. It
|
|
7
|
+
does not repeat these rules.
|
|
8
|
+
- Preview requests never save or run work.
|
|
9
|
+
- Completed scheduled Automations are read-only.
|
|
10
|
+
|
|
11
|
+
## Editor rules
|
|
12
|
+
|
|
13
|
+
- Send only changed fields. Keep unsupported triggers and stored
|
|
14
|
+
Destinations unless the user replaces them. Keep outcome order.
|
|
15
|
+
- New messages can target only the current Destination or the creator.
|
|
16
|
+
- A public reader sees outcomes as read-only and cannot turn on creator
|
|
17
|
+
credentials. Warn that an instruction or event selector change switches to
|
|
18
|
+
system credentials.
|
|
19
|
+
- Pause, Resume, and Delete are creator-only. Hide them from other viewers.
|
|
20
|
+
- Query refreshes must not replace an unsaved draft. After a stale save, let
|
|
21
|
+
the user keep their changes or load the latest version.
|
|
22
|
+
- Block navigation with unsaved changes.
|
|
23
|
+
- Lifecycle actions and "Make active" read the latest revision first.
|
|
24
|
+
|
|
25
|
+
## Page rules
|
|
26
|
+
|
|
27
|
+
- Each Automation has one page at `/automations/:id`. Keep the list query
|
|
28
|
+
string on the way back. Drop tab-only params: `result`, `run`, `version`.
|
|
29
|
+
- `AutomationRecentRuns` shows terminal runs only. Do not infer queued or
|
|
30
|
+
running work from terminal records.
|
|
31
|
+
- Delete keeps errors in the dialog and blocks dismissal while pending.
|
|
32
|
+
|
|
33
|
+
Browser journeys: `e2e/automation-editor.spec.ts` and
|
|
34
|
+
`e2e/automations.spec.ts`.
|
|
@@ -43,7 +43,7 @@ export function AutomationActions({
|
|
|
43
43
|
const completed = automation.status === "completed";
|
|
44
44
|
const paused = automation.status === "paused";
|
|
45
45
|
const blocked = automation.status === "blocked";
|
|
46
|
-
const canPause =
|
|
46
|
+
const canPause = !completed;
|
|
47
47
|
const action = paused || blocked ? "resume" : "pause";
|
|
48
48
|
const mutation = useMutation({
|
|
49
49
|
mutationFn: async () => {
|
|
@@ -135,25 +135,23 @@ export function AutomationActions({
|
|
|
135
135
|
</Popover>
|
|
136
136
|
</>
|
|
137
137
|
) : null}
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
</Tooltip>
|
|
156
|
-
) : null}
|
|
138
|
+
<Tooltip>
|
|
139
|
+
<TooltipTrigger
|
|
140
|
+
render={
|
|
141
|
+
<Button
|
|
142
|
+
focusableWhenDisabled
|
|
143
|
+
aria-label="Delete automation"
|
|
144
|
+
size="icon"
|
|
145
|
+
variant="danger"
|
|
146
|
+
onClick={(event) => onDelete(event.currentTarget)}
|
|
147
|
+
disabled={mutation.isPending}
|
|
148
|
+
>
|
|
149
|
+
<Trash2 aria-hidden="true" size={16} />
|
|
150
|
+
</Button>
|
|
151
|
+
}
|
|
152
|
+
/>
|
|
153
|
+
<TooltipContent size="compact">Delete automation</TooltipContent>
|
|
154
|
+
</Tooltip>
|
|
157
155
|
</div>
|
|
158
156
|
{mutation.error ? (
|
|
159
157
|
<div className="max-w-sm">
|
|
@@ -135,8 +135,7 @@ export function AutomationEditor(props: {
|
|
|
135
135
|
const dropsCreatorCredentials =
|
|
136
136
|
!owned &&
|
|
137
137
|
original.credentialMode === "creator" &&
|
|
138
|
-
(changes.instruction !== undefined ||
|
|
139
|
-
(original.kind === "event" && changes.trigger !== undefined));
|
|
138
|
+
(changes.instruction !== undefined || changes[triggerField] !== undefined);
|
|
140
139
|
const triggerError = fieldError(triggerField);
|
|
141
140
|
const inputState = (key: string) => ({
|
|
142
141
|
"aria-invalid": Boolean(fieldError(key)) || undefined,
|
|
@@ -426,8 +425,8 @@ export function AutomationEditor(props: {
|
|
|
426
425
|
{dropsCreatorCredentials ? (
|
|
427
426
|
<FormNotice title="Saving switches this automation to system credentials.">
|
|
428
427
|
Only {creator} can let a changed instruction
|
|
429
|
-
{original.kind === "event" ? " or trigger" : ""} use
|
|
430
|
-
connected accounts.
|
|
428
|
+
{original.kind === "event" ? " or trigger" : " or schedule"} use
|
|
429
|
+
their connected accounts.
|
|
431
430
|
</FormNotice>
|
|
432
431
|
) : null}
|
|
433
432
|
{fieldError("credentialMode") ? (
|
|
@@ -57,13 +57,13 @@ export function AutomationFilters(props: {
|
|
|
57
57
|
key: "type",
|
|
58
58
|
label: "Type",
|
|
59
59
|
value: filters.type === "all" ? "" : filters.type,
|
|
60
|
-
|
|
60
|
+
valueLabel: filters.type === "scheduled" ? "Scheduled" : "Event",
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
key: "creator",
|
|
64
64
|
label: "Creator",
|
|
65
65
|
value: filters.creator,
|
|
66
|
-
|
|
66
|
+
valueLabel:
|
|
67
67
|
data?.creators.find((option) => option.value === filters.creator)
|
|
68
68
|
?.label ?? filters.creator,
|
|
69
69
|
},
|
|
@@ -71,7 +71,7 @@ export function AutomationFilters(props: {
|
|
|
71
71
|
key: "destination",
|
|
72
72
|
label: "Destination",
|
|
73
73
|
value: filters.destination,
|
|
74
|
-
|
|
74
|
+
valueLabel:
|
|
75
75
|
data?.destinations.find(
|
|
76
76
|
(option) => option.value === filters.destination,
|
|
77
77
|
)?.label ?? filters.destination,
|
|
@@ -80,7 +80,7 @@ export function AutomationFilters(props: {
|
|
|
80
80
|
key: "state",
|
|
81
81
|
label: "State",
|
|
82
82
|
value: filters.state === "all" ? "" : filters.state,
|
|
83
|
-
|
|
83
|
+
valueLabel: stateOptions.find((option) => option.value === filters.state)
|
|
84
84
|
?.label,
|
|
85
85
|
},
|
|
86
86
|
].filter((filter) => filter.value);
|
|
@@ -163,11 +163,11 @@ export function AutomationFilters(props: {
|
|
|
163
163
|
key={filter.key}
|
|
164
164
|
aria-label={`Remove ${filter.key} filter`}
|
|
165
165
|
className="min-h-9 max-w-full text-xs"
|
|
166
|
-
title={`${filter.label}: ${filter.
|
|
166
|
+
title={`${filter.label}: ${filter.valueLabel}`}
|
|
167
167
|
onClick={() => onChange(filter.key, "")}
|
|
168
168
|
>
|
|
169
169
|
<span className="truncate">
|
|
170
|
-
{filter.label}: {filter.
|
|
170
|
+
{filter.label}: {filter.valueLabel}
|
|
171
171
|
</span>
|
|
172
172
|
<X aria-hidden="true" className="shrink-0" size={12} />
|
|
173
173
|
</Button>
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type TimeRangeDays,
|
|
16
16
|
} from "../../components/controls/TimeRangeSelector";
|
|
17
17
|
import { Card } from "../../components/layout/Card";
|
|
18
|
-
import {
|
|
18
|
+
import { DisplayText } from "../../display-text/DisplayText";
|
|
19
19
|
import { automationPath, formatCostSummary, peoplePath } from "../../format";
|
|
20
20
|
import { pathWithSearch } from "../../searchParams";
|
|
21
21
|
import { dashboardInlineLinkClass } from "../../styles";
|
|
@@ -76,7 +76,7 @@ function AutomationSettings(props: { automation: AutomationSummary }) {
|
|
|
76
76
|
<div className="min-w-0 [&>section:first-child]:border-t-0 [&>section:first-child]:pt-0">
|
|
77
77
|
<AutomationFormSection title="What to do">
|
|
78
78
|
<div className="text-base leading-relaxed [overflow-wrap:anywhere]">
|
|
79
|
-
<
|
|
79
|
+
<DisplayText
|
|
80
80
|
mentions={automation.mentions ?? []}
|
|
81
81
|
text={automation.instruction}
|
|
82
82
|
/>
|
|
@@ -197,9 +197,9 @@ function AutomationAbout(props: { automation: AutomationSummary }) {
|
|
|
197
197
|
<p className="mt-3 mb-0 text-xs leading-relaxed text-dashboard-text-muted">
|
|
198
198
|
{automation.destination.visibility === "private"
|
|
199
199
|
? null
|
|
200
|
-
: "You can edit
|
|
201
|
-
Only {automation.createdBy} can
|
|
202
|
-
|
|
200
|
+
: "You can edit, pause, or delete it because it is in a public channel. "}
|
|
201
|
+
Only {automation.createdBy} can change where results go, turn on their
|
|
202
|
+
connected accounts, or resume or run it while it uses them.
|
|
203
203
|
</p>
|
|
204
204
|
)}
|
|
205
205
|
</RailCard>
|
|
@@ -140,8 +140,8 @@ export function AutomationAttention({
|
|
|
140
140
|
<p className="m-0 break-words">
|
|
141
141
|
{automation.statusReason ??
|
|
142
142
|
"Check the last run to see what needs fixing."}{" "}
|
|
143
|
-
{!automation.ownedByViewer
|
|
144
|
-
? `
|
|
143
|
+
{!automation.ownedByViewer && automation.credentialMode === "creator"
|
|
144
|
+
? `It uses accounts connected by ${automation.createdBy}. Ask them to resume it, or switch it to Junior's credentials first.`
|
|
145
145
|
: null}
|
|
146
146
|
</p>
|
|
147
147
|
) : null}
|